diff --git a/backend/src/index.ts b/backend/src/index.ts index 7a6e992..fbee0b4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3,6 +3,10 @@ import cors from "cors"; import { env } from "./config/env.js"; import { checkDatabaseConnection } from "./database/db.js"; import { projectsRouter } from "./modules/projects/projects.routes.js"; +import { epicsRouter } from "./modules/epics/epics.routes.js"; +import { featuresRouter } from "./modules/features/features.routes.js"; +import { pbisRouter } from "./modules/pbis/pbis.routes.js"; +import { criteriaRouter } from "./modules/criteria/criteria.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js"; import { errorHandler } from "./middleware/errorHandler.js"; import { requireAuth } from "./middleware/requireAuth.js"; @@ -34,6 +38,12 @@ app.get("/health", async (_req: Request, res: Response) => { app.use("/api/v1/projects", requireAuth, projectsRouter); app.use("/api/projects", requireAuth, projectsRouter); +// Hierarquia do backlog: épicos, features, PBIs e critérios de aceitação (S1-05/06/07/10) +app.use("/api/v1/epics", requireAuth, epicsRouter); +app.use("/api/v1/features", requireAuth, featuresRouter); +app.use("/api/v1/pbis", requireAuth, pbisRouter); +app.use("/api/v1/criteria", requireAuth, criteriaRouter); + // Root Information Endpoint app.get("/api/v1", (_req: Request, res: Response) => { res.json({ @@ -43,7 +53,10 @@ app.get("/api/v1", (_req: Request, res: Response) => { documentation: "/docs", modules: [ { name: "projects", status: "ready" }, - { name: "requirements", status: "in_development" }, + { name: "epics", status: "ready" }, + { name: "features", status: "ready" }, + { name: "pbis", status: "ready" }, + { name: "criteria", status: "ready" }, { name: "decisions", status: "in_development" }, { name: "ai-bridge", status: "ready" }, ], diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index af48a20..a127779 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { AppError } from "../modules/projects/projects.service.js"; +import { AppError } from "../shared/errors.js"; export function errorHandler( err: Error, diff --git a/backend/src/middleware/requireRole.ts b/backend/src/middleware/requireRole.ts index c1efe83..79e1c6e 100644 --- a/backend/src/middleware/requireRole.ts +++ b/backend/src/middleware/requireRole.ts @@ -8,7 +8,7 @@ export function requireRole(...roles: UserRole[]) { return; } if (!roles.includes(req.auth.role)) { - res.status(403).json({ error: "Seu perfil não permite alterar projetos.", code: "FORBIDDEN" }); + res.status(403).json({ error: "Seu perfil não permite realizar esta operação.", code: "FORBIDDEN" }); return; } next(); diff --git a/backend/src/modules/criteria/criteria.controller.ts b/backend/src/modules/criteria/criteria.controller.ts new file mode 100644 index 0000000..5cb4ba4 --- /dev/null +++ b/backend/src/modules/criteria/criteria.controller.ts @@ -0,0 +1,43 @@ +import { Request, Response, NextFunction } from "express"; +import { criteriaService, CriteriaService } from "./criteria.service.js"; + +function getParamId(param: string | string[] | undefined): string { + if (Array.isArray(param)) return param[0] ?? ""; + return param ?? ""; +} + +export class CriteriaController { + constructor(private readonly service: CriteriaService = criteriaService) {} + + create = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const usuarioId = req.auth?.id ?? null; + const result = await this.service.create(req.body, usuarioId); + res.status(201).json(result); + } catch (error) { + next(error); + } + }; + + list = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const result = await this.service.list(req.query); + res.status(200).json({ items: result }); + } catch (error) { + next(error); + } + }; + + delete = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = getParamId(req.params.id); + const usuarioId = req.auth?.id ?? null; + const result = await this.service.delete(id, usuarioId); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; +} + +export const criteriaController = new CriteriaController(); diff --git a/backend/src/modules/criteria/criteria.repository.ts b/backend/src/modules/criteria/criteria.repository.ts new file mode 100644 index 0000000..41351be --- /dev/null +++ b/backend/src/modules/criteria/criteria.repository.ts @@ -0,0 +1,141 @@ +import { Pool, PoolClient } from "pg"; +import { pool } from "../../database/db.js"; +import { CreateCriterionDTO, Criterion, CriterionEntityType } from "./criteria.types.js"; +import { auditService } from "../audit/audit.service.js"; + +const ENTITY_TABLE: Record = { + epico: "epico", + feature: "feature", + pbi: "pbi", +}; + +export class CriteriaRepository { + private pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async entityExists(tipo: CriterionEntityType, id: string): Promise { + const table = ENTITY_TABLE[tipo]; + const result = await this.pool.query(`SELECT 1 FROM ${table} WHERE id = $1`, [id]); + return (result.rowCount ?? 0) > 0; + } + + async findById(id: string): Promise { + const result = await this.pool.query(`SELECT * FROM criterio_aceitacao WHERE id = $1`, [id]); + return result.rows[0] ?? null; + } + + async listByEntity(tipo: CriterionEntityType, entidadeId: string): Promise { + const result = await this.pool.query( + `SELECT * FROM criterio_aceitacao WHERE entidade_tipo = $1 AND entidade_id = $2 ORDER BY ordem ASC`, + [tipo, entidadeId], + ); + return result.rows; + } + + async countByEntity(tipo: CriterionEntityType, entidadeId: string): Promise { + const result = await this.pool.query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM criterio_aceitacao WHERE entidade_tipo = $1 AND entidade_id = $2`, + [tipo, entidadeId], + ); + return result.rows[0]?.total ?? 0; + } + + async create(dto: CreateCriterionDTO, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + 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`, + [dto.entidade_tipo, dto.entidade_id], + ); + const ordem = ordemResult.rows[0]?.proxima_ordem ?? 1; + + const isScenario = dto.entidade_tipo === "pbi"; + const insertQuery = ` + INSERT INTO criterio_aceitacao (entidade_tipo, entidade_id, texto, nome, dado, quando, entao, ordem) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `; + const values = [ + dto.entidade_tipo, + dto.entidade_id, + isScenario ? null : dto.texto, + isScenario ? dto.nome : null, + isScenario ? dto.dado : null, + isScenario ? dto.quando : null, + isScenario ? dto.entao : null, + ordem, + ]; + + const result = await client.query(insertQuery, values); + const created = result.rows[0]; + + await auditService.record( + { + usuario_id: usuarioId ?? null, + entidade_tipo: dto.entidade_tipo, + entidade_id: dto.entidade_id, + acao: "ADICIONAR_CRITERIO", + dados_json: { criterio_id: created.id, ordem: created.ordem, nome: created.nome, texto: created.texto }, + }, + client, + ); + + await client.query("COMMIT"); + return created; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async delete(id: string, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const existing = await client.query(`SELECT * FROM criterio_aceitacao WHERE id = $1`, [id]); + const removed = existing.rows[0]; + if (!removed) { + await client.query("ROLLBACK"); + return null; + } + + await client.query(`DELETE FROM criterio_aceitacao WHERE id = $1`, [id]); + + await client.query( + `UPDATE criterio_aceitacao SET ordem = ordem - 1 WHERE entidade_tipo = $1 AND entidade_id = $2 AND ordem > $3`, + [removed.entidade_tipo, removed.entidade_id, removed.ordem], + ); + + await auditService.record( + { + usuario_id: usuarioId ?? null, + entidade_tipo: removed.entidade_tipo, + entidade_id: removed.entidade_id, + acao: "REMOVER_CRITERIO", + dados_json: { criterio_id: removed.id, ordem: removed.ordem, nome: removed.nome, texto: removed.texto }, + }, + client, + ); + + await client.query("COMMIT"); + return removed; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} + +export const criteriaRepository = new CriteriaRepository(); diff --git a/backend/src/modules/criteria/criteria.routes.test.ts b/backend/src/modules/criteria/criteria.routes.test.ts new file mode 100644 index 0000000..69132cf --- /dev/null +++ b/backend/src/modules/criteria/criteria.routes.test.ts @@ -0,0 +1,154 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; +import { CriteriaController } from "./criteria.controller.js"; +import { CriteriaService } from "./criteria.service.js"; +import { CriteriaRepository } from "./criteria.repository.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { CreateCriterionDTO, Criterion, CriterionEntityType } from "./criteria.types.js"; + +const PBI_ID = "d0000000-0000-4000-8000-000000000001"; + +class MockCriteriaRepo extends CriteriaRepository { + public criteria: Criterion[] = []; + private seq = 0; + + constructor() { super(); } + + async entityExists(_tipo: CriterionEntityType, id: string): Promise { + return id === PBI_ID; + } + + async findById(id: string): Promise { + return this.criteria.find((c) => c.id === id) ?? null; + } + + async listByEntity(tipo: CriterionEntityType, entidadeId: string): Promise { + return this.criteria.filter((c) => c.entidade_tipo === tipo && c.entidade_id === entidadeId).sort((a, b) => a.ordem - b.ordem); + } + + async create(dto: CreateCriterionDTO): Promise { + this.seq += 1; + const existentes = await this.listByEntity(dto.entidade_tipo, dto.entidade_id); + const isScenario = dto.entidade_tipo === "pbi"; + const created: Criterion = { + id: `e0000000-0000-4000-8000-00000000000${this.seq}`, + entidade_tipo: dto.entidade_tipo, + entidade_id: dto.entidade_id, + texto: isScenario ? null : dto.texto, + nome: isScenario ? dto.nome : null, + dado: isScenario ? dto.dado : null, + quando: isScenario ? dto.quando : null, + entao: isScenario ? dto.entao : null, + ordem: existentes.length + 1, + created_at: new Date().toISOString(), + }; + this.criteria.push(created); + return created; + } + + async delete(id: string): Promise { + const index = this.criteria.findIndex((c) => c.id === id); + if (index === -1) return null; + const [removed] = this.criteria.splice(index, 1); + return removed; + } +} + +test("Testes de integração HTTP - Rotas de Critérios de Aceitação", async (t) => { + const repository = new MockCriteriaRepo(); + const service = new CriteriaService(repository); + const controller = new CriteriaController(service); + + const testApp = express(); + testApp.use(express.json()); + + const router = express.Router(); + router.post("/", controller.create); + router.get("/", controller.list); + router.delete("/:id", controller.delete); + + testApp.use("/api/v1/criteria", router); + testApp.use(errorHandler); + + let server: Server; + let baseUrl: string; + + before(async () => { + await new Promise((resolve) => { + server = testApp.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}/api/v1/criteria`; + resolve(); + }); + }); + }); + + after(async () => { + await new Promise((resolve) => { server.close(() => resolve()); }); + }); + + await t.test("POST /api/v1/criteria - adiciona cenário nomeado de PBI com status 201", async () => { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + entidade_tipo: "pbi", + entidade_id: PBI_ID, + nome: "Cenário via HTTP", + dado: "que eu esteja autenticado", + quando: "eu confirmar", + entao: "o sistema deve responder", + }), + }); + + assert.equal(res.status, 201); + const body = (await res.json()) as Criterion; + assert.equal(body.nome, "Cenário via HTTP"); + }); + + await t.test("POST /api/v1/criteria - retorna 400 quando o cenário está incompleto", async () => { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entidade_tipo: "pbi", entidade_id: PBI_ID, nome: "Incompleto", dado: "algo" }), + }); + + assert.equal(res.status, 400); + }); + + await t.test("POST /api/v1/criteria - retorna 404 para entidade inexistente", async () => { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entidade_tipo: "epico", entidade_id: "ffffffff-ffff-4fff-8fff-ffffffffffff", texto: "Critério órfão" }), + }); + + assert.equal(res.status, 404); + }); + + await t.test("GET /api/v1/criteria - lista critérios ordenados de uma entidade", async () => { + const res = await fetch(`${baseUrl}?entidade_tipo=pbi&entidade_id=${PBI_ID}`); + assert.equal(res.status, 200); + const body = (await res.json()) as { items: Criterion[] }; + assert.equal(body.items.length, 1); + }); + + await t.test("DELETE /api/v1/criteria/:id - remove critério existente", async () => { + const created = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entidade_tipo: "pbi", entidade_id: PBI_ID, nome: "A remover", dado: "d", quando: "q", entao: "e" }), + }).then((r) => r.json()) as Criterion; + + const res = await fetch(`${baseUrl}/${created.id}`, { method: "DELETE" }); + assert.equal(res.status, 200); + }); + + await t.test("DELETE /api/v1/criteria/:id - retorna 404 para critério inexistente", async () => { + const res = await fetch(`${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff`, { method: "DELETE" }); + assert.equal(res.status, 404); + }); +}); diff --git a/backend/src/modules/criteria/criteria.routes.ts b/backend/src/modules/criteria/criteria.routes.ts new file mode 100644 index 0000000..75a012d --- /dev/null +++ b/backend/src/modules/criteria/criteria.routes.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { criteriaController } from "./criteria.controller.js"; +import { requireRole } from "../../middleware/requireRole.js"; + +export const criteriaRouter = Router(); + +const canWrite = requireRole("admin", "po"); +criteriaRouter.post("/", canWrite, criteriaController.create); +criteriaRouter.get("/", criteriaController.list); +criteriaRouter.delete("/:id", canWrite, criteriaController.delete); diff --git a/backend/src/modules/criteria/criteria.service.test.ts b/backend/src/modules/criteria/criteria.service.test.ts new file mode 100644 index 0000000..ed031d1 --- /dev/null +++ b/backend/src/modules/criteria/criteria.service.test.ts @@ -0,0 +1,172 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { CriteriaService } from "./criteria.service.js"; +import { CriteriaRepository } from "./criteria.repository.js"; +import { ValidationError, NotFoundError } from "../../shared/errors.js"; +import { CreateCriterionDTO, Criterion, CriterionEntityType } from "./criteria.types.js"; + +const EPICO_ID = "11111111-1111-4111-8111-111111111111"; +const FEATURE_ID = "22222222-2222-4222-8222-222222222222"; +const PBI_ID = "33333333-3333-4333-8333-333333333333"; +const UNKNOWN_ID = "99999999-9999-4999-8999-999999999999"; + +class InMemoryCriteriaRepository extends CriteriaRepository { + private criteria: Criterion[] = []; + private seq = 0; + public knownEntities = new Set([EPICO_ID, FEATURE_ID, PBI_ID]); + + constructor() { super(); } + + async entityExists(_tipo: CriterionEntityType, id: string): Promise { + return this.knownEntities.has(id); + } + + async findById(id: string): Promise { + return this.criteria.find((c) => c.id === id) ?? null; + } + + async listByEntity(tipo: CriterionEntityType, entidadeId: string): Promise { + return this.criteria + .filter((c) => c.entidade_tipo === tipo && c.entidade_id === entidadeId) + .sort((a, b) => a.ordem - b.ordem); + } + + async countByEntity(tipo: CriterionEntityType, entidadeId: string): Promise { + return this.criteria.filter((c) => c.entidade_tipo === tipo && c.entidade_id === entidadeId).length; + } + + async create(dto: CreateCriterionDTO): Promise { + this.seq += 1; + const existentes = await this.listByEntity(dto.entidade_tipo, dto.entidade_id); + const ordem = existentes.length > 0 ? existentes[existentes.length - 1].ordem + 1 : 1; + const isScenario = dto.entidade_tipo === "pbi"; + const created: Criterion = { + id: `d9000000-0000-4000-8000-00000000000${this.seq}`, + entidade_tipo: dto.entidade_tipo, + entidade_id: dto.entidade_id, + texto: isScenario ? null : dto.texto, + nome: isScenario ? dto.nome : null, + dado: isScenario ? dto.dado : null, + quando: isScenario ? dto.quando : null, + entao: isScenario ? dto.entao : null, + ordem, + created_at: new Date().toISOString(), + }; + this.criteria.push(created); + return created; + } + + async delete(id: string): Promise { + const index = this.criteria.findIndex((c) => c.id === id); + if (index === -1) return null; + const [removed] = this.criteria.splice(index, 1); + for (const criterion of this.criteria) { + if (criterion.entidade_tipo === removed.entidade_tipo && criterion.entidade_id === removed.entidade_id && criterion.ordem > removed.ordem) { + criterion.ordem -= 1; + } + } + return removed; + } +} + +function setup() { + const repository = new InMemoryCriteriaRepository(); + const service = new CriteriaService(repository); + return { service, repository }; +} + +test("PBI-01.2.1 Cenário 1: adiciona critério de texto à lista do épico", async () => { + const { service } = setup(); + + const result = await service.create({ entidade_tipo: "epico", entidade_id: EPICO_ID, texto: "Critério amplo do épico" }); + + assert.equal(result.texto, "Critério amplo do épico"); + assert.equal(result.ordem, 1); +}); + +test("PBI-01.2.1 Cenário 2: mantém múltiplos critérios ordenados ao final da lista", async () => { + const { service } = setup(); + + await service.create({ entidade_tipo: "epico", entidade_id: EPICO_ID, texto: "Primeiro critério" }); + const second = await service.create({ entidade_tipo: "epico", entidade_id: EPICO_ID, texto: "Segundo critério" }); + + assert.equal(second.ordem, 2); + const list = await service.list({ entidade_tipo: "epico", entidade_id: EPICO_ID }); + assert.deepEqual(list.map((c) => c.texto), ["Primeiro critério", "Segundo critério"]); +}); + +test("PBI-01.2.1 Cenário 3: remove critério e reordena os demais", async () => { + const { service } = setup(); + + const first = await service.create({ entidade_tipo: "epico", entidade_id: EPICO_ID, texto: "Primeiro" }); + await service.create({ entidade_tipo: "epico", entidade_id: EPICO_ID, texto: "Segundo" }); + const third = await service.create({ entidade_tipo: "epico", entidade_id: EPICO_ID, texto: "Terceiro" }); + + await service.delete(first.id); + + const list = await service.list({ entidade_tipo: "epico", entidade_id: EPICO_ID }); + assert.deepEqual(list.map((c) => c.texto), ["Segundo", "Terceiro"]); + assert.equal(list.find((c) => c.id === third.id)?.ordem, 2); +}); + +test("PBI-01.2.2: registra regra geral de feature como critério de texto", async () => { + const { service } = setup(); + + const result = await service.create({ entidade_tipo: "feature", entidade_id: FEATURE_ID, texto: "Regra geral da feature" }); + + assert.equal(result.entidade_tipo, "feature"); + assert.equal(result.texto, "Regra geral da feature"); +}); + +test("PBI-01.2.3 Cenário 1: adiciona cenário completo nomeado ao PBI", async () => { + const { service } = setup(); + + const result = await service.create({ + entidade_tipo: "pbi", + entidade_id: PBI_ID, + nome: "Criar item com sucesso", + dado: "que eu esteja autenticado", + quando: "eu confirmar a criação", + entao: "o item deve ser criado", + }); + + assert.equal(result.nome, "Criar item com sucesso"); + assert.equal(result.dado, "que eu esteja autenticado"); + assert.equal(result.texto, null); +}); + +test("PBI-01.2.3 Cenário 2: impede cenário incompleto sem os blocos DADO/QUANDO/ENTÃO", async () => { + const { service } = setup(); + + await assert.rejects( + async () => await service.create({ entidade_tipo: "pbi", entidade_id: PBI_ID, nome: "Cenário incompleto", dado: "algo" }), + (err: Error) => { + assert.ok(err instanceof ValidationError); + return true; + }, + ); +}); + +test("PBI-01.2.3 Cenário 3: exige nome do cenário no PBI", async () => { + const { service } = setup(); + + await assert.rejects( + async () => await service.create({ entidade_tipo: "pbi", entidade_id: PBI_ID, dado: "d", quando: "q", entao: "e" }), + (err: Error) => { + assert.ok(err instanceof ValidationError); + return true; + }, + ); +}); + +test("impede registrar critério para entidade inexistente", async () => { + const { service } = setup(); + + await assert.rejects( + async () => await service.create({ entidade_tipo: "epico", entidade_id: UNKNOWN_ID, texto: "Critério órfão" }), + (err: Error) => { + assert.ok(err instanceof NotFoundError); + return true; + }, + ); +}); diff --git a/backend/src/modules/criteria/criteria.service.ts b/backend/src/modules/criteria/criteria.service.ts new file mode 100644 index 0000000..35fb11b --- /dev/null +++ b/backend/src/modules/criteria/criteria.service.ts @@ -0,0 +1,54 @@ +import { createCriterionSchema, criterionQuerySchema, Criterion } from "./criteria.types.js"; +import { CriteriaRepository, criteriaRepository } from "./criteria.repository.js"; +import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; + +export class CriteriaService { + constructor(private readonly repository: CriteriaRepository = criteriaRepository) {} + + async create(input: unknown, usuarioId?: string | null): Promise { + const parseResult = createCriterionSchema.safeParse(input); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + const dto = parseResult.data; + + const entityExists = await this.repository.entityExists(dto.entidade_tipo, dto.entidade_id); + if (!entityExists) { + throw new NotFoundError(`${this.entityLabel(dto.entidade_tipo)} não encontrado.`); + } + + return await this.repository.create(dto, usuarioId); + } + + async list(queryInput: unknown): Promise { + const parseResult = criterionQuerySchema.safeParse(queryInput); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + const { entidade_tipo, entidade_id } = parseResult.data; + return await this.repository.listByEntity(entidade_tipo, entidade_id); + } + + async delete(id: string, usuarioId?: string | null): Promise { + validateUuid(id, "ID do critério"); + + const removed = await this.repository.delete(id, usuarioId); + if (!removed) { + throw new NotFoundError("Critério não encontrado."); + } + + return removed; + } + + private entityLabel(tipo: "epico" | "feature" | "pbi"): string { + if (tipo === "epico") return "Épico"; + if (tipo === "feature") return "Feature"; + return "PBI"; + } +} + +export const criteriaService = new CriteriaService(); diff --git a/backend/src/modules/criteria/criteria.types.ts b/backend/src/modules/criteria/criteria.types.ts new file mode 100644 index 0000000..056d50b --- /dev/null +++ b/backend/src/modules/criteria/criteria.types.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +export const CRITERION_ENTITY_TYPES = ["epico", "feature", "pbi"] as const; +export type CriterionEntityType = (typeof CRITERION_ENTITY_TYPES)[number]; + +const textCriterionShape = { + texto: z + .string({ required_error: "O texto do critério é obrigatório." }) + .trim() + .min(1, "O texto do critério é obrigatório.") + .max(1000, "O texto não pode exceder 1000 caracteres."), +}; + +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, +}); + +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, +}); + +const pbiCriterionSchema = z.object({ + entidade_tipo: z.literal("pbi"), + entidade_id: z.string({ required_error: "A entidade é obrigatória." }).uuid("A entidade deve ser um UUID válido."), + nome: z + .string({ required_error: "O nome do cenário é obrigatório." }) + .trim() + .min(1, "O nome do cenário é obrigatório.") + .max(255, "O nome não pode exceder 255 caracteres."), + 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."), +}); + +export const createCriterionSchema = z.discriminatedUnion("entidade_tipo", [ + epicoCriterionSchema, + featureCriterionSchema, + pbiCriterionSchema, +]); + +export type CreateCriterionDTO = z.infer; + +export const criterionQuerySchema = z.object({ + entidade_tipo: z.enum(CRITERION_ENTITY_TYPES, { required_error: "O tipo de entidade é obrigatório." }), + entidade_id: z.string({ required_error: "A entidade é obrigatória." }).uuid("A entidade deve ser um UUID válido."), +}); + +export type CriterionQueryDTO = z.infer; + +export interface Criterion { + id: string; + entidade_tipo: CriterionEntityType; + entidade_id: string; + texto: string | null; + nome: string | null; + dado: string | null; + quando: string | null; + entao: string | null; + ordem: number; + created_at: Date | string; +} diff --git a/backend/src/modules/epics/epics.controller.ts b/backend/src/modules/epics/epics.controller.ts new file mode 100644 index 0000000..375b4dd --- /dev/null +++ b/backend/src/modules/epics/epics.controller.ts @@ -0,0 +1,64 @@ +import { Request, Response, NextFunction } from "express"; +import { epicsService, EpicsService } from "./epics.service.js"; + +function getParamId(param: string | string[] | undefined): string { + if (Array.isArray(param)) return param[0] ?? ""; + return param ?? ""; +} + +export class EpicsController { + constructor(private readonly service: EpicsService = epicsService) {} + + create = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const usuarioId = req.auth?.id ?? null; + const result = await this.service.create(req.body, usuarioId); + res.status(201).json(result); + } catch (error) { + next(error); + } + }; + + list = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const result = await this.service.list(req.query); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; + + getById = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = getParamId(req.params.id); + const result = await this.service.getById(id); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; + + update = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = getParamId(req.params.id); + const usuarioId = req.auth?.id ?? null; + const result = await this.service.update(id, req.body, usuarioId); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; + + complete = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = getParamId(req.params.id); + const usuarioId = req.auth?.id ?? null; + const result = await this.service.complete(id, usuarioId); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; +} + +export const epicsController = new EpicsController(); diff --git a/backend/src/modules/epics/epics.repository.ts b/backend/src/modules/epics/epics.repository.ts new file mode 100644 index 0000000..333ce65 --- /dev/null +++ b/backend/src/modules/epics/epics.repository.ts @@ -0,0 +1,199 @@ +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"; + +export class EpicsRepository { + private pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async findById(id: string): Promise { + const query = ` + SELECT + e.*, + COALESCE((SELECT COUNT(*)::int FROM feature f WHERE f.epico_id = e.id), 0) AS features_count, + COALESCE((SELECT COUNT(*)::int FROM criterio_aceitacao c WHERE c.entidade_tipo = 'epico' AND c.entidade_id = e.id), 0) AS criterios_count + FROM epico e + WHERE e.id = $1 + `; + const result = await this.pool.query(query, [id]); + return result.rows[0] ?? null; + } + + async create(data: CreateEpicDTO, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const insertQuery = ` + INSERT INTO epico (projeto_id, titulo, descricao, objetivo, escopo_macro, resultado_esperado, prioridade) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + const values = [ + data.projeto_id, + data.titulo.trim(), + data.descricao?.trim() ?? null, + data.objetivo?.trim() ?? null, + data.escopo_macro?.trim() ?? null, + data.resultado_esperado?.trim() ?? null, + data.prioridade, + ]; + + const result = await client.query(insertQuery, values); + const created = result.rows[0]; + + await auditService.record( + { + usuario_id: usuarioId ?? null, + entidade_tipo: "epico", + entidade_id: created.id, + acao: "CRIAR_EPICO", + dados_json: { titulo: created.titulo, projeto_id: created.projeto_id, status: created.status }, + }, + client, + ); + + await client.query("COMMIT"); + return created; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async findAll(query: EpicQueryDTO): Promise { + const whereConditions: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (query.projeto_id) { + whereConditions.push(`e.projeto_id = $${paramIndex}`); + params.push(query.projeto_id); + paramIndex++; + } + if (query.status) { + whereConditions.push(`e.status = $${paramIndex}`); + params.push(query.status); + paramIndex++; + } + + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; + + const countQuery = `SELECT COUNT(*)::int AS total FROM epico e ${whereClause}`; + const countResult = await this.pool.query<{ total: number }>(countQuery, params); + const total = countResult.rows[0]?.total ?? 0; + + const dataParams = [...params, query.limit, query.offset]; + const dataQuery = ` + SELECT + e.*, + COALESCE((SELECT COUNT(*)::int FROM feature f WHERE f.epico_id = e.id), 0) AS features_count, + COALESCE((SELECT COUNT(*)::int FROM criterio_aceitacao c WHERE c.entidade_tipo = 'epico' AND c.entidade_id = e.id), 0) AS criterios_count + FROM epico e + ${whereClause} + ORDER BY e.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + const dataResult = await this.pool.query(dataQuery, dataParams); + + return { items: dataResult.rows, total, limit: query.limit, offset: query.offset }; + } + + async update(id: string, data: UpdateEpicDTO, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const existing = await this.findById(id); + if (!existing) { + await client.query("ROLLBACK"); + return null; + } + + const updates: string[] = []; + const values: unknown[] = []; + let valIndex = 1; + + if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; } + if (data.descricao !== undefined) { updates.push(`descricao = $${valIndex}`); values.push(data.descricao?.trim() ?? null); valIndex++; } + if (data.objetivo !== undefined) { updates.push(`objetivo = $${valIndex}`); values.push(data.objetivo?.trim() ?? null); valIndex++; } + if (data.escopo_macro !== undefined) { updates.push(`escopo_macro = $${valIndex}`); values.push(data.escopo_macro?.trim() ?? null); valIndex++; } + if (data.resultado_esperado !== undefined) { updates.push(`resultado_esperado = $${valIndex}`); values.push(data.resultado_esperado?.trim() ?? null); valIndex++; } + if (data.prioridade !== undefined) { updates.push(`prioridade = $${valIndex}`); values.push(data.prioridade); valIndex++; } + + updates.push(`updated_at = CURRENT_TIMESTAMP`); + values.push(id); + + const result = await client.query( + `UPDATE epico SET ${updates.join(", ")} WHERE id = $${valIndex} RETURNING *`, + values, + ); + const updated = result.rows[0]; + + await auditService.record( + { + usuario_id: usuarioId ?? null, + entidade_tipo: "epico", + entidade_id: id, + acao: "ATUALIZAR_EPICO", + justificativa: data.justificativa ?? null, + dados_json: { alteracoes: data, anterior: { titulo: existing.titulo }, novo: { titulo: updated.titulo } }, + }, + client, + ); + + await client.query("COMMIT"); + return await this.findById(id); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async markConcluded(id: string, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const existing = await this.findById(id); + if (!existing) { + await client.query("ROLLBACK"); + return null; + } + + await client.query(`UPDATE epico SET status = 'concluido', updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id]); + + await auditService.record( + { + usuario_id: usuarioId ?? null, + entidade_tipo: "epico", + entidade_id: id, + acao: "CONCLUIR_EPICO", + dados_json: { status_anterior: existing.status, status_novo: "concluido" }, + }, + client, + ); + + await client.query("COMMIT"); + return await this.findById(id); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} + +export const epicsRepository = new EpicsRepository(); diff --git a/backend/src/modules/epics/epics.routes.test.ts b/backend/src/modules/epics/epics.routes.test.ts new file mode 100644 index 0000000..1539375 --- /dev/null +++ b/backend/src/modules/epics/epics.routes.test.ts @@ -0,0 +1,142 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; +import { EpicsController } from "./epics.controller.js"; +import { EpicsService } from "./epics.service.js"; +import { EpicsRepository } from "./epics.repository.js"; +import { ProjectsRepository } from "../projects/projects.repository.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { CreateEpicDTO, UpdateEpicDTO, Epic, EpicWithStats, PaginatedEpics, EpicQueryDTO } from "./epics.types.js"; +import { ProjectWithStats } from "../projects/projects.types.js"; + +class MockEpicsRepo extends EpicsRepository { + public epics: EpicWithStats[] = []; + private seq = 0; + + constructor() { super(); } + + async findById(id: string): Promise { + return this.epics.find((e) => e.id === id) ?? null; + } + + async create(data: CreateEpicDTO): Promise { + this.seq += 1; + const created: EpicWithStats = { + id: `a0000000-0000-4000-8000-00000000000${this.seq}`, + projeto_id: data.projeto_id, + titulo: data.titulo.trim(), + descricao: data.descricao?.trim() ?? null, + objetivo: data.objetivo?.trim() ?? null, + escopo_macro: data.escopo_macro?.trim() ?? null, + resultado_esperado: data.resultado_esperado?.trim() ?? null, + prioridade: data.prioridade, + status: "rascunho", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + features_count: 0, + criterios_count: 0, + }; + this.epics.push(created); + return created; + } + + async findAll(query: EpicQueryDTO): Promise { + return { items: this.epics, total: this.epics.length, limit: query.limit, offset: query.offset }; + } + + async update(_id: string, _data: UpdateEpicDTO): Promise { throw new Error("não usado"); } + async markConcluded(): Promise { throw new Error("não usado"); } +} + +class MockProjectsRepo extends ProjectsRepository { + constructor() { super(); } + async findById(id: string): Promise { + if (id !== "d0000000-0000-4000-8000-000000000001") return null; + return { + id, nome: "Projeto Teste", cliente: "Cliente", descricao: null, status: "ativo", + data_inicio: new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + }; + } +} + +test("Testes de integração HTTP - Rotas de Épicos", async (t) => { + const epicsRepo = new MockEpicsRepo(); + const projectsRepo = new MockProjectsRepo(); + const service = new EpicsService(epicsRepo, projectsRepo); + const controller = new EpicsController(service); + + const testApp = express(); + testApp.use(express.json()); + + const router = express.Router(); + router.post("/", controller.create); + router.get("/", controller.list); + router.get("/:id", controller.getById); + router.patch("/:id/complete", controller.complete); + + testApp.use("/api/v1/epics", router); + testApp.use(errorHandler); + + let server: Server; + let baseUrl: string; + + before(async () => { + await new Promise((resolve) => { + server = testApp.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}/api/v1/epics`; + resolve(); + }); + }); + }); + + after(async () => { + await new Promise((resolve) => { server.close(() => resolve()); }); + }); + + await t.test("POST /api/v1/epics - cria épico com status 201", async () => { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projeto_id: "d0000000-0000-4000-8000-000000000001", titulo: "Épico via HTTP" }), + }); + + assert.equal(res.status, 201); + const body = (await res.json()) as Epic; + assert.equal(body.titulo, "Épico via HTTP"); + assert.equal(body.status, "rascunho"); + }); + + await t.test("POST /api/v1/epics - retorna 404 se o projeto não existir", async () => { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projeto_id: "ffffffff-ffff-4fff-8fff-ffffffffffff", titulo: "Épico órfão" }), + }); + + assert.equal(res.status, 404); + }); + + await t.test("GET /api/v1/epics - lista épicos com status 200", async () => { + const res = await fetch(baseUrl); + assert.equal(res.status, 200); + const body = (await res.json()) as PaginatedEpics; + assert.ok(Array.isArray(body.items)); + }); + + await t.test("PATCH /api/v1/epics/:id/complete - retorna 400 quando faltam campos obrigatórios", async () => { + const created = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projeto_id: "d0000000-0000-4000-8000-000000000001", titulo: "Épico incompleto" }), + }).then((r) => r.json()) as Epic; + + const res = await fetch(`${baseUrl}/${created.id}/complete`, { method: "PATCH" }); + assert.equal(res.status, 400); + const body = (await res.json()) as { code: string; details: { campos_faltantes: string[] } }; + assert.equal(body.code, "VALIDATION_ERROR"); + assert.ok(body.details.campos_faltantes.length > 0); + }); +}); diff --git a/backend/src/modules/epics/epics.routes.ts b/backend/src/modules/epics/epics.routes.ts new file mode 100644 index 0000000..c7de769 --- /dev/null +++ b/backend/src/modules/epics/epics.routes.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { epicsController } from "./epics.controller.js"; +import { requireRole } from "../../middleware/requireRole.js"; + +export const epicsRouter = Router(); + +const canWrite = requireRole("admin", "po"); +epicsRouter.post("/", canWrite, epicsController.create); +epicsRouter.get("/", epicsController.list); +epicsRouter.get("/:id", epicsController.getById); +epicsRouter.put("/:id", canWrite, epicsController.update); +epicsRouter.patch("/:id", canWrite, epicsController.update); +epicsRouter.patch("/:id/complete", canWrite, epicsController.complete); diff --git a/backend/src/modules/epics/epics.service.test.ts b/backend/src/modules/epics/epics.service.test.ts new file mode 100644 index 0000000..5d9c086 --- /dev/null +++ b/backend/src/modules/epics/epics.service.test.ts @@ -0,0 +1,202 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EpicsService } from "./epics.service.js"; +import { EpicsRepository } from "./epics.repository.js"; +import { ProjectsRepository } from "../projects/projects.repository.js"; +import { ValidationError, NotFoundError } from "../../shared/errors.js"; +import { CreateEpicDTO, UpdateEpicDTO, Epic, EpicWithStats, PaginatedEpics, EpicQueryDTO } from "./epics.types.js"; +import { Project, ProjectWithStats, CreateProjectDTO, UpdateProjectDTO, PaginatedProjects, ProjectQueryDTO } from "../projects/projects.types.js"; + +const PROJETO_ID = "d0000000-0000-4000-8000-000000000001"; +const PROJETO_ARQUIVADO_ID = "d0000000-0000-4000-8000-000000000002"; + +class InMemoryEpicsRepository extends EpicsRepository { + private epics: EpicWithStats[] = []; + public criteriosPorEpico = new Map(); + private seq = 0; + + constructor() { super(); } + + async findById(id: string): Promise { + const found = this.epics.find((e) => e.id === id); + if (!found) return null; + return { ...found, criterios_count: this.criteriosPorEpico.get(id) ?? 0 }; + } + + async create(data: CreateEpicDTO): Promise { + this.seq += 1; + const created: EpicWithStats = { + id: `a0000000-0000-4000-8000-00000000000${this.seq}`, + projeto_id: data.projeto_id, + titulo: data.titulo.trim(), + descricao: data.descricao?.trim() ?? null, + objetivo: data.objetivo?.trim() ?? null, + escopo_macro: data.escopo_macro?.trim() ?? null, + resultado_esperado: data.resultado_esperado?.trim() ?? null, + prioridade: data.prioridade, + status: "rascunho", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + features_count: 0, + criterios_count: 0, + }; + this.epics.push(created); + return created; + } + + async findAll(query: EpicQueryDTO): Promise { + const items = this.epics.filter((e) => !query.projeto_id || e.projeto_id === query.projeto_id); + return { items, total: items.length, limit: query.limit, offset: query.offset }; + } + + async update(id: string, data: UpdateEpicDTO): Promise { + const index = this.epics.findIndex((e) => e.id === id); + if (index === -1) return null; + const updated: EpicWithStats = { + ...this.epics[index], + ...(data.titulo !== undefined ? { titulo: data.titulo.trim() } : {}), + ...(data.descricao !== undefined ? { descricao: data.descricao?.trim() ?? null } : {}), + ...(data.objetivo !== undefined ? { objetivo: data.objetivo?.trim() ?? null } : {}), + ...(data.escopo_macro !== undefined ? { escopo_macro: data.escopo_macro?.trim() ?? null } : {}), + ...(data.resultado_esperado !== undefined ? { resultado_esperado: data.resultado_esperado?.trim() ?? null } : {}), + }; + this.epics[index] = updated; + return { ...updated, criterios_count: this.criteriosPorEpico.get(id) ?? 0 }; + } + + async markConcluded(id: string): Promise { + const index = this.epics.findIndex((e) => e.id === id); + if (index === -1) return null; + this.epics[index] = { ...this.epics[index], status: "concluido" }; + return { ...this.epics[index], criterios_count: this.criteriosPorEpico.get(id) ?? 0 }; + } +} + +class StubProjectsRepository extends ProjectsRepository { + public projects: ProjectWithStats[] = []; + + constructor() { super(); } + + async findById(id: string): Promise { + return this.projects.find((p) => p.id === id) ?? null; + } + + async findActiveByName(): Promise { return null; } + async create(data: CreateProjectDTO): Promise { throw new Error("não usado neste teste"); } + async findAll(_query: ProjectQueryDTO): Promise { throw new Error("não usado neste teste"); } + async update(_id: string, _data: UpdateProjectDTO): Promise { throw new Error("não usado neste teste"); } + async archive(): Promise { throw new Error("não usado neste teste"); } +} + +function setup() { + const epicsRepo = new InMemoryEpicsRepository(); + const projectsRepo = new StubProjectsRepository(); + projectsRepo.projects.push({ + id: PROJETO_ID, nome: "Projeto Ativo", cliente: "Cliente", descricao: null, status: "ativo", + data_inicio: new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + }); + const service = new EpicsService(epicsRepo, projectsRepo); + return { service, epicsRepo, projectsRepo }; +} + +test("PBI-01.1.2 Cenário 1: cria épico completo vinculado ao projeto com status rascunho", async () => { + const { service } = setup(); + + const result = await service.create({ + projeto_id: PROJETO_ID, + titulo: "Especificar backlog", + descricao: "descrição", + objetivo: "objetivo", + escopo_macro: "escopo", + resultado_esperado: "resultado", + }); + + assert.equal(result.titulo, "Especificar backlog"); + assert.equal(result.status, "rascunho"); +}); + +test("PBI-01.1.2 Cenário 2: permite salvar rascunho incompleto com apenas título", async () => { + const { service } = setup(); + + const result = await service.create({ projeto_id: PROJETO_ID, titulo: "Épico incompleto" }); + + assert.equal(result.status, "rascunho"); + assert.equal(result.descricao, null); +}); + +test("PBI-01.1.2 Cenário 3: impede conclusão sem escopo macro ou resultado esperado", async () => { + const { service } = setup(); + + const created = await service.create({ + projeto_id: PROJETO_ID, + titulo: "Épico sem escopo", + descricao: "descrição", + objetivo: "objetivo", + }); + + await assert.rejects( + async () => await service.complete(created.id), + (err: Error) => { + assert.ok(err instanceof ValidationError); + const details = (err as ValidationError).details as { campos_faltantes: string[] }; + assert.ok(details.campos_faltantes.includes("escopo_macro")); + assert.ok(details.campos_faltantes.includes("resultado_esperado")); + return true; + }, + ); +}); + +test("impede conclusão de épico sem nenhum critério de aceitação registrado", async () => { + const { service, epicsRepo } = setup(); + + const created = await service.create({ + projeto_id: PROJETO_ID, + titulo: "Épico completo sem critério", + descricao: "descrição", + objetivo: "objetivo", + escopo_macro: "escopo", + resultado_esperado: "resultado", + }); + + await assert.rejects( + async () => await service.complete(created.id), + (err: Error) => { + assert.ok(err instanceof ValidationError); + const details = (err as ValidationError).details as { campos_faltantes: string[] }; + assert.ok(details.campos_faltantes.includes("criterios_aceitacao")); + return true; + }, + ); + + epicsRepo.criteriosPorEpico.set(created.id, 1); + const completed = await service.complete(created.id); + assert.equal(completed.status, "concluido"); +}); + +test("impede cadastro de épico em projeto inexistente", async () => { + const { service } = setup(); + + await assert.rejects( + async () => await service.create({ projeto_id: "99999999-9999-4999-8999-999999999999", titulo: "Épico órfão" }), + (err: Error) => { + assert.ok(err instanceof NotFoundError); + return true; + }, + ); +}); + +test("impede cadastro de épico em projeto arquivado", async () => { + const { service, projectsRepo } = setup(); + projectsRepo.projects.push({ + id: PROJETO_ARQUIVADO_ID, nome: "Projeto Arquivado", cliente: "Cliente", descricao: null, status: "arquivado", + data_inicio: new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + }); + + await assert.rejects( + async () => await service.create({ projeto_id: PROJETO_ARQUIVADO_ID, titulo: "Épico em projeto arquivado" }), + (err: Error) => { + assert.ok(err instanceof ValidationError); + return true; + }, + ); +}); diff --git a/backend/src/modules/epics/epics.service.ts b/backend/src/modules/epics/epics.service.ts new file mode 100644 index 0000000..0cbecc4 --- /dev/null +++ b/backend/src/modules/epics/epics.service.ts @@ -0,0 +1,109 @@ +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 { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; + +export class EpicsService { + constructor( + private readonly repository: EpicsRepository = epicsRepository, + private readonly projectsRepo: ProjectsRepository = projectsRepository, + ) {} + + async create(input: unknown, usuarioId?: string | null): Promise { + const parseResult = createEpicSchema.safeParse(input); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + const dto = parseResult.data; + + const projeto = await this.projectsRepo.findById(dto.projeto_id); + if (!projeto) { + throw new NotFoundError("Projeto não encontrado."); + } + if (projeto.status === "arquivado") { + throw new ValidationError("Não é possível cadastrar épicos em um projeto arquivado."); + } + + return await this.repository.create(dto, usuarioId); + } + + async list(queryInput: unknown): Promise { + const parseResult = epicQuerySchema.safeParse(queryInput); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + return await this.repository.findAll(parseResult.data); + } + + async getById(id: string): Promise { + validateUuid(id, "ID do épico"); + + const epic = await this.repository.findById(id); + if (!epic) { + throw new NotFoundError("Épico não encontrado."); + } + + return epic; + } + + async update(id: string, input: unknown, usuarioId?: string | null): Promise { + validateUuid(id, "ID do épico"); + + const existing = await this.repository.findById(id); + if (!existing) { + throw new NotFoundError("Épico não encontrado."); + } + + const parseResult = updateEpicSchema.safeParse(input); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + const updated = await this.repository.update(id, parseResult.data, usuarioId); + if (!updated) { + throw new NotFoundError("Épico não encontrado."); + } + + return updated; + } + + async complete(id: string, usuarioId?: string | null): Promise { + validateUuid(id, "ID do épico"); + + const existing = await this.repository.findById(id); + if (!existing) { + throw new NotFoundError("Épico não encontrado."); + } + if (existing.status === "concluido") { + return existing; + } + + const camposFaltantes: string[] = EPIC_REQUIRED_FIELDS.filter( + (field) => !existing[field] || String(existing[field]).trim().length === 0, + ); + if ((existing.criterios_count ?? 0) === 0) { + camposFaltantes.push("criterios_aceitacao"); + } + + if (camposFaltantes.length > 0) { + throw new ValidationError( + `Não é possível concluir o épico: preencha os campos obrigatórios do guia antes de concluir.`, + { campos_faltantes: camposFaltantes }, + ); + } + + const completed = await this.repository.markConcluded(id, usuarioId); + if (!completed) { + throw new NotFoundError("Épico não encontrado."); + } + + return completed; + } +} + +export const epicsService = new EpicsService(); diff --git a/backend/src/modules/epics/epics.types.ts b/backend/src/modules/epics/epics.types.ts new file mode 100644 index 0000000..d9e914c --- /dev/null +++ b/backend/src/modules/epics/epics.types.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +export const EPIC_STATUSES = ["rascunho", "concluido"] as const; +export type EpicStatus = (typeof EPIC_STATUSES)[number]; + +export const EPIC_PRIORITIES = ["Must", "Should", "Could"] as const; +export type EpicPriority = (typeof EPIC_PRIORITIES)[number]; + +export const EPIC_REQUIRED_FIELDS = ["descricao", "objetivo", "escopo_macro", "resultado_esperado"] as const; + +const uuidField = (label: string) => z.string({ required_error: `${label} é obrigatório.` }).uuid(`${label} deve ser um UUID válido.`); + +export const createEpicSchema = z.object({ + projeto_id: uuidField("O projeto"), + titulo: z + .string({ required_error: "O título do épico é obrigatório." }) + .trim() + .min(1, "O título do épico é obrigatório.") + .max(255, "O título não pode exceder 255 caracteres."), + descricao: z.string().trim().optional().nullable(), + objetivo: z.string().trim().optional().nullable(), + escopo_macro: z.string().trim().optional().nullable(), + resultado_esperado: z.string().trim().optional().nullable(), + prioridade: z.enum(EPIC_PRIORITIES).default("Must"), +}); + +export type CreateEpicDTO = z.infer; + +export const updateEpicSchema = z.object({ + titulo: z.string().trim().min(1, "O título do épico não pode ser vazio.").max(255, "O título não pode exceder 255 caracteres.").optional(), + descricao: z.string().trim().optional().nullable(), + objetivo: z.string().trim().optional().nullable(), + escopo_macro: z.string().trim().optional().nullable(), + resultado_esperado: z.string().trim().optional().nullable(), + prioridade: z.enum(EPIC_PRIORITIES).optional(), + justificativa: z.string().trim().optional().nullable(), +}); + +export type UpdateEpicDTO = z.infer; + +export const epicQuerySchema = z.object({ + projeto_id: z.string().uuid().optional(), + status: z.enum(EPIC_STATUSES).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type EpicQueryDTO = z.infer; + +export interface Epic { + id: string; + projeto_id: string; + titulo: string; + descricao: string | null; + objetivo: string | null; + escopo_macro: string | null; + resultado_esperado: string | null; + prioridade: EpicPriority; + status: EpicStatus; + created_at: Date | string; + updated_at: Date | string; +} + +export interface EpicWithStats extends Epic { + features_count?: number; + criterios_count?: number; +} + +export interface PaginatedEpics { + items: EpicWithStats[]; + total: number; + limit: number; + offset: number; +} diff --git a/backend/src/modules/features/features.controller.ts b/backend/src/modules/features/features.controller.ts new file mode 100644 index 0000000..7ad75eb --- /dev/null +++ b/backend/src/modules/features/features.controller.ts @@ -0,0 +1,64 @@ +import { Request, Response, NextFunction } from "express"; +import { featuresService, FeaturesService } from "./features.service.js"; + +function getParamId(param: string | string[] | undefined): string { + if (Array.isArray(param)) return param[0] ?? ""; + return param ?? ""; +} + +export class FeaturesController { + constructor(private readonly service: FeaturesService = featuresService) {} + + create = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const usuarioId = req.auth?.id ?? null; + const result = await this.service.create(req.body, usuarioId); + res.status(201).json(result); + } catch (error) { + next(error); + } + }; + + list = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const result = await this.service.list(req.query); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; + + getById = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = getParamId(req.params.id); + const result = await this.service.getById(id); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; + + update = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = getParamId(req.params.id); + const usuarioId = req.auth?.id ?? null; + const result = await this.service.update(id, req.body, usuarioId); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; + + complete = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = getParamId(req.params.id); + const usuarioId = req.auth?.id ?? null; + const result = await this.service.complete(id, usuarioId); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; +} + +export const featuresController = new FeaturesController(); diff --git a/backend/src/modules/features/features.repository.ts b/backend/src/modules/features/features.repository.ts new file mode 100644 index 0000000..3745d68 --- /dev/null +++ b/backend/src/modules/features/features.repository.ts @@ -0,0 +1,194 @@ +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"; + +const SELECT_WITH_STATS = ` + SELECT + f.*, + e.titulo AS epico_titulo, + e.projeto_id AS projeto_id, + COALESCE((SELECT COUNT(*)::int FROM pbi p WHERE p.feature_id = f.id), 0) AS pbis_count, + COALESCE((SELECT COUNT(*)::int FROM criterio_aceitacao c WHERE c.entidade_tipo = 'feature' AND c.entidade_id = f.id), 0) AS criterios_count + FROM feature f + JOIN epico e ON e.id = f.epico_id +`; + +export class FeaturesRepository { + private pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async findById(id: string): Promise { + const result = await this.pool.query(`${SELECT_WITH_STATS} WHERE f.id = $1`, [id]); + return result.rows[0] ?? null; + } + + async create(data: CreateFeatureDTO, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const insertQuery = ` + INSERT INTO feature (epico_id, titulo, descricao, objetivo, prioridade) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `; + const values = [ + data.epico_id, + data.titulo.trim(), + data.descricao?.trim() ?? null, + data.objetivo?.trim() ?? null, + data.prioridade, + ]; + + const result = await client.query(insertQuery, values); + const created = result.rows[0]; + + await auditService.record( + { + usuario_id: usuarioId ?? null, + entidade_tipo: "feature", + entidade_id: created.id, + acao: "CRIAR_FEATURE", + dados_json: { titulo: created.titulo, epico_id: created.epico_id, status: created.status }, + }, + client, + ); + + await client.query("COMMIT"); + return created; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async findAll(query: FeatureQueryDTO): Promise { + const whereConditions: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (query.epico_id) { + whereConditions.push(`f.epico_id = $${paramIndex}`); + params.push(query.epico_id); + paramIndex++; + } + if (query.status) { + whereConditions.push(`f.status = $${paramIndex}`); + params.push(query.status); + paramIndex++; + } + + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; + + const countQuery = `SELECT COUNT(*)::int AS total FROM feature f ${whereClause}`; + const countResult = await this.pool.query<{ total: number }>(countQuery, params); + const total = countResult.rows[0]?.total ?? 0; + + const dataParams = [...params, query.limit, query.offset]; + const dataQuery = ` + ${SELECT_WITH_STATS} + ${whereClause} + ORDER BY f.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + const dataResult = await this.pool.query(dataQuery, dataParams); + + return { items: dataResult.rows, total, limit: query.limit, offset: query.offset }; + } + + async update(id: string, data: UpdateFeatureDTO, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const existing = await this.findById(id); + if (!existing) { + await client.query("ROLLBACK"); + return null; + } + + const updates: string[] = []; + const values: unknown[] = []; + let valIndex = 1; + + if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; } + if (data.descricao !== undefined) { updates.push(`descricao = $${valIndex}`); values.push(data.descricao?.trim() ?? null); valIndex++; } + if (data.objetivo !== undefined) { updates.push(`objetivo = $${valIndex}`); values.push(data.objetivo?.trim() ?? null); valIndex++; } + if (data.prioridade !== undefined) { updates.push(`prioridade = $${valIndex}`); values.push(data.prioridade); valIndex++; } + + updates.push(`updated_at = CURRENT_TIMESTAMP`); + values.push(id); + + const result = await client.query( + `UPDATE feature SET ${updates.join(", ")} WHERE id = $${valIndex} RETURNING *`, + values, + ); + const updated = result.rows[0]; + + await auditService.record( + { + usuario_id: usuarioId ?? null, + entidade_tipo: "feature", + entidade_id: id, + acao: "ATUALIZAR_FEATURE", + justificativa: data.justificativa ?? null, + dados_json: { alteracoes: data, anterior: { titulo: existing.titulo }, novo: { titulo: updated.titulo } }, + }, + client, + ); + + await client.query("COMMIT"); + return await this.findById(id); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async markConcluded(id: string, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const existing = await this.findById(id); + if (!existing) { + await client.query("ROLLBACK"); + return null; + } + + await client.query(`UPDATE feature SET status = 'concluido', updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id]); + + await auditService.record( + { + usuario_id: usuarioId ?? null, + entidade_tipo: "feature", + entidade_id: id, + acao: "CONCLUIR_FEATURE", + dados_json: { status_anterior: existing.status, status_novo: "concluido" }, + }, + client, + ); + + await client.query("COMMIT"); + return await this.findById(id); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} + +export const featuresRepository = new FeaturesRepository(); diff --git a/backend/src/modules/features/features.routes.test.ts b/backend/src/modules/features/features.routes.test.ts new file mode 100644 index 0000000..d9fb392 --- /dev/null +++ b/backend/src/modules/features/features.routes.test.ts @@ -0,0 +1,127 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; +import { FeaturesController } from "./features.controller.js"; +import { FeaturesService } from "./features.service.js"; +import { FeaturesRepository } from "./features.repository.js"; +import { EpicsRepository } from "../epics/epics.repository.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { CreateFeatureDTO, Feature, FeatureWithStats, PaginatedFeatures, FeatureQueryDTO } from "./features.types.js"; +import { EpicWithStats } from "../epics/epics.types.js"; + +const EPIC_ID = "e0000000-0000-4000-8000-000000000001"; + +class MockFeaturesRepo extends FeaturesRepository { + public features: FeatureWithStats[] = []; + private seq = 0; + + constructor() { super(); } + + async findById(id: string): Promise { + return this.features.find((f) => f.id === id) ?? null; + } + + async create(data: CreateFeatureDTO): Promise { + this.seq += 1; + const created: FeatureWithStats = { + id: `f0000000-0000-4000-8000-00000000000${this.seq}`, + epico_id: data.epico_id, + titulo: data.titulo.trim(), + descricao: data.descricao?.trim() ?? null, + objetivo: data.objetivo?.trim() ?? null, + prioridade: data.prioridade, + status: "rascunho", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + pbis_count: 0, + criterios_count: 0, + }; + this.features.push(created); + return created; + } + + async findAll(query: FeatureQueryDTO): Promise { + return { items: this.features, total: this.features.length, limit: query.limit, offset: query.offset }; + } +} + +class MockEpicsRepo extends EpicsRepository { + constructor() { super(); } + async findById(id: string): Promise { + if (id !== EPIC_ID) return null; + return { + id, projeto_id: "proj-1", titulo: "Épico", descricao: null, objetivo: null, escopo_macro: null, + resultado_esperado: null, prioridade: "Must", status: "rascunho", + created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + }; + } +} + +test("Testes de integração HTTP - Rotas de Features", async (t) => { + const featuresRepo = new MockFeaturesRepo(); + const epicsRepo = new MockEpicsRepo(); + const service = new FeaturesService(featuresRepo, epicsRepo); + const controller = new FeaturesController(service); + + const testApp = express(); + testApp.use(express.json()); + + const router = express.Router(); + router.post("/", controller.create); + router.get("/", controller.list); + router.get("/:id", controller.getById); + + testApp.use("/api/v1/features", router); + testApp.use(errorHandler); + + let server: Server; + let baseUrl: string; + + before(async () => { + await new Promise((resolve) => { + server = testApp.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}/api/v1/features`; + resolve(); + }); + }); + }); + + after(async () => { + await new Promise((resolve) => { server.close(() => resolve()); }); + }); + + await t.test("POST /api/v1/features - cria feature com status 201", async () => { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ epico_id: EPIC_ID, titulo: "Feature via HTTP" }), + }); + + assert.equal(res.status, 201); + const body = (await res.json()) as Feature; + assert.equal(body.titulo, "Feature via HTTP"); + }); + + await t.test("POST /api/v1/features - retorna 400 sem épico selecionado", async () => { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ titulo: "Feature sem épico" }), + }); + + assert.equal(res.status, 400); + }); + + await t.test("POST /api/v1/features - retorna 404 para épico inexistente", async () => { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ epico_id: "ffffffff-ffff-4fff-8fff-ffffffffffff", titulo: "Feature órfã" }), + }); + + assert.equal(res.status, 404); + }); +}); diff --git a/backend/src/modules/features/features.routes.ts b/backend/src/modules/features/features.routes.ts new file mode 100644 index 0000000..1544511 --- /dev/null +++ b/backend/src/modules/features/features.routes.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { featuresController } from "./features.controller.js"; +import { requireRole } from "../../middleware/requireRole.js"; + +export const featuresRouter = Router(); + +const canWrite = requireRole("admin", "po"); +featuresRouter.post("/", canWrite, featuresController.create); +featuresRouter.get("/", featuresController.list); +featuresRouter.get("/:id", featuresController.getById); +featuresRouter.put("/:id", canWrite, featuresController.update); +featuresRouter.patch("/:id", canWrite, featuresController.update); +featuresRouter.patch("/:id/complete", canWrite, featuresController.complete); diff --git a/backend/src/modules/features/features.service.test.ts b/backend/src/modules/features/features.service.test.ts new file mode 100644 index 0000000..8e37828 --- /dev/null +++ b/backend/src/modules/features/features.service.test.ts @@ -0,0 +1,134 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { FeaturesService } from "./features.service.js"; +import { FeaturesRepository } from "./features.repository.js"; +import { EpicsRepository } from "../epics/epics.repository.js"; +import { ValidationError, NotFoundError } from "../../shared/errors.js"; +import { CreateFeatureDTO, UpdateFeatureDTO, Feature, FeatureWithStats, PaginatedFeatures, FeatureQueryDTO } from "./features.types.js"; +import { EpicWithStats } from "../epics/epics.types.js"; + +const EPICO_ID = "c0000000-0000-4000-8000-000000000001"; + +class InMemoryFeaturesRepository extends FeaturesRepository { + private features: FeatureWithStats[] = []; + private seq = 0; + + constructor() { super(); } + + async findById(id: string): Promise { + return this.features.find((f) => f.id === id) ?? null; + } + + async create(data: CreateFeatureDTO): Promise { + this.seq += 1; + const created: FeatureWithStats = { + id: `b0000000-0000-4000-8000-00000000000${this.seq}`, + epico_id: data.epico_id, + titulo: data.titulo.trim(), + descricao: data.descricao?.trim() ?? null, + objetivo: data.objetivo?.trim() ?? null, + prioridade: data.prioridade, + status: "rascunho", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + pbis_count: 0, + criterios_count: 0, + epico_titulo: "Épico de teste", + projeto_id: "d0000000-0000-4000-8000-000000000001", + }; + this.features.push(created); + return created; + } + + async findAll(query: FeatureQueryDTO): Promise { + const items = this.features.filter((f) => !query.epico_id || f.epico_id === query.epico_id); + return { items, total: items.length, limit: query.limit, offset: query.offset }; + } + + async update(id: string, data: UpdateFeatureDTO): Promise { + const index = this.features.findIndex((f) => f.id === id); + if (index === -1) return null; + this.features[index] = { + ...this.features[index], + ...(data.titulo !== undefined ? { titulo: data.titulo.trim() } : {}), + ...(data.descricao !== undefined ? { descricao: data.descricao?.trim() ?? null } : {}), + ...(data.objetivo !== undefined ? { objetivo: data.objetivo?.trim() ?? null } : {}), + }; + return this.features[index]; + } + + async markConcluded(id: string): Promise { + const index = this.features.findIndex((f) => f.id === id); + if (index === -1) return null; + this.features[index] = { ...this.features[index], status: "concluido" }; + return this.features[index]; + } +} + +class StubEpicsRepository extends EpicsRepository { + public epics: EpicWithStats[] = []; + constructor() { super(); } + async findById(id: string): Promise { + return this.epics.find((e) => e.id === id) ?? null; + } +} + +function setup() { + const featuresRepo = new InMemoryFeaturesRepository(); + const epicsRepo = new StubEpicsRepository(); + epicsRepo.epics.push({ + id: EPICO_ID, projeto_id: "d0000000-0000-4000-8000-000000000001", titulo: "Épico base", descricao: null, objetivo: null, + escopo_macro: null, resultado_esperado: null, prioridade: "Must", status: "rascunho", + created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + }); + const service = new FeaturesService(featuresRepo, epicsRepo); + return { service, featuresRepo, epicsRepo }; +} + +test("PBI-01.1.3 Cenário 1: cria feature completa vinculada ao épico com status rascunho", async () => { + const { service } = setup(); + + const result = await service.create({ epico_id: EPICO_ID, titulo: "Estruturação dos itens", descricao: "d", objetivo: "o" }); + + assert.equal(result.titulo, "Estruturação dos itens"); + assert.equal(result.status, "rascunho"); + assert.equal(result.epico_id, EPICO_ID); +}); + +test("PBI-01.1.3 Cenário 2: exibe o épico de origem ao consultar a feature", async () => { + const { service } = setup(); + + const created = await service.create({ epico_id: EPICO_ID, titulo: "Feature com contexto" }); + const found = await service.getById(created.id); + + assert.equal(found.epico_titulo, "Épico de teste"); + assert.equal(found.projeto_id, "d0000000-0000-4000-8000-000000000001"); +}); + +test("PBI-01.1.3 Cenário 3: impede criação de feature em épico inexistente", async () => { + const { service } = setup(); + + await assert.rejects( + async () => await service.create({ epico_id: "99999999-9999-4999-8999-999999999999", titulo: "Feature órfã" }), + (err: Error) => { + assert.ok(err instanceof NotFoundError); + return true; + }, + ); +}); + +test("impede conclusão de feature sem descrição ou objetivo", async () => { + const { service } = setup(); + + const created = await service.create({ epico_id: EPICO_ID, titulo: "Feature incompleta" }); + + await assert.rejects( + async () => await service.complete(created.id), + (err: Error) => { + assert.ok(err instanceof ValidationError); + const details = (err as ValidationError).details as { campos_faltantes: string[] }; + assert.deepEqual(details.campos_faltantes.sort(), ["descricao", "objetivo"]); + return true; + }, + ); +}); diff --git a/backend/src/modules/features/features.service.ts b/backend/src/modules/features/features.service.ts new file mode 100644 index 0000000..6322163 --- /dev/null +++ b/backend/src/modules/features/features.service.ts @@ -0,0 +1,103 @@ +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 { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; + +export class FeaturesService { + constructor( + private readonly repository: FeaturesRepository = featuresRepository, + private readonly epicsRepo: EpicsRepository = epicsRepository, + ) {} + + async create(input: unknown, usuarioId?: string | null): Promise { + const parseResult = createFeatureSchema.safeParse(input); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + const dto = parseResult.data; + + const epico = await this.epicsRepo.findById(dto.epico_id); + if (!epico) { + throw new NotFoundError("Épico não encontrado."); + } + + return await this.repository.create(dto, usuarioId); + } + + async list(queryInput: unknown): Promise { + const parseResult = featureQuerySchema.safeParse(queryInput); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + return await this.repository.findAll(parseResult.data); + } + + async getById(id: string): Promise { + validateUuid(id, "ID da feature"); + + const feature = await this.repository.findById(id); + if (!feature) { + throw new NotFoundError("Feature não encontrada."); + } + + return feature; + } + + async update(id: string, input: unknown, usuarioId?: string | null): Promise { + validateUuid(id, "ID da feature"); + + const existing = await this.repository.findById(id); + if (!existing) { + throw new NotFoundError("Feature não encontrada."); + } + + const parseResult = updateFeatureSchema.safeParse(input); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + const updated = await this.repository.update(id, parseResult.data, usuarioId); + if (!updated) { + throw new NotFoundError("Feature não encontrada."); + } + + return updated; + } + + async complete(id: string, usuarioId?: string | null): Promise { + validateUuid(id, "ID da feature"); + + const existing = await this.repository.findById(id); + if (!existing) { + throw new NotFoundError("Feature não encontrada."); + } + if (existing.status === "concluido") { + return existing; + } + + const camposFaltantes: string[] = FEATURE_REQUIRED_FIELDS.filter( + (field) => !existing[field] || String(existing[field]).trim().length === 0, + ); + + if (camposFaltantes.length > 0) { + throw new ValidationError( + "Não é possível concluir a feature: preencha os campos obrigatórios do guia antes de concluir.", + { campos_faltantes: camposFaltantes }, + ); + } + + const completed = await this.repository.markConcluded(id, usuarioId); + if (!completed) { + throw new NotFoundError("Feature não encontrada."); + } + + return completed; + } +} + +export const featuresService = new FeaturesService(); diff --git a/backend/src/modules/features/features.types.ts b/backend/src/modules/features/features.types.ts new file mode 100644 index 0000000..5f1b2e6 --- /dev/null +++ b/backend/src/modules/features/features.types.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +export const FEATURE_STATUSES = ["rascunho", "concluido"] as const; +export type FeatureStatus = (typeof FEATURE_STATUSES)[number]; + +export const FEATURE_PRIORITIES = ["Must", "Should", "Could"] as const; +export type FeaturePriority = (typeof FEATURE_PRIORITIES)[number]; + +export const FEATURE_REQUIRED_FIELDS = ["descricao", "objetivo"] as const; + +export const createFeatureSchema = z.object({ + epico_id: z.string({ required_error: "O épico é obrigatório." }).uuid("O épico deve ser um UUID válido."), + titulo: z + .string({ required_error: "O título da feature é obrigatório." }) + .trim() + .min(1, "O título da feature é obrigatório.") + .max(255, "O título não pode exceder 255 caracteres."), + descricao: z.string().trim().optional().nullable(), + objetivo: z.string().trim().optional().nullable(), + prioridade: z.enum(FEATURE_PRIORITIES).default("Must"), +}); + +export type CreateFeatureDTO = z.infer; + +export const updateFeatureSchema = z.object({ + titulo: z.string().trim().min(1, "O título da feature não pode ser vazio.").max(255, "O título não pode exceder 255 caracteres.").optional(), + descricao: z.string().trim().optional().nullable(), + objetivo: z.string().trim().optional().nullable(), + prioridade: z.enum(FEATURE_PRIORITIES).optional(), + justificativa: z.string().trim().optional().nullable(), +}); + +export type UpdateFeatureDTO = z.infer; + +export const featureQuerySchema = z.object({ + epico_id: z.string().uuid().optional(), + status: z.enum(FEATURE_STATUSES).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type FeatureQueryDTO = z.infer; + +export interface Feature { + id: string; + epico_id: string; + titulo: string; + descricao: string | null; + objetivo: string | null; + prioridade: FeaturePriority; + status: FeatureStatus; + created_at: Date | string; + updated_at: Date | string; +} + +export interface FeatureWithStats extends Feature { + pbis_count?: number; + criterios_count?: number; + epico_titulo?: string; + projeto_id?: string; +} + +export interface PaginatedFeatures { + items: FeatureWithStats[]; + total: number; + limit: number; + offset: number; +} diff --git a/backend/src/modules/pbis/pbis.controller.ts b/backend/src/modules/pbis/pbis.controller.ts new file mode 100644 index 0000000..58ac83c --- /dev/null +++ b/backend/src/modules/pbis/pbis.controller.ts @@ -0,0 +1,64 @@ +import { Request, Response, NextFunction } from "express"; +import { pbisService, PbisService } from "./pbis.service.js"; + +function getParamId(param: string | string[] | undefined): string { + if (Array.isArray(param)) return param[0] ?? ""; + return param ?? ""; +} + +export class PbisController { + constructor(private readonly service: PbisService = pbisService) {} + + create = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const usuarioId = req.auth?.id ?? null; + const result = await this.service.create(req.body, usuarioId); + res.status(201).json(result); + } catch (error) { + next(error); + } + }; + + list = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const result = await this.service.list(req.query); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; + + getById = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = getParamId(req.params.id); + const result = await this.service.getById(id); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; + + update = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = getParamId(req.params.id); + const usuarioId = req.auth?.id ?? null; + const result = await this.service.update(id, req.body, usuarioId); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; + + complete = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const id = getParamId(req.params.id); + const usuarioId = req.auth?.id ?? null; + const result = await this.service.complete(id, usuarioId); + res.status(200).json(result); + } catch (error) { + next(error); + } + }; +} + +export const pbisController = new PbisController(); diff --git a/backend/src/modules/pbis/pbis.repository.ts b/backend/src/modules/pbis/pbis.repository.ts new file mode 100644 index 0000000..c7e4965 --- /dev/null +++ b/backend/src/modules/pbis/pbis.repository.ts @@ -0,0 +1,210 @@ +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"; + +const SELECT_WITH_CONTEXT = ` + SELECT + p.*, + f.titulo AS feature_titulo, + e.id AS epico_id, + e.titulo AS epico_titulo, + e.projeto_id AS projeto_id, + COALESCE((SELECT COUNT(*)::int FROM criterio_aceitacao c WHERE c.entidade_tipo = 'pbi' AND c.entidade_id = p.id), 0) AS criterios_count + FROM pbi p + JOIN feature f ON f.id = p.feature_id + JOIN epico e ON e.id = f.epico_id +`; + +export class PbisRepository { + private pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async findById(id: string): Promise { + const result = await this.pool.query(`${SELECT_WITH_CONTEXT} WHERE p.id = $1`, [id]); + return result.rows[0] ?? null; + } + + async create(data: CreatePbiDTO, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const seqResult = await client.query<{ proxima_sequencia: number }>( + `SELECT COUNT(*)::int + 1 AS proxima_sequencia FROM pbi WHERE feature_id = $1`, + [data.feature_id], + ); + const sequencia = seqResult.rows[0]?.proxima_sequencia ?? 1; + const codigo = `PBI-${String(sequencia).padStart(3, "0")}`; + + const insertQuery = ` + INSERT INTO pbi (feature_id, codigo, titulo, historia_como_um, historia_eu_quero, historia_para_que, regras_observacoes, tipo, prioridade) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + `; + const values = [ + data.feature_id, + codigo, + data.titulo.trim(), + data.historia_como_um.trim(), + data.historia_eu_quero.trim(), + data.historia_para_que.trim(), + data.regras_observacoes?.trim() ?? null, + data.tipo, + data.prioridade, + ]; + + const result = await client.query(insertQuery, values); + const created = result.rows[0]; + + await auditService.record( + { + usuario_id: usuarioId ?? null, + entidade_tipo: "pbi", + entidade_id: created.id, + acao: "CRIAR_PBI", + dados_json: { codigo: created.codigo, titulo: created.titulo, feature_id: created.feature_id, status: created.status }, + }, + client, + ); + + await client.query("COMMIT"); + return created; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async findAll(query: PbiQueryDTO): Promise { + const whereConditions: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (query.feature_id) { + whereConditions.push(`p.feature_id = $${paramIndex}`); + params.push(query.feature_id); + paramIndex++; + } + if (query.status) { + whereConditions.push(`p.status = $${paramIndex}`); + params.push(query.status); + paramIndex++; + } + + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; + + const countQuery = `SELECT COUNT(*)::int AS total FROM pbi p ${whereClause}`; + const countResult = await this.pool.query<{ total: number }>(countQuery, params); + const total = countResult.rows[0]?.total ?? 0; + + const dataParams = [...params, query.limit, query.offset]; + const dataQuery = ` + ${SELECT_WITH_CONTEXT} + ${whereClause} + ORDER BY p.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + const dataResult = await this.pool.query(dataQuery, dataParams); + + return { items: dataResult.rows, total, limit: query.limit, offset: query.offset }; + } + + async update(id: string, data: UpdatePbiDTO, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const existing = await this.findById(id); + if (!existing) { + await client.query("ROLLBACK"); + return null; + } + + const updates: string[] = []; + const values: unknown[] = []; + let valIndex = 1; + + if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; } + if (data.historia_como_um !== undefined) { updates.push(`historia_como_um = $${valIndex}`); values.push(data.historia_como_um.trim()); valIndex++; } + if (data.historia_eu_quero !== undefined) { updates.push(`historia_eu_quero = $${valIndex}`); values.push(data.historia_eu_quero.trim()); valIndex++; } + if (data.historia_para_que !== undefined) { updates.push(`historia_para_que = $${valIndex}`); values.push(data.historia_para_que.trim()); valIndex++; } + if (data.regras_observacoes !== undefined) { updates.push(`regras_observacoes = $${valIndex}`); values.push(data.regras_observacoes?.trim() ?? null); valIndex++; } + if (data.tipo !== undefined) { updates.push(`tipo = $${valIndex}`); values.push(data.tipo); valIndex++; } + if (data.prioridade !== undefined) { updates.push(`prioridade = $${valIndex}`); values.push(data.prioridade); valIndex++; } + + updates.push(`updated_at = CURRENT_TIMESTAMP`); + values.push(id); + + const result = await client.query( + `UPDATE pbi SET ${updates.join(", ")} WHERE id = $${valIndex} RETURNING *`, + values, + ); + const updated = result.rows[0]; + + await auditService.record( + { + usuario_id: usuarioId ?? null, + 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 } }, + }, + client, + ); + + await client.query("COMMIT"); + return await this.findById(id); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async markConcluded(id: string, usuarioId?: string | null): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + const existing = await this.findById(id); + if (!existing) { + await client.query("ROLLBACK"); + return null; + } + + await client.query(`UPDATE pbi SET status = 'concluido', updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id]); + + await auditService.record( + { + usuario_id: usuarioId ?? null, + entidade_tipo: "pbi", + entidade_id: id, + acao: "CONCLUIR_PBI", + dados_json: { status_anterior: existing.status, status_novo: "concluido" }, + }, + client, + ); + + await client.query("COMMIT"); + return await this.findById(id); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} + +export const pbisRepository = new PbisRepository(); diff --git a/backend/src/modules/pbis/pbis.routes.test.ts b/backend/src/modules/pbis/pbis.routes.test.ts new file mode 100644 index 0000000..85b242c --- /dev/null +++ b/backend/src/modules/pbis/pbis.routes.test.ts @@ -0,0 +1,146 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; +import { PbisController } from "./pbis.controller.js"; +import { PbisService } from "./pbis.service.js"; +import { PbisRepository } from "./pbis.repository.js"; +import { FeaturesRepository } from "../features/features.repository.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { CreatePbiDTO, Pbi, PbiWithContext, PaginatedPbis, PbiQueryDTO } from "./pbis.types.js"; +import { FeatureWithStats } from "../features/features.types.js"; + +const FEATURE_ID = "f0000000-0000-4000-8000-000000000001"; + +class MockPbisRepo extends PbisRepository { + public pbis: PbiWithContext[] = []; + private seq = 0; + + constructor() { super(); } + + async findById(id: string): Promise { + return this.pbis.find((p) => p.id === id) ?? null; + } + + async create(data: CreatePbiDTO): Promise { + this.seq += 1; + const created: PbiWithContext = { + id: `c0000000-0000-4000-8000-00000000000${this.seq}`, + feature_id: data.feature_id, + codigo: `PBI-${String(this.seq).padStart(3, "0")}`, + titulo: data.titulo.trim(), + historia_como_um: data.historia_como_um.trim(), + historia_eu_quero: data.historia_eu_quero.trim(), + historia_para_que: data.historia_para_que.trim(), + regras_observacoes: null, + tipo: data.tipo, + prioridade: data.prioridade, + status: "rascunho", + score_completude: 0, + provenance: "human-authored", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + criterios_count: 0, + }; + this.pbis.push(created); + return created; + } + + async findAll(query: PbiQueryDTO): Promise { + return { items: this.pbis, total: this.pbis.length, limit: query.limit, offset: query.offset }; + } +} + +class MockFeaturesRepo extends FeaturesRepository { + constructor() { super(); } + async findById(id: string): Promise { + if (id !== FEATURE_ID) return null; + return { + id, epico_id: "epic-1", titulo: "Feature", descricao: "d", objetivo: "o", + prioridade: "Must", status: "rascunho", created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + }; + } +} + +test("Testes de integração HTTP - Rotas de PBIs", async (t) => { + const pbisRepo = new MockPbisRepo(); + const featuresRepo = new MockFeaturesRepo(); + const service = new PbisService(pbisRepo, featuresRepo); + const controller = new PbisController(service); + + const testApp = express(); + testApp.use(express.json()); + + const router = express.Router(); + router.post("/", controller.create); + router.get("/", controller.list); + router.get("/:id", controller.getById); + router.patch("/:id/complete", controller.complete); + + testApp.use("/api/v1/pbis", router); + testApp.use(errorHandler); + + let server: Server; + let baseUrl: string; + + before(async () => { + await new Promise((resolve) => { + server = testApp.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}/api/v1/pbis`; + resolve(); + }); + }); + }); + + after(async () => { + await new Promise((resolve) => { server.close(() => resolve()); }); + }); + + await t.test("POST /api/v1/pbis - cria PBI com história completa e status 201", async () => { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feature_id: FEATURE_ID, + titulo: "Cadastrar PBI via HTTP", + historia_como_um: "Product Owner", + historia_eu_quero: "cadastrar um PBI", + historia_para_que: "descrever o comportamento esperado", + }), + }); + + assert.equal(res.status, 201); + const body = (await res.json()) as Pbi; + assert.equal(body.status, "rascunho"); + assert.match(body.codigo, /^PBI-\d{3}$/); + }); + + await t.test("POST /api/v1/pbis - retorna 400 quando falta um bloco da história", async () => { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feature_id: FEATURE_ID, titulo: "PBI incompleto", historia_como_um: "PO", historia_eu_quero: "algo" }), + }); + + assert.equal(res.status, 400); + }); + + await t.test("PATCH /api/v1/pbis/:id/complete - retorna 400 sem cenário de aceitação", async () => { + const created = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feature_id: FEATURE_ID, + titulo: "PBI sem cenário", + historia_como_um: "PO", + historia_eu_quero: "algo", + historia_para_que: "algo", + }), + }).then((r) => r.json()) as Pbi; + + const res = await fetch(`${baseUrl}/${created.id}/complete`, { method: "PATCH" }); + assert.equal(res.status, 400); + }); +}); diff --git a/backend/src/modules/pbis/pbis.routes.ts b/backend/src/modules/pbis/pbis.routes.ts new file mode 100644 index 0000000..fefdd90 --- /dev/null +++ b/backend/src/modules/pbis/pbis.routes.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { pbisController } from "./pbis.controller.js"; +import { requireRole } from "../../middleware/requireRole.js"; + +export const pbisRouter = Router(); + +const canWrite = requireRole("admin", "po"); +pbisRouter.post("/", canWrite, pbisController.create); +pbisRouter.get("/", pbisController.list); +pbisRouter.get("/:id", pbisController.getById); +pbisRouter.put("/:id", canWrite, pbisController.update); +pbisRouter.patch("/:id", canWrite, pbisController.update); +pbisRouter.patch("/:id/complete", canWrite, pbisController.complete); diff --git a/backend/src/modules/pbis/pbis.service.test.ts b/backend/src/modules/pbis/pbis.service.test.ts new file mode 100644 index 0000000..01ef196 --- /dev/null +++ b/backend/src/modules/pbis/pbis.service.test.ts @@ -0,0 +1,167 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { PbisService } from "./pbis.service.js"; +import { PbisRepository } from "./pbis.repository.js"; +import { FeaturesRepository } from "../features/features.repository.js"; +import { ValidationError, NotFoundError } from "../../shared/errors.js"; +import { CreatePbiDTO, Pbi, PbiWithContext, PaginatedPbis, PbiQueryDTO } from "./pbis.types.js"; +import { FeatureWithStats } from "../features/features.types.js"; + +const FEATURE_ID = "b0000000-0000-4000-8000-000000000001"; + +class InMemoryPbisRepository extends PbisRepository { + private pbis: PbiWithContext[] = []; + public criteriosPorPbi = new Map(); + private seq = 0; + + constructor() { super(); } + + async findById(id: string): Promise { + const found = this.pbis.find((p) => p.id === id); + if (!found) return null; + return { ...found, criterios_count: this.criteriosPorPbi.get(id) ?? 0 }; + } + + async create(data: CreatePbiDTO): Promise { + this.seq += 1; + const created: PbiWithContext = { + id: `c9000000-0000-4000-8000-00000000000${this.seq}`, + feature_id: data.feature_id, + codigo: `PBI-${String(this.seq).padStart(3, "0")}`, + titulo: data.titulo.trim(), + historia_como_um: data.historia_como_um.trim(), + historia_eu_quero: data.historia_eu_quero.trim(), + historia_para_que: data.historia_para_que.trim(), + regras_observacoes: data.regras_observacoes?.trim() ?? null, + tipo: data.tipo, + prioridade: data.prioridade, + status: "rascunho", + score_completude: 0, + provenance: "human-authored", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + criterios_count: 0, + feature_titulo: "Feature de teste", + }; + this.pbis.push(created); + return created; + } + + async findAll(query: PbiQueryDTO): Promise { + const items = this.pbis.filter((p) => !query.feature_id || p.feature_id === query.feature_id); + return { items, total: items.length, limit: query.limit, offset: query.offset }; + } + + async markConcluded(id: string): Promise { + const index = this.pbis.findIndex((p) => p.id === id); + if (index === -1) return null; + this.pbis[index] = { ...this.pbis[index], status: "concluido" }; + return { ...this.pbis[index], criterios_count: this.criteriosPorPbi.get(id) ?? 0 }; + } +} + +class StubFeaturesRepository extends FeaturesRepository { + public features: FeatureWithStats[] = []; + constructor() { super(); } + async findById(id: string): Promise { + return this.features.find((f) => f.id === id) ?? null; + } +} + +function setup() { + const pbisRepo = new InMemoryPbisRepository(); + const featuresRepo = new StubFeaturesRepository(); + featuresRepo.features.push({ + id: FEATURE_ID, epico_id: "c0000000-0000-4000-8000-000000000001", titulo: "Feature base", descricao: null, objetivo: null, + prioridade: "Must", status: "rascunho", created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + }); + const service = new PbisService(pbisRepo, featuresRepo); + return { service, pbisRepo, featuresRepo }; +} + +test("PBI-01.1.4 Cenário 1: cria PBI com história completa vinculado à feature com status rascunho", async () => { + const { service } = setup(); + + const result = await service.create({ + feature_id: FEATURE_ID, + titulo: "Cadastrar item", + historia_como_um: "Product Owner", + historia_eu_quero: "cadastrar um item", + historia_para_que: "eu organize o backlog", + }); + + assert.equal(result.status, "rascunho"); + assert.match(result.codigo, /^PBI-\d{3}$/); +}); + +test("PBI-01.1.4 Cenário 3: impede conclusão sem nenhum cenário de aceitação", async () => { + const { service } = setup(); + + const created = await service.create({ + feature_id: FEATURE_ID, + titulo: "Cadastrar item", + historia_como_um: "Product Owner", + historia_eu_quero: "cadastrar um item", + historia_para_que: "eu organize o backlog", + }); + + await assert.rejects( + async () => await service.complete(created.id), + (err: Error) => { + assert.ok(err instanceof ValidationError); + return true; + }, + ); +}); + +test("permite concluir PBI após registrar ao menos um cenário de aceitação", async () => { + const { service, pbisRepo } = setup(); + + const created = await service.create({ + feature_id: FEATURE_ID, + titulo: "Cadastrar item", + historia_como_um: "Product Owner", + historia_eu_quero: "cadastrar um item", + historia_para_que: "eu organize o backlog", + }); + + pbisRepo.criteriosPorPbi.set(created.id, 1); + const completed = await service.complete(created.id); + assert.equal(completed.status, "concluido"); +}); + +test("impede cadastro de PBI em feature inexistente", async () => { + const { service } = setup(); + + await assert.rejects( + async () => await service.create({ + feature_id: "99999999-9999-4999-8999-999999999999", + titulo: "PBI órfão", + historia_como_um: "PO", + historia_eu_quero: "algo", + historia_para_que: "algo", + }), + (err: Error) => { + assert.ok(err instanceof NotFoundError); + return true; + }, + ); +}); + +test("PBI-01.1.4 Cenário 2: exige os três blocos da história separadamente", async () => { + const { service } = setup(); + + await assert.rejects( + async () => await service.create({ + feature_id: FEATURE_ID, + titulo: "PBI incompleto", + historia_como_um: "", + historia_eu_quero: "", + historia_para_que: "", + }), + (err: Error) => { + assert.ok(err instanceof ValidationError); + return true; + }, + ); +}); diff --git a/backend/src/modules/pbis/pbis.service.ts b/backend/src/modules/pbis/pbis.service.ts new file mode 100644 index 0000000..0400b61 --- /dev/null +++ b/backend/src/modules/pbis/pbis.service.ts @@ -0,0 +1,99 @@ +import { createPbiSchema, updatePbiSchema, pbiQuerySchema, Pbi, PbiWithContext, PaginatedPbis } from "./pbis.types.js"; +import { PbisRepository, pbisRepository } from "./pbis.repository.js"; +import { FeaturesRepository, featuresRepository } from "../features/features.repository.js"; +import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; + +export class PbisService { + constructor( + private readonly repository: PbisRepository = pbisRepository, + private readonly featuresRepo: FeaturesRepository = featuresRepository, + ) {} + + async create(input: unknown, usuarioId?: string | null): Promise { + const parseResult = createPbiSchema.safeParse(input); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + const dto = parseResult.data; + + const feature = await this.featuresRepo.findById(dto.feature_id); + if (!feature) { + throw new NotFoundError("Feature não encontrada."); + } + + return await this.repository.create(dto, usuarioId); + } + + async list(queryInput: unknown): Promise { + const parseResult = pbiQuerySchema.safeParse(queryInput); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + return await this.repository.findAll(parseResult.data); + } + + async getById(id: string): Promise { + validateUuid(id, "ID do PBI"); + + const pbi = await this.repository.findById(id); + if (!pbi) { + throw new NotFoundError("PBI não encontrado."); + } + + return pbi; + } + + async update(id: string, input: unknown, usuarioId?: string | null): Promise { + validateUuid(id, "ID do PBI"); + + const existing = await this.repository.findById(id); + if (!existing) { + throw new NotFoundError("PBI não encontrado."); + } + + const parseResult = updatePbiSchema.safeParse(input); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + throw new ValidationError(issue.message, parseResult.error.format()); + } + + const updated = await this.repository.update(id, parseResult.data, usuarioId); + if (!updated) { + throw new NotFoundError("PBI não encontrado."); + } + + return updated; + } + + async complete(id: string, usuarioId?: string | null): Promise { + validateUuid(id, "ID do PBI"); + + const existing = await this.repository.findById(id); + if (!existing) { + throw new NotFoundError("PBI não encontrado."); + } + if (existing.status === "concluido") { + return existing; + } + + if ((existing.criterios_count ?? 0) === 0) { + throw new ValidationError( + "Não é possível concluir o PBI: é necessário ao menos um cenário de aceitação DADO/QUANDO/ENTÃO.", + { campos_faltantes: ["cenarios_aceitacao"] }, + ); + } + + const completed = await this.repository.markConcluded(id, usuarioId); + if (!completed) { + throw new NotFoundError("PBI não encontrado."); + } + + return completed; + } +} + +export const pbisService = new PbisService(); diff --git a/backend/src/modules/pbis/pbis.types.ts b/backend/src/modules/pbis/pbis.types.ts new file mode 100644 index 0000000..baf168d --- /dev/null +++ b/backend/src/modules/pbis/pbis.types.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; + +export const PBI_STATUSES = ["rascunho", "concluido"] as const; +export type PbiStatus = (typeof PBI_STATUSES)[number]; + +export const PBI_PRIORITIES = ["Must", "Should", "Could"] as const; +export type PbiPriority = (typeof PBI_PRIORITIES)[number]; + +export const createPbiSchema = z.object({ + feature_id: z.string({ required_error: "A feature é obrigatória." }).uuid("A feature deve ser um UUID válido."), + titulo: z + .string({ required_error: "O título do PBI é obrigatório." }) + .trim() + .min(1, "O título do PBI é obrigatório.") + .max(255, "O título não pode exceder 255 caracteres."), + historia_como_um: z + .string({ required_error: "O bloco COMO UM é obrigatório." }) + .trim() + .min(1, "O bloco COMO UM é obrigatório."), + historia_eu_quero: z + .string({ required_error: "O bloco EU QUERO é obrigatório." }) + .trim() + .min(1, "O bloco EU QUERO é obrigatório."), + historia_para_que: z + .string({ required_error: "O bloco PARA QUE é obrigatório." }) + .trim() + .min(1, "O bloco PARA QUE é obrigatório."), + regras_observacoes: z.string().trim().optional().nullable(), + tipo: z.string().trim().min(1).max(50).default("Funcional"), + prioridade: z.enum(PBI_PRIORITIES).default("Must"), +}); + +export type CreatePbiDTO = z.infer; + +export const updatePbiSchema = z.object({ + titulo: z.string().trim().min(1, "O título do PBI não pode ser vazio.").max(255, "O título não pode exceder 255 caracteres.").optional(), + historia_como_um: z.string().trim().min(1, "O bloco COMO UM não pode ser vazio.").optional(), + historia_eu_quero: z.string().trim().min(1, "O bloco EU QUERO não pode ser vazio.").optional(), + historia_para_que: z.string().trim().min(1, "O bloco PARA QUE não pode ser vazio.").optional(), + regras_observacoes: z.string().trim().optional().nullable(), + tipo: z.string().trim().min(1).max(50).optional(), + prioridade: z.enum(PBI_PRIORITIES).optional(), + justificativa: z.string().trim().optional().nullable(), +}); + +export type UpdatePbiDTO = z.infer; + +export const pbiQuerySchema = z.object({ + feature_id: z.string().uuid().optional(), + status: z.enum(PBI_STATUSES).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type PbiQueryDTO = z.infer; + +export interface Pbi { + id: string; + feature_id: string; + codigo: string; + titulo: string; + historia_como_um: string; + historia_eu_quero: string; + historia_para_que: string; + regras_observacoes: string | null; + tipo: string; + prioridade: PbiPriority; + status: PbiStatus; + score_completude: number; + provenance: string; + created_at: Date | string; + updated_at: Date | string; +} + +export interface PbiWithContext extends Pbi { + criterios_count?: number; + feature_titulo?: string; + epico_id?: string; + epico_titulo?: string; + projeto_id?: string; +} + +export interface PaginatedPbis { + items: PbiWithContext[]; + total: number; + limit: number; + offset: number; +} diff --git a/backend/src/modules/projects/projects.service.test.ts b/backend/src/modules/projects/projects.service.test.ts index d9b97f7..ea0490c 100644 --- a/backend/src/modules/projects/projects.service.test.ts +++ b/backend/src/modules/projects/projects.service.test.ts @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { ProjectsService, ConflictError, ValidationError, NotFoundError } from "./projects.service.js"; +import { ProjectsService } from "./projects.service.js"; +import { ConflictError, ValidationError, NotFoundError } from "../../shared/errors.js"; import { ProjectsRepository } from "./projects.repository.js"; import { Project, ProjectWithStats, PaginatedProjects, CreateProjectDTO, UpdateProjectDTO, ProjectQueryDTO } from "./projects.types.js"; diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts index 85fd3bf..76bfc91 100644 --- a/backend/src/modules/projects/projects.service.ts +++ b/backend/src/modules/projects/projects.service.ts @@ -8,49 +8,13 @@ import { PaginatedProjects, } from "./projects.types.js"; import { ProjectsRepository, projectsRepository } from "./projects.repository.js"; - -const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -export class AppError extends Error { - constructor( - public readonly message: string, - public readonly statusCode: number = 400, - public readonly code: string = "BAD_REQUEST", - public readonly details?: unknown, - ) { - super(message); - this.name = "AppError"; - } -} - -export class NotFoundError extends AppError { - constructor(message: string = "Recurso não encontrado.") { - super(message, 404, "NOT_FOUND"); - this.name = "NotFoundError"; - } -} - -export class ConflictError extends AppError { - constructor(message: string = "Conflito de integridade com recurso existente.") { - super(message, 409, "CONFLICT"); - this.name = "ConflictError"; - } -} - -export class ValidationError extends AppError { - constructor(message: string, details?: unknown) { - super(message, 400, "VALIDATION_ERROR", details); - this.name = "ValidationError"; - } -} +import { ConflictError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; export class ProjectsService { constructor(private readonly repository: ProjectsRepository = projectsRepository) {} private validateUuid(id: string): void { - if (!id || !UUID_REGEX.test(id)) { - throw new ValidationError("ID do projeto inválido. Deve ser um UUID válido."); - } + validateUuid(id, "ID do projeto"); } async create(input: unknown, usuarioId?: string | null): Promise { diff --git a/backend/src/shared/errors.ts b/backend/src/shared/errors.ts new file mode 100644 index 0000000..79def66 --- /dev/null +++ b/backend/src/shared/errors.ts @@ -0,0 +1,40 @@ +export class AppError extends Error { + constructor( + public readonly message: string, + public readonly statusCode: number = 400, + public readonly code: string = "BAD_REQUEST", + public readonly details?: unknown, + ) { + super(message); + this.name = "AppError"; + } +} + +export class NotFoundError extends AppError { + constructor(message: string = "Recurso não encontrado.") { + super(message, 404, "NOT_FOUND"); + this.name = "NotFoundError"; + } +} + +export class ConflictError extends AppError { + constructor(message: string = "Conflito de integridade com recurso existente.") { + super(message, 409, "CONFLICT"); + this.name = "ConflictError"; + } +} + +export class ValidationError extends AppError { + constructor(message: string, details?: unknown) { + super(message, 400, "VALIDATION_ERROR", details); + this.name = "ValidationError"; + } +} + +export const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function validateUuid(id: string, label: string): void { + if (!id || !UUID_REGEX.test(id)) { + throw new ValidationError(`${label} inválido. Deve ser um UUID válido.`); + } +} diff --git a/database/migrations/005_backlog_hierarchy_domain.sql b/database/migrations/005_backlog_hierarchy_domain.sql new file mode 100644 index 0000000..8ca689f --- /dev/null +++ b/database/migrations/005_backlog_hierarchy_domain.sql @@ -0,0 +1,54 @@ +-- ============================================================================== +-- Migration 005: Campos obrigatórios do guia para épico/feature/pbi e +-- estrutura polimórfica ordenada dos critérios de aceitação (S1-05/06/07/10) +-- ============================================================================== + +ALTER TABLE epico + ADD COLUMN IF NOT EXISTS descricao TEXT, + ADD COLUMN IF NOT EXISTS resultado_esperado TEXT, + ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'rascunho'; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ck_epico_status') THEN + ALTER TABLE epico ADD CONSTRAINT ck_epico_status CHECK (status IN ('rascunho', 'concluido')); + END IF; +END $$; + +ALTER TABLE feature + ADD COLUMN IF NOT EXISTS descricao TEXT, + ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'rascunho'; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ck_feature_status') THEN + ALTER TABLE feature ADD CONSTRAINT ck_feature_status CHECK (status IN ('rascunho', 'concluido')); + END IF; +END $$; + +ALTER TABLE pbi + ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'rascunho'; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ck_pbi_status') THEN + ALTER TABLE pbi ADD CONSTRAINT ck_pbi_status CHECK (status IN ('rascunho', 'concluido')); + END IF; +END $$; + +-- Critérios de aceitação polimórficos: nome do cenário (exclusivo de PBI) e +-- ordem persistida, exigidos por PBI-01.2.1 a PBI-01.2.3. +ALTER TABLE criterio_aceitacao + ADD COLUMN IF NOT EXISTS nome VARCHAR(255), + ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 1; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ck_criterio_entidade_tipo') THEN + ALTER TABLE criterio_aceitacao ADD CONSTRAINT ck_criterio_entidade_tipo CHECK (entidade_tipo IN ('epico', 'feature', 'pbi')); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_epico_projeto ON epico(projeto_id); +CREATE INDEX IF NOT EXISTS idx_feature_epico ON feature(epico_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_criterio_entidade_ordem ON criterio_aceitacao(entidade_tipo, entidade_id, ordem); diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1cca77e..fa0f77c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -293,6 +293,530 @@ paths: schema: $ref: '#/components/schemas/Error' + /api/v1/epics: + post: + summary: Cadastra um épico dentro de um projeto (PBI-01.1.2 / S1-05) + operationId: createEpic + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. Bloqueia projeto arquivado ou inexistente. + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEpicRequest' + responses: + '201': + description: Épico criado com status rascunho + content: + application/json: + schema: + $ref: '#/components/schemas/EpicResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Projeto não encontrado + + get: + summary: Lista épicos de um projeto (S1-05) + operationId: listEpics + security: + - cookieAuth: [] + parameters: + - name: projeto_id + in: query + schema: + type: string + format: uuid + - name: status + in: query + schema: + type: string + enum: [rascunho, concluido] + - name: limit + in: query + schema: + type: integer + default: 50 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + '200': + description: Lista paginada de épicos + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEpicsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/epics/{id}: + get: + summary: Obtém detalhes de um épico por ID (S1-05) + operationId: getEpicById + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Detalhes do épico com estatísticas + content: + application/json: + schema: + $ref: '#/components/schemas/EpicResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Épico não encontrado + + put: + summary: Atualiza dados de um épico (S1-05) + operationId: updateEpic + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. PATCH no mesmo caminho é um alias desta operação. + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateEpicRequest' + responses: + '200': + description: Épico atualizado + content: + application/json: + schema: + $ref: '#/components/schemas/EpicResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Épico não encontrado + + /api/v1/epics/{id}/complete: + patch: + summary: Marca um épico como concluído quando os campos obrigatórios do guia e ao menos um critério existirem (PBI-01.1.2 / S1-05) + operationId: completeEpic + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Épico concluído + content: + application/json: + schema: + $ref: '#/components/schemas/EpicResponse' + '400': + description: Campos obrigatórios ou critérios de aceitação ausentes + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Épico não encontrado + + /api/v1/features: + post: + summary: Cadastra uma feature dentro de um épico (PBI-01.1.3 / S1-06) + operationId: createFeature + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFeatureRequest' + responses: + '201': + description: Feature criada com status rascunho + content: + application/json: + schema: + $ref: '#/components/schemas/FeatureResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Épico não encontrado + + get: + summary: Lista features de um épico (S1-06) + operationId: listFeatures + security: + - cookieAuth: [] + parameters: + - name: epico_id + in: query + schema: + type: string + format: uuid + - name: status + in: query + schema: + type: string + enum: [rascunho, concluido] + responses: + '200': + description: Lista paginada de features + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedFeaturesResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/features/{id}: + get: + summary: Obtém detalhes de uma feature, incluindo o épico de origem (PBI-01.1.3 / S1-06) + operationId: getFeatureById + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Detalhes da feature com contexto do épico + content: + application/json: + schema: + $ref: '#/components/schemas/FeatureResponse' + '404': + description: Feature não encontrada + + put: + summary: Atualiza dados de uma feature (S1-06) + operationId: updateFeature + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. PATCH no mesmo caminho é um alias desta operação. + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFeatureRequest' + responses: + '200': + description: Feature atualizada + content: + application/json: + schema: + $ref: '#/components/schemas/FeatureResponse' + '404': + description: Feature não encontrada + + /api/v1/features/{id}/complete: + patch: + summary: Marca uma feature como concluída quando descrição e objetivo estiverem preenchidos (PBI-01.1.3 / S1-06) + operationId: completeFeature + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Feature concluída + content: + application/json: + schema: + $ref: '#/components/schemas/FeatureResponse' + '400': + description: Campos obrigatórios ausentes + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Feature não encontrada + + /api/v1/pbis: + post: + summary: Cadastra um PBI com história em três blocos dentro de uma feature (PBI-01.1.4 / S1-07) + operationId: createPbi + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. O código é gerado automaticamente pelo sistema. + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePbiRequest' + responses: + '201': + description: PBI criado com status rascunho e código provisório gerado + content: + application/json: + schema: + $ref: '#/components/schemas/PbiResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Feature não encontrada + + get: + summary: Lista PBIs de uma feature (S1-07) + operationId: listPbis + security: + - cookieAuth: [] + parameters: + - name: feature_id + in: query + schema: + type: string + format: uuid + - name: status + in: query + schema: + type: string + enum: [rascunho, concluido] + responses: + '200': + description: Lista paginada de PBIs + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedPbisResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/pbis/{id}: + get: + summary: Obtém detalhes de um PBI, incluindo a cadeia feature/épico/projeto (PBI-01.1.4 / S1-07) + operationId: getPbiById + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Detalhes do PBI com contexto hierárquico + content: + application/json: + schema: + $ref: '#/components/schemas/PbiResponse' + '404': + description: PBI não encontrado + + put: + summary: Atualiza dados de um PBI (S1-07) + operationId: updatePbi + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. PATCH no mesmo caminho é um alias desta operação. + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePbiRequest' + responses: + '200': + description: PBI atualizado + content: + application/json: + schema: + $ref: '#/components/schemas/PbiResponse' + '404': + description: PBI não encontrado + + /api/v1/pbis/{id}/complete: + patch: + summary: Marca um PBI como concluído quando ao menos um cenário DADO/QUANDO/ENTÃO existir (PBI-01.1.4 / S1-07) + operationId: completePbi + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: PBI concluído + content: + application/json: + schema: + $ref: '#/components/schemas/PbiResponse' + '400': + description: Nenhum cenário de aceitação registrado + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: PBI não encontrado + + /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. + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCriterionRequest' + responses: + '201': + description: Critério adicionado ao final da lista da entidade + content: + application/json: + schema: + $ref: '#/components/schemas/CriterionResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Entidade referenciada não encontrada + + get: + summary: Lista critérios de aceitação ordenados de uma entidade (S1-10) + operationId: listCriteria + security: + - cookieAuth: [] + parameters: + - name: entidade_tipo + in: query + required: true + schema: + type: string + enum: [epico, feature, pbi] + - name: entidade_id + in: query + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Critérios ordenados da entidade + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: '#/components/schemas/CriterionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/criteria/{id}: + delete: + summary: Remove um critério de aceitação e reordena os demais da mesma entidade (PBI-01.2.1 / S1-10) + operationId: deleteCriterion + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Critério removido + content: + application/json: + schema: + $ref: '#/components/schemas/CriterionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Critério não encontrado + /ingest/document: post: summary: Solicita ingestão assíncrona de documento @@ -361,146 +885,535 @@ paths: '502': description: Ollama indisponível -components: - securitySchemes: - cookieAuth: - type: apiKey - in: cookie - name: sinapse_session +components: + securitySchemes: + cookieAuth: + type: apiKey + in: cookie + name: sinapse_session + + schemas: + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + example: usuario@exemplo.com + password: + type: string + format: password + writeOnly: true + + AuthenticatedUser: + type: object + required: [id, nome, email, role] + properties: + id: + type: string + format: uuid + nome: + type: string + email: + type: string + format: email + role: + type: string + enum: [admin, po, dev] + + AuthUserResponse: + type: object + required: [user] + properties: + user: + $ref: '#/components/schemas/AuthenticatedUser' + + CreateProjectRequest: + type: object + required: [nome, cliente] + properties: + nome: + type: string + minLength: 1 + maxLength: 255 + cliente: + type: string + minLength: 1 + maxLength: 255 + descricao: + type: string + nullable: true + status: + type: string + enum: [ativo, em_andamento, concluido, arquivado] + default: ativo + data_inicio: + type: string + format: date-time + nullable: true + + UpdateProjectRequest: + type: object + properties: + nome: + type: string + minLength: 1 + maxLength: 255 + cliente: + type: string + minLength: 1 + maxLength: 255 + descricao: + type: string + nullable: true + status: + type: string + enum: [ativo, em_andamento, concluido, arquivado] + data_inicio: + type: string + format: date-time + nullable: true + justificativa: + type: string + + ProjectResponse: + type: object + required: [id, nome, cliente, status, created_at, updated_at] + properties: + id: + type: string + format: uuid + nome: + type: string + cliente: + type: string + descricao: + type: string + nullable: true + status: + type: string + enum: [ativo, em_andamento, concluido, arquivado] + data_inicio: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + epicos_count: + type: integer + documentos_count: + type: integer + + PaginatedProjectsResponse: + type: object + required: [items, total, limit, offset] + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectResponse' + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + + CreateEpicRequest: + type: object + required: [projeto_id, titulo] + properties: + projeto_id: + type: string + format: uuid + titulo: + type: string + minLength: 1 + maxLength: 255 + descricao: + type: string + nullable: true + objetivo: + type: string + nullable: true + escopo_macro: + type: string + nullable: true + resultado_esperado: + type: string + nullable: true + prioridade: + type: string + enum: [Must, Should, Could] + default: Must - schemas: - LoginRequest: + UpdateEpicRequest: type: object - required: [email, password] properties: - email: + titulo: type: string - format: email - example: usuario@exemplo.com - password: + minLength: 1 + maxLength: 255 + descricao: + type: string + nullable: true + objetivo: + type: string + nullable: true + escopo_macro: + type: string + nullable: true + resultado_esperado: + type: string + nullable: true + prioridade: + type: string + enum: [Must, Should, Could] + justificativa: type: string - format: password - writeOnly: true - AuthenticatedUser: + EpicResponse: type: object - required: [id, nome, email, role] + required: [id, projeto_id, titulo, status, created_at, updated_at] properties: id: type: string format: uuid - nome: + projeto_id: type: string - email: + format: uuid + titulo: type: string - format: email - role: + descricao: type: string - enum: [admin, po, dev] + nullable: true + objetivo: + type: string + nullable: true + escopo_macro: + type: string + nullable: true + resultado_esperado: + type: string + nullable: true + prioridade: + type: string + enum: [Must, Should, Could] + status: + type: string + enum: [rascunho, concluido] + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + features_count: + type: integer + criterios_count: + type: integer - AuthUserResponse: + PaginatedEpicsResponse: type: object - required: [user] + required: [items, total, limit, offset] properties: - user: - $ref: '#/components/schemas/AuthenticatedUser' + items: + type: array + items: + $ref: '#/components/schemas/EpicResponse' + total: + type: integer + limit: + type: integer + offset: + type: integer - CreateProjectRequest: + CreateFeatureRequest: type: object - required: [nome, cliente] + required: [epico_id, titulo] properties: - nome: + epico_id: + type: string + format: uuid + titulo: type: string minLength: 1 maxLength: 255 - cliente: + descricao: + type: string + nullable: true + objetivo: + type: string + nullable: true + prioridade: + type: string + enum: [Must, Should, Could] + default: Must + + UpdateFeatureRequest: + type: object + properties: + titulo: type: string minLength: 1 maxLength: 255 descricao: type: string nullable: true + objetivo: + type: string + nullable: true + prioridade: + type: string + enum: [Must, Should, Could] + justificativa: + type: string + + FeatureResponse: + type: object + required: [id, epico_id, titulo, status, created_at, updated_at] + properties: + id: + type: string + format: uuid + epico_id: + type: string + format: uuid + titulo: + type: string + descricao: + type: string + nullable: true + objetivo: + type: string + nullable: true + prioridade: + type: string + enum: [Must, Should, Could] status: type: string - enum: [ativo, em_andamento, concluido, arquivado] - default: ativo - data_inicio: + enum: [rascunho, concluido] + created_at: type: string format: date-time - nullable: true + updated_at: + type: string + format: date-time + pbis_count: + type: integer + criterios_count: + type: integer + epico_titulo: + type: string + projeto_id: + type: string + format: uuid - UpdateProjectRequest: + PaginatedFeaturesResponse: type: object + required: [items, total, limit, offset] properties: - nome: + items: + type: array + items: + $ref: '#/components/schemas/FeatureResponse' + total: + type: integer + limit: + type: integer + offset: + type: integer + + CreatePbiRequest: + type: object + required: [feature_id, titulo, historia_como_um, historia_eu_quero, historia_para_que] + properties: + feature_id: + type: string + format: uuid + titulo: type: string minLength: 1 maxLength: 255 - cliente: + historia_como_um: type: string minLength: 1 - maxLength: 255 - descricao: + historia_eu_quero: + type: string + minLength: 1 + historia_para_que: + type: string + minLength: 1 + regras_observacoes: type: string nullable: true - status: + tipo: type: string - enum: [ativo, em_andamento, concluido, arquivado] - data_inicio: + default: Funcional + prioridade: + type: string + enum: [Must, Should, Could] + default: Must + + UpdatePbiRequest: + type: object + properties: + titulo: + type: string + minLength: 1 + maxLength: 255 + historia_como_um: + type: string + minLength: 1 + historia_eu_quero: + type: string + minLength: 1 + historia_para_que: + type: string + minLength: 1 + regras_observacoes: type: string - format: date-time nullable: true + tipo: + type: string + prioridade: + type: string + enum: [Must, Should, Could] justificativa: type: string - ProjectResponse: + PbiResponse: type: object - required: [id, nome, cliente, status, created_at, updated_at] + required: [id, feature_id, codigo, titulo, historia_como_um, historia_eu_quero, historia_para_que, status, created_at, updated_at] properties: id: type: string format: uuid - nome: + feature_id: type: string - cliente: + format: uuid + codigo: type: string - descricao: + titulo: + type: string + historia_como_um: + type: string + historia_eu_quero: + type: string + historia_para_que: + type: string + regras_observacoes: type: string nullable: true + tipo: + type: string + prioridade: + type: string + enum: [Must, Should, Could] status: type: string - enum: [ativo, em_andamento, concluido, arquivado] - data_inicio: + enum: [rascunho, concluido] + score_completude: + type: integer + provenance: type: string - nullable: true created_at: type: string format: date-time updated_at: type: string format: date-time - epicos_count: - type: integer - documentos_count: + criterios_count: type: integer + feature_titulo: + type: string + epico_id: + type: string + format: uuid + epico_titulo: + type: string + projeto_id: + type: string + format: uuid - PaginatedProjectsResponse: + PaginatedPbisResponse: type: object required: [items, total, limit, offset] properties: items: type: array items: - $ref: '#/components/schemas/ProjectResponse' + $ref: '#/components/schemas/PbiResponse' total: type: integer - minimum: 0 limit: type: integer - minimum: 1 offset: type: integer - minimum: 0 + + CreateCriterionRequest: + type: object + required: [entidade_tipo, entidade_id] + description: Épico e feature exigem "texto"; PBI exige "nome", "dado", "quando" e "entao" em conjunto. + properties: + entidade_tipo: + type: string + enum: [epico, feature, pbi] + entidade_id: + type: string + format: uuid + texto: + type: string + nome: + type: string + dado: + type: string + quando: + type: string + entao: + type: string + + CriterionResponse: + type: object + required: [id, entidade_tipo, entidade_id, ordem, created_at] + properties: + id: + type: string + format: uuid + entidade_tipo: + type: string + enum: [epico, feature, pbi] + entidade_id: + type: string + format: uuid + texto: + type: string + nullable: true + nome: + type: string + nullable: true + dado: + type: string + nullable: true + quando: + type: string + nullable: true + entao: + type: string + nullable: true + ordem: + type: integer + minimum: 1 + created_at: + type: string + format: date-time IngestDocumentRequest: type: object diff --git a/frontend/src/auth/api.ts b/frontend/src/auth/api.ts index 8c4a018..62dc6d6 100644 --- a/frontend/src/auth/api.ts +++ b/frontend/src/auth/api.ts @@ -1,7 +1,7 @@ export interface User { id: string; name: string; email: string; role: "admin" | "po" | "dev" } export class ApiError extends Error { - constructor(public status: number) { super(`HTTP ${status}`); } + constructor(public status: number, public details?: unknown) { super(`HTTP ${status}`); } } // Session credentials stay in a server-managed HttpOnly cookie. @@ -17,7 +17,8 @@ export async function apiRequest(path: string, init: RequestInit = {}) { if (response.status === 401 && !path.startsWith("/auth/")) { window.dispatchEvent(new Event("session-expired")); } - throw new ApiError(response.status); + const details = await response.clone().json().catch(() => undefined); + throw new ApiError(response.status, details); } return response; } diff --git a/frontend/src/auth/navigation.ts b/frontend/src/auth/navigation.ts index a256cf4..08af46f 100644 --- a/frontend/src/auth/navigation.ts +++ b/frontend/src/auth/navigation.ts @@ -1,7 +1,11 @@ export const routes = { architecture: "/", projects: "/projects", requirements: "/requirements", rag: "/rag" } as const; +const SEGMENT = "[a-zA-Z0-9_-]+"; + export function isProjectPath(path: string) { - return /^\/projects(?:\/[a-zA-Z0-9_-]+)?$/.test(path); + return new RegExp( + `^/projects(?:/${SEGMENT}(?:/epics/${SEGMENT}(?:/features/${SEGMENT}(?:/pbis/${SEGMENT})?)?)?)?$`, + ).test(path); } /** Only known internal pages are valid post-login destinations. */ diff --git a/frontend/src/backlog/Backlog.test.tsx b/frontend/src/backlog/Backlog.test.tsx new file mode 100644 index 0000000..7e33842 --- /dev/null +++ b/frontend/src/backlog/Backlog.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Projects as ProjectsPage } from "../projects/Projects"; +import { parseBacklogRoute } from "./navigation"; + +const response = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); +const Projects = ({ pathname }: { pathname: string }) => ; +afterEach(() => { cleanup(); vi.unstubAllGlobals(); window.history.replaceState(null, "", "/"); }); + +const epic = { + id: "epic-1", projeto_id: "project-1", titulo: "Especificar o backlog", descricao: null, objetivo: null, + escopo_macro: null, resultado_esperado: null, prioridade: "Must", status: "rascunho", features_count: 0, criterios_count: 0, +}; + +it("reconhece as rotas aninhadas do backlog e rejeita caminhos fora do padrão", () => { + expect(parseBacklogRoute("/projects/p1/epics/new")).toEqual({ screen: "epic-new", projectId: "p1" }); + expect(parseBacklogRoute("/projects/p1/epics/e1")).toEqual({ screen: "epic-detail", projectId: "p1", epicId: "e1" }); + expect(parseBacklogRoute("/projects/p1/epics/e1/features/new")).toEqual({ screen: "feature-new", projectId: "p1", epicId: "e1" }); + expect(parseBacklogRoute("/projects/p1/epics/e1/features/f1")).toEqual({ screen: "feature-detail", projectId: "p1", epicId: "e1", featureId: "f1" }); + expect(parseBacklogRoute("/projects/p1/epics/e1/features/f1/pbis/new")).toEqual({ screen: "pbi-new", projectId: "p1", epicId: "e1", featureId: "f1" }); + expect(parseBacklogRoute("/projects/p1/epics/e1/features/f1/pbis/b1")).toEqual({ screen: "pbi-detail", projectId: "p1", epicId: "e1", featureId: "f1", pbiId: "b1" }); + expect(parseBacklogRoute("/projects/p1")).toBeNull(); + expect(parseBacklogRoute("/projects")).toBeNull(); +}); + +it("PBI-01.1.2 Cenário 1: cria épico e navega ao seu detalhe", async () => { + const request = vi.fn(async (url: string, init?: RequestInit) => { + if (url === "/api/v1/epics" && init?.method === "POST") return response(epic, 201); + throw new Error(`Requisição inesperada: ${url}`); + }); + vi.stubGlobal("fetch", request); + render(); + + fireEvent.change(screen.getByLabelText("Título (obrigatório)"), { target: { value: "Especificar o backlog" } }); + fireEvent.click(screen.getByText("Criar épico", { selector: "button" })); + + await waitFor(() => expect(window.location.pathname).toBe("/projects/project-1/epics/epic-1")); + const body = JSON.parse(request.mock.calls[0][1]?.body as string); + expect(body.projeto_id).toBe("project-1"); + expect(body.titulo).toBe("Especificar o backlog"); +}); + +it("PBI-01.1.2 Cenário 3: mostra os campos faltantes quando a conclusão é recusada", async () => { + const request = vi.fn(async (url: string, init?: RequestInit) => { + if (url === `/api/v1/epics/${epic.id}` && init?.method === undefined) return response(epic); + if (url === `/api/v1/epics/${epic.id}/complete` && init?.method === "PATCH") { + return response({ error: "Não é possível concluir o épico.", code: "VALIDATION_ERROR", details: { campos_faltantes: ["escopo_macro", "criterios_aceitacao"] } }, 400); + } + if (url === `/api/v1/features?epico_id=${epic.id}&limit=100`) return response({ items: [], total: 0, limit: 100, offset: 0 }); + throw new Error(`Requisição inesperada: ${url}`); + }); + vi.stubGlobal("fetch", request); + render(); + + await screen.findByText("Especificar o backlog"); + fireEvent.click(screen.getByText("Marcar como concluído")); + + await screen.findByText(/Escopo macro, Critérios de aceitação/); +}); + +it("PBI-01.1.3 Cenário 2: exibe o épico de origem ao abrir a feature", async () => { + const feature = { + id: "feature-1", epico_id: epic.id, titulo: "Estruturação dos itens", descricao: "d", objetivo: "o", + prioridade: "Must", status: "rascunho", pbis_count: 0, criterios_count: 0, epico_titulo: "Especificar o backlog", projeto_id: "project-1", + }; + const request = vi.fn(async (url: string) => { + if (url === `/api/v1/features/${feature.id}`) return response(feature); + if (url.startsWith("/api/v1/pbis?feature_id=")) return response({ items: [], total: 0, limit: 100, offset: 0 }); + throw new Error(`Requisição inesperada: ${url}`); + }); + vi.stubGlobal("fetch", request); + render(); + + expect(await screen.findByText("Épico: Especificar o backlog")).toBeTruthy(); + expect(screen.getByText("Estruturação dos itens")).toBeTruthy(); +}); + +it("PBI-01.1.4 Cenário 2: apresenta os três blocos da história como campos distintos", async () => { + vi.stubGlobal("fetch", vi.fn()); + render(); + + expect(screen.getByLabelText("COMO UM (obrigatório)")).toBeTruthy(); + expect(screen.getByLabelText("EU QUERO (obrigatório)")).toBeTruthy(); + expect(screen.getByLabelText("PARA QUE (obrigatório)")).toBeTruthy(); +}); diff --git a/frontend/src/backlog/Backlog.tsx b/frontend/src/backlog/Backlog.tsx new file mode 100644 index 0000000..7f408c6 --- /dev/null +++ b/frontend/src/backlog/Backlog.tsx @@ -0,0 +1,51 @@ +import type { BacklogRoute } from "./navigation"; +import { navigate } from "./navigation"; +import { EpicForm, EpicDetail } from "./Epics"; +import { FeatureList, FeatureForm, FeatureDetail } from "./Features"; +import { PbiList, PbiForm, PbiDetail } from "./Pbis"; + +export function BacklogScreen({ route, canCreate }: { route: Exclude; canCreate: boolean }) { + if (route.screen === "epic-new") { + return canCreate ? : ; + } + + if (route.screen === "epic-detail") { + return ( + + + + ); + } + + if (route.screen === "feature-new") { + return canCreate + ? + : ; + } + + if (route.screen === "feature-detail") { + return ( + + + + ); + } + + if (route.screen === "pbi-new") { + return canCreate + ? + : ; + } + + return ; +} + +function SomenteLeitura({ voltar }: { voltar: string }) { + return ( +
+

Acesso de leitura

+

Seu perfil não permite criar itens do backlog.

+ +
+ ); +} diff --git a/frontend/src/backlog/Epics.tsx b/frontend/src/backlog/Epics.tsx new file mode 100644 index 0000000..e07a604 --- /dev/null +++ b/frontend/src/backlog/Epics.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { ApiError } from "../auth/api"; +import { navigate } from "./navigation"; +import { createEpic, completeEpic, getEpic, listEpics, camposFaltantesDe, type Epic, type EpicInput } from "./api"; +import { descreverCamposFaltantes } from "./fields"; +import "../projects/projects.css"; + +type ListResult = { state: "loading" } | { state: "error"; message: string } | { state: "ready"; epics: Epic[] }; +const emptyInput: EpicInput = { projeto_id: "", titulo: "", descricao: "", objetivo: "", escopo_macro: "", resultado_esperado: "" }; + +export function EpicList({ projetoId, canCreate }: { projetoId: string; canCreate: boolean }) { + const [result, setResult] = useState({ state: "loading" }); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setResult({ state: "loading" }); + listEpics(projetoId, controller.signal) + .then((epics) => { if (!controller.signal.aborted) setResult({ state: "ready", epics }); }) + .catch((error) => { + if (controller.signal.aborted) return; + setResult({ state: "error", message: error instanceof ApiError && error.status === 401 ? "É necessário entrar para acessar os épicos." : "Não foi possível carregar os épicos." }); + }); + return () => controller.abort(); + }, [projetoId, attempt]); + + return ( +
+
+

Épicos do projeto

Épicos

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

{result.message}

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

Nenhum épico cadastrado

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

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

{epic.titulo}

+

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

+ +
+ ))}
)} +
+ ); +} + +export function EpicForm({ projetoId }: { projetoId: string }) { + const [values, setValues] = useState({ ...emptyInput, projeto_id: projetoId }); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const submitting = useRef(false); + const mounted = useRef(true); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); + + return ( +
+

Épicos / Novo épico

Criar épico

+

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

+
{ + event.preventDefault(); + if (submitting.current) return; + if (!values.titulo.trim()) { setMessage("Informe o título do épico."); return; } + submitting.current = true; setBusy(true); setMessage(""); + try { + const epic = await createEpic({ ...values, projeto_id: projetoId }); + if (mounted.current) navigate(`/projects/${projetoId}/epics/${epic.id}`); + } catch (error) { + if (!mounted.current) return; + setMessage(error instanceof ApiError && error.status === 404 ? "Projeto não encontrado." : "Não foi possível criar o épico. Tente novamente."); + } finally { + submitting.current = false; + if (mounted.current) setBusy(false); + } + }}> + {([ + ["titulo", "Título", "input"], ["objetivo", "Objetivo", "textarea"], ["descricao", "Descrição", "textarea"], + ["escopo_macro", "Escopo macro", "textarea"], ["resultado_esperado", "Resultado esperado", "textarea"], + ] as const).map(([field, label, kind]) => ( +
+ + {kind === "textarea" + ?