diff --git a/backend/scripts/validate-s105.mts b/backend/scripts/validate-s105.mts index 7bfcdcd..4145642 100644 --- a/backend/scripts/validate-s105.mts +++ b/backend/scripts/validate-s105.mts @@ -166,7 +166,8 @@ try { ); const criteriaRepo = new CriteriaRepository(pool); const concurrentDeletes = await Promise.allSettled( - insertedCriteria.rows.map(({ id }) => criteriaRepo.delete(id)), + insertedCriteria.rows.map(({ id }) => + criteriaRepo.delete(id, null, "Remoção concorrente para validar o último critério")), ); assert.equal(concurrentDeletes.filter((result) => result.status === "fulfilled").length, 1); const rejected = concurrentDeletes.find((result) => result.status === "rejected"); diff --git a/backend/src/index.ts b/backend/src/index.ts index 5b95ab0..072c5fb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,6 +18,7 @@ import { searchRouter } from "./modules/search/search.routes.js"; import { chatRouter } from "./modules/chat/chat.routes.js"; import { developersRouter } from "./modules/developers/developers.routes.js"; import { adminRouter } from "./modules/admin/admin.routes.js"; +import { auditRouter } from "./modules/audit/audit.routes.js"; import { errorHandler } from "./middleware/errorHandler.js"; import { requireAuth } from "./middleware/requireAuth.js"; @@ -57,6 +58,7 @@ app.use("/api/v1/features", requireAuth, featuresRouter); app.use("/api/v1/pbis", requireAuth, pbisRouter); app.use("/api/v1/criteria", requireAuth, criteriaRouter); app.use("/api/v1/quality", qualityRouter); +app.use("/api/v1/audit", auditRouter); // Documentos do projeto app.use("/api/v1/projects/:projectId/documents", documentsRouter); diff --git a/backend/src/modules/audit/audit.justificativa.test.ts b/backend/src/modules/audit/audit.justificativa.test.ts new file mode 100644 index 0000000..08a532e --- /dev/null +++ b/backend/src/modules/audit/audit.justificativa.test.ts @@ -0,0 +1,235 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EpicsService } from "../epics/epics.service.js"; +import { FeaturesService } from "../features/features.service.js"; +import { PbisService } from "../pbis/pbis.service.js"; +import { ValidationError } from "../../shared/errors.js"; +import { PbiQualityConfigurationRecord } from "../quality/quality.types.js"; + +const PROJETO_ID = "d0000000-0000-4000-8000-000000000001"; +const EPICO_ID = "a0000000-0000-4000-8000-000000000001"; +const FEATURE_ID = "f0000000-0000-4000-8000-000000000001"; +const PBI_ID = "b0000000-0000-4000-8000-000000000001"; + +class StubQualityConfigRepo { + public config: PbiQualityConfigurationRecord = { + rule_version: "pbi-quality-v1", + checks: { + titulo_infinitivo: true, + historia_completa: true, + cenario_estruturado: true, + termos_vagos: true, + prototipo_vinculado: true, + }, + vague_terms: ["adequado"], + exigir_justificativa_item_concluido: true, + updated_at: new Date().toISOString(), + updated_by: null, + }; + + async getPbiConfiguration() { + return this.config; + } +} + +class StubProjectsRepo { + async findById(id: string) { + if (id === PROJETO_ID) { + return { id: PROJETO_ID, status: "ativo" } as any; + } + return null; + } +} + +class StubEpicsRepo { + public epic: any = { + id: EPICO_ID, + projeto_id: PROJETO_ID, + titulo: "Título Épico", + descricao: "Descrição", + objetivo: "Objetivo", + escopo_macro: "Escopo", + resultado_esperado: "Resultado", + status: "rascunho", + projeto_status: "ativo", + }; + + async findById(id: string) { + if (id === EPICO_ID) return this.epic; + return null; + } + + async update(id: string, data: any) { + this.epic = { ...this.epic, ...data }; + return this.epic; + } +} + +class StubFeaturesRepo { + public feature: any = { + id: FEATURE_ID, + epico_id: EPICO_ID, + projeto_id: PROJETO_ID, + titulo: "Título Feature", + descricao: "Descrição", + objetivo: "Objetivo", + status: "rascunho", + projeto_status: "ativo", + }; + + async findById(id: string) { + if (id === FEATURE_ID) return this.feature; + return null; + } + + async update(id: string, data: any) { + this.feature = { ...this.feature, ...data }; + return this.feature; + } +} + +class StubPbisRepo { + public pbi: any = { + id: PBI_ID, + feature_id: FEATURE_ID, + projeto_id: PROJETO_ID, + titulo: "Cadastrar item", + historia_como_um: "PO", + historia_eu_quero: "Cadastrar", + historia_para_que: "Manter", + status: "rascunho", + projeto_status: "ativo", + }; + + async findById(id: string) { + if (id === PBI_ID) return this.pbi; + return null; + } + + async update(id: string, data: any) { + this.pbi = { ...this.pbi, ...data }; + return this.pbi; + } +} + +class StubQualityService { + async validatePbi() { + return { score_completude: 100 } as any; + } +} + +test("S1-24: Rascunhos salvam sem exigir justificativa", async () => { + const qualityConfigRepo = new StubQualityConfigRepo(); + const projectsRepo = new StubProjectsRepo(); + const epicsRepo = new StubEpicsRepo(); + const featuresRepo = new StubFeaturesRepo(); + const pbisRepo = new StubPbisRepo(); + const qualityService = new StubQualityService(); + + const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any); + const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any); + const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any); + + // Status is rascunho + epicsRepo.epic.status = "rascunho"; + featuresRepo.feature.status = "rascunho"; + pbisRepo.pbi.status = "rascunho"; + + // Updating without justification should succeed for rascunhos + const epicUpdated = await epicsService.update(EPICO_ID, { titulo: "Título Épico Alterado" }); + assert.equal(epicUpdated.titulo, "Título Épico Alterado"); + + const featureUpdated = await featuresService.update(FEATURE_ID, { titulo: "Título Feature Alterado" }); + assert.equal(featureUpdated.titulo, "Título Feature Alterado"); + + const pbiUpdated = await pbisService.update(PBI_ID, { titulo: "Cadastrar novo item" }); + assert.equal(pbiUpdated.titulo, "Cadastrar novo item"); +}); + +test("S1-24: Política ativa bloqueia alteração de item concluído sem justificativa", async () => { + const qualityConfigRepo = new StubQualityConfigRepo(); + qualityConfigRepo.config.exigir_justificativa_item_concluido = true; + + const projectsRepo = new StubProjectsRepo(); + const epicsRepo = new StubEpicsRepo(); + const featuresRepo = new StubFeaturesRepo(); + const pbisRepo = new StubPbisRepo(); + const qualityService = new StubQualityService(); + + const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any); + const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any); + const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any); + + // Set status to concluido + epicsRepo.epic.status = "concluido"; + featuresRepo.feature.status = "concluido"; + pbisRepo.pbi.status = "concluido"; + + // Attempting to update without justification must throw ValidationError + await assert.rejects( + () => epicsService.update(EPICO_ID, { titulo: "Novo Épico Concluído" }), + (err: any) => err instanceof ValidationError && err.message.includes("justificativa é obrigatória"), + ); + + await assert.rejects( + () => featuresService.update(FEATURE_ID, { titulo: "Nova Feature Concluída" }), + (err: any) => err instanceof ValidationError && err.message.includes("justificativa é obrigatória"), + ); + + await assert.rejects( + () => pbisService.update(PBI_ID, { titulo: "Consultar item cadastrado" }), + (err: any) => err instanceof ValidationError && err.message.includes("justificativa é obrigatória"), + ); +}); + +test("S1-24: Permitir salvar alteração de item concluído quando justificativa é fornecida", async () => { + const qualityConfigRepo = new StubQualityConfigRepo(); + qualityConfigRepo.config.exigir_justificativa_item_concluido = true; + + const projectsRepo = new StubProjectsRepo(); + const epicsRepo = new StubEpicsRepo(); + const featuresRepo = new StubFeaturesRepo(); + const pbisRepo = new StubPbisRepo(); + const qualityService = new StubQualityService(); + + const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any); + const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any); + const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any); + + epicsRepo.epic.status = "concluido"; + featuresRepo.feature.status = "concluido"; + pbisRepo.pbi.status = "concluido"; + + // Updating WITH justification must succeed + const epicUpdated = await epicsService.update(EPICO_ID, { titulo: "Épico Reajustado", justificativa: "Ajuste solicitado pelo cliente" }); + assert.equal(epicUpdated.titulo, "Épico Reajustado"); + + const featureUpdated = await featuresService.update(FEATURE_ID, { titulo: "Feature Reajustada", justificativa: "Mudança no escopo de integração" }); + assert.equal(featureUpdated.titulo, "Feature Reajustada"); + + const pbiUpdated = await pbisService.update(PBI_ID, { titulo: "Alterar item concluído", justificativa: "Correção ortográfica" }); + assert.equal(pbiUpdated.titulo, "Alterar item concluído"); +}); + +test("S1-24: Política desativada permite salvar item concluído sem justificativa", async () => { + const qualityConfigRepo = new StubQualityConfigRepo(); + qualityConfigRepo.config.exigir_justificativa_item_concluido = false; + + const projectsRepo = new StubProjectsRepo(); + const epicsRepo = new StubEpicsRepo(); + const featuresRepo = new StubFeaturesRepo(); + const pbisRepo = new StubPbisRepo(); + const qualityService = new StubQualityService(); + + const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any); + const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any); + const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any); + + epicsRepo.epic.status = "concluido"; + featuresRepo.feature.status = "concluido"; + pbisRepo.pbi.status = "concluido"; + + // When policy is disabled, saving without justification passes + const epicUpdated = await epicsService.update(EPICO_ID, { titulo: "Épico Sem Justificativa Obrigatória" }); + assert.equal(epicUpdated.titulo, "Épico Sem Justificativa Obrigatória"); +}); diff --git a/backend/src/modules/audit/audit.payloads.ts b/backend/src/modules/audit/audit.payloads.ts new file mode 100644 index 0000000..05465ba --- /dev/null +++ b/backend/src/modules/audit/audit.payloads.ts @@ -0,0 +1,21 @@ +/** Builds a backward-compatible audit payload without duplicating justification. */ +export function buildAuditChangeData( + beforeValue: Before, + afterValue: After, + requestedChanges: Record, +): Record { + const before = beforeValue as Record; + const after = afterValue as Record; + const fields = Object.keys(requestedChanges).filter( + (field) => field !== "justificativa" && field in before && field in after, + ); + const pick = (source: Record) => Object.fromEntries( + fields.map((field) => [field, source[field]]), + ); + + return { + alteracoes: pick(after), + anterior: pick(before), + novo: pick(after), + }; +} diff --git a/backend/src/modules/audit/audit.routes.ts b/backend/src/modules/audit/audit.routes.ts new file mode 100644 index 0000000..92d76f0 --- /dev/null +++ b/backend/src/modules/audit/audit.routes.ts @@ -0,0 +1,48 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { auditService } from "./audit.service.js"; +import { requireAuth } from "../../middleware/requireAuth.js"; +import { validateUuid, ValidationError } from "../../shared/errors.js"; +import { z } from "zod"; +import { ENTITY_TYPES } from "../quality/quality.types.js"; +import { decodeAuditCursor } from "./audit.service.js"; + +export const auditRouter = Router(); + +const historyQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(25), + cursor: z.string().optional(), +}).strict(); + +auditRouter.use(requireAuth); + +function getStringParam(param: string | string[] | undefined): string { + if (Array.isArray(param)) return param[0] ?? ""; + return param ?? ""; +} + +auditRouter.get("/:entidade_tipo/:entidade_id/history", async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const entidadeTipo = getStringParam(req.params.entidade_tipo); + const entidadeId = getStringParam(req.params.entidade_id); + if (!ENTITY_TYPES.includes(entidadeTipo as (typeof ENTITY_TYPES)[number])) { + throw new ValidationError("Tipo de entidade inválido."); + } + validateUuid(entidadeId, "ID da entidade"); + const parsedQuery = historyQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + throw new ValidationError("Parâmetros de paginação inválidos.", parsedQuery.error.format()); + } + const cursor = parsedQuery.data.cursor + ? decodeAuditCursor(parsedQuery.data.cursor) + : undefined; + const history = await auditService.getHistory( + entidadeTipo as (typeof ENTITY_TYPES)[number], + entidadeId, + parsedQuery.data.limit, + cursor, + ); + res.status(200).json(history); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/audit/audit.service.test.ts b/backend/src/modules/audit/audit.service.test.ts new file mode 100644 index 0000000..d58fb12 --- /dev/null +++ b/backend/src/modules/audit/audit.service.test.ts @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Pool } from "pg"; +import { AuditService, decodeAuditCursor, encodeAuditCursor } from "./audit.service.js"; +import { buildAuditChangeData } from "./audit.payloads.js"; + +const entityId = "33333333-3333-4333-8333-333333333333"; + +function auditRow(id: string, createdAt: string) { + return { + id, + usuario_id: null, + usuario_nome: null, + entidade_tipo: "epico", + entidade_id: entityId, + acao: "ATUALIZAR_EPICO", + justificativa: null, + dados_json: {}, + created_at: createdAt, + }; +} + +test("cursor de auditoria faz roundtrip canônico e rejeita conteúdo inválido", () => { + const cursor = { + created_at: "2026-09-24T12:00:00.000Z", + id: "11111111-1111-4111-8111-111111111111", + }; + + assert.deepEqual(decodeAuditCursor(encodeAuditCursor(cursor)), cursor); + assert.throws(() => decodeAuditCursor("!!!"), /Cursor do histórico inválido/); + assert.throws(() => decodeAuditCursor(Buffer.from("{}", "utf8").toString("base64url")), /Cursor do histórico inválido/); +}); + +test("payload de auditoria registra campos anteriores/novos sem duplicar a justificativa", () => { + const payload = buildAuditChangeData( + { titulo: "Antes", objetivo: "Objetivo antigo" }, + { titulo: "Depois", objetivo: "Objetivo novo" }, + { titulo: "Depois", objetivo: "Objetivo novo", justificativa: "Aprovado" }, + ); + + assert.deepEqual(payload, { + alteracoes: { titulo: "Depois", objetivo: "Objetivo novo" }, + anterior: { titulo: "Antes", objetivo: "Objetivo antigo" }, + novo: { titulo: "Depois", objetivo: "Objetivo novo" }, + }); +}); + +test("histórico busca limite+1 e cria cursor estável na última linha devolvida", async () => { + const timestamp = "2026-09-24T12:00:00.000Z"; + const calls: Array<{ sql: string; values: unknown[] }> = []; + const db = { + query: async (sql: string, values: unknown[] = []) => { + calls.push({ sql, values }); + if (sql.includes("SELECT 1 FROM epico")) return { rows: [{ "?column?": 1 }], rowCount: 1 }; + return { + rows: [ + auditRow("33333333-3333-4333-8333-333333333333", timestamp), + auditRow("22222222-2222-4222-8222-222222222222", timestamp), + auditRow("11111111-1111-4111-8111-111111111111", timestamp), + ], + rowCount: 3, + }; + }, + } as unknown as Pool; + + const page = await new AuditService(db).getHistory("epico", entityId, 2); + + assert.equal(page.items.length, 2); + assert.deepEqual(decodeAuditCursor(page.next_cursor!), { + created_at: timestamp, + id: "22222222-2222-4222-8222-222222222222", + }); + assert.match(calls[1].sql, /\(a\.created_at, a\.id\) < \(\$3::timestamptz, \$4::uuid\)/); + assert.match(calls[1].sql, /ORDER BY a\.created_at DESC, a\.id DESC/); + assert.equal(calls[1].values[4], 3); +}); + +test("histórico rejeita limites fora do intervalo e entidade inexistente", async () => { + let entityExists = false; + const db = { + query: async () => entityExists + ? { rows: [{ id: entityId }], rowCount: 1 } + : { rows: [], rowCount: 0 }, + } as unknown as Pool; + const service = new AuditService(db); + + await assert.rejects(service.getHistory("epico", entityId, 101), /entre 1 e 100/); + await assert.rejects(service.getHistory("epico", entityId), /Item não encontrado/); + await assert.rejects(service.getHistory("inválido" as never, entityId), /Tipo de entidade inválido/); +}); + +test("histórico de PBI associa a versão salva sem alterar consultas de outros itens", async () => { + const timestamp = "2026-09-24T12:00:00.000Z"; + const calls: string[] = []; + const db = { + query: async (sql: string) => { + calls.push(sql); + if (sql.includes("SELECT 1 FROM pbi")) return { rows: [{ id: entityId }], rowCount: 1 }; + return { + rows: [{ ...auditRow("33333333-3333-4333-8333-333333333333", timestamp), entidade_tipo: "pbi", pbi_versao: 3, pbi_snapshot: { titulo: "PBI atualizado" } }], + rowCount: 1, + }; + }, + } as unknown as Pool; + + const page = await new AuditService(db).getHistory("pbi", entityId); + assert.match(calls[1], /LEFT JOIN pbi_versao v/); + assert.match(calls[1], /jsonb_strip_nulls\(jsonb_build_object/); + assert.doesNotMatch(calls[1], /v\.snapshot_json AS pbi_snapshot/); + assert.deepEqual(page.items[0].pbi_snapshot, { titulo: "PBI atualizado" }); + assert.equal(page.items[0].pbi_versao, 3); +}); diff --git a/backend/src/modules/audit/audit.service.ts b/backend/src/modules/audit/audit.service.ts index d34a05c..4b09954 100644 --- a/backend/src/modules/audit/audit.service.ts +++ b/backend/src/modules/audit/audit.service.ts @@ -1,5 +1,55 @@ import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; +import { NotFoundError, ValidationError } from "../../shared/errors.js"; +import { ENTITY_TYPES, type EntityType } from "../quality/quality.types.js"; + +const ENTITY_TABLE: Record = { + epico: "epico", + feature: "feature", + pbi: "pbi", +}; + +export interface AuditHistoryCursor { + created_at: string; + id: string; +} + +export interface AuditHistoryPage { + items: AuditHistoryRecord[]; + next_cursor: string | null; +} + +export function encodeAuditCursor(cursor: AuditHistoryCursor): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +export function decodeAuditCursor(value: string): AuditHistoryCursor { + try { + if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length > 256) { + throw new Error("Formato inválido"); + } + const decoded = Buffer.from(value, "base64url").toString("utf8"); + if (Buffer.from(decoded, "utf8").toString("base64url") !== value) { + throw new Error("Codificação inválida"); + } + const parsed = JSON.parse(decoded) as Record; + const createdAt = typeof parsed.created_at === "string" + ? new Date(parsed.created_at) + : null; + if ( + !createdAt + || Number.isNaN(createdAt.getTime()) + || typeof parsed.id !== "string" + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(parsed.id) + || Object.keys(parsed).some((key) => !["created_at", "id"].includes(key)) + ) { + throw new Error("Cursor inválido"); + } + return { created_at: createdAt.toISOString(), id: parsed.id }; + } catch { + throw new ValidationError("Cursor do histórico inválido."); + } +} export interface AuditEntry { usuario_id?: string | null; @@ -10,6 +60,20 @@ export interface AuditEntry { dados_json: Record; } +export interface AuditHistoryRecord { + id: string; + usuario_id: string | null; + usuario_nome: string | null; + entidade_tipo: string; + entidade_id: string; + acao: string; + justificativa: string | null; + dados_json: Record; + created_at: string; + pbi_versao: number | null; + pbi_snapshot: Record | null; +} + export class AuditService { private db: Pool | PoolClient; @@ -41,6 +105,89 @@ export class AuditService { await executor.query(query, values); } + + async getHistory( + entidadeTipo: EntityType, + entidadeId: string, + limit = 25, + cursor?: AuditHistoryCursor, + ): Promise { + if (!ENTITY_TYPES.includes(entidadeTipo)) { + throw new ValidationError("Tipo de entidade inválido."); + } + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new ValidationError("O limite do histórico deve estar entre 1 e 100."); + } + + const entity = await this.db.query( + `SELECT 1 FROM ${ENTITY_TABLE[entidadeTipo]} WHERE id = $1`, + [entidadeId], + ); + if (!entity.rowCount) throw new NotFoundError("Item não encontrado."); + + const pbiVersionJoin = entidadeTipo === "pbi" + ? `LEFT JOIN pbi_versao v + ON v.pbi_id = a.entidade_id + AND v.created_at = a.created_at + AND v.autor_id IS NOT DISTINCT FROM a.usuario_id` + : ""; + const pbiVersionFields = entidadeTipo === "pbi" + ? `v.versao AS pbi_versao, + CASE WHEN v.id IS NULL THEN NULL ELSE jsonb_strip_nulls(jsonb_build_object( + 'titulo', v.snapshot_json->'titulo', + 'historia_como_um', v.snapshot_json->'historia_como_um', + 'historia_eu_quero', v.snapshot_json->'historia_eu_quero', + 'historia_para_que', v.snapshot_json->'historia_para_que', + 'regras_observacoes', v.snapshot_json->'regras_observacoes', + 'tipo', v.snapshot_json->'tipo', + 'prioridade', v.snapshot_json->'prioridade', + 'requer_interface', v.snapshot_json->'requer_interface', + 'status', v.snapshot_json->'status' + )) END AS pbi_snapshot` + : "NULL::integer AS pbi_versao, NULL::jsonb AS pbi_snapshot"; + const query = ` + SELECT + a.id, + a.usuario_id, + u.nome AS usuario_nome, + a.entidade_tipo, + a.entidade_id, + a.acao, + a.justificativa, + a.dados_json, + a.created_at, + ${pbiVersionFields} + FROM auditoria a + LEFT JOIN usuario u ON u.id = a.usuario_id + ${pbiVersionJoin} + WHERE a.entidade_tipo = $1 AND a.entidade_id = $2 + AND ($3::timestamptz IS NULL OR (a.created_at, a.id) < ($3::timestamptz, $4::uuid)) + ORDER BY a.created_at DESC, a.id DESC + LIMIT $5 + `; + const result = await this.db.query(query, [ + entidadeTipo, + entidadeId, + cursor?.created_at ?? null, + cursor?.id ?? null, + limit + 1, + ]); + const hasMore = result.rows.length > limit; + const items = result.rows.slice(0, limit).map((row) => ({ + ...row, + created_at: new Date(row.created_at).toISOString(), + pbi_versao: row.pbi_versao ?? null, + pbi_snapshot: row.pbi_snapshot ?? null, + })); + const lastItem = items.at(-1); + + return { + items, + next_cursor: hasMore && lastItem + ? encodeAuditCursor({ created_at: lastItem.created_at, id: lastItem.id }) + : null, + }; + } } export const auditService = new AuditService(); diff --git a/backend/src/modules/criteria/criteria.controller.ts b/backend/src/modules/criteria/criteria.controller.ts index 576563c..4383b20 100644 --- a/backend/src/modules/criteria/criteria.controller.ts +++ b/backend/src/modules/criteria/criteria.controller.ts @@ -32,7 +32,11 @@ export class CriteriaController { try { const id = getParamId(req.params.id); const usuarioId = req.auth?.id ?? null; - const result = await this.service.delete(id, usuarioId); + const result = await this.service.delete( + id, + usuarioId, + req.body?.justificativa, + ); res.status(200).json(result); } catch (error) { next(error); diff --git a/backend/src/modules/criteria/criteria.repository.ts b/backend/src/modules/criteria/criteria.repository.ts index 47a0e74..7187a91 100644 --- a/backend/src/modules/criteria/criteria.repository.ts +++ b/backend/src/modules/criteria/criteria.repository.ts @@ -4,6 +4,10 @@ import { pool } from "../../database/db.js"; import { CreateCriterionDTO, Criterion, CriterionEntityType } from "./criteria.types.js"; import { auditService } from "../audit/audit.service.js"; import { ValidationError } from "../../shared/errors.js"; +import { + assertJustificationForCompletedItem, + normalizeJustification, +} from "../quality/completed-item-policy.js"; const ENTITY_TABLE: Record = { epico: "epico", @@ -137,6 +141,12 @@ export class CriteriaRepository { await client.query("BEGIN"); await lockHierarchy(client); await this.lockEntity(client, dto.entidade_tipo, dto.entidade_id); + await assertJustificationForCompletedItem( + client, + dto.entidade_tipo, + dto.entidade_id, + dto.justificativa, + ); const ordemResult = await client.query<{ proxima_ordem: number }>( `SELECT COALESCE(MAX(ordem), 0) + 1 AS proxima_ordem FROM criterio_aceitacao WHERE entidade_tipo = $1 AND entidade_id = $2`, @@ -170,6 +180,7 @@ export class CriteriaRepository { entidade_tipo: dto.entidade_tipo, entidade_id: dto.entidade_id, acao: "ADICIONAR_CRITERIO", + justificativa: normalizeJustification(dto.justificativa), dados_json: { criterio_id: created.id, ordem: created.ordem, nome: created.nome, texto: created.texto }, }, client, @@ -185,7 +196,11 @@ export class CriteriaRepository { } } - async delete(id: string, usuarioId?: string | null): Promise { + async delete( + id: string, + usuarioId?: string | null, + justificativa?: string | null, + ): Promise { const client: PoolClient = await this.pool.connect(); try { @@ -211,6 +226,12 @@ export class CriteriaRepository { if (!(await this.entityIsWritableInTransaction(client, removed.entidade_tipo, removed.entidade_id))) { throw new ValidationError("Não é possível alterar critérios de uma entidade arquivada."); } + await assertJustificationForCompletedItem( + client, + removed.entidade_tipo, + removed.entidade_id, + justificativa, + ); if (await this.removalBreaksCompletionInTransaction(client, removed.entidade_tipo, removed.entidade_id)) { throw new ValidationError("Não é possível remover o último critério de uma entidade já concluída. Reabra o item antes de remover."); } @@ -228,6 +249,7 @@ export class CriteriaRepository { entidade_tipo: removed.entidade_tipo, entidade_id: removed.entidade_id, acao: "REMOVER_CRITERIO", + justificativa: normalizeJustification(justificativa), dados_json: { criterio_id: removed.id, ordem: removed.ordem, nome: removed.nome, texto: removed.texto }, }, client, @@ -243,7 +265,12 @@ export class CriteriaRepository { } } - async move(id: string, direction: "up" | "down", usuarioId?: string | null): Promise { + async move( + id: string, + direction: "up" | "down", + usuarioId?: string | null, + justificativa?: string | null, + ): Promise { const client: PoolClient = await this.pool.connect(); try { @@ -283,6 +310,13 @@ export class CriteriaRepository { return listaSemAlteracao.rows; } + await assertJustificationForCompletedItem( + client, + current.entidade_tipo, + current.entidade_id, + justificativa, + ); + // Passa por uma ordem sentinela negativa para não colidir com o índice único durante a troca. await client.query(`UPDATE criterio_aceitacao SET ordem = -1 WHERE id = $1`, [current.id]); await client.query(`UPDATE criterio_aceitacao SET ordem = $1 WHERE id = $2`, [current.ordem, sibling.id]); @@ -294,6 +328,7 @@ export class CriteriaRepository { entidade_tipo: current.entidade_tipo, entidade_id: current.entidade_id, acao: "REORDENAR_CRITERIO", + justificativa: normalizeJustification(justificativa), dados_json: { criterio_id: current.id, direcao: direction, ordem_anterior: current.ordem, ordem_novo: sibling.ordem }, }, client, diff --git a/backend/src/modules/criteria/criteria.service.ts b/backend/src/modules/criteria/criteria.service.ts index 07c18c0..d3a4762 100644 --- a/backend/src/modules/criteria/criteria.service.ts +++ b/backend/src/modules/criteria/criteria.service.ts @@ -1,4 +1,4 @@ -import { createCriterionSchema, criterionQuerySchema, moveCriterionSchema, Criterion } from "./criteria.types.js"; +import { createCriterionSchema, criterionJustificationSchema, criterionQuerySchema, moveCriterionSchema, Criterion } from "./criteria.types.js"; import { CriteriaRepository, criteriaRepository } from "./criteria.repository.js"; import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; @@ -36,9 +36,21 @@ export class CriteriaService { return await this.repository.listByEntity(entidade_tipo, entidade_id); } - async delete(id: string, usuarioId?: string | null): Promise { + async delete( + id: string, + usuarioId?: string | null, + justificationInput?: unknown, + ): Promise { validateUuid(id, "ID do critério"); + const justificationResult = criterionJustificationSchema.safeParse({ + justificativa: justificationInput, + }); + if (!justificationResult.success) { + const issue = justificationResult.error.issues[0]; + throw new ValidationError(issue.message, justificationResult.error.format()); + } + const existing = await this.repository.findById(id); if (!existing) { throw new NotFoundError("Critério não encontrado."); @@ -52,7 +64,11 @@ export class CriteriaService { ); } - const removed = await this.repository.delete(id, usuarioId); + const removed = await this.repository.delete( + id, + usuarioId, + justificationResult.data.justificativa, + ); if (!removed) { throw new NotFoundError("Critério não encontrado."); } @@ -77,7 +93,12 @@ export class CriteriaService { throw new ValidationError(`Não é possível alterar critérios de um(a) ${this.entityLabel(existing.entidade_tipo).toLowerCase()} arquivado(a).`); } - const lista = await this.repository.move(id, parseResult.data.direction, usuarioId); + const lista = await this.repository.move( + id, + parseResult.data.direction, + usuarioId, + parseResult.data.justificativa, + ); if (!lista) { throw new NotFoundError("Critério não encontrado."); } diff --git a/backend/src/modules/criteria/criteria.types.ts b/backend/src/modules/criteria/criteria.types.ts index 0e72f6e..010dca9 100644 --- a/backend/src/modules/criteria/criteria.types.ts +++ b/backend/src/modules/criteria/criteria.types.ts @@ -11,16 +11,28 @@ const textCriterionShape = { .max(1000, "O texto não pode exceder 1000 caracteres."), }; +const justificationShape = { + justificativa: z + .string() + .trim() + .max(2000, "A justificativa não pode exceder 2000 caracteres.") + .optional(), +}; + +export const criterionJustificationSchema = z.object(justificationShape); + const epicoCriterionSchema = z.object({ entidade_tipo: z.literal("epico"), entidade_id: z.string({ required_error: "A entidade é obrigatória." }).uuid("A entidade deve ser um UUID válido."), ...textCriterionShape, + ...justificationShape, }); const featureCriterionSchema = z.object({ entidade_tipo: z.literal("feature"), entidade_id: z.string({ required_error: "A entidade é obrigatória." }).uuid("A entidade deve ser um UUID válido."), ...textCriterionShape, + ...justificationShape, }); const pbiCriterionSchema = z.object({ @@ -34,6 +46,7 @@ const pbiCriterionSchema = z.object({ dado: z.string({ required_error: "O bloco DADO é obrigatório." }).trim().min(1, "O bloco DADO é obrigatório."), quando: z.string({ required_error: "O bloco QUANDO é obrigatório." }).trim().min(1, "O bloco QUANDO é obrigatório."), entao: z.string({ required_error: "O bloco ENTÃO é obrigatório." }).trim().min(1, "O bloco ENTÃO é obrigatório."), + ...justificationShape, }); export const createCriterionSchema = z.discriminatedUnion("entidade_tipo", [ @@ -53,6 +66,7 @@ export type CriterionQueryDTO = z.infer; export const moveCriterionSchema = z.object({ direction: z.enum(["up", "down"], { required_error: "A direção do movimento é obrigatória." }), + justificativa: justificationShape.justificativa, }); export type MoveCriterionDTO = z.infer; diff --git a/backend/src/modules/epics/epics.repository.ts b/backend/src/modules/epics/epics.repository.ts index d62da20..cc25efd 100644 --- a/backend/src/modules/epics/epics.repository.ts +++ b/backend/src/modules/epics/epics.repository.ts @@ -3,6 +3,8 @@ import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; import { CreateEpicDTO, UpdateEpicDTO, EpicQueryDTO, Epic, EpicWithStats, PaginatedEpics } from "./epics.types.js"; import { auditService } from "../audit/audit.service.js"; +import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; +import { buildAuditChangeData } from "../audit/audit.payloads.js"; export class EpicsRepository { private pool: Pool; @@ -127,6 +129,12 @@ export class EpicsRepository { await client.query("ROLLBACK"); return null; } + await assertJustificationForCompletedItem( + client, + "epico", + id, + data.justificativa, + ); const updates: string[] = []; const values: unknown[] = []; @@ -155,7 +163,7 @@ export class EpicsRepository { entidade_id: id, acao: "ATUALIZAR_EPICO", justificativa: data.justificativa ?? null, - dados_json: { alteracoes: data, anterior: { titulo: existing.titulo }, novo: { titulo: updated.titulo } }, + dados_json: buildAuditChangeData(existing, updated, data), }, client, ); diff --git a/backend/src/modules/epics/epics.service.ts b/backend/src/modules/epics/epics.service.ts index 9dc5de8..5f218d8 100644 --- a/backend/src/modules/epics/epics.service.ts +++ b/backend/src/modules/epics/epics.service.ts @@ -1,12 +1,14 @@ import { createEpicSchema, updateEpicSchema, epicQuerySchema, Epic, EpicWithStats, PaginatedEpics, EPIC_REQUIRED_FIELDS } from "./epics.types.js"; import { EpicsRepository, epicsRepository } from "./epics.repository.js"; import { ProjectsRepository, projectsRepository } from "../projects/projects.repository.js"; +import { QualityConfigurationRepository, qualityConfigurationRepository } from "../quality/quality.configuration.repository.js"; import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; export class EpicsService { constructor( private readonly repository: EpicsRepository = epicsRepository, private readonly projectsRepo: ProjectsRepository = projectsRepository, + private readonly qualityConfigRepo: QualityConfigurationRepository = qualityConfigurationRepository, ) {} private async ensureWritable(epic: Epic): Promise { @@ -81,6 +83,10 @@ export class EpicsService { if (EPIC_REQUIRED_FIELDS.some((field) => !String(merged[field] ?? "").trim())) { throw new ValidationError("Não é possível remover campos obrigatórios de um épico concluído."); } + const config = await this.qualityConfigRepo.getPbiConfiguration(); + if (config.exigir_justificativa_item_concluido && !parseResult.data.justificativa?.trim()) { + throw new ValidationError("A justificativa é obrigatória ao alterar um item concluído.", { code: "JUSTIFICATIVA_REQUERIDA", campo: "justificativa" }); + } } const updated = await this.repository.update(id, parseResult.data, usuarioId); if (!updated) { diff --git a/backend/src/modules/features/features.repository.ts b/backend/src/modules/features/features.repository.ts index f3f49c8..7834ae5 100644 --- a/backend/src/modules/features/features.repository.ts +++ b/backend/src/modules/features/features.repository.ts @@ -3,6 +3,8 @@ import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; import { CreateFeatureDTO, UpdateFeatureDTO, FeatureQueryDTO, Feature, FeatureWithStats, PaginatedFeatures } from "./features.types.js"; import { auditService } from "../audit/audit.service.js"; +import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; +import { buildAuditChangeData } from "../audit/audit.payloads.js"; const SELECT_WITH_STATS = ` SELECT @@ -122,6 +124,12 @@ export class FeaturesRepository { await client.query("ROLLBACK"); return null; } + await assertJustificationForCompletedItem( + client, + "feature", + id, + data.justificativa, + ); const updates: string[] = []; const values: unknown[] = []; @@ -148,7 +156,7 @@ export class FeaturesRepository { entidade_id: id, acao: "ATUALIZAR_FEATURE", justificativa: data.justificativa ?? null, - dados_json: { alteracoes: data, anterior: { titulo: existing.titulo }, novo: { titulo: updated.titulo } }, + dados_json: buildAuditChangeData(existing, updated, data), }, client, ); diff --git a/backend/src/modules/features/features.service.ts b/backend/src/modules/features/features.service.ts index 2e343e5..d032fd8 100644 --- a/backend/src/modules/features/features.service.ts +++ b/backend/src/modules/features/features.service.ts @@ -1,12 +1,14 @@ import { createFeatureSchema, updateFeatureSchema, featureQuerySchema, Feature, FeatureWithStats, PaginatedFeatures, FEATURE_REQUIRED_FIELDS } from "./features.types.js"; import { FeaturesRepository, featuresRepository } from "./features.repository.js"; import { EpicsRepository, epicsRepository } from "../epics/epics.repository.js"; +import { QualityConfigurationRepository, qualityConfigurationRepository } from "../quality/quality.configuration.repository.js"; import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; export class FeaturesService { constructor( private readonly repository: FeaturesRepository = featuresRepository, private readonly epicsRepo: EpicsRepository = epicsRepository, + private readonly qualityConfigRepo: QualityConfigurationRepository = qualityConfigurationRepository, ) {} async create(input: unknown, usuarioId?: string | null): Promise { @@ -68,6 +70,13 @@ export class FeaturesService { throw new ValidationError(issue.message, parseResult.error.format()); } + if (existing.status === "concluido") { + const config = await this.qualityConfigRepo.getPbiConfiguration(); + if (config.exigir_justificativa_item_concluido && !parseResult.data.justificativa?.trim()) { + throw new ValidationError("A justificativa é obrigatória ao alterar um item concluído.", { code: "JUSTIFICATIVA_REQUERIDA", campo: "justificativa" }); + } + } + const updated = await this.repository.update(id, parseResult.data, usuarioId); if (!updated) { throw new NotFoundError("Feature não encontrada."); diff --git a/backend/src/modules/pbis/pbis.repository.ts b/backend/src/modules/pbis/pbis.repository.ts index a14aca9..d1d1089 100644 --- a/backend/src/modules/pbis/pbis.repository.ts +++ b/backend/src/modules/pbis/pbis.repository.ts @@ -3,6 +3,8 @@ import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; import { CreatePbiDTO, UpdatePbiDTO, PbiQueryDTO, Pbi, PbiWithContext, PaginatedPbis } from "./pbis.types.js"; import { auditService } from "../audit/audit.service.js"; +import { assertJustificationForCompletedItem, normalizeJustification } from "../quality/completed-item-policy.js"; +import { buildAuditChangeData } from "../audit/audit.payloads.js"; const SELECT_WITH_CONTEXT = ` SELECT @@ -137,6 +139,12 @@ export class PbisRepository { await client.query("ROLLBACK"); return null; } + await assertJustificationForCompletedItem( + client, + "pbi", + id, + data.justificativa, + ); const updates: string[] = []; const values: unknown[] = []; @@ -166,12 +174,18 @@ export class PbisRepository { entidade_tipo: "pbi", entidade_id: id, acao: "ATUALIZAR_PBI", - justificativa: data.justificativa ?? null, - dados_json: { alteracoes: data, anterior: { titulo: existing.titulo }, novo: { titulo: updated.titulo } }, + justificativa: normalizeJustification(data.justificativa), + dados_json: buildAuditChangeData(existing, updated, data), }, client, ); + 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)`, + [id, JSON.stringify(updated), normalizeJustification(data.justificativa) ?? "", usuarioId ?? null], + ); + await client.query("COMMIT"); return await this.findById(id); } catch (error) { diff --git a/backend/src/modules/pbis/pbis.service.ts b/backend/src/modules/pbis/pbis.service.ts index f56e4ac..5d981cd 100644 --- a/backend/src/modules/pbis/pbis.service.ts +++ b/backend/src/modules/pbis/pbis.service.ts @@ -2,6 +2,7 @@ import { createPbiSchema, updatePbiSchema, pbiQuerySchema, Pbi, PbiWithContext, import { PbisRepository, pbisRepository } from "./pbis.repository.js"; import { FeaturesRepository, featuresRepository } from "../features/features.repository.js"; import { QualityService, qualityService, RelatorioQualidadePbi } from "../quality/quality.service.js"; +import { QualityConfigurationRepository, qualityConfigurationRepository } from "../quality/quality.configuration.repository.js"; import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; const COMPLETION_BLOCKING_FIELDS: Record = { @@ -15,6 +16,7 @@ export class PbisService { private readonly repository: PbisRepository = pbisRepository, private readonly featuresRepo: FeaturesRepository = featuresRepository, private readonly qualityChecker: QualityService = qualityService, + private readonly qualityConfigRepo: QualityConfigurationRepository = qualityConfigurationRepository, ) {} async create(input: unknown, usuarioId?: string | null): Promise { @@ -97,6 +99,13 @@ export class PbisService { throw new ValidationError(issue.message, parseResult.error.format()); } + if (existing.status === "concluido") { + const config = await this.qualityConfigRepo.getPbiConfiguration(); + if (config.exigir_justificativa_item_concluido && !parseResult.data.justificativa?.trim()) { + throw new ValidationError("A justificativa é obrigatória ao alterar um item concluído.", { code: "JUSTIFICATIVA_REQUERIDA", campo: "justificativa" }); + } + } + const updated = await this.repository.update( id, parseResult.data, diff --git a/backend/src/modules/projects/hierarchy-archive.db.test.ts b/backend/src/modules/projects/hierarchy-archive.db.test.ts index 83be095..8f43c3c 100644 --- a/backend/src/modules/projects/hierarchy-archive.db.test.ts +++ b/backend/src/modules/projects/hierarchy-archive.db.test.ts @@ -15,6 +15,7 @@ import { ArchiveConflict } from "./archive.types.js"; import { FeaturesRepository } from "../features/features.repository.js"; import { featureQuerySchema } from "../features/features.types.js"; import { PbisRepository } from "../pbis/pbis.repository.js"; +import { AuditService } from "../audit/audit.service.js"; import { CriteriaRepository } from "../criteria/criteria.repository.js"; test("S1-09: arquivamento direto, preservação, filtros e escrita concorrente", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async (t) => { @@ -125,3 +126,101 @@ test("S1-09: uma hierarquia ativa continua permitindo criar feature e PBI", { sk await db.end(); } }); + +test("S1-24: transações exigem justificativa em alterações de itens concluídos", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async (t) => { + const db = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const [project, epic, feature, pbi, lateFeature] = Array.from({ length: 5 }, randomUUID); + const configId = "00000000-0000-4000-8000-000000000001"; + let originalConfiguration: unknown; + const criteria = new CriteriaRepository(db); + const epics = new EpicsRepository(db); + const features = new FeaturesRepository(db); + const pbis = new PbisRepository(db); + + try { + const savedConfiguration = await db.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 db.query( + `UPDATE quality_configuration + SET configuration = jsonb_set(configuration, '{exigir_justificativa_item_concluido}', 'true'::jsonb, true) + WHERE id = $1`, + [configId], + ); + + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES ($1::uuid,$2::text,'Teste','ativo')", [project, project]); + await db.query("INSERT INTO epico(id,projeto_id,titulo,descricao,objetivo,escopo_macro,resultado_esperado,status) VALUES ($1,$2,'Épico','Descrição','Objetivo','Escopo','Resultado','concluido')", [epic, project]); + await db.query("INSERT INTO feature(id,epico_id,titulo,descricao,objetivo,status) VALUES ($1,$2,'Feature','Descrição','Objetivo','concluido'),($3,$2,'Feature tardia','Descrição','Objetivo','rascunho')", [feature, epic, lateFeature]); + await db.query("INSERT INTO pbi(id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que,status) VALUES ($1,$2,'PBI-001','PBI','PO','alterar','preservar','concluido')", [pbi, feature]); + + await t.test("update de épico, feature e PBI revalida sob lock e audita a justificativa", async () => { + await assert.rejects(epics.update(epic, { titulo: "Épico sem justificativa" }), /justificativa é obrigatória/); + await assert.rejects(features.update(feature, { titulo: "Feature sem justificativa" }), /justificativa é obrigatória/); + await assert.rejects(pbis.update(pbi, { titulo: "PBI sem justificativa" }), /justificativa é obrigatória/); + + await epics.update(epic, { titulo: "Épico corrigido", justificativa: "Ajuste solicitado pelo PO" }); + await features.update(feature, { titulo: "Feature corrigida", justificativa: "Atualização do escopo aprovado" }); + await pbis.update(pbi, { titulo: "PBI corrigido", justificativa: "Correção após validação" }); + + const audit = await db.query<{ entidade_tipo: string; justificativa: string }>( + "SELECT entidade_tipo, justificativa FROM auditoria WHERE entidade_id = ANY($1::uuid[]) AND acao LIKE 'ATUALIZAR_%' ORDER BY entidade_tipo", + [[epic, feature, pbi]], + ); + assert.deepEqual(audit.rows.map((row) => row.justificativa), [ + "Ajuste solicitado pelo PO", + "Atualização do escopo aprovado", + "Correção após validação", + ]); + const history = await new AuditService(db).getHistory("pbi", pbi); + assert.equal(history.items[0]?.pbi_versao, 1); + assert.equal(history.items[0]?.pbi_snapshot?.titulo, "PBI corrigido"); + }); + + await t.test("create, delete e move de critérios exigem justificativa e registram auditoria", async () => { + for (const [tipo, entidadeId] of [["epico", epic], ["feature", feature], ["pbi", pbi]] as const) { + const input = tipo === "pbi" + ? { entidade_tipo: tipo, entidade_id: entidadeId, nome: `Cenário ${tipo}`, dado: "contexto", quando: "ação", entao: "resultado" } + : { entidade_tipo: tipo, entidade_id: entidadeId, texto: `Critério ${tipo}` }; + await assert.rejects(criteria.create(input as any), /justificativa é obrigatória/); + const first = await criteria.create({ ...input, justificativa: `Adicionar ${tipo}` } as any); + const second = await criteria.create({ ...input, justificativa: `Adicionar segundo ${tipo}` } as any); + + await assert.rejects(criteria.move(second.id, "up"), /justificativa é obrigatória/); + await criteria.move(second.id, "up", undefined, `Reordenar ${tipo}`); + + await assert.rejects(criteria.delete(first.id), /justificativa é obrigatória/); + await criteria.delete(first.id, undefined, `Remover ${tipo}`); + } + + const audit = await db.query<{ justificativa: string }>( + "SELECT justificativa FROM auditoria WHERE entidade_id = ANY($1::uuid[]) AND acao IN ('ADICIONAR_CRITERIO','REMOVER_CRITERIO','REORDENAR_CRITERIO') ORDER BY created_at, id", + [[epic, feature, pbi]], + ); + assert.ok(audit.rows.length >= 12); + assert.ok(audit.rows.every((row) => row.justificativa?.trim())); + }); + + await t.test("rejeita usando o status atual mesmo que a leitura prévia tenha sido rascunho", async () => { + const preflight = await db.query<{ status: string }>("SELECT status FROM feature WHERE id = $1", [lateFeature]); + assert.equal(preflight.rows[0]?.status, "rascunho"); + await db.query("UPDATE feature SET status = 'concluido' WHERE id = $1", [lateFeature]); + await assert.rejects( + features.update(lateFeature, { titulo: "Requisição com preflight obsoleto" }), + /justificativa é obrigatória/, + ); + }); + } finally { + await db.query("DELETE FROM auditoria WHERE entidade_id = ANY($1::uuid[])", [[epic, feature, pbi, lateFeature]]); + await db.query("DELETE FROM projeto WHERE id = $1", [project]); + if (originalConfiguration !== undefined) { + await db.query( + "UPDATE quality_configuration SET configuration = $2::jsonb WHERE id = $1", + [configId, JSON.stringify(originalConfiguration)], + ); + } + await db.end(); + } +}); diff --git a/backend/src/modules/quality/completed-item-policy.test.ts b/backend/src/modules/quality/completed-item-policy.test.ts new file mode 100644 index 0000000..9201699 --- /dev/null +++ b/backend/src/modules/quality/completed-item-policy.test.ts @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { PoolClient } from "pg"; +import { ValidationError } from "../../shared/errors.js"; +import { assertJustificationForCompletedItem } from "./completed-item-policy.js"; + +const entityId = "33333333-3333-4333-8333-333333333333"; +const checks = { + titulo_infinitivo: true, + historia_completa: true, + cenario_estruturado: true, + termos_vagos: true, + prototipo_vinculado: true, +}; + +function transactionClient(status: string, requireJustification: boolean) { + const queries: string[] = []; + const client = { + query: async (sql: string) => { + queries.push(sql); + if (sql.includes("SELECT status FROM")) { + return { rows: [{ status }], rowCount: 1 }; + } + if (sql.includes("FROM quality_configuration")) { + return { + rows: [{ + id: "00000000-0000-4000-8000-000000000001", + version: 1, + configuration: { + checks, + vague_terms: [], + exigir_justificativa_item_concluido: requireJustification, + }, + updated_at: new Date("2026-01-01T00:00:00Z"), + updated_by: null, + updated_by_name: null, + }], + rowCount: 1, + }; + } + throw new Error(`Query inesperada no teste: ${sql}`); + }, + }; + return { client: client as unknown as PoolClient, queries }; +} + +test("política transacional revalida o status atual antes de permitir escrita", async () => { + const { client, queries } = transactionClient("concluido", true); + + await assert.rejects( + assertJustificationForCompletedItem(client, "pbi", entityId), + (error: unknown) => error instanceof ValidationError + && error.message.includes("justificativa é obrigatória"), + ); + + assert.equal(queries.length, 2); + assert.match(queries[1], /FOR SHARE OF c/); +}); + +test("política desativada permite alteração concluída sem justificativa", async () => { + const { client, queries } = transactionClient("concluido", false); + + await assertJustificationForCompletedItem(client, "epico", entityId); + + assert.equal(queries.length, 2); +}); + +test("item ativo não consulta nem depende da política de justificativa", async () => { + const { client, queries } = transactionClient("ativo", true); + + await assertJustificationForCompletedItem(client, "feature", entityId); + + assert.equal(queries.length, 1); +}); + +test("rejeita entidade ausente dentro da transação", async () => { + const client = { + query: async () => ({ rows: [], rowCount: 0 }), + } as unknown as PoolClient; + + await assert.rejects( + assertJustificationForCompletedItem(client, "epico", entityId), + /Item não encontrado/, + ); +}); diff --git a/backend/src/modules/quality/completed-item-policy.ts b/backend/src/modules/quality/completed-item-policy.ts new file mode 100644 index 0000000..ef967da --- /dev/null +++ b/backend/src/modules/quality/completed-item-policy.ts @@ -0,0 +1,57 @@ +import type { Pool, PoolClient } from "pg"; +import { NotFoundError, ValidationError } from "../../shared/errors.js"; +import { qualityConfigurationRepository } from "./quality.configuration.repository.js"; +import type { EntityType } from "./quality.types.js"; + +const ENTITY_TABLE: Record = { + epico: "epico", + feature: "feature", + pbi: "pbi", +}; + +export const JUSTIFICATION_REQUIRED_MESSAGE = + "A justificativa é obrigatória ao alterar um item concluído."; + +/** + * Authoritative policy check for writes: callers must invoke it after acquiring + * the hierarchy/entity locks and pass the same transaction client used to write. + */ +export async function assertJustificationForCompletedItem( + client: Pool | PoolClient, + entityType: EntityType, + entityId: string, + justification?: string | null, +): Promise { + const table = ENTITY_TABLE[entityType]; + const entity = await client.query<{ status: string }>( + `SELECT status FROM ${table} WHERE id = $1`, + [entityId], + ); + const status = entity.rows[0]?.status; + + if (status === undefined) { + throw new NotFoundError("Item não encontrado."); + } + if (status !== "concluido") return; + + const configuration = await qualityConfigurationRepository.getPbiConfiguration( + client, + { lock: true }, + ); + if ( + configuration.exigir_justificativa_item_concluido + && !justification?.trim() + ) { + throw new ValidationError(JUSTIFICATION_REQUIRED_MESSAGE, { + code: "JUSTIFICATIVA_REQUERIDA", + campo: "justificativa", + }); + } +} + +export function normalizeJustification( + justification?: string | null, +): string | null { + const normalized = justification?.trim(); + return normalized || null; +} diff --git a/backend/src/modules/quality/quality.configuration.repository.test.ts b/backend/src/modules/quality/quality.configuration.repository.test.ts new file mode 100644 index 0000000..f1820e3 --- /dev/null +++ b/backend/src/modules/quality/quality.configuration.repository.test.ts @@ -0,0 +1,97 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Pool, PoolClient } from "pg"; +import { QualityConfigurationRepository } from "./quality.configuration.repository.js"; +import type { PbiQualityConfigurationInput } from "./quality.types.js"; + +const checks = { + titulo_infinitivo: true, + historia_completa: true, + cenario_estruturado: true, + termos_vagos: true, + prototipo_vinculado: true, +}; + +function configurationDatabase(existingPolicy: boolean) { + let configuration: Record = { + checks, + vague_terms: ["adequado"], + exigir_justificativa_item_concluido: existingPolicy, + }; + let version = 3; + const client = { + query: async (sql: string, values: unknown[] = []) => { + if (sql.includes("SELECT") && sql.includes("FOR UPDATE")) { + return { rows: [{ + id: "00000000-0000-4000-8000-000000000001", + version, + configuration, + updated_at: new Date("2026-01-01T00:00:00Z"), + updated_by: null, + updated_by_name: null, + }] }; + } + if (sql.includes("UPDATE quality_configuration")) { + version += 1; + configuration = JSON.parse(String(values[1])); + return { rows: [{ + id: "00000000-0000-4000-8000-000000000001", + version, + configuration, + updated_at: new Date("2026-01-02T00:00:00Z"), + updated_by: String(values[2]), + updated_by_name: null, + }] }; + } + if (sql.includes("INSERT INTO auditoria")) return { rows: [], rowCount: 1 }; + if (sql.includes("SELECT id, nome FROM usuario")) return { rows: [{ id: values[0], nome: "Admin" }] }; + return { rows: [], rowCount: 1 }; + }, + release() {}, + }; + const db = { + connect: async () => client, + } as unknown as Pool; + return { repository: new QualityConfigurationRepository(db), getConfiguration: () => configuration }; +} + +const inputWithoutPolicy: PbiQualityConfigurationInput = { + checks, + vague_terms: ["adequado", "correto"], +}; + +test("salvar campos antigos preserva política de justificativa desativada", async () => { + const { repository, getConfiguration } = configurationDatabase(false); + + const saved = await repository.updatePbiConfiguration( + inputWithoutPolicy, + "11111111-1111-4111-8111-111111111111", + ); + + assert.equal(saved.exigir_justificativa_item_concluido, false); + assert.equal(getConfiguration().exigir_justificativa_item_concluido, false); + assert.deepEqual(saved.vague_terms, ["adequado", "correto"]); +}); + +test("salvar campos antigos preserva política de justificativa ativada", async () => { + const { repository } = configurationDatabase(true); + + const saved = await repository.updatePbiConfiguration( + inputWithoutPolicy, + "11111111-1111-4111-8111-111111111111", + ); + + assert.equal(saved.exigir_justificativa_item_concluido, true); +}); + +test("permite alterar explicitamente a política sem perder os demais campos", async () => { + const { repository } = configurationDatabase(false); + + const saved = await repository.updatePbiConfiguration( + { ...inputWithoutPolicy, exigir_justificativa_item_concluido: true }, + "11111111-1111-4111-8111-111111111111", + ); + + assert.equal(saved.exigir_justificativa_item_concluido, true); + assert.deepEqual(saved.checks, checks); +}); diff --git a/backend/src/modules/quality/quality.configuration.repository.ts b/backend/src/modules/quality/quality.configuration.repository.ts index 2e30cc6..8561c85 100644 --- a/backend/src/modules/quality/quality.configuration.repository.ts +++ b/backend/src/modules/quality/quality.configuration.repository.ts @@ -1,4 +1,4 @@ -import { Pool } from "pg"; +import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; import { auditService } from "../audit/audit.service.js"; import { ValidationError } from "../../shared/errors.js"; @@ -18,6 +18,7 @@ type ConfigurationRow = { configuration: { checks: Record; vague_terms: string[]; + exigir_justificativa_item_concluido?: boolean; }; updated_at: Date | string; updated_by: string | null; @@ -27,8 +28,11 @@ type ConfigurationRow = { export class QualityConfigurationRepository { constructor(private readonly db: Pool = pool) {} - async getPbiConfiguration(): Promise { - const result = await this.db.query( + async getPbiConfiguration( + executor: Pool | PoolClient = this.db, + options: { lock?: boolean } = {}, + ): Promise { + const result = await executor.query( ` SELECT c.id, @@ -40,6 +44,7 @@ export class QualityConfigurationRepository { FROM quality_configuration c LEFT JOIN usuario u ON u.id = c.updated_by WHERE c.id = $1 + ${options.lock ? "FOR SHARE OF c" : ""} `, [CONFIGURATION_ID], ); @@ -110,7 +115,14 @@ export class QualityConfigurationRepository { `, [ CONFIGURATION_ID, - JSON.stringify(input), + JSON.stringify({ + ...before.configuration, + ...input, + exigir_justificativa_item_concluido: + input.exigir_justificativa_item_concluido + ?? before.configuration.exigir_justificativa_item_concluido + ?? true, + }), usuarioId, ], ); @@ -126,7 +138,7 @@ export class QualityConfigurationRepository { dados_json: { actor_id: usuarioId, before: before.configuration, - after: input, + after: after.configuration, version: after.version, }, }, @@ -223,6 +235,8 @@ export class QualityConfigurationRepository { vague_terms: raw.vague_terms ?? [...TERMOS_VAGOS_PADRAO], + exigir_justificativa_item_concluido: + raw.exigir_justificativa_item_concluido ?? true, updated_at: new Date(row.updated_at).toISOString(), updated_by: @@ -237,4 +251,4 @@ export class QualityConfigurationRepository { } export const qualityConfigurationRepository = - new QualityConfigurationRepository(); \ No newline at end of file + new QualityConfigurationRepository(); diff --git a/backend/src/modules/quality/quality.controller.ts b/backend/src/modules/quality/quality.controller.ts index 594f4f4..dfa1d43 100644 --- a/backend/src/modules/quality/quality.controller.ts +++ b/backend/src/modules/quality/quality.controller.ts @@ -12,6 +12,7 @@ const configurationSchema = z.object({ prototipo_vinculado: z.boolean(), }).strict(), vague_terms: z.array(z.string().trim().min(1).max(80)).max(100), + exigir_justificativa_item_concluido: z.boolean().optional(), }).strict(); function getParamId(param: string | string[] | undefined): string { diff --git a/backend/src/modules/quality/quality.types.ts b/backend/src/modules/quality/quality.types.ts index d9bd8ad..962111b 100644 --- a/backend/src/modules/quality/quality.types.ts +++ b/backend/src/modules/quality/quality.types.ts @@ -33,10 +33,12 @@ export interface PbiQualityRuleConfiguration { export interface PbiQualityConfigurationInput { checks: Record; vague_terms: string[]; + exigir_justificativa_item_concluido?: boolean; } export interface PbiQualityConfigurationRecord extends PbiQualityConfigurationInput { rule_version: string; + exigir_justificativa_item_concluido: boolean; updated_at: string; updated_by: { id: string; nome: string } | null; } diff --git a/docs/PR36_CORRECTION_PLAN.md b/docs/PR36_CORRECTION_PLAN.md new file mode 100644 index 0000000..a277151 --- /dev/null +++ b/docs/PR36_CORRECTION_PLAN.md @@ -0,0 +1,274 @@ +# Plano de correção QA — PR #36 (S1-24) + +**Objetivo:** corrigir os desvios encontrados na regra de justificativa e no histórico de auditoria da PR #36, preservando o comportamento existente de backlog e a base atual da `main`. + +**Estado atual:** correções publicadas na branch da PR #36 em cinco commits (`f75903e`, `b46ba35`, `d79360d`, `3287bb3`, `aeb7bc2`). A base continua sendo a `main` atual (`4df5a48`); nenhum merge foi feito. Build/testes locais e todos os checks da CI estão verdes, incluindo as integrações PostgreSQL dedicadas. + +**Baseline da execução:** backend: `npm test` passou (152 aprovados, 5 ignorados por dependência de ambiente externo). Frontend: o runner local usa Node `v26.7.0`, no qual `localStorage` não foi disponibilizado pelo ambiente de teste; 73 testes falharam antes/durante renderizações (incluindo chamadas a `window.localStorage.clear`) e vários expiraram. Tratar esse resultado como limitação de baseline até repetir com a versão/runtime exigida pela CI. `npm ci` foi executado em ambos os pacotes do worktree. + +**Decisão de autorização (Etapa 0/F6):** as rotas de escrita do backlog usam papéis globais `admin`/`po`; não foi encontrado middleware/relacionamento de membership usuário–projeto nos endpoints de backlog. A rota de histórico já exige sessão. Portanto, esta correção preservará o modelo global atual, validará `entidade_tipo` contra a enumeração do domínio e UUID/existência da entidade, e não inventará isolamento por projeto. Se a política de produto mudar, o isolamento deve ser implementado transversalmente aos endpoints de backlog, não apenas nesta rota. + +**Desenho antes das alterações maiores (F1/F2/F4):** manter validação de serviço para retorno rápido, mas tornar a validação transacional autoritativa. Depois do lock de hierarquia/entidade, reler o status atual e a configuração de justificativa pelo mesmo `PoolClient`; quando aplicável, rejeitar justificativa ausente/vazia e registrar a justificativa no evento de auditoria da mesma transação. Critérios e edições de épico/feature/PBI usarão a mesma função de política, para não haver contratos divergentes. Na UI, obter a configuração real para decidir quando pedir justificativa; a API permanece responsável por impor a regra. + +**Desenho antes do histórico (F3/F5/F7):** separar estados de sucesso-vazio e falha; paginação será aditiva e retrocompatível com clientes que esperam `items`, com `limit` máximo e cursor estável usando `(created_at, id)`. A UI fará carregamento incremental. A trilha existente (`dados_json`, `pbi_versao`) será apresentada com campos alterados/valores legíveis sem expor JSON bruto; preservar leitura de eventos legados e limitar a exibição a campos de backlog autorizados pelo contrato atual. + +**Verificação de autorização do F6:** backlog é global por papel hoje, enquanto isolamento explícito no contexto canônico se aplica à busca/RAG por projeto. O plano não aplicará membership somente ao audit endpoint. Erros de tipo de entidade inválido serão rejeitados em vez de responder lista vazia; entidade inexistente não retornará trilha. + +**Revisão interna após fatia F1/F2/F4:** política compartilhada agora consulta status do item e configuração usando o mesmo `PoolClient`, depois dos locks; a linha singleton da configuração recebe lock compartilhado para que alteração administrativa aguarde o fim da mutação. Eventos de critérios recebem justificativa. Um teste PostgreSQL foi incluído no arquivo já executado pela CI e cobre todas as entidades, os três tipos de mutação de critério e uma pré-leitura obsoleta; localmente esse teste permanece ignorado sem `ARCHIVE_TEST_DATABASE_URL`. Testes unitários de política e preservação da configuração passaram. + +**Decisão de paginação F5:** implementar cursor opaco base64url contendo timestamp ISO e UUID, ordenado por `(created_at DESC, id DESC)`, com limite padrão 25 e máximo 100. A resposta manterá `items` e adicionará `next_cursor`; clientes antigos que ignoram campos adicionais continuam compatíveis. O frontend carrega mais sob demanda. + +**Revisão de escopo F7 antes da implementação:** além de adicionar `anterior`/`novo` à auditoria das atualizações, a rota paginada de histórico de PBI vai associar cada evento ao snapshot de `pbi_versao` persistido na mesma transação (chaveando pela entidade, timestamp transacional e autor). A UI exibirá o número da versão e só os campos de backlog autorizados, mantendo o JSON completo fora da resposta visível. Isso evita uma API independente e faz a tela aproveitar o versionamento que a PR já grava. + +## Regras de execução + +- Fazer uma fatia por vez e revisar o diff antes de começar a seguinte. +- Não misturar refatorações ou mudanças de escopo com as correções QA. +- A política de justificativa deve ser aplicada no servidor; validações de interface são apenas orientação para o usuário. +- Auditoria e alteração de negócio devem confirmar ou falhar na mesma transação. +- Preservar compatibilidade da API e os comportamentos atuais para rascunhos, itens ativos, arquivados e usuários sem permissão. +- Não alterar autorização por suposição: primeiro confirmar o contrato atual de visibilidade entre usuários e projetos. +- Não fazer merge. Ao final, publicar/atualizar a branch da PR e pedir nova revisão somente depois dos gates definidos abaixo. + +## Achados e prioridade + +| ID | Prioridade | Achado | Estado | +|---|---|---|---| +| F1 | P1 | Critérios de item concluído podem ser alterados sem justificativa | Corrigido; cobertura unitária e de integração adicionada | +| F2 | P1 | A validação de status/configuração pode ficar obsoleta entre serviço e transação | Corrigido com validação sob lock na transação; teste PostgreSQL dedicado passou na CI | +| F3 | P2 | Falha ao buscar histórico é exibida como histórico vazio | Corrigido com estados de erro e retry; testes de componente verdes | +| F4 | P2 | Salvar configuração de qualidade pode descartar a opção de justificativa | Corrigido preservando a configuração existente; testes verdes | +| F5 | P2 | Histórico é retornado sem limite ou paginação | Corrigido com cursor e limite de 100; testes de contrato verdes | +| F6 | P2 condicional | Rota de histórico não valida acesso do usuário ao item/projeto | Decisão documentada: mantém política global atual e valida tipo/existência | +| F7 | P2 | Eventos/snapshots não são apresentados como comparação de alterações | Corrigido com diffs e snapshot PBI filtrado para campos permitidos; testes verdes | + +## Etapa 0 — Baseline, contrato e mapa de impacto + +**Trabalho** + +1. Registrar os comandos de teste disponíveis em `backend/package.json` e `frontend/package.json`. +2. Ler schemas, migrations, rotas, serviços, repositórios, testes existentes e OpenAPI referentes a auditoria, qualidade, critérios e backlog. +3. Confirmar quais operações de critérios são suportadas pela API e quais papéis podem realizá-las. +4. Confirmar se hoje há autorização por projeto ou se usuários autenticados compartilham visibilidade global do backlog. +5. Capturar baseline dos testes/builds relevantes antes das alterações. + +**Revisão interna da etapa** + +- Não presumir acesso baseado apenas em UUID; identificar middleware e política efetiva. +- Conferir se o baseline falha antes de alterar qualquer código e registrar falhas preexistentes separadamente. +- Não prosseguir com F6 até estabelecer a regra de autorização esperada. + +**Saída/gate** + +- Mapa de arquivos e contratos afetados, comandos de verificação e política de autorização documentados neste plano ou no PR. +- Nenhum arquivo de produção alterado nesta etapa. + +## Etapa 1 — F1: justificativa em alterações de critérios + +**Implementação prevista** + +1. Definir contrato único para enviar justificativa nas operações de criação, exclusão e reordenação de critérios quando a entidade estiver concluída e a configuração exigir justificativa. +2. Obter e validar status/configuração dentro da transação que altera o critério, sob o mecanismo de lock usado pelo backlog. +3. Gravar o evento de auditoria com a justificativa na mesma transação da alteração; rollback deve desfazer ambos. +4. Atualizar `CriteriaEditor` para pedir justificativa apenas quando aplicável, preservar o texto em erro e focar o campo inválido. +5. Evitar duplicar regras divergentes entre épico, feature e PBI; extrair helper somente se reduzir duplicação sem obscurecer a regra. + +**Testes** + +- Criar, excluir e reordenar critérios em épico, feature e PBI concluídos: sem justificativa rejeita; com justificativa atualiza e audita. +- Configuração desativada: operações continuam permitidas sem justificativa. +- Rascunho/ativo: comportamento existente é preservado. +- Item/projeto arquivado e usuário sem permissão continuam bloqueados. +- Falha ao gravar auditoria causa rollback da mutação. +- UI exibe validação acessível e não perde justificativa digitada após erro. + +**Revisão interna da etapa** + +- Procurar qualquer endpoint alternativo/compatível que altere critérios sem passar pela política. +- Verificar que ação de reordenar não deixa ordens duplicadas ou lacunas em sucesso/rollback. +- Inspecionar dados de auditoria: não armazenar conteúdo desnecessário além da justificativa e metadados já acordados. + +**Gate** + +- Testes novos de serviço/repositório/rota e UI passam; revisão do diff confirma cobertura das três operações e três entidades. + +## Etapa 2 — F2: validação atômica contra concorrência + +**Implementação prevista** + +1. Fazer com que a transação de update leia a versão atual do item sob lock e decida ali se a justificativa é obrigatória. +2. Fazer a configuração ser lida de maneira consistente com a política no ponto de gravação, evitando usar a resposta prévia do serviço como autoridade final. +3. Manter validações rápidas no serviço para boa mensagem ao usuário, mas considerar a validação transacional a fonte de verdade. +4. Aplicar o mesmo desenho a épico, feature e PBI, sem retirar regras adicionais existentes (campos obrigatórios, arquivamento, projeto ativo). + +**Testes** + +- Reproduzir disputa entre `concluir` e `atualizar`: se conclusão confirmar primeiro, update sem justificativa deve ser rejeitado. +- Testar o caso inverso: update precede conclusão e mantém resultado coerente. +- Cobrir as três entidades e provar que atualização e auditoria não ficam parcialmente aplicadas. + +**Revisão interna da etapa** + +- Examinar ordem de locks para prevenir deadlocks com arquivamento, conclusão e edição. +- Confirmar que leituras feitas dentro da transação usam a mesma conexão (`client`), não o pool global. +- Verificar rollback/release em todos os caminhos, inclusive erro de validação. + +**Gate** + +- Teste concorrente determinístico (barreiras/promises ou fixture apropriada, sem sleeps frágeis) reproduz a janela e passa após a correção. + +## Etapa 3 — F4: persistência ponta a ponta da configuração + +**Implementação prevista** + +1. Seguir o campo `exigir_justificativa_item_concluido` do banco à resposta da API, tipos/parse do frontend e payload de atualização. +2. Garantir que uma gravação parcial não apague a opção já persistida; decidir entre preservar campos omitidos ou exigir o objeto completo, conforme contrato existente. +3. Se a configuração possuir UI de edição, expor a opção com rótulo/descrição explícitos e estado carregando/erro/salvo. Se não houver UI prevista, garantir preservação no cliente que salva a configuração e documentar como a opção é administrada. +4. Fazer a UI de backlog seguir a configuração retornada pela API em vez de exigir justificativa incondicionalmente. + +**Testes** + +- GET → PUT sem alterar a opção preserva ligado e desligado. +- GET → alterar outros campos → salvar → GET mantém o estado da política. +- Política ligada/desligada afeta edição e critérios na UI e servidor. +- Campo ausente tem semântica explícita e compatível com dados já existentes. + +**Revisão interna da etapa** + +- Conferir defaults de migration, configuração inicial, dados existentes e fallback de versões antigas do cliente. +- Confirmar que validação não permite tipos inválidos ou estado ambíguo. + +**Gate** + +- Testes de repositório/controller/API e frontend demonstram preservação após salvar e recarregar. + +## Etapa 4 — F3: estados de carregamento do histórico + +**Implementação prevista** + +1. Modelar explicitamente estados `loading`, `error` e `success` (com lista vazia ou preenchida), sem inferir erro como lista vazia. +2. Exibir mensagem curta e acionável; incluir botão “Tentar novamente” com proteção para chamadas em andamento. +3. Preservar cancelamento com `AbortController` e impedir atualização de estado após desmontagem. +4. Manter estado vazio somente quando a API responder sucesso com zero registros. + +**Testes** + +- Resposta 200 vazia → estado vazio. +- Erro HTTP, falha de rede e exceção síncrona → estado de erro. +- Retry bem-sucedido → lista renderiza. +- Troca rápida de entidade/desmontagem → resposta cancelada não sobrescreve a tela atual. +- Acessibilidade: região de status/alerta e controle de retry navegáveis por teclado. + +**Revisão interna da etapa** + +- Confirmar que erros não apagam o último histórico válido sem motivo e que não se mostra informação antiga como se fosse atual. +- Verificar mensagens sem expor detalhes internos da API. + +**Gate** + +- Testes de componente para vazio, erro, retry e cancelamento passam. + +## Etapa 5 — F5: limite/paginação do endpoint de auditoria + +**Implementação prevista** + +1. Definir contrato retrocompatível de paginação (preferir cursor ou `limit`/`offset` limitado, com ordenação estável por `created_at` e `id`). +2. Validar e limitar valores no servidor; nunca interpolar valores não validados no SQL. +3. Atualizar cliente e componente para carregar páginas adicionais sem perder estados de erro/vazio. +4. Avaliar índice composto que suporte filtro por entidade e ordenação; adicionar migration somente se plano/consulta mostrar necessidade e respeitar padrão do repositório. + +**Testes** + +- Valores padrão, mínimo, máximo, inválidos e consulta sem resultados. +- Duas páginas sem repetição/perda quando há timestamps iguais. +- Ordem determinística e contagem/indicador de continuidade corretos. +- Confirmar plano de consulta/índice com volume representativo ou justificar índice existente. + +**Revisão interna da etapa** + +- Definir compatibilidade para consumidores existentes e payload de resposta. +- Evitar N+1 no nome do autor; preservar query única/eficiente. + +**Gate** + +- Contrato API e UI aprovados; testes cobrem volume e paginação determinística. + +## Etapa 6 — F6: autorização do histórico, condicionada ao contrato de produto + +**Decisão inicial obrigatória** + +- Se o produto possui visibilidade global para todos os usuários autenticados, registrar essa decisão e restringir/validar tipos de entidade, UUID e existência para evitar consultas ambíguas. +- Se há isolamento por projeto/papel, exigir autorização para ler o item antes de buscar eventos, seguindo o mesmo guard usado nos endpoints de leitura do backlog. + +**Testes quando isolamento for exigido** + +- Usuário permitido lê histórico. +- Usuário sem acesso recebe resposta sem dados (404/403 conforme padrão existente). +- IDs válidos de outro projeto não revelam autor, justificativa, existência ou volume de alterações. +- Tipos desconhecidos são rejeitados com erro de validação. + +**Revisão interna da etapa** + +- Comparar resposta com a política dos endpoints de detalhe, não inventar regra nova para auditoria. +- Garantir que mensagens e códigos não permitam enumeração de itens. + +**Gate** + +- Política documentada e cobertura de autorização alinhada aos endpoints existentes. + +## Etapa 7 — F7: utilidade do histórico e snapshots + +**Implementação prevista após confirmar escopo** + +1. Comparar o requisito S1-24 e a UI esperada: histórico deve ser apenas trilha de ator/data/ação/justificativa ou deve mostrar campos alterados e valores anterior/novo? +2. Verificar o formato já persistido em `dados_json` e `pbi_versao`; não migrar estrutura antes de confirmar consumidores/compatibilidade. +3. Se precisa reconstrução/diff, expor representação segura e tipada dos campos alterados e renderizar comparação na tela; não despejar JSON bruto. +4. Definir política de retenção/exclusão coerente com auditoria e LGPD antes de oferecer snapshots completos. + +**Testes** + +- Alteração de cada tipo de entidade mostra campos corretos e valores legíveis. +- Rascunhos sem justificativa são diferenciados de registros antigos sem dado. +- Snapshot/versionamento não vaza campos ou dados removidos que não deveriam ser exibidos. + +**Revisão interna da etapa** + +- Confirmar requisito com backlog/PR antes de ampliar escopo. +- Garantir que uma versão antiga do evento continue renderizável. + +**Gate** + +- Escopo de auditoria confirmado e formato versionado/documentado antes de alterar payload persistido. + +## Etapa 8 — Verificação integrada e entrega + +**Execução** + +1. Rodar testes focados de cada fatia. +2. Rodar suíte completa e typecheck do backend; suíte/build/testes relevantes do frontend. +3. Rodar verificações de integração com PostgreSQL disponíveis na CI, especialmente transações, constraints e migration. +4. Revisar diff completo contra `main`, executar `git diff --check`, conferir migration reversível/idempotente conforme padrão do projeto, e procurar rotas/UI alternativas não cobertas. +5. Confirmar CI verde na PR atualizada, sem conflitos com `main`. + +**Checklist final de revisão QA** + +- [x] Política de justificativa respeitada para updates e mudanças de critérios em épico, feature e PBI (unitários verdes; DB integration adicionada). +- [x] Configuração ligada/desligada permanece correta após salvar e recarregar (testes de merge/preservação). +- [x] Concorrência não permite bypass nem gravação parcial (teste PostgreSQL dedicado passou na CI). +- [x] Histórico distingue vazio de indisponível e suporta retry (testes frontend verdes). +- [x] Paginação tem limite máximo, ordenação estável e contrato compatível (backend/frontend verdes). +- [x] Autorização do histórico coincide com a política de projeto existente (decisão documentada e validação de tipo/existência). +- [x] Eventos e versões atendem ao escopo acordado e podem ser interpretados pela UI (snapshot filtrado, sem expor propriedades internas). +- [x] Builds, testes, typechecks, integração e CI passam (backend, frontend e todos os checks PostgreSQL passaram na CI). +- [x] Diff não contém alterações locais estranhas, segredos, artefatos ou whitespace acidental (revisão local; `git diff --check` sem erros). +- [x] Nenhum merge realizado; correções publicadas e nova revisão solicitada na PR. + +## Ordem e dependências + +`Etapa 0 → F1 → F2 → F4 → F3 → F5 → F6 (decisão) → F7 (escopo) → Etapa 8` + +F1 e F2 formam a integridade da regra e devem preceder ajustes visuais. F4 garante que o frontend e servidor usem a mesma política. F3 é independente e pode ser desenvolvida após a estabilização do contrato de erro. F5 depende da definição do payload do histórico. F6/F7 exigem decisão/contrato explícitos para não expandir escopo sem base. + +## Critério de conclusão + +O trabalho termina quando todos os achados confirmados estiverem corrigidos e testados; os riscos condicionais tiverem decisão registrada; a revisão do diff não apontar regressão; e a CI da PR estiver verde. Merge permanece fora do escopo. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 36ea696..cac5f9b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -886,13 +886,68 @@ paths: schema: $ref: '#/components/schemas/Error' + /api/v1/audit/{entidade_tipo}/{entidade_id}/history: + get: + summary: Consulta o histórico de auditoria paginado de uma entidade + operationId: getEntityAuditHistory + security: + - cookieAuth: [] + parameters: + - name: entidade_tipo + in: path + required: true + schema: + type: string + enum: [epico, feature, pbi] + - name: entidade_id + in: path + required: true + schema: + type: string + format: uuid + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + - name: cursor + in: query + description: Cursor opaco retornado pela página anterior + schema: + type: string + responses: + '200': + description: Eventos ordenados do mais recente para o mais antigo + content: + application/json: + schema: + type: object + required: [items, next_cursor] + properties: + items: + type: array + items: + $ref: '#/components/schemas/AuditHistoryItem' + next_cursor: + type: string + nullable: true + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Entidade não encontrada + /api/v1/criteria: post: summary: Registra um critério de aceitação polimórfico (PBI-01.2.1, PBI-01.2.2, PBI-01.2.3 / S1-10) operationId: createCriterion description: >- Épico e feature exigem texto livre; PBI exige nome, DADO, QUANDO e ENTÃO em conjunto. - Novo critério é sempre adicionado ao final da ordem existente na entidade. + Novo critério é sempre adicionado ao final da ordem existente na entidade. Para item concluído, + a política organizacional pode exigir justificativa, armazenada na auditoria da mutação. security: - cookieAuth: [] requestBody: @@ -955,6 +1010,7 @@ paths: delete: summary: Remove um critério de aceitação e reordena os demais da mesma entidade (PBI-01.2.1 / S1-10) operationId: deleteCriterion + description: A política organizacional pode exigir justificativa ao alterar critérios de item concluído. security: - cookieAuth: [] parameters: @@ -964,6 +1020,16 @@ paths: schema: type: string format: uuid + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + justificativa: + type: string + maxLength: 2000 responses: '200': description: Critério removido @@ -984,7 +1050,8 @@ paths: operationId: moveCriterion description: >- Operação idempotente: mover o primeiro item para cima (ou o último para baixo) não - retorna erro, apenas devolve a lista sem alteração. + retorna erro, apenas devolve a lista sem alteração. Para item concluído, a política + organizacional pode exigir justificativa para uma movimentação efetiva. security: - cookieAuth: [] parameters: @@ -1005,6 +1072,9 @@ paths: direction: type: string enum: [up, down] + justificativa: + type: string + maxLength: 2000 responses: '200': description: Lista ordenada da entidade após a movimentação @@ -1750,6 +1820,9 @@ components: type: string entao: type: string + justificativa: + type: string + maxLength: 2000 CriterionResponse: type: object @@ -1786,6 +1859,47 @@ components: type: string format: date-time + AuditHistoryItem: + type: object + required: [id, entidade_tipo, entidade_id, acao, dados_json, created_at] + properties: + id: + type: string + format: uuid + usuario_id: + type: string + format: uuid + nullable: true + usuario_nome: + type: string + nullable: true + entidade_tipo: + type: string + enum: [epico, feature, pbi] + entidade_id: + type: string + format: uuid + acao: + type: string + justificativa: + type: string + nullable: true + dados_json: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + pbi_versao: + type: integer + nullable: true + description: Versão persistida correspondente, quando o evento for uma atualização de PBI. + pbi_snapshot: + type: object + nullable: true + additionalProperties: true + description: Snapshot do PBI filtrado para campos permitidos pelo contrato. + ValidationResultResponse: type: object required: [aprovado] @@ -1901,12 +2015,15 @@ components: type: array maxItems: 100 items: { type: string, minLength: 1, maxLength: 80 } + exigir_justificativa_item_concluido: + type: boolean + description: Se alterações em itens concluídos exigem justificativa; omissão preserva a configuração atual. PbiQualityConfiguration: allOf: - $ref: '#/components/schemas/PbiQualityConfigurationInput' - type: object - required: [rule_version, updated_at, updated_by] + required: [rule_version, updated_at, updated_by, exigir_justificativa_item_concluido] properties: rule_version: { type: string, example: pbi-quality-v2 } updated_at: { type: string, format: date-time } diff --git a/frontend/src/api/api_backlog.ts b/frontend/src/api/api_backlog.ts index d1d9101..96d7678 100644 --- a/frontend/src/api/api_backlog.ts +++ b/frontend/src/api/api_backlog.ts @@ -20,6 +20,7 @@ export interface EpicInput { objetivo: string; escopo_macro: string; resultado_esperado: string; + justificativa?: string | null; } export interface Epic extends EpicInput { @@ -37,6 +38,7 @@ export interface FeatureInput { titulo: string; descricao: string; objetivo: string; + justificativa?: string | null; } export interface Feature extends FeatureInput { @@ -58,6 +60,8 @@ export interface PbiInput { historia_eu_quero: string; historia_para_que: string; requer_interface: boolean; + regras_observacoes?: string | null; + justificativa?: string | null; } export interface Pbi extends PbiInput { @@ -667,6 +671,7 @@ export interface PbiQualityConfigurationRecord { boolean >; vague_terms: string[]; + exigir_justificativa_item_concluido: boolean; updated_at?: string; } @@ -708,7 +713,8 @@ export async function getPbiQualityConfiguration( data.vague_terms.some( (term: unknown) => typeof term !== "string", - ) + ) || (data.exigir_justificativa_item_concluido !== undefined + && typeof data.exigir_justificativa_item_concluido !== "boolean") ) { throw new Error( "Resposta de configuração de qualidade inválida", @@ -724,6 +730,8 @@ export async function getPbiQualityConfiguration( ]), ) as Record, vague_terms: data.vague_terms, + exigir_justificativa_item_concluido: + data.exigir_justificativa_item_concluido ?? true, updated_at: asText(data.updated_at), }; } @@ -753,6 +761,7 @@ export interface TextCriterionInput { entidade_tipo: "epico" | "feature"; entidade_id: string; texto: string; + justificativa?: string; } export interface ScenarioCriterionInput { @@ -762,6 +771,7 @@ export interface ScenarioCriterionInput { dado: string; quando: string; entao: string; + justificativa?: string; } function parseCriterion( @@ -854,11 +864,15 @@ export async function createCriterion( export async function deleteCriterion( id: string, + justificativa?: string, ): Promise { await apiRequest( `/criteria/${encodeURIComponent(id)}`, { method: "DELETE", + ...(justificativa + ? { body: JSON.stringify({ justificativa }) } + : {}), }, ); } @@ -866,6 +880,7 @@ export async function deleteCriterion( export async function moveCriterion( id: string, direction: "up" | "down", + justificativa?: string, ): Promise { const response = await apiRequest( `/criteria/${encodeURIComponent( @@ -875,6 +890,7 @@ export async function moveCriterion( method: "PATCH", body: JSON.stringify({ direction, + ...(justificativa ? { justificativa } : {}), }), }, ); @@ -888,4 +904,53 @@ export async function moveCriterion( } return data.items.map(parseCriterion); -} \ No newline at end of file +} + +export interface AuditHistoryItem { + id: string; + usuario_id: string | null; + usuario_nome: string | null; + entidade_tipo: string; + entidade_id: string; + acao: string; + justificativa: string | null; + dados_json: Record; + created_at: string; + pbi_versao?: number | null; + pbi_snapshot?: Record | null; +} + +export interface AuditHistoryPage { + items: AuditHistoryItem[]; + next_cursor: string | null; +} + +export async function getItemHistory( + entidadeTipo: string, + entidadeId: string, + signal?: AbortSignal, + cursor?: string, + limit = 25, +): Promise { + const query = new URLSearchParams({ limit: String(limit) }); + if (cursor) query.set("cursor", cursor); + const response = await apiRequest( + `/audit/${encodeURIComponent(entidadeTipo)}/${encodeURIComponent(entidadeId)}/history?${query.toString()}`, + { signal }, + ); + const data = await response.json(); + if ( + !data + || typeof data !== "object" + || !Array.isArray(data.items) + || (data.next_cursor !== undefined + && data.next_cursor !== null + && typeof data.next_cursor !== "string") + ) { + throw new Error("Resposta do histórico de auditoria inválida"); + } + return { + items: data.items, + next_cursor: data.next_cursor ?? null, + }; +} diff --git a/frontend/src/backlog/Justificativa.test.tsx b/frontend/src/backlog/Justificativa.test.tsx new file mode 100644 index 0000000..7b400b9 --- /dev/null +++ b/frontend/src/backlog/Justificativa.test.tsx @@ -0,0 +1,244 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ItemHistoryView } from "../views/backlog/ItemHistoryView"; +import { EpicDetail } from "../views/backlog/EpicsView"; +import { FeatureDetail } from "../views/backlog/FeaturesView"; +import { CriteriaEditor } from "../views/backlog/CriteriaView"; + +const epicConcluido = { + id: "epic-c1", + projeto_id: "project-1", + titulo: "Épico Concluído", + descricao: "Descrição", + objetivo: "Objetivo", + escopo_macro: "Escopo", + resultado_esperado: "Resultado", + status: "concluido" as const, + prioridade: "Must" as const, + features_count: 0, + criterios_count: 1, + projeto_status: "ativo", + archived_at: null, +}; + +const featureConcluida = { + id: "feat-c1", + epico_id: "epic-c1", + projeto_id: "project-1", + epico_titulo: "Épico Concluído", + titulo: "Feature Concluída", + descricao: "Descrição", + objetivo: "Objetivo", + status: "concluido" as const, + prioridade: "Must" as const, + pbis_count: 0, + criterios_count: 1, + projeto_status: "ativo", + archived_at: null, +}; + +const reply = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn((url: string) => { + if (url.includes("/history")) { + return reply({ + items: [ + { + id: "hist-1", + usuario_id: "user-1", + usuario_nome: "Maria PO", + entidade_tipo: "epico", + entidade_id: "epic-c1", + acao: "ATUALIZAR_EPICO", + justificativa: "Ajuste na regra de negócio solicitado pelo cliente", + dados_json: { + alteracoes: { titulo: "Título atualizado" }, + anterior: { titulo: "Título anterior" }, + novo: { titulo: "Título atualizado" }, + }, + created_at: new Date().toISOString(), + }, + ], + }); + } + if (url.includes("/criteria")) return reply({ items: [] }); + if (url.includes("/quality")) return reply({ checks: {}, vague_terms: [], exigir_justificativa_item_concluido: true }); + if (url.includes("/epics/epic-c1")) return reply(epicConcluido); + if (url.includes("/features/feat-c1")) return reply(featureConcluida); + return reply({}); + })); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it("ItemHistoryView renderiza histórico com autor, data e justificativa", async () => { + render(); + expect(await screen.findByText("Maria PO")).toBeTruthy(); + expect(screen.getByText(/Ajuste na regra de negócio/i)).toBeTruthy(); + expect(screen.getByText(/Título anterior → Título atualizado/)).toBeTruthy(); +}); + +it("ItemHistoryView diferencia falha de histórico vazio e permite tentar novamente", async () => { + const request = vi.fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(reply({ items: [ + { + id: "hist-retry", + usuario_id: "user-1", + usuario_nome: "Maria PO", + entidade_tipo: "epico", + entidade_id: "epic-c1", + acao: "ATUALIZAR_EPICO", + justificativa: "Correção após retry", + dados_json: {}, + created_at: new Date().toISOString(), + }, + ] })); + vi.stubGlobal("fetch", request); + + render(); + expect(await screen.findByText(/Não foi possível carregar o histórico/)).toBeTruthy(); + expect(screen.queryByText(/Nenhum registro de alteração/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Tentar novamente" })); + expect(await screen.findByText("Maria PO")).toBeTruthy(); + expect(screen.queryByText(/Nenhum registro de alteração/)).toBeNull(); +}); + +it("ItemHistoryView carrega páginas seguintes sem duplicar eventos", async () => { + const first = { + id: "hist-page-1", usuario_id: null, usuario_nome: "Ana", entidade_tipo: "epico", + entidade_id: "epic-c1", acao: "ATUALIZAR_EPICO", justificativa: null, + dados_json: {}, created_at: new Date().toISOString(), + }; + const second = { ...first, id: "hist-page-2", usuario_nome: "Bruno" }; + const request = vi.fn((url: string) => url.includes("cursor=") + ? reply({ items: [first, second], next_cursor: null }) + : reply({ items: [first], next_cursor: "cursor-next" })); + vi.stubGlobal("fetch", request); + + render(); + expect(await screen.findByText("Ana")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Carregar mais registros" })); + expect(await screen.findByText("Bruno")).toBeTruthy(); + expect(screen.getAllByText("Ana")).toHaveLength(1); + expect(request).toHaveBeenCalledWith( + expect.stringContaining("cursor=cursor-next"), + expect.any(Object), + ); +}); + +it("EpicDetail exige justificativa ao alterar épico concluído", async () => { + render(); + + const editBtn = await screen.findByText("Editar"); + fireEvent.click(editBtn); + + expect(screen.getByLabelText(/Justificativa da alteração/i)).toBeTruthy(); + + const saveBtn = screen.getByText("Salvar alterações"); + fireEvent.click(saveBtn); + + expect(await screen.findByText("A justificativa é obrigatória ao alterar um item concluído.")).toBeTruthy(); +}); + +it("FeatureDetail exige justificativa ao alterar feature concluída", async () => { + render(); + + const editBtn = await screen.findByText("Editar"); + fireEvent.click(editBtn); + + expect(screen.getByLabelText(/Justificativa da alteração/i)).toBeTruthy(); + + const saveBtn = screen.getByText("Salvar alterações"); + fireEvent.click(saveBtn); + + expect(await screen.findByText("A justificativa é obrigatória ao alterar um item concluído.")).toBeTruthy(); +}); + +it("EpicDetail respeita política organizacional que desativa justificativa", async () => { + const request = vi.fn((url: string, init?: RequestInit) => { + if (url.includes("/quality")) { + return reply({ + rule_version: "pbi-quality-v4", + checks: { + titulo_infinitivo: true, + historia_completa: true, + cenario_estruturado: true, + termos_vagos: true, + prototipo_vinculado: true, + }, + vague_terms: [], + exigir_justificativa_item_concluido: false, + }); + } + if (url.includes("/epics/epic-c1") && init?.method === "PATCH") { + return reply({ ...epicConcluido, titulo: "Épico editado" }); + } + if (url.includes("/epics/epic-c1")) return reply(epicConcluido); + if (url.includes("/criteria")) return reply({ items: [] }); + if (url.includes("/history")) return reply({ items: [], next_cursor: null }); + return reply({}); + }); + vi.stubGlobal("fetch", request); + + render(); + fireEvent.click(await screen.findByText("Editar")); + await waitFor(() => expect(screen.queryByLabelText(/Justificativa da alteração/)).toBeNull()); + fireEvent.click(screen.getByText("Salvar alterações")); + await waitFor(() => expect(request).toHaveBeenCalledWith( + expect.stringContaining("/epics/epic-c1"), + expect.objectContaining({ method: "PATCH" }), + )); + const updateCall = request.mock.calls.find(([url, init]) => url.includes("/epics/epic-c1") && init?.method === "PATCH"); + expect(JSON.parse(String(updateCall?.[1]?.body))).not.toHaveProperty("justificativa"); +}); + +it("Critérios de item concluído exigem e enviam justificativa ao criar", async () => { + const request = vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + return reply({ + id: "criterion-1", + entidade_tipo: "epico", + entidade_id: "epic-c1", + texto: "Cenário aceito", + nome: null, + dado: null, + quando: null, + entao: null, + ordem: 1, + }, 201); + } + return reply({ items: [] }); + }); + vi.stubGlobal("fetch", request); + + render( + , + ); + fireEvent.click(await screen.findByRole("button", { name: "Novo critério" })); + fireEvent.change(screen.getByLabelText("Texto do critério"), { target: { value: "Novo critério" } }); + fireEvent.click(screen.getByRole("button", { name: "Adicionar" })); + expect(await screen.findByText(/A justificativa é obrigatória para alterar critérios/)).toBeTruthy(); + expect(request).toHaveBeenCalledTimes(1); + + fireEvent.change(screen.getByLabelText(/Justificativa para alterar critérios/), { target: { value: "Mudança aprovada pelo PO" } }); + fireEvent.click(screen.getByRole("button", { name: "Adicionar" })); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + expect(JSON.parse(String(request.mock.calls[1][1]?.body))).toMatchObject({ + justificativa: "Mudança aprovada pelo PO", + }); +}); diff --git a/frontend/src/models/auditChanges.test.ts b/frontend/src/models/auditChanges.test.ts new file mode 100644 index 0000000..fd3fde5 --- /dev/null +++ b/frontend/src/models/auditChanges.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { describeAuditChanges } from "./auditChanges"; + +describe("describeAuditChanges", () => { + it("apresenta valores anteriores e novos dos campos conhecidos", () => { + expect(describeAuditChanges({ + alteracoes: { titulo: "Título novo", objetivo: "Objetivo novo", justificativa: "não duplicar" }, + anterior: { titulo: "Título antigo", objetivo: null }, + novo: { titulo: "Título novo", objetivo: "Objetivo novo" }, + })).toEqual([ + { field: "titulo", label: "Título", before: "Título antigo", after: "Título novo" }, + { field: "objetivo", label: "Objetivo", before: "Não informado", after: "Objetivo novo" }, + ]); + }); + + it("mantém compatibilidade com eventos legados e não expõe propriedades desconhecidas", () => { + expect(describeAuditChanges({ + alteracoes: { titulo: "Título corrigido", token_interno: "segredo" }, + anterior: { titulo: "Título antigo" }, + novo: { titulo: "Título corrigido" }, + })).toEqual([ + { field: "titulo", label: "Título", before: "Título antigo", after: "Título corrigido" }, + ]); + }); + + it("apresenta snapshot de versão PBI somente pelos campos permitidos", () => { + expect(describeAuditChanges({ + pbi_snapshot: { titulo: "Versão salva", historia_eu_quero: "acompanhar versão", campo_interno: "não expor" }, + })).toEqual([ + { field: "titulo", label: "Título", after: "Versão salva" }, + { field: "historia_eu_quero", label: "EU QUERO", after: "acompanhar versão" }, + ]); + }); +}); diff --git a/frontend/src/models/auditChanges.ts b/frontend/src/models/auditChanges.ts new file mode 100644 index 0000000..0a86da2 --- /dev/null +++ b/frontend/src/models/auditChanges.ts @@ -0,0 +1,77 @@ +export interface AuditChangeDescription { + field: string; + label: string; + before?: string; + after?: string; +} + +const FIELD_LABELS: Record = { + titulo: "Título", + descricao: "Descrição", + objetivo: "Objetivo", + escopo_macro: "Escopo macro", + resultado_esperado: "Resultado esperado", + prioridade: "Prioridade", + historia_como_um: "COMO UM", + historia_eu_quero: "EU QUERO", + historia_para_que: "PARA QUE", + regras_observacoes: "Regras e observações", + tipo: "Tipo", + requer_interface: "Exige interface ou protótipo", + status: "Status", + status_anterior: "Status anterior", + status_novo: "Status novo", + nome: "Nome do cenário", + texto: "Texto do critério", + dado: "DADO", + quando: "QUANDO", + entao: "ENTÃO", + ordem: "Ordem", + ordem_anterior: "Ordem anterior", + ordem_novo: "Nova ordem", + direcao: "Direção", +}; + +function toRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function displayValue(value: unknown): string | null { + if (value === null) return "Não informado"; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return null; +} + +/** Converts known audit fields to readable labels; never renders arbitrary JSON. */ +export function describeAuditChanges( + data: Record | null | undefined, +): AuditChangeDescription[] { + const payload = toRecord(data); + if (!payload) return []; + + const changes = toRecord(payload.alteracoes) + ?? toRecord(payload.pbi_snapshot) + ?? payload; + const before = toRecord(payload.anterior) ?? {}; + const after = { ...changes, ...(toRecord(payload.novo) ?? {}) }; + const fields = Object.keys(changes).filter((field) => field !== "justificativa"); + + return fields.flatMap((field) => { + const label = FIELD_LABELS[field]; + if (!label) return []; + + const nextValue = displayValue(after[field]); + const previousValue = displayValue(before[field]); + if (nextValue === null && previousValue === null) return []; + + return [{ + field, + label, + ...(previousValue !== null ? { before: previousValue } : {}), + ...(nextValue !== null ? { after: nextValue } : {}), + }]; + }); +} diff --git a/frontend/src/viewmodels/usePbiQualityConfiguration.ts b/frontend/src/viewmodels/usePbiQualityConfiguration.ts new file mode 100644 index 0000000..b3897ca --- /dev/null +++ b/frontend/src/viewmodels/usePbiQualityConfiguration.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { + getPbiQualityConfiguration, + type PbiQualityConfigurationRecord, +} from "../api/api_backlog"; + +export type QualityConfigurationResult = + | { state: "loading" } + | { state: "error" } + | { state: "ready"; config: PbiQualityConfigurationRecord }; + +/** Loads the current organization policy; completed-item callers fail closed. */ +export function usePbiQualityConfiguration(enabled = true) { + const [result, setResult] = + useState({ state: "loading" }); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + if (!enabled) { + setResult({ state: "loading" }); + return; + } + + const controller = new AbortController(); + setResult({ state: "loading" }); + + getPbiQualityConfiguration(controller.signal) + .then((config) => { + if (!controller.signal.aborted) { + setResult({ state: "ready", config }); + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setResult({ state: "error" }); + } + }); + + return () => controller.abort(); + }, [attempt, enabled]); + + return { + result, + retry: () => setAttempt((value) => value + 1), + }; +} diff --git a/frontend/src/views/backlog/CriteriaView.tsx b/frontend/src/views/backlog/CriteriaView.tsx index a7d0505..18c31b8 100644 --- a/frontend/src/views/backlog/CriteriaView.tsx +++ b/frontend/src/views/backlog/CriteriaView.tsx @@ -29,12 +29,16 @@ export function CriteriaEditor({ entidadeId, canEdit, titulo, + itemConcluido = false, + justificativaObrigatoria = false, onCriteriaChange, }: { entidadeTipo: CriterionEntityType; entidadeId: string; canEdit: boolean; titulo: string; + itemConcluido?: boolean; + justificativaObrigatoria?: boolean; onCriteriaChange?: (items: Criterion[]) => void; }) { const [result, setResult] = useState({ @@ -48,6 +52,7 @@ export function CriteriaEditor({ useState(emptyCenario); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); + const [justificativa, setJustificativa] = useState(""); const [movendoId, setMovendoId] = useState(null); @@ -129,6 +134,12 @@ export function CriteriaEditor({ return; } + if (justificativaObrigatoria && !justificativa.trim()) { + setMessage("A justificativa é obrigatória para alterar critérios de um item concluído."); + document.getElementById("criteria-justification")?.focus(); + return; + } + setBusy(true); try { @@ -138,12 +149,18 @@ export function CriteriaEditor({ entidade_tipo: "pbi", entidade_id: entidadeId, ...cenarioForm, + ...(justificativaObrigatoria + ? { justificativa: justificativa.trim() } + : {}), } : { entidade_tipo: entidadeTipo as "epico" | "feature", entidade_id: entidadeId, ...textoForm, + ...(justificativaObrigatoria + ? { justificativa: justificativa.trim() } + : {}), }, ); @@ -167,6 +184,8 @@ export function CriteriaEditor({ setTextoForm(emptyTexto); setCenarioForm(emptyCenario); + setJustificativa(""); + setMessage(""); setFormOpen(false); } catch { setMessage( @@ -178,6 +197,12 @@ export function CriteriaEditor({ } async function remover(id: string) { + if (justificativaObrigatoria && !justificativa.trim()) { + setMessage("Informe uma justificativa antes de remover este critério."); + document.getElementById("criteria-justification")?.focus(); + return; + } + const confirmed = window.confirm( "Remover este critério? Os demais serão reordenados.", ); @@ -189,7 +214,10 @@ export function CriteriaEditor({ setBusy(true); try { - await deleteCriterion(id); + await deleteCriterion( + id, + justificativaObrigatoria ? justificativa.trim() : undefined, + ); versaoRef.current += 1; if (result.state === "ready") { @@ -206,6 +234,8 @@ export function CriteriaEditor({ } else { recarregar(); } + setJustificativa(""); + setMessage(""); } catch { setMessage( "Não foi possível remover o critério.", @@ -219,12 +249,19 @@ export function CriteriaEditor({ id: string, direction: "up" | "down", ) { + if (justificativaObrigatoria && !justificativa.trim()) { + setMessage("Informe uma justificativa antes de reordenar estes critérios."); + document.getElementById("criteria-justification")?.focus(); + return; + } + setMovendoId(id); try { const items = await moveCriterion( id, direction, + justificativaObrigatoria ? justificativa.trim() : undefined, ); versaoRef.current += 1; @@ -235,6 +272,8 @@ export function CriteriaEditor({ }); onCriteriaChange?.(items); + setJustificativa(""); + setMessage(""); } catch { setMessage( "Não foi possível reordenar os critérios.", @@ -274,6 +313,29 @@ export function CriteriaEditor({ )} + {canEdit && itemConcluido && justificativaObrigatoria && ( +
+ +