diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3e29c4..07fd9db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,10 +52,11 @@ jobs: - run: npm run test:seed working-directory: backend - name: Test archive cascade on PostgreSQL - run: node --import tsx --test src/modules/projects/projects.archive.db.test.ts src/modules/projects/hierarchy-archive.db.test.ts src/database/migration-010.db.test.ts + run: node --import tsx --test src/modules/projects/projects.archive.db.test.ts src/modules/projects/hierarchy-archive.db.test.ts src/modules/projects/projects.backlog-tree.db.test.ts src/database/migration-010.db.test.ts src/database/migration-011.db.test.ts working-directory: backend env: ARCHIVE_TEST_DATABASE_URL: postgresql://seed_test:seed_test_only@localhost:5432/sinapse_seed_test + BACKLOG_TREE_TEST_DATABASE_URL: postgresql://seed_test:seed_test_only@localhost:5432/sinapse_seed_test validate-environment: name: Validate Workflows and Docker Setup diff --git a/backend/src/database/migration-011.db.test.ts b/backend/src/database/migration-011.db.test.ts new file mode 100644 index 0000000..1e55d18 --- /dev/null +++ b/backend/src/database/migration-011.db.test.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { Pool } from "pg"; +import { validateTarget } from "./seed-lib.js"; + +const migrationPath = resolve(process.cwd(), "../database/migrations/011_unique_entity_technology.sql"); + +test("migration 011 remove vínculos duplicados e pode ser reaplicada", { skip: !process.env.BACKLOG_TREE_TEST_DATABASE_URL }, async () => { + const db = new Pool({ max: 1, connectionString: validateTarget(process.env.BACKLOG_TREE_TEST_DATABASE_URL, "test") }); + const client = await db.connect(); + const schema = `technology_migration_${randomUUID().replaceAll("-", "")}`; + const migration = await readFile(migrationPath, "utf8"); + const entityId = randomUUID(); + const technologyId = randomUUID(); + try { + await client.query(`CREATE SCHEMA ${schema}`); + await client.query(`SET search_path TO ${schema}`); + await client.query("CREATE TABLE entidade_tecnologia (id uuid PRIMARY KEY, entidade_tipo text NOT NULL, entidade_id uuid NOT NULL, tecnologia_id uuid NOT NULL)"); + await client.query( + "INSERT INTO entidade_tecnologia VALUES ($1,'pbi',$3,$4),($2,'pbi',$3,$4)", + [randomUUID(), randomUUID(), entityId, technologyId], + ); + + await client.query("BEGIN"); + await client.query(migration); + assert.equal((await client.query("SELECT count(*)::int AS total FROM entidade_tecnologia")).rows[0].total, 1); + await client.query("SAVEPOINT duplicate_association"); + await assert.rejects(client.query( + "INSERT INTO entidade_tecnologia VALUES ($1,'pbi',$2,$3)", + [randomUUID(), entityId, technologyId], + ), (error: unknown) => error instanceof Error && /duplicate key/i.test(error.message)); + await client.query("ROLLBACK TO SAVEPOINT duplicate_association"); + + await client.query(migration); + assert.equal((await client.query("SELECT count(*)::int AS total FROM entidade_tecnologia")).rows[0].total, 1); + await client.query("COMMIT"); + } finally { + await client.query("ROLLBACK").catch(() => undefined); + await client.query("RESET search_path"); + await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`); + client.release(); + await db.end(); + } +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 072c5fb..572f62f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,6 +17,7 @@ import { documentsRouter } from "./modules/documents/documents.routes.js"; import { searchRouter } from "./modules/search/search.routes.js"; import { chatRouter } from "./modules/chat/chat.routes.js"; import { developersRouter } from "./modules/developers/developers.routes.js"; +import { technologiesRouter } from "./modules/technologies/technologies.routes.js"; import { adminRouter } from "./modules/admin/admin.routes.js"; import { auditRouter } from "./modules/audit/audit.routes.js"; import { errorHandler } from "./middleware/errorHandler.js"; @@ -72,6 +73,7 @@ app.use("/api/v1/chat", chatRouter); // Desenvolvedores e competências app.use("/api/v1/developers", developersRouter); +app.use("/api/v1/technologies", requireAuth, technologiesRouter); // Painel administrativo app.use("/api/v1/admin", adminRouter); diff --git a/backend/src/modules/epics/epics.repository.ts b/backend/src/modules/epics/epics.repository.ts index cc25efd..3a02af6 100644 --- a/backend/src/modules/epics/epics.repository.ts +++ b/backend/src/modules/epics/epics.repository.ts @@ -5,6 +5,7 @@ import { CreateEpicDTO, UpdateEpicDTO, EpicQueryDTO, Epic, EpicWithStats, Pagina import { auditService } from "../audit/audit.service.js"; import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; import { buildAuditChangeData } from "../audit/audit.payloads.js"; +import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; export class EpicsRepository { private pool: Pool; @@ -17,6 +18,7 @@ export class EpicsRepository { const query = ` SELECT e.*, + COALESCE((SELECT array_agg(et.tecnologia_id ORDER BY et.tecnologia_id) FROM entidade_tecnologia et WHERE et.entidade_tipo = 'epico' AND et.entidade_id = e.id), ARRAY[]::uuid[]) AS tecnologias_ids, p.status AS projeto_status, 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 @@ -53,6 +55,7 @@ export class EpicsRepository { const result = await client.query(insertQuery, values); const created = result.rows[0]; + await replaceEntityTechnologies(client, "epico", created.id, data.tecnologias_ids); await auditService.record( { @@ -60,7 +63,7 @@ export class EpicsRepository { entidade_tipo: "epico", entidade_id: created.id, acao: "CRIAR_EPICO", - dados_json: { titulo: created.titulo, projeto_id: created.projeto_id, status: created.status }, + dados_json: { titulo: created.titulo, projeto_id: created.projeto_id, status: created.status, tecnologias_ids: data.tecnologias_ids ?? [] }, }, client, ); @@ -155,6 +158,13 @@ export class EpicsRepository { values, ); const updated = result.rows[0]; + await replaceEntityTechnologies(client, "epico", id, data.tecnologias_ids); + const updatedWithTechnologies = { + ...updated, + tecnologias_ids: data.tecnologias_ids === undefined + ? existing.tecnologias_ids ?? [] + : await getEntityTechnologyIds(client, "epico", id), + }; await auditService.record( { @@ -163,7 +173,7 @@ export class EpicsRepository { entidade_id: id, acao: "ATUALIZAR_EPICO", justificativa: data.justificativa ?? null, - dados_json: buildAuditChangeData(existing, updated, data), + dados_json: buildAuditChangeData(existing, updatedWithTechnologies, data), }, client, ); diff --git a/backend/src/modules/epics/epics.types.ts b/backend/src/modules/epics/epics.types.ts index c329e96..6a02c25 100644 --- a/backend/src/modules/epics/epics.types.ts +++ b/backend/src/modules/epics/epics.types.ts @@ -22,6 +22,7 @@ export const createEpicSchema = z.object({ objetivo: z.string().trim().optional().nullable(), escopo_macro: z.string().trim().optional().nullable(), resultado_esperado: z.string().trim().optional().nullable(), + tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), prioridade: z.enum(EPIC_PRIORITIES).default("Must"), status: z.literal("rascunho").optional(), }); @@ -36,6 +37,7 @@ export const updateEpicSchema = z.object({ resultado_esperado: z.string().trim().optional().nullable(), prioridade: z.enum(EPIC_PRIORITIES).optional(), justificativa: z.string().trim().optional().nullable(), + tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), status: z.never().optional(), }); @@ -58,6 +60,7 @@ export interface Epic { objetivo: string | null; escopo_macro: string | null; resultado_esperado: string | null; + tecnologias_ids?: string[]; prioridade: EpicPriority; status: EpicStatus; created_at: Date | string; diff --git a/backend/src/modules/features/features.repository.ts b/backend/src/modules/features/features.repository.ts index 7834ae5..ffdb31c 100644 --- a/backend/src/modules/features/features.repository.ts +++ b/backend/src/modules/features/features.repository.ts @@ -5,10 +5,12 @@ import { CreateFeatureDTO, UpdateFeatureDTO, FeatureQueryDTO, Feature, FeatureWi import { auditService } from "../audit/audit.service.js"; import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; import { buildAuditChangeData } from "../audit/audit.payloads.js"; +import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; const SELECT_WITH_STATS = ` SELECT f.*, + COALESCE((SELECT array_agg(et.tecnologia_id ORDER BY et.tecnologia_id) FROM entidade_tecnologia et WHERE et.entidade_tipo = 'feature' AND et.entidade_id = f.id), ARRAY[]::uuid[]) AS tecnologias_ids, e.titulo AS epico_titulo, e.projeto_id AS projeto_id, p.status AS projeto_status, @@ -54,6 +56,7 @@ export class FeaturesRepository { const result = await client.query(insertQuery, values); const created = result.rows[0]; + await replaceEntityTechnologies(client, "feature", created.id, data.tecnologias_ids); await auditService.record( { @@ -61,7 +64,7 @@ export class FeaturesRepository { entidade_tipo: "feature", entidade_id: created.id, acao: "CRIAR_FEATURE", - dados_json: { titulo: created.titulo, epico_id: created.epico_id, status: created.status }, + dados_json: { titulo: created.titulo, epico_id: created.epico_id, status: created.status, tecnologias_ids: data.tecnologias_ids ?? [] }, }, client, ); @@ -148,6 +151,13 @@ export class FeaturesRepository { values, ); const updated = result.rows[0]; + await replaceEntityTechnologies(client, "feature", id, data.tecnologias_ids); + const updatedWithTechnologies = { + ...updated, + tecnologias_ids: data.tecnologias_ids === undefined + ? existing.tecnologias_ids ?? [] + : await getEntityTechnologyIds(client, "feature", id), + }; await auditService.record( { @@ -156,7 +166,7 @@ export class FeaturesRepository { entidade_id: id, acao: "ATUALIZAR_FEATURE", justificativa: data.justificativa ?? null, - dados_json: buildAuditChangeData(existing, updated, data), + dados_json: buildAuditChangeData(existing, updatedWithTechnologies, data), }, client, ); diff --git a/backend/src/modules/features/features.types.ts b/backend/src/modules/features/features.types.ts index 97e79f8..bf4676c 100644 --- a/backend/src/modules/features/features.types.ts +++ b/backend/src/modules/features/features.types.ts @@ -17,6 +17,7 @@ export const createFeatureSchema = z.object({ .max(255, "O título não pode exceder 255 caracteres."), descricao: z.string().trim().optional().nullable(), objetivo: z.string().trim().optional().nullable(), + tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), prioridade: z.enum(FEATURE_PRIORITIES).default("Must"), }); @@ -28,6 +29,7 @@ export const updateFeatureSchema = z.object({ objetivo: z.string().trim().optional().nullable(), prioridade: z.enum(FEATURE_PRIORITIES).optional(), justificativa: z.string().trim().optional().nullable(), + tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), }); export type UpdateFeatureDTO = z.infer; @@ -47,6 +49,7 @@ export interface Feature { titulo: string; descricao: string | null; objetivo: string | null; + tecnologias_ids?: string[]; prioridade: FeaturePriority; status: FeatureStatus; created_at: Date | string; diff --git a/backend/src/modules/pbis/pbis.repository.ts b/backend/src/modules/pbis/pbis.repository.ts index d1d1089..07f3e16 100644 --- a/backend/src/modules/pbis/pbis.repository.ts +++ b/backend/src/modules/pbis/pbis.repository.ts @@ -5,10 +5,12 @@ import { CreatePbiDTO, UpdatePbiDTO, PbiQueryDTO, Pbi, PbiWithContext, Paginated import { auditService } from "../audit/audit.service.js"; import { assertJustificationForCompletedItem, normalizeJustification } from "../quality/completed-item-policy.js"; import { buildAuditChangeData } from "../audit/audit.payloads.js"; +import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; const SELECT_WITH_CONTEXT = ` SELECT p.*, + COALESCE((SELECT array_agg(et.tecnologia_id ORDER BY et.tecnologia_id) FROM entidade_tecnologia et WHERE et.entidade_tipo = 'pbi' AND et.entidade_id = p.id), ARRAY[]::uuid[]) AS tecnologias_ids, f.titulo AS feature_titulo, e.id AS epico_id, e.titulo AS epico_titulo, @@ -69,6 +71,7 @@ export class PbisRepository { const result = await client.query(insertQuery, values); const created = result.rows[0]; + await replaceEntityTechnologies(client, "pbi", created.id, data.tecnologias_ids); await auditService.record( { @@ -76,7 +79,7 @@ export class PbisRepository { 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, requer_interface: created.requer_interface }, + dados_json: { codigo: created.codigo, titulo: created.titulo, feature_id: created.feature_id, status: created.status, requer_interface: created.requer_interface, tecnologias_ids: data.tecnologias_ids ?? [] }, }, client, ); @@ -167,6 +170,13 @@ export class PbisRepository { values, ); const updated = result.rows[0]; + await replaceEntityTechnologies(client, "pbi", id, data.tecnologias_ids); + const updatedWithTechnologies = { + ...updated, + tecnologias_ids: data.tecnologias_ids === undefined + ? existing.tecnologias_ids ?? [] + : await getEntityTechnologyIds(client, "pbi", id), + }; await auditService.record( { @@ -175,7 +185,7 @@ export class PbisRepository { entidade_id: id, acao: "ATUALIZAR_PBI", justificativa: normalizeJustification(data.justificativa), - dados_json: buildAuditChangeData(existing, updated, data), + dados_json: buildAuditChangeData(existing, updatedWithTechnologies, data), }, client, ); diff --git a/backend/src/modules/pbis/pbis.types.ts b/backend/src/modules/pbis/pbis.types.ts index 00dcbd2..03d1b31 100644 --- a/backend/src/modules/pbis/pbis.types.ts +++ b/backend/src/modules/pbis/pbis.types.ts @@ -29,6 +29,7 @@ export const createPbiSchema = z.object({ tipo: z.string().trim().min(1).max(50).default("Funcional"), prioridade: z.enum(PBI_PRIORITIES).default("Must"), requer_interface: z.boolean().default(false), + tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), }); export type CreatePbiDTO = z.infer; @@ -43,6 +44,7 @@ export const updatePbiSchema = z.object({ prioridade: z.enum(PBI_PRIORITIES).optional(), requer_interface: z.boolean().optional(), justificativa: z.string().trim().optional().nullable(), + tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), }); export type UpdatePbiDTO = z.infer; @@ -68,6 +70,7 @@ export interface Pbi { tipo: string; prioridade: PbiPriority; requer_interface: boolean; + tecnologias_ids?: string[]; prototipo_vinculado?: boolean; status: PbiStatus; score_completude: number | null; diff --git a/backend/src/modules/projects/projects.backlog-tree.db.test.ts b/backend/src/modules/projects/projects.backlog-tree.db.test.ts new file mode 100644 index 0000000..be9210b --- /dev/null +++ b/backend/src/modules/projects/projects.backlog-tree.db.test.ts @@ -0,0 +1,202 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { ProjectsRepository } from "./projects.repository.js"; +import { replaceEntityTechnologies } from "../technologies/entity-technologies.js"; +import { EpicsRepository } from "../epics/epics.repository.js"; +import { FeaturesRepository } from "../features/features.repository.js"; +import { PbisRepository } from "../pbis/pbis.repository.js"; + +test("backlog tree uses one repeatable-read snapshot and releases its connection", async () => { + const statements: string[] = []; + const project = { id: randomUUID(), nome: "Teste", status: "ativo" }; + const fakeClient = { + async query(sql: string) { + statements.push(sql.trim()); + if (sql.includes("FROM projeto")) return { rows: [project], rowCount: 1 }; + return { rows: [], rowCount: 0 }; + }, + release() { statements.push("RELEASE"); }, + }; + const fakePool = { connect: async () => fakeClient } as unknown as Pool; + const result = await new ProjectsRepository(fakePool).findBacklogTree(project.id); + + assert.equal(result?.project.id, project.id); + assert.deepEqual(statements[0], "BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY"); + assert.equal(statements.at(-2), "COMMIT"); + assert.equal(statements.at(-1), "RELEASE"); + assert.ok(statements.slice(1, -2).every((sql) => sql.startsWith("SELECT"))); +}); + +test("backlog tree PostgreSQL integration", { skip: !process.env.BACKLOG_TREE_TEST_DATABASE_URL }, async (t) => { + const connectionString = validateTarget(process.env.BACKLOG_TREE_TEST_DATABASE_URL, "test"); + const db = new Pool({ connectionString }); + const repository = new ProjectsRepository(db); + const projectId = randomUUID(); + const otherProjectId = randomUUID(); + let epicId = ""; + let featureId = ""; + let pbiId = ""; + const technologyIds = [randomUUID(), randomUUID(), randomUUID()]; + + try { + await db.query( + "INSERT INTO projeto (id,nome,cliente,status) VALUES ($1,'Tree test','QA','ativo'),($2,'Other tree test','QA','ativo')", + [projectId, otherProjectId], + ); + await db.query( + "INSERT INTO tecnologia (id,nome) VALUES ($1,'Tree Epic Tech'),($2,'Tree Feature Tech'),($3,'Tree PBI Tech')", + technologyIds, + ); + + const epics = new EpicsRepository(db); + const features = new FeaturesRepository(db); + const pbis = new PbisRepository(db); + const epic = await epics.create({ + projeto_id: projectId, + titulo: "Epic", + prioridade: "Must", + tecnologias_ids: [technologyIds[0], technologyIds[0]], + }); + epicId = epic.id; + const feature = await features.create({ + epico_id: epic.id, + titulo: "Feature", + prioridade: "Must", + tecnologias_ids: [technologyIds[1]], + }); + featureId = feature.id; + const pbi = await pbis.create({ + feature_id: feature.id, + titulo: "PBI", + historia_como_um: "PO", + historia_eu_quero: "filtrar", + historia_para_que: "com confiança", + tipo: "Funcional", + prioridade: "Must", + requer_interface: false, + tecnologias_ids: [technologyIds[2]], + }); + pbiId = pbi.id; + + await assert.rejects( + epics.create({ + projeto_id: projectId, + titulo: "Must roll back", + prioridade: "Must", + tecnologias_ids: [randomUUID()], + }), + /tecnologias selecionadas não existem/i, + ); + assert.equal( + (await db.query("SELECT count(*)::int AS total FROM epico WHERE projeto_id=$1", [projectId])).rows[0].total, + 1, + ); + + await t.test("associações por nível entram na árvore e ficam isoladas por projeto", async () => { + const tree = await repository.findBacklogTree(projectId); + assert.ok(tree); + assert.deepEqual(tree.epics[0].tecnologias.map((item) => item.id), [technologyIds[0]]); + assert.deepEqual(tree.epics[0].features[0].tecnologias.map((item) => item.id), [technologyIds[1]]); + assert.deepEqual(tree.epics[0].features[0].pbis[0].tecnologias.map((item) => item.id), [technologyIds[2]]); + assert.deepEqual((await repository.findBacklogTree(otherProjectId))?.epics, []); + assert.equal(await repository.findBacklogTree(randomUUID()), null); + }); + + await t.test("substituir com lista vazia remove tags e auditoria registra a alteração", async () => { + const client = await db.connect(); + try { + await client.query("BEGIN"); + await replaceEntityTechnologies(client, "epico", epicId, []); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + assert.deepEqual((await repository.findBacklogTree(projectId))?.epics[0].tecnologias, []); + + await new EpicsRepository(db).update(epicId, { tecnologias_ids: [technologyIds[0]] }); + assert.deepEqual((await repository.findBacklogTree(projectId))?.epics[0].tecnologias.map((item) => item.id), [technologyIds[0]]); + + const audit = await db.query<{ dados_json: Record }>( + "SELECT dados_json FROM auditoria WHERE entidade_id=$1 AND acao='ATUALIZAR_EPICO' ORDER BY created_at DESC LIMIT 1", + [epicId], + ); + assert.deepEqual((audit.rows[0].dados_json.alteracoes as Record).tecnologias_ids, [technologyIds[0]]); + + const invalidClient = await db.connect(); + try { + await invalidClient.query("BEGIN"); + await assert.rejects( + replaceEntityTechnologies(invalidClient, "epico", epicId, [randomUUID()]), + /tecnologias selecionadas não existem/i, + ); + await invalidClient.query("ROLLBACK"); + } finally { + invalidClient.release(); + } + assert.deepEqual((await repository.findBacklogTree(projectId))?.epics[0].tecnologias.map((item) => item.id), [technologyIds[0]]); + }); + + await t.test("baseline de escala: adiciona 25 épicos, 125 features e 625 PBIs", async () => { + const benchmarkEpicIds = Array.from({ length: 25 }, () => randomUUID()); + const benchmarkFeatureIds = Array.from({ length: 125 }, () => randomUUID()); + const benchmarkPbiIds = Array.from({ length: 625 }, () => randomUUID()); + const featureEpicIds = benchmarkFeatureIds.map((_, index) => benchmarkEpicIds[index % benchmarkEpicIds.length]); + const pbiFeatureIds = benchmarkPbiIds.map((_, index) => benchmarkFeatureIds[index % benchmarkFeatureIds.length]); + const pbiCodes = benchmarkPbiIds.map((_, index) => `TREE-BENCH-${index}`); + + await db.query( + `INSERT INTO epico (id,projeto_id,titulo) + SELECT item.id,$2,'Benchmark epic ' || item.ordinality + FROM unnest($1::uuid[]) WITH ORDINALITY AS item(id,ordinality)`, + [benchmarkEpicIds, projectId], + ); + await db.query( + `INSERT INTO feature (id,epico_id,titulo) + SELECT item.id,item.epico_id,'Benchmark feature ' || item.ordinality + FROM unnest($1::uuid[],$2::uuid[]) WITH ORDINALITY AS item(id,epico_id,ordinality)`, + [benchmarkFeatureIds, featureEpicIds], + ); + await db.query( + `INSERT INTO pbi (id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que) + SELECT item.id,item.feature_id,item.codigo,'Benchmark PBI ' || item.ordinality,'PO','consultar','obter resultado' + FROM unnest($1::uuid[],$2::uuid[],$3::text[]) WITH ORDINALITY AS item(id,feature_id,codigo,ordinality)`, + [benchmarkPbiIds, pbiFeatureIds, pbiCodes], + ); + + const startedAt = performance.now(); + const tree = await repository.findBacklogTree(projectId); + const durationMs = performance.now() - startedAt; + assert.ok(tree); + const featureCount = tree.epics.reduce((total, epic) => total + epic.features.length, 0); + const pbiCount = tree.epics.reduce( + (total, epic) => total + epic.features.reduce((subtotal, feature) => subtotal + feature.pbis.length, 0), + 0, + ); + const responseBytes = Buffer.byteLength(JSON.stringify(tree)); + console.log(`[S1-16 scale] 26 epics, ${featureCount} features, ${pbiCount} PBIs; ${durationMs.toFixed(1)} ms; ${responseBytes} bytes`); + assert.equal(tree.epics.length, 26); // 25 de benchmark + 1 da fixture funcional acima. + assert.equal(featureCount, 126); // 125 de benchmark + 1 da fixture funcional acima. + assert.equal(pbiCount, 626); // 625 de benchmark + 1 da fixture funcional acima. + assert.ok(durationMs < 2000, `árvore levou ${durationMs.toFixed(1)} ms; limite de 2000 ms excedido`); + assert.ok(responseBytes < 5 * 1024 * 1024, `resposta excedeu 5 MiB: ${responseBytes} bytes`); + }); + } finally { + await db.query( + `DELETE FROM auditoria WHERE entidade_id IN ( + SELECT id FROM epico WHERE projeto_id = $1 + UNION SELECT f.id FROM feature f JOIN epico e ON e.id=f.epico_id WHERE e.projeto_id = $1 + UNION SELECT p.id FROM pbi p JOIN feature f ON f.id=p.feature_id JOIN epico e ON e.id=f.epico_id WHERE e.projeto_id = $1 + )`, + [projectId], + ); + await db.query("DELETE FROM projeto WHERE id = ANY($1::uuid[])", [[projectId, otherProjectId]]); + await db.query("DELETE FROM tecnologia WHERE id = ANY($1::uuid[])", [technologyIds]); + await db.end(); + } +}); diff --git a/backend/src/modules/projects/projects.controller.ts b/backend/src/modules/projects/projects.controller.ts index 586f87c..4f4be07 100644 --- a/backend/src/modules/projects/projects.controller.ts +++ b/backend/src/modules/projects/projects.controller.ts @@ -1,80 +1,222 @@ -import { Request, Response, NextFunction } from "express"; -import { projectsService, ProjectsService } from "./projects.service.js"; +import { + Request, + Response, + NextFunction, +} from "express"; +import { + projectsService, + ProjectsService, +} from "./projects.service.js"; import { ValidationError } from "../../shared/errors.js"; import { z } from "zod"; const confirmation = z.object({ confirmado: z.literal(true), - impacto: z.object({ projeto: z.number().int().min(0).max(1), epicos: z.number().int().nonnegative(), features: z.number().int().nonnegative(), pbis: z.number().int().nonnegative() }).strict(), - justificativa: z.string().trim().max(2000).optional(), + + impacto: z + .object({ + projeto: z + .number() + .int() + .min(0) + .max(1), + + epicos: z + .number() + .int() + .nonnegative(), + + features: z + .number() + .int() + .nonnegative(), + + pbis: z + .number() + .int() + .nonnegative(), + }) + .strict(), + + justificativa: z + .string() + .trim() + .max(2000) + .optional(), }); -function getParamId(param: string | string[] | undefined): string { - if (Array.isArray(param)) return param[0] ?? ""; +function getParamId( + param: string | string[] | undefined, +): string { + if (Array.isArray(param)) { + return param[0] ?? ""; + } + return param ?? ""; } export class ProjectsController { - constructor(private readonly service: ProjectsService = projectsService) {} + constructor( + private readonly service: + ProjectsService = projectsService, + ) {} - create = async (req: Request, res: Response, next: NextFunction): Promise => { + 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); + 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 => { + list = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { - const result = await this.service.list(req.query); + 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); } }; - getById = async (req: Request, res: Response, next: NextFunction): Promise => { + backlogTree = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { - const id = getParamId(req.params.id); - const result = await this.service.getById(id); + const id = + getParamId(req.params.id); + + const result = + await this.service + .getBacklogTree(id); + res.status(200).json(result); } catch (error) { next(error); } }; - update = async (req: Request, res: Response, next: NextFunction): Promise => { + 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); + 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); } }; - archive = async (req: Request, res: Response, next: NextFunction): Promise => { + archive = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { - const id = getParamId(req.params.id); - const usuarioId = req.auth?.id ?? null; - const parsed = confirmation.safeParse(req.body); - if (!parsed.success) throw new ValidationError("Consulte a prévia e confirme explicitamente o arquivamento."); - const result = await this.service.archive(id, usuarioId, parsed.data.justificativa, parsed.data.impacto); + const id = + getParamId(req.params.id); + + const usuarioId = + req.auth?.id ?? null; + + const parsed = + confirmation.safeParse( + req.body, + ); + + if (!parsed.success) { + throw new ValidationError( + "Consulte a prévia e confirme explicitamente o arquivamento.", + ); + } + + const result = + await this.service.archive( + id, + usuarioId, + parsed.data.justificativa, + parsed.data.impacto, + ); + res.status(200).json(result); } catch (error) { next(error); } }; - archiveImpact = async (req: Request, res: Response, next: NextFunction): Promise => { + archiveImpact = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { - res.status(200).json(await this.service.archiveImpact(getParamId(req.params.id))); - } catch (error) { next(error); } + const id = + getParamId(req.params.id); + + const result = + await this.service + .archiveImpact(id); + + res.status(200).json(result); + } catch (error) { + next(error); + } }; } -export const projectsController = new ProjectsController(); +export const projectsController = + new ProjectsController(); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.repository.ts b/backend/src/modules/projects/projects.repository.ts index 19493e3..2945162 100644 --- a/backend/src/modules/projects/projects.repository.ts +++ b/backend/src/modules/projects/projects.repository.ts @@ -1,4 +1,7 @@ -import { Pool, PoolClient } from "pg"; +import { + Pool, + PoolClient, +} from "pg"; import { pool } from "../../database/db.js"; import { CreateProjectDTO, @@ -7,9 +10,14 @@ import { Project, ProjectWithStats, PaginatedProjects, + ProjectBacklogTree, + BacklogTechnology, } from "./projects.types.js"; import { auditService } from "../audit/audit.service.js"; -import { ArchiveConflict, type ArchiveImpact } from "./archive.types.js"; +import { + ArchiveConflict, + type ArchiveImpact, +} from "./archive.types.js"; export class ProjectsRepository { private pool: Pool; @@ -18,8 +26,14 @@ export class ProjectsRepository { this.pool = customPool ?? pool; } - async findActiveByName(nome: string, excludeId?: string): Promise { - const params: unknown[] = [nome.trim()]; + async findActiveByName( + nome: string, + excludeId?: string, + ): Promise { + const params: unknown[] = [ + nome.trim(), + ]; + let query = ` SELECT * FROM projeto @@ -34,11 +48,18 @@ export class ProjectsRepository { query += ` LIMIT 1`; - const result = await this.pool.query(query, params); + const result = + await this.pool.query( + query, + params, + ); + return result.rows[0] ?? null; } - async findById(id: string): Promise { + async findById( + id: string, + ): Promise { const query = ` SELECT p.id, @@ -50,29 +71,411 @@ export class ProjectsRepository { p.created_at, p.updated_at, p.archived_at, - COALESCE((SELECT COUNT(*)::int FROM epico e WHERE e.projeto_id = p.id), 0) AS epicos_count, - COALESCE((SELECT COUNT(*)::int FROM documento d WHERE d.projeto_id = p.id), 0) AS documentos_count + COALESCE( + ( + SELECT COUNT(*)::int + FROM epico e + WHERE e.projeto_id = p.id + ), + 0 + ) AS epicos_count, + COALESCE( + ( + SELECT COUNT(*)::int + FROM documento d + WHERE d.projeto_id = p.id + ), + 0 + ) AS documentos_count FROM projeto p WHERE p.id = $1 `; - const result = await this.pool.query(query, [id]); + const result = + await this.pool + .query( + query, + [id], + ); + return result.rows[0] ?? null; } - async archiveImpact(id: string, connection: Pool | PoolClient = this.pool): Promise { - const result = await connection.query(` - SELECT CASE WHEN root.status = 'arquivado' THEN 0 ELSE 1 END AS projeto, - (SELECT COUNT(*)::int FROM epico WHERE projeto_id = $1 AND status != 'arquivado') AS epicos, - (SELECT COUNT(*)::int FROM feature f JOIN epico e ON e.id = f.epico_id WHERE e.projeto_id = $1 AND f.status != 'arquivado') AS features, - (SELECT COUNT(*)::int FROM pbi p JOIN feature f ON f.id = p.feature_id JOIN epico e ON e.id = f.epico_id WHERE e.projeto_id = $1 AND p.status != 'arquivado') AS pbis - FROM projeto root WHERE root.id = $1 - `, [id]); + async findBacklogTree( + id: string, + ): Promise { + const client = await this.pool.connect(); + + try { + await client.query( + "BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY", + ); + const projectResult = + await client.query< + Pick< + Project, + "id" | "nome" | "status" + > + >( + ` + SELECT + id, + nome, + status + FROM projeto + WHERE id = $1 + `, + [id], + ); + + const project = + projectResult.rows[0]; + + if (!project) { + await client.query("COMMIT"); + return null; + } + + type ItemRow = { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + }; + + type FeatureRow = + ItemRow & { + epico_id: string; + }; + + type PbiRow = + ItemRow & { + feature_id: string; + codigo: string; + }; + + const technologyJoin = ( + entityAlias: string, + entityType: string, + ) => ` + LEFT JOIN entidade_tecnologia et + ON et.entidade_id = ${entityAlias}.id + AND et.entidade_tipo = '${entityType}' + LEFT JOIN tecnologia t + ON t.id = et.tecnologia_id + `; + + const technologyAggregate = ` + COALESCE( + jsonb_agg( + DISTINCT jsonb_build_object( + 'id', + t.id, + 'nome', + t.nome + ) + ) FILTER ( + WHERE t.id IS NOT NULL + ), + '[]'::jsonb + ) AS tecnologias + `; + + const epicsResult = await client.query( + ` + SELECT + e.id, + e.titulo, + e.status, + ${technologyAggregate} + FROM epico e + ${technologyJoin( + "e", + "epico", + )} + WHERE e.projeto_id = $1 + GROUP BY e.id + ORDER BY + e.created_at ASC, + e.id ASC + `, + [id], + ); + + const featuresResult = await client.query( + ` + SELECT + f.id, + f.epico_id, + f.titulo, + f.status, + ${technologyAggregate} + FROM feature f + JOIN epico e + ON e.id = f.epico_id + ${technologyJoin( + "f", + "feature", + )} + WHERE e.projeto_id = $1 + GROUP BY f.id + ORDER BY + f.created_at ASC, + f.id ASC + `, + [id], + ); + + const pbisResult = await client.query( + ` + SELECT + p.id, + p.feature_id, + p.codigo, + p.titulo, + p.status, + ${technologyAggregate} + FROM pbi p + JOIN feature f + ON f.id = p.feature_id + JOIN epico e + ON e.id = f.epico_id + ${technologyJoin( + "p", + "pbi", + )} + WHERE e.projeto_id = $1 + GROUP BY p.id + ORDER BY + p.created_at ASC, + p.id ASC + `, + [id], + ); + + const pbisByFeature = + new Map< + string, + PbiRow[] + >(); + + for (const pbi of pbisResult.rows) { + const current = + pbisByFeature.get( + pbi.feature_id, + ) ?? []; + + current.push(pbi); + + pbisByFeature.set( + pbi.feature_id, + current, + ); + } + + const featuresByEpic = + new Map< + string, + Array< + FeatureRow & { + pbis: PbiRow[]; + } + > + >(); + + for ( + const feature + of featuresResult.rows + ) { + const current = + featuresByEpic.get( + feature.epico_id, + ) ?? []; + + current.push({ + ...feature, + + pbis: + pbisByFeature.get( + feature.id, + ) ?? [], + }); + + featuresByEpic.set( + feature.epico_id, + current, + ); + } + + const technologies = + new Map< + string, + BacklogTechnology + >(); + + const collectTechnologies = ( + items: Array<{ + tecnologias: + BacklogTechnology[]; + }>, + ) => { + for (const item of items) { + for ( + const technology + of item.tecnologias + ) { + technologies.set( + technology.id, + technology, + ); + } + } + }; + + collectTechnologies( + epicsResult.rows, + ); + + collectTechnologies( + featuresResult.rows, + ); + + collectTechnologies( + pbisResult.rows, + ); + + const tree: ProjectBacklogTree = { + project, + + epics: + epicsResult.rows.map( + (epic) => ({ + id: epic.id, + titulo: epic.titulo, + status: epic.status, + + tecnologias: + epic.tecnologias, + + features: ( + featuresByEpic.get( + epic.id, + ) ?? [] + ).map( + (feature) => ({ + id: feature.id, + + titulo: + feature.titulo, + + status: + feature.status, + + tecnologias: + feature.tecnologias, + + pbis: + feature.pbis.map( + (pbi) => ({ + id: pbi.id, + + codigo: + pbi.codigo, + + titulo: + pbi.titulo, + + status: + pbi.status, + + tecnologias: + pbi.tecnologias, + }), + ), + }), + ), + }), + ), + + technologies: [ + ...technologies.values(), + ].sort( + (a, b) => + a.nome.localeCompare( + b.nome, + "pt-BR", + ), + ), + }; + await client.query("COMMIT"); + return tree; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async archiveImpact( + id: string, + connection: + | Pool + | PoolClient = this.pool, + ): Promise { + const result = + await connection + .query( + ` + SELECT + CASE + WHEN root.status = 'arquivado' + THEN 0 + ELSE 1 + END AS projeto, + + ( + SELECT COUNT(*)::int + FROM epico + WHERE projeto_id = $1 + AND status != 'arquivado' + ) AS epicos, + + ( + SELECT COUNT(*)::int + FROM feature f + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + AND f.status != 'arquivado' + ) AS features, + + ( + SELECT COUNT(*)::int + FROM pbi p + JOIN feature f + ON f.id = p.feature_id + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + AND p.status != 'arquivado' + ) AS pbis + + FROM projeto root + WHERE root.id = $1 + `, + [id], + ); + return result.rows[0] ?? null; } - async create(data: CreateProjectDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + async create( + data: CreateProjectDTO, + usuarioId?: string | null, + ): Promise { + const client: PoolClient = + await this.pool.connect(); try { await client.query("BEGIN"); @@ -84,90 +487,186 @@ export class ProjectsRepository { descricao, status, data_inicio - ) VALUES ($1, $2, $3, $4, COALESCE($5, CURRENT_TIMESTAMP)) + ) + VALUES ( + $1, + $2, + $3, + $4, + COALESCE( + $5, + CURRENT_TIMESTAMP + ) + ) RETURNING * `; const projectValues = [ data.nome.trim(), data.cliente.trim(), - data.descricao?.trim() ?? null, + data.descricao?.trim() + ?? null, data.status ?? "ativo", data.data_inicio ?? null, ]; - const projectResult = await client.query(insertProjectQuery, projectValues); - const createdProject = projectResult.rows[0]; + const projectResult = + await client.query( + insertProjectQuery, + projectValues, + ); + + const createdProject = + projectResult.rows[0]; - // Registrar auditoria transacional await auditService.record( { - usuario_id: usuarioId ?? null, - entidade_tipo: "projeto", - entidade_id: createdProject.id, - acao: "CRIAR_PROJETO", + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + + entidade_id: + createdProject.id, + + acao: + "CRIAR_PROJETO", + dados_json: { - nome: createdProject.nome, - cliente: createdProject.cliente, - descricao: createdProject.descricao, - status: createdProject.status, - data_inicio: createdProject.data_inicio, + nome: + createdProject.nome, + + cliente: + createdProject.cliente, + + descricao: + createdProject.descricao, + + status: + createdProject.status, + + data_inicio: + createdProject.data_inicio, }, }, client, ); await client.query("COMMIT"); + return createdProject; } catch (error) { - await client.query("ROLLBACK"); + await client.query( + "ROLLBACK", + ); + throw error; } finally { client.release(); } } - async findAll(query: ProjectQueryDTO): Promise { - const whereConditions: string[] = []; + async findAll( + query: ProjectQueryDTO, + ): Promise { + const whereConditions: + string[] = []; + const params: unknown[] = []; + let paramIndex = 1; if (!query.status) { - whereConditions.push("p.status != 'arquivado'"); + whereConditions.push( + "p.status != 'arquivado'", + ); } - if (query.status && query.status !== "todos") { - whereConditions.push(`p.status = $${paramIndex}`); + if ( + query.status && + query.status !== "todos" + ) { + whereConditions.push( + `p.status = $${paramIndex}`, + ); + params.push(query.status); paramIndex++; } - if (query.busca && query.busca.trim().length > 0) { + if ( + query.busca && + query.busca.trim().length > 0 + ) { whereConditions.push( - `(p.nome ILIKE $${paramIndex} OR p.cliente ILIKE $${paramIndex} OR p.descricao ILIKE $${paramIndex})`, + `( + p.nome ILIKE $${paramIndex} + OR p.cliente ILIKE $${paramIndex} + OR p.descricao ILIKE $${paramIndex} + )`, ); - params.push(`%${query.busca.trim()}%`); + + params.push( + `%${query.busca.trim()}%`, + ); + paramIndex++; } - const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; - - // Total count - const countQuery = `SELECT COUNT(*)::int AS total FROM projeto p ${whereClause}`; - const countResult = await this.pool.query<{ total: number }>(countQuery, params); - const total = countResult.rows[0]?.total ?? 0; - - // Sorting - let orderByClause = "ORDER BY p.created_at DESC"; - if (query.order === "created_at_asc") { - orderByClause = "ORDER BY p.created_at ASC"; - } else if (query.order === "nome_asc") { - orderByClause = "ORDER BY p.nome ASC"; - } else if (query.order === "nome_desc") { - orderByClause = "ORDER BY p.nome DESC"; + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join( + " AND ", + )}` + : ""; + + const countQuery = ` + SELECT + COUNT(*)::int AS total + FROM projeto p + ${whereClause} + `; + + const countResult = + await this.pool.query<{ + total: number; + }>( + countQuery, + params, + ); + + const total = + countResult.rows[0]?.total + ?? 0; + + let orderByClause = + "ORDER BY p.created_at DESC"; + + if ( + query.order + === "created_at_asc" + ) { + orderByClause = + "ORDER BY p.created_at ASC"; + } else if ( + query.order === "nome_asc" + ) { + orderByClause = + "ORDER BY p.nome ASC"; + } else if ( + query.order === "nome_desc" + ) { + orderByClause = + "ORDER BY p.nome DESC"; } - const dataParams = [...params, query.limit, query.offset]; + const dataParams = [ + ...params, + query.limit, + query.offset, + ]; + const dataQuery = ` SELECT p.id, @@ -179,15 +678,38 @@ export class ProjectsRepository { p.created_at, p.updated_at, p.archived_at, - COALESCE((SELECT COUNT(*)::int FROM epico e WHERE e.projeto_id = p.id), 0) AS epicos_count, - COALESCE((SELECT COUNT(*)::int FROM documento d WHERE d.projeto_id = p.id), 0) AS documentos_count + + COALESCE( + ( + SELECT COUNT(*)::int + FROM epico e + WHERE e.projeto_id = p.id + ), + 0 + ) AS epicos_count, + + COALESCE( + ( + SELECT COUNT(*)::int + FROM documento d + WHERE d.projeto_id = p.id + ), + 0 + ) AS documentos_count + FROM projeto p ${whereClause} ${orderByClause} - LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + LIMIT $${paramIndex} + OFFSET $${paramIndex + 1} `; - const dataResult = await this.pool.query(dataQuery, dataParams); + const dataResult = + await this.pool + .query( + dataQuery, + dataParams, + ); return { items: dataResult.rows, @@ -197,51 +719,136 @@ export class ProjectsRepository { }; } - async update(id: string, data: UpdateProjectDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + async update( + id: string, + data: UpdateProjectDTO, + usuarioId?: string | null, + ): Promise { + const client: PoolClient = + await this.pool.connect(); try { await client.query("BEGIN"); - const existingProject = (await client.query("SELECT * FROM projeto WHERE id = $1 FOR UPDATE", [id])).rows[0]; + const existingResult = + await client.query( + ` + SELECT * + FROM projeto + WHERE id = $1 + FOR UPDATE + `, + [id], + ); + + const existingProject = + existingResult.rows[0]; + if (!existingProject) { - await client.query("ROLLBACK"); + await client.query( + "ROLLBACK", + ); + return null; } + if ( + existingProject.status + === "arquivado" + ) { + throw new ArchiveConflict( + "Projeto arquivado está disponível apenas para leitura.", + ); + } + + if ( + String(data.status) + === "arquivado" + ) { + throw new ArchiveConflict( + "Use a ação de arquivamento com prévia e confirmação.", + ); + } + const updates: string[] = []; - if (existingProject.status === "arquivado") throw new ArchiveConflict("Projeto arquivado está disponível apenas para leitura."); - if (String(data.status) === "arquivado") throw new ArchiveConflict("Use a ação de arquivamento com prévia e confirmação."); const values: unknown[] = []; + let valIndex = 1; - if (data.nome !== undefined) { - updates.push(`nome = $${valIndex}`); - values.push(data.nome.trim()); + if ( + data.nome !== undefined + ) { + updates.push( + `nome = $${valIndex}`, + ); + + values.push( + data.nome.trim(), + ); + valIndex++; } - if (data.cliente !== undefined) { - updates.push(`cliente = $${valIndex}`); - values.push(data.cliente.trim()); + + if ( + data.cliente !== undefined + ) { + updates.push( + `cliente = $${valIndex}`, + ); + + values.push( + data.cliente.trim(), + ); + valIndex++; } - if (data.descricao !== undefined) { - updates.push(`descricao = $${valIndex}`); - values.push(data.descricao?.trim() ?? null); + + if ( + data.descricao + !== undefined + ) { + updates.push( + `descricao = $${valIndex}`, + ); + + values.push( + data.descricao?.trim() + ?? null, + ); + valIndex++; } - if (data.status !== undefined) { - updates.push(`status = $${valIndex}`); + + if ( + data.status !== undefined + ) { + updates.push( + `status = $${valIndex}`, + ); + values.push(data.status); valIndex++; } - if (data.data_inicio !== undefined) { - updates.push(`data_inicio = $${valIndex}`); - values.push(data.data_inicio ?? null); + + if ( + data.data_inicio + !== undefined + ) { + updates.push( + `data_inicio = $${valIndex}`, + ); + + values.push( + data.data_inicio ?? null, + ); + valIndex++; } - updates.push(`updated_at = CURRENT_TIMESTAMP`); + updates.push( + "updated_at = CURRENT_TIMESTAMP", + ); + values.push(id); const updateQuery = ` @@ -251,30 +858,61 @@ export class ProjectsRepository { RETURNING * `; - const result = await client.query(updateQuery, values); - const updated = result.rows[0]; + const result = + await client.query( + updateQuery, + values, + ); + + const updated = + result.rows[0]; - // Registrar auditoria await auditService.record( { - usuario_id: usuarioId ?? null, - entidade_tipo: "projeto", + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + entidade_id: id, - acao: "ATUALIZAR_PROJETO", - justificativa: data.justificativa ?? null, + + acao: + "ATUALIZAR_PROJETO", + + justificativa: + data.justificativa + ?? null, + dados_json: { alteracoes: data, + anterior: { - nome: existingProject.nome, - cliente: existingProject.cliente, - descricao: existingProject.descricao, - status: existingProject.status, + nome: + existingProject.nome, + + cliente: + existingProject.cliente, + + descricao: + existingProject.descricao, + + status: + existingProject.status, }, + novo: { - nome: updated.nome, - cliente: updated.cliente, - descricao: updated.descricao, - status: updated.status, + nome: + updated.nome, + + cliente: + updated.cliente, + + descricao: + updated.descricao, + + status: + updated.status, }, }, }, @@ -282,61 +920,188 @@ export class ProjectsRepository { ); await client.query("COMMIT"); + return await this.findById(id); } catch (error) { - await client.query("ROLLBACK"); + await client.query( + "ROLLBACK", + ); + throw error; } finally { client.release(); } } - async archive(id: string, usuarioId?: string | null, justificativa?: string, expected?: ArchiveImpact): Promise { - const client: PoolClient = await this.pool.connect(); + async archive( + id: string, + usuarioId?: string | null, + justificativa?: string, + expected?: ArchiveImpact, + ): Promise { + const client: PoolClient = + await this.pool.connect(); try { await client.query("BEGIN"); - // Keep preview validation, cascade and audit atomic, including concurrent - // child inserts. All hierarchy writers wait until this transaction ends. - await client.query("LOCK TABLE projeto, epico, feature, pbi IN SHARE ROW EXCLUSIVE MODE"); - const existingProject = (await client.query("SELECT * FROM projeto WHERE id = $1 FOR UPDATE", [id])).rows[0]; + await client.query( + ` + LOCK TABLE + projeto, + epico, + feature, + pbi + IN SHARE ROW EXCLUSIVE MODE + `, + ); + + const existingResult = + await client.query( + ` + SELECT * + FROM projeto + WHERE id = $1 + FOR UPDATE + `, + [id], + ); + + const existingProject = + existingResult.rows[0]; + if (!existingProject) { - await client.query("ROLLBACK"); + await client.query( + "ROLLBACK", + ); + return null; } - if (existingProject.status === "arquivado") { - await client.query("COMMIT"); + if ( + existingProject.status + === "arquivado" + ) { + await client.query( + "COMMIT", + ); + return existingProject; } - const impact = (await this.archiveImpact(id, client))!; - if (expected && (Object.keys(impact) as (keyof ArchiveImpact)[]).some(key => impact[key] !== expected[key])) { - throw new ArchiveConflict("A quantidade de itens mudou. Consulte a prévia e confirme novamente."); + + const impact = ( + await this.archiveImpact( + id, + client, + ) + )!; + + if ( + expected && + ( + Object.keys( + impact, + ) as ( + keyof ArchiveImpact + )[] + ).some( + (key) => + impact[key] + !== expected[key], + ) + ) { + throw new ArchiveConflict( + "A quantidade de itens mudou. Consulte a prévia e confirme novamente.", + ); } const updateQuery = ` UPDATE projeto - SET status = 'arquivado', archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING * `; - await client.query(updateQuery, [id]); - await client.query(`UPDATE epico SET status = 'arquivado', archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE projeto_id = $1 AND status != 'arquivado'`, [id]); - await client.query(`UPDATE feature SET status = 'arquivado', archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE epico_id IN (SELECT id FROM epico WHERE projeto_id = $1) AND status != 'arquivado'`, [id]); - await client.query(`UPDATE pbi SET status = 'arquivado', archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE feature_id IN (SELECT f.id FROM feature f JOIN epico e ON e.id = f.epico_id WHERE e.projeto_id = $1) AND status != 'arquivado'`, [id]); + await client.query( + updateQuery, + [id], + ); + + await client.query( + ` + UPDATE epico + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE projeto_id = $1 + AND status != 'arquivado' + `, + [id], + ); + + await client.query( + ` + UPDATE feature + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE epico_id IN ( + SELECT id + FROM epico + WHERE projeto_id = $1 + ) + AND status != 'arquivado' + `, + [id], + ); + + await client.query( + ` + UPDATE pbi + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE feature_id IN ( + SELECT f.id + FROM feature f + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + ) + AND status != 'arquivado' + `, + [id], + ); await auditService.record( { - usuario_id: usuarioId ?? null, - entidade_tipo: "projeto", + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + entidade_id: id, - acao: "ARQUIVAR_PROJETO", - justificativa: justificativa ?? null, + + acao: + "ARQUIVAR_PROJETO", + + justificativa: + justificativa ?? null, + dados_json: { - status_anterior: existingProject.status, - status_novo: "arquivado", + status_anterior: + existingProject.status, + + status_novo: + "arquivado", + impacto: impact, }, }, @@ -344,9 +1109,13 @@ export class ProjectsRepository { ); await client.query("COMMIT"); + return await this.findById(id); } catch (error) { - await client.query("ROLLBACK"); + await client.query( + "ROLLBACK", + ); + throw error; } finally { client.release(); @@ -354,4 +1123,5 @@ export class ProjectsRepository { } } -export const projectsRepository = new ProjectsRepository(); +export const projectsRepository = + new ProjectsRepository(); diff --git a/backend/src/modules/projects/projects.routes.test.ts b/backend/src/modules/projects/projects.routes.test.ts index ed6c3bf..1e12dec 100644 --- a/backend/src/modules/projects/projects.routes.test.ts +++ b/backend/src/modules/projects/projects.routes.test.ts @@ -14,72 +14,180 @@ import { ProjectWithStats, PaginatedProjects, ProjectQueryDTO, + ProjectBacklogTree, } from "./projects.types.js"; class MockRepo extends ProjectsRepository { public projects: ProjectWithStats[] = []; async archiveImpact(id: string) { - return this.projects.some(project => project.id === id) - ? { projeto: 1, epicos: 2, features: 3, pbis: 4 } : null; + return this.projects.some((project) => project.id === id) + ? { + projeto: 1, + epicos: 2, + features: 3, + pbis: 4, + } + : null; } constructor() { super(); } - async findActiveByName(nome: string, excludeId?: string): Promise { + async findActiveByName( + nome: string, + excludeId?: string, + ): Promise { const trimmed = nome.trim().toLowerCase(); + const found = this.projects.find( - (p) => - p.nome.trim().toLowerCase() === trimmed && - p.status !== "arquivado" && - (!excludeId || p.id !== excludeId), + (project) => + project.nome.trim().toLowerCase() === trimmed && + project.status !== "arquivado" && + (!excludeId || project.id !== excludeId), ); + return found ? { ...found } : null; } - async findById(id: string): Promise { - const found = this.projects.find((p) => p.id === id); + async findById( + id: string, + ): Promise { + const found = this.projects.find( + (project) => project.id === id, + ); + return found ? { ...found } : null; } - async create(data: CreateProjectDTO, _usuarioId?: string | null): Promise { + async findBacklogTree( + id: string, + ): Promise { + const project = this.projects.find( + (item) => item.id === id, + ); + + if (!project) { + return null; + } + + return { + project: { + id: project.id, + nome: project.nome, + status: project.status, + }, + technologies: [ + { + id: "tech-react", + nome: "React", + }, + ], + epics: [ + { + id: "epic-1", + titulo: "Organizar backlog", + status: "rascunho", + tecnologias: [], + features: [ + { + id: "feature-1", + titulo: "Navegar hierarquia", + status: "concluido", + tecnologias: [ + { + id: "tech-react", + nome: "React", + }, + ], + pbis: [ + { + id: "pbi-1", + codigo: "PBI-01.4.1", + titulo: "Expandir árvore", + status: "rascunho", + tecnologias: [ + { + id: "tech-react", + nome: "React", + }, + ], + }, + ], + }, + ], + }, + ], + }; + } + + async create( + data: CreateProjectDTO, + _usuarioId?: string | null, + ): Promise { const newProject: ProjectWithStats = { id: "a0000000-0000-4000-8000-000000000001", nome: data.nome.trim(), cliente: data.cliente.trim(), descricao: data.descricao?.trim() ?? null, status: data.status ?? "ativo", - data_inicio: data.data_inicio ?? new Date().toISOString(), + data_inicio: + data.data_inicio ?? + new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString(), epicos_count: 0, documentos_count: 0, }; + this.projects.push(newProject); + return { ...newProject }; } - async findAll(query: ProjectQueryDTO): Promise { + async findAll( + query: ProjectQueryDTO, + ): Promise { let filtered = [...this.projects]; - if (query.status && query.status !== "todos") { - filtered = filtered.filter((p) => p.status === query.status); + if ( + query.status && + query.status !== "todos" + ) { + filtered = filtered.filter( + (project) => + project.status === query.status, + ); } - if (query.busca && query.busca.trim().length > 0) { - const q = query.busca.toLowerCase(); + if ( + query.busca && + query.busca.trim().length > 0 + ) { + const search = query.busca.toLowerCase(); + filtered = filtered.filter( - (p) => - p.nome.toLowerCase().includes(q) || - p.cliente.toLowerCase().includes(q) || - (p.descricao && p.descricao.toLowerCase().includes(q)), + (project) => + project.nome + .toLowerCase() + .includes(search) || + project.cliente + .toLowerCase() + .includes(search) || + (project.descricao && + project.descricao + .toLowerCase() + .includes(search)), ); } const total = filtered.length; - const paginated = filtered.slice(query.offset, query.offset + query.limit); + + const paginated = filtered.slice( + query.offset, + query.offset + query.limit, + ); return { items: paginated, @@ -89,28 +197,64 @@ class MockRepo extends ProjectsRepository { }; } - async update(id: string, data: UpdateProjectDTO, _usuarioId?: string | null): Promise { - const index = this.projects.findIndex((p) => p.id === id); - if (index === -1) return null; + async update( + id: string, + data: UpdateProjectDTO, + _usuarioId?: string | null, + ): Promise { + const index = this.projects.findIndex( + (project) => project.id === id, + ); + + if (index === -1) { + return null; + } const current = this.projects[index]; + const updated: ProjectWithStats = { ...current, - ...(data.nome !== undefined ? { nome: data.nome.trim() } : {}), - ...(data.cliente !== undefined ? { cliente: data.cliente.trim() } : {}), - ...(data.descricao !== undefined ? { descricao: data.descricao?.trim() ?? null } : {}), - ...(data.status !== undefined ? { status: data.status } : {}), - ...(data.data_inicio !== undefined ? { data_inicio: data.data_inicio ?? null } : {}), + ...(data.nome !== undefined + ? { nome: data.nome.trim() } + : {}), + ...(data.cliente !== undefined + ? { cliente: data.cliente.trim() } + : {}), + ...(data.descricao !== undefined + ? { + descricao: + data.descricao?.trim() ?? null, + } + : {}), + ...(data.status !== undefined + ? { status: data.status } + : {}), + ...(data.data_inicio !== undefined + ? { + data_inicio: + data.data_inicio ?? null, + } + : {}), updated_at: new Date().toISOString(), }; this.projects[index] = updated; + return { ...updated }; } - async archive(id: string, _usuarioId?: string | null, _justificativa?: string): Promise { - const index = this.projects.findIndex((p) => p.id === id); - if (index === -1) return null; + async archive( + id: string, + _usuarioId?: string | null, + _justificativa?: string, + ): Promise { + const index = this.projects.findIndex( + (project) => project.id === id, + ); + + if (index === -1) { + return null; + } const updated: ProjectWithStats = { ...this.projects[index], @@ -119,156 +263,460 @@ class MockRepo extends ProjectsRepository { }; this.projects[index] = updated; + return { ...updated }; } } -test("Testes de integração HTTP - Rotas de Projetos", async (t) => { - const mockRepo = new MockRepo(); - const service = new ProjectsService(mockRepo); - const controller = new ProjectsController(service); - - const testApp = express(); - testApp.use(express.json()); - - const router = express.Router(); - router.post("/", controller.create); - router.get("/", controller.list); - router.get("/:id/archive-impact", controller.archiveImpact); - router.get("/:id", controller.getById); - router.put("/:id", controller.update); - router.patch("/:id", controller.update); - router.patch("/:id/archive", controller.archive); - - testApp.use("/api/v1/projects", 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/projects`; - resolve(); +test( + "Testes de integração HTTP - Rotas de Projetos", + async (context) => { + const mockRepo = new MockRepo(); + const service = new ProjectsService(mockRepo); + const controller = + new ProjectsController(service); + + const testApp = express(); + + testApp.use(express.json()); + + const router = express.Router(); + + router.post("/", controller.create); + router.get("/", controller.list); + + router.get( + "/:id/archive-impact", + controller.archiveImpact, + ); + + router.get( + "/:id/backlog-tree", + controller.backlogTree, + ); + + router.get("/:id", controller.getById); + router.put("/:id", controller.update); + router.patch("/:id", controller.update); + + router.patch( + "/:id/archive", + controller.archive, + ); + + testApp.use( + "/api/v1/projects", + 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 address = + server.address() as AddressInfo; + + baseUrl = + `http://127.0.0.1:${address.port}` + + "/api/v1/projects"; + + resolve(); + }, + ); }); }); - }); - after(async () => { - await new Promise((resolve) => { - server.close(() => resolve()); - }); - }); - - await t.test("POST /api/v1/projects - cria projeto com status 201", async () => { - const res = await fetch(baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nome: "Novo Projeto HTTP", - cliente: "PRO4TECH", - descricao: "Descrição do projeto", - }), + after(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); }); - assert.equal(res.status, 201); - const body = (await res.json()) as Project; - assert.equal(body.nome, "Novo Projeto HTTP"); - assert.equal(body.status, "ativo"); - }); - - await t.test("POST /api/v1/projects - retorna 400 se nome estiver vazio", async () => { - const res = await fetch(baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nome: "", - cliente: "PRO4TECH", - }), - }); + await context.test( + "POST /api/v1/projects - cria projeto com status 201", + async () => { + const response = await fetch( + baseUrl, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + nome: "Novo Projeto HTTP", + cliente: "PRO4TECH", + descricao: + "Descrição do projeto", + }), + }, + ); + + assert.equal(response.status, 201); + + const body = + (await response.json()) as Project; + + assert.equal( + body.nome, + "Novo Projeto HTTP", + ); + + assert.equal(body.status, "ativo"); + }, + ); - assert.equal(res.status, 400); - const body = (await res.json()) as { error: string }; - assert.match(body.error, /nome.*obrigat[óo]rio/i); - }); - - await t.test("POST /api/v1/projects - retorna 409 se nome já estiver em uso por projeto ativo", async () => { - const res = await fetch(baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nome: "Novo Projeto HTTP", - cliente: "Outro Cliente", - }), - }); + await context.test( + "POST /api/v1/projects - retorna 400 se nome estiver vazio", + async () => { + const response = await fetch( + baseUrl, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + nome: "", + cliente: "PRO4TECH", + }), + }, + ); + + assert.equal(response.status, 400); + + const body = + (await response.json()) as { + error: string; + }; + + assert.match( + body.error, + /nome.*obrigat[óo]rio/i, + ); + }, + ); - assert.equal(res.status, 409); - const body = (await res.json()) as { error: string }; - assert.equal(body.error, "Já existe um projeto ativo com este nome."); - }); - - await t.test("GET /api/v1/projects - lista projetos com status 200", async () => { - const res = await fetch(baseUrl); - assert.equal(res.status, 200); - const body = (await res.json()) as PaginatedProjects; - assert.ok(Array.isArray(body.items)); - assert.equal(body.total, 1); - }); - - await t.test("GET /api/v1/projects/:id - retorna 200 para projeto existente", async () => { - const res = await fetch(`${baseUrl}/a0000000-0000-4000-8000-000000000001`); - assert.equal(res.status, 200); - const body = (await res.json()) as ProjectWithStats; - assert.equal(body.nome, "Novo Projeto HTTP"); - }); - - await t.test("GET /api/v1/projects/:id - retorna 400 para UUID inválido", async () => { - const res = await fetch(`${baseUrl}/uuid-invalido`); - assert.equal(res.status, 400); - }); - - await t.test("GET /api/v1/projects/:id - retorna 404 para ID inexistente", async () => { - const res = await fetch(`${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff`); - assert.equal(res.status, 404); - }); - - await t.test("prévia informa contagens e retorna 404 para projeto ausente", async () => { - const response = await fetch(`${baseUrl}/a0000000-0000-4000-8000-000000000001/archive-impact`); - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { projeto: 1, epicos: 2, features: 3, pbis: 4 }); - assert.equal((await fetch(`${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff/archive-impact`)).status, 404); - assert.equal((await fetch(`${baseUrl}/invalid/archive-impact`)).status, 400); - }); - - await t.test("arquivamento exige confirmação e contagens válidas", async () => { - for (const body of [{}, { confirmado: false }, { confirmado: true }, { confirmado: true, impacto: { projeto: 1, epicos: -1, features: 0, pbis: 0 } }]) { - const response = await fetch(`${baseUrl}/a0000000-0000-4000-8000-000000000001/archive`, { - method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), - }); - assert.equal(response.status, 400); - assert.equal(mockRepo.projects[0].status, "ativo"); - } - }); + await context.test( + "POST /api/v1/projects - retorna 409 se nome já estiver em uso por projeto ativo", + async () => { + const response = await fetch( + baseUrl, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + nome: "Novo Projeto HTTP", + cliente: "Outro Cliente", + }), + }, + ); + + assert.equal(response.status, 409); + + const body = + (await response.json()) as { + error: string; + }; + + assert.equal( + body.error, + "Já existe um projeto ativo com este nome.", + ); + }, + ); - await t.test("não permite contornar a confirmação via cadastro ou edição", async () => { - for (const [path, method] of [["", "POST"], ["/a0000000-0000-4000-8000-000000000001", "PATCH"]]) { - const response = await fetch(`${baseUrl}${path}`, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nome: "Outro", cliente: "Teste", status: "arquivado" }) }); - assert.equal(response.status, 400); - } - assert.equal((await fetch(`${baseUrl}?status=inexistente`)).status, 400); - }); - - await t.test("PATCH /api/v1/projects/:id/archive - arquiva projeto", async () => { - const res = await fetch(`${baseUrl}/a0000000-0000-4000-8000-000000000001/archive`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ confirmado: true, impacto: { projeto: 1, epicos: 0, features: 0, pbis: 0 }, justificativa: "Projeto concluído" }), - }); + await context.test( + "GET /api/v1/projects - lista projetos com status 200", + async () => { + const response = await fetch(baseUrl); + + assert.equal(response.status, 200); + + const body = + (await response.json()) as PaginatedProjects; + + assert.ok( + Array.isArray(body.items), + ); + + assert.equal(body.total, 1); + }, + ); - assert.equal(res.status, 200); - const body = (await res.json()) as ProjectWithStats; - assert.equal(body.status, "arquivado"); - }); -}); + await context.test( + "GET /api/v1/projects/:id - retorna 200 para projeto existente", + async () => { + const response = await fetch( + `${baseUrl}/a0000000-0000-4000-8000-000000000001`, + ); + + assert.equal(response.status, 200); + + const body = + (await response.json()) as ProjectWithStats; + + assert.equal( + body.nome, + "Novo Projeto HTTP", + ); + }, + ); + + await context.test( + "GET /api/v1/projects/:id - retorna 400 para UUID inválido", + async () => { + const response = await fetch( + `${baseUrl}/uuid-invalido`, + ); + + assert.equal(response.status, 400); + }, + ); + + await context.test( + "GET /api/v1/projects/:id - retorna 404 para ID inexistente", + async () => { + const response = await fetch( + `${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff`, + ); + + assert.equal(response.status, 404); + }, + ); + + await context.test( + "GET /api/v1/projects/:id/backlog-tree - retorna a hierarquia isolada do projeto", + async () => { + const id = + "a0000000-0000-4000-8000-000000000001"; + + const response = await fetch( + `${baseUrl}/${id}/backlog-tree`, + ); + + assert.equal(response.status, 200); + + const body = + (await response.json()) as ProjectBacklogTree; + + assert.equal( + body.project.id, + id, + ); + + assert.equal( + body.epics[0].features[0].pbis[0] + .codigo, + "PBI-01.4.1", + ); + + assert.deepEqual( + body.technologies, + [ + { + id: "tech-react", + nome: "React", + }, + ], + ); + + assert.equal( + ( + await fetch( + `${baseUrl}/invalid/backlog-tree`, + ) + ).status, + 400, + ); + + assert.equal( + ( + await fetch( + `${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff/backlog-tree`, + ) + ).status, + 404, + ); + }, + ); + + await context.test( + "prévia informa contagens e retorna 404 para projeto ausente", + async () => { + const response = await fetch( + `${baseUrl}/a0000000-0000-4000-8000-000000000001/archive-impact`, + ); + + assert.equal(response.status, 200); + + assert.deepEqual( + await response.json(), + { + projeto: 1, + epicos: 2, + features: 3, + pbis: 4, + }, + ); + + assert.equal( + ( + await fetch( + `${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff/archive-impact`, + ) + ).status, + 404, + ); + + assert.equal( + ( + await fetch( + `${baseUrl}/invalid/archive-impact`, + ) + ).status, + 400, + ); + }, + ); + + await context.test( + "arquivamento exige confirmação e contagens válidas", + async () => { + const requests = [ + {}, + { confirmado: false }, + { confirmado: true }, + { + confirmado: true, + impacto: { + projeto: 1, + epicos: -1, + features: 0, + pbis: 0, + }, + }, + ]; + + for (const body of requests) { + const response = await fetch( + `${baseUrl}/a0000000-0000-4000-8000-000000000001/archive`, + { + method: "PATCH", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify(body), + }, + ); + + assert.equal( + response.status, + 400, + ); + + assert.equal( + mockRepo.projects[0].status, + "ativo", + ); + } + }, + ); + + await context.test( + "não permite contornar a confirmação via cadastro ou edição", + async () => { + const requests: Array< + [string, string] + > = [ + ["", "POST"], + [ + "/a0000000-0000-4000-8000-000000000001", + "PATCH", + ], + ]; + + for (const [path, method] of requests) { + const response = await fetch( + `${baseUrl}${path}`, + { + method, + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + nome: "Outro", + cliente: "Teste", + status: "arquivado", + }), + }, + ); + + assert.equal( + response.status, + 400, + ); + } + + assert.equal( + ( + await fetch( + `${baseUrl}?status=inexistente`, + ) + ).status, + 400, + ); + }, + ); + + await context.test( + "PATCH /api/v1/projects/:id/archive - arquiva projeto", + async () => { + const response = await fetch( + `${baseUrl}/a0000000-0000-4000-8000-000000000001/archive`, + { + method: "PATCH", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + confirmado: true, + impacto: { + projeto: 1, + epicos: 0, + features: 0, + pbis: 0, + }, + justificativa: + "Projeto concluído", + }), + }, + ); + + assert.equal(response.status, 200); + + const body = + (await response.json()) as ProjectWithStats; + + assert.equal( + body.status, + "arquivado", + ); + }, + ); + }, +); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.routes.ts b/backend/src/modules/projects/projects.routes.ts index 5edc2f8..3657b5f 100644 --- a/backend/src/modules/projects/projects.routes.ts +++ b/backend/src/modules/projects/projects.routes.ts @@ -4,7 +4,10 @@ import { requireRole } from "../../middleware/requireRole.js"; export const projectsRouter = Router(); -const canWrite = requireRole("admin", "po"); +const canWrite = requireRole( + "admin", + "po", +); /** * @swagger @@ -60,7 +63,11 @@ const canWrite = requireRole("admin", "po"); * 403: * description: Sem permissão */ -projectsRouter.post("/", canWrite, projectsController.create); +projectsRouter.post( + "/", + canWrite, + projectsController.create, +); /** * @swagger @@ -125,7 +132,38 @@ projectsRouter.post("/", canWrite, projectsController.create); * 401: * description: Não autenticado */ -projectsRouter.get("/", projectsController.list); +projectsRouter.get( + "/", + projectsController.list, +); + +/** + * @swagger + * /api/v1/projects/{id}/backlog-tree: + * get: + * summary: Obter a árvore completa do backlog de um projeto + * tags: [Projects] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Projeto com épicos, features, PBIs e tecnologias associadas + * 400: + * description: ID inválido + * 404: + * description: Projeto não encontrado + */ +projectsRouter.get( + "/:id/backlog-tree", + projectsController.backlogTree, +); /** * @swagger @@ -155,7 +193,10 @@ projectsRouter.get("/", projectsController.list); * 404: * description: Projeto não encontrado */ -projectsRouter.get("/:id", projectsController.getById); +projectsRouter.get( + "/:id", + projectsController.getById, +); /** * @swagger @@ -216,7 +257,11 @@ projectsRouter.get("/:id", projectsController.getById); * 404: * description: Projeto não encontrado */ -projectsRouter.put("/:id", canWrite, projectsController.update); +projectsRouter.put( + "/:id", + canWrite, + projectsController.update, +); /** * @swagger @@ -277,7 +322,11 @@ projectsRouter.put("/:id", canWrite, projectsController.update); * 404: * description: Projeto não encontrado */ -projectsRouter.patch("/:id", canWrite, projectsController.update); +projectsRouter.patch( + "/:id", + canWrite, + projectsController.update, +); /** * @swagger @@ -319,5 +368,14 @@ projectsRouter.patch("/:id", canWrite, projectsController.update); * 404: * description: Projeto não encontrado */ -projectsRouter.patch("/:id/archive", canWrite, projectsController.archive); -projectsRouter.get("/:id/archive-impact", canWrite, projectsController.archiveImpact); +projectsRouter.patch( + "/:id/archive", + canWrite, + projectsController.archive, +); + +projectsRouter.get( + "/:id/archive-impact", + canWrite, + projectsController.archiveImpact, +); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.service.test.ts b/backend/src/modules/projects/projects.service.test.ts index ea0490c..02d972a 100644 --- a/backend/src/modules/projects/projects.service.test.ts +++ b/backend/src/modules/projects/projects.service.test.ts @@ -1,69 +1,201 @@ import test from "node:test"; import assert from "node:assert/strict"; import { ProjectsService } from "./projects.service.js"; -import { ConflictError, ValidationError, NotFoundError } from "../../shared/errors.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"; - -class InMemoryProjectsRepository extends ProjectsRepository { - private projects: ProjectWithStats[] = []; +import { + Project, + ProjectWithStats, + PaginatedProjects, + CreateProjectDTO, + UpdateProjectDTO, + ProjectQueryDTO, + ProjectBacklogTree, +} from "./projects.types.js"; + +class InMemoryProjectsRepository + extends ProjectsRepository { + private projects: + ProjectWithStats[] = []; constructor() { super(); } - async findActiveByName(nome: string, excludeId?: string): Promise { - const trimmed = nome.trim().toLowerCase(); - const found = this.projects.find( - (p) => - p.nome.trim().toLowerCase() === trimmed && - p.status !== "arquivado" && - (!excludeId || p.id !== excludeId), - ); - return found ? { ...found } : null; + async findActiveByName( + nome: string, + excludeId?: string, + ): Promise { + const trimmed = + nome.trim().toLowerCase(); + + const found = + this.projects.find( + (project) => + project.nome + .trim() + .toLowerCase() + === trimmed + && project.status + !== "arquivado" + && ( + !excludeId + || project.id + !== excludeId + ), + ); + + return found + ? { ...found } + : null; } - async findById(id: string): Promise { - const found = this.projects.find((p) => p.id === id); - return found ? { ...found } : null; + async findById( + id: string, + ): Promise { + const found = + this.projects.find( + (project) => + project.id === id, + ); + + return found + ? { ...found } + : null; } - async create(data: CreateProjectDTO, _usuarioId?: string | null): Promise { - const newProject: ProjectWithStats = { - id: "11111111-1111-4111-8111-111111111111", - nome: data.nome.trim(), - cliente: data.cliente.trim(), - descricao: data.descricao?.trim() ?? null, - status: data.status ?? "ativo", - data_inicio: data.data_inicio ?? new Date().toISOString(), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - epicos_count: 0, - documentos_count: 0, + async findBacklogTree( + id: string, + ): Promise { + const project = + this.projects.find( + (item) => + item.id === id, + ); + + if (!project) { + return null; + } + + return { + project: { + id: project.id, + nome: project.nome, + status: project.status, + }, + epics: [], + technologies: [], }; - this.projects.push(newProject); - return { ...newProject }; } - async findAll(query: ProjectQueryDTO): Promise { - let filtered = [...this.projects]; + async create( + data: CreateProjectDTO, + _usuarioId?: string | null, + ): Promise { + const newProject: + ProjectWithStats = { + id: + "11111111-1111-4111-8111-111111111111", + + nome: + data.nome.trim(), + + cliente: + data.cliente.trim(), + + descricao: + data.descricao?.trim() + ?? null, - if (query.status && query.status !== "todos") { - filtered = filtered.filter((p) => p.status === query.status); + status: + data.status ?? "ativo", + + data_inicio: + data.data_inicio + ?? new Date() + .toISOString(), + + created_at: + new Date() + .toISOString(), + + updated_at: + new Date() + .toISOString(), + + epicos_count: 0, + documentos_count: 0, + }; + + this.projects.push( + newProject, + ); + + return { + ...newProject, + }; + } + + async findAll( + query: ProjectQueryDTO, + ): Promise { + let filtered = [ + ...this.projects, + ]; + + if ( + query.status + && query.status !== "todos" + ) { + filtered = + filtered.filter( + (project) => + project.status + === query.status, + ); } - if (query.busca && query.busca.trim().length > 0) { - const q = query.busca.toLowerCase(); - filtered = filtered.filter( - (p) => - p.nome.toLowerCase().includes(q) || - p.cliente.toLowerCase().includes(q) || - (p.descricao && p.descricao.toLowerCase().includes(q)), - ); + if ( + query.busca + && query.busca + .trim() + .length > 0 + ) { + const search = + query.busca + .toLowerCase(); + + filtered = + filtered.filter( + (project) => + project.nome + .toLowerCase() + .includes(search) + || project.cliente + .toLowerCase() + .includes(search) + || ( + project.descricao + && project.descricao + .toLowerCase() + .includes(search) + ), + ); } - const total = filtered.length; - const paginated = filtered.slice(query.offset, query.offset + query.limit); + const total = + filtered.length; + + const paginated = + filtered.slice( + query.offset, + query.offset + + query.limit, + ); return { items: paginated, @@ -73,187 +205,496 @@ class InMemoryProjectsRepository extends ProjectsRepository { }; } - async update(id: string, data: UpdateProjectDTO, _usuarioId?: string | null): Promise { - const index = this.projects.findIndex((p) => p.id === id); - if (index === -1) return null; - - const current = this.projects[index]; - const updated: ProjectWithStats = { - ...current, - ...(data.nome !== undefined ? { nome: data.nome.trim() } : {}), - ...(data.cliente !== undefined ? { cliente: data.cliente.trim() } : {}), - ...(data.descricao !== undefined ? { descricao: data.descricao?.trim() ?? null } : {}), - ...(data.status !== undefined ? { status: data.status } : {}), - ...(data.data_inicio !== undefined ? { data_inicio: data.data_inicio ?? null } : {}), - updated_at: new Date().toISOString(), - }; + async update( + id: string, + data: UpdateProjectDTO, + _usuarioId?: string | null, + ): Promise { + const index = + this.projects.findIndex( + (project) => + project.id === id, + ); - this.projects[index] = updated; - return { ...updated }; - } + if (index === -1) { + return null; + } - async archive(id: string, _usuarioId?: string | null, _justificativa?: string): Promise { - const index = this.projects.findIndex((p) => p.id === id); - if (index === -1) return null; + const current = + this.projects[index]; + + const updated: + ProjectWithStats = { + ...current, + + ...(data.nome !== undefined + ? { + nome: + data.nome.trim(), + } + : {}), + + ...(data.cliente + !== undefined + ? { + cliente: + data.cliente.trim(), + } + : {}), + + ...(data.descricao + !== undefined + ? { + descricao: + data.descricao + ?.trim() + ?? null, + } + : {}), + + ...(data.status + !== undefined + ? { + status: + data.status, + } + : {}), + + ...(data.data_inicio + !== undefined + ? { + data_inicio: + data.data_inicio + ?? null, + } + : {}), + + updated_at: + new Date() + .toISOString(), + }; + + this.projects[index] = + updated; - const updated: ProjectWithStats = { - ...this.projects[index], - status: "arquivado", - updated_at: new Date().toISOString(), + return { + ...updated, }; + } + + async archive( + id: string, + _usuarioId?: string | null, + _justificativa?: string, + ): Promise { + const index = + this.projects.findIndex( + (project) => + project.id === id, + ); + + if (index === -1) { + return null; + } + + const updated: + ProjectWithStats = { + ...this.projects[index], + + status: "arquivado", + + updated_at: + new Date() + .toISOString(), + }; + + this.projects[index] = + updated; - this.projects[index] = updated; - return { ...updated }; + return { + ...updated, + }; } } -test("PBI-01.1.1 Cenário 1: deve criar projeto com dados válidos e status 'ativo'", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - const result = await service.create({ - nome: "Plataforma Sinapse", - cliente: "PRO4TECH", - descricao: "Base Inteligente de Requisitos", - }); - - assert.equal(result.nome, "Plataforma Sinapse"); - assert.equal(result.cliente, "PRO4TECH"); - assert.equal(result.descricao, "Base Inteligente de Requisitos"); - assert.equal(result.status, "ativo"); - assert.ok(result.id); -}); - -test("PBI-01.1.1 Cenário 2: deve impedir criação de projeto sem nome", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - await assert.rejects( - async () => { +test( + "PBI-01.1.1 Cenário 1: deve criar projeto com dados válidos e status 'ativo'", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + const result = await service.create({ - nome: " ", - cliente: "PRO4TECH", + nome: + "Plataforma Sinapse", + + cliente: + "PRO4TECH", + + descricao: + "Base Inteligente de Requisitos", }); - }, - (err: Error) => { - assert.ok(err instanceof ValidationError); - assert.match(err.message, /nome.*obrigat[óo]rio/i); - return true; - }, - ); -}); - -test("PBI-01.1.1: deve impedir criação de projeto sem cliente", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - await assert.rejects( - async () => { + + assert.equal( + result.nome, + "Plataforma Sinapse", + ); + + assert.equal( + result.cliente, + "PRO4TECH", + ); + + assert.equal( + result.descricao, + "Base Inteligente de Requisitos", + ); + + assert.equal( + result.status, + "ativo", + ); + + assert.ok(result.id); + }, +); + +test( + "PBI-01.1.1 Cenário 2: deve impedir criação de projeto sem nome", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + await assert.rejects( + async () => { + await service.create({ + nome: " ", + cliente: "PRO4TECH", + }); + }, + + (error: Error) => { + assert.ok( + error + instanceof ValidationError, + ); + + assert.match( + error.message, + /nome.*obrigat[óo]rio/i, + ); + + return true; + }, + ); + }, +); + +test( + "PBI-01.1.1: deve impedir criação de projeto sem cliente", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + await assert.rejects( + async () => { + await service.create({ + nome: + "Projeto Válido", + cliente: "", + }); + }, + + (error: Error) => { + assert.ok( + error + instanceof ValidationError, + ); + + assert.match( + error.message, + /cliente.*obrigat[óo]rio/i, + ); + + return true; + }, + ); + }, +); + +test( + "PBI-01.1.1 Cenário 3: deve impedir criação com nome duplicado de projeto ativo (case-insensitive)", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + await service.create({ + nome: "Sinapse Memory", + cliente: "PRO4TECH", + }); + + await assert.rejects( + async () => { + await service.create({ + nome: + " sinapse memory ", + + cliente: + "Outro Cliente", + }); + }, + + (error: Error) => { + assert.ok( + error + instanceof ConflictError, + ); + + assert.equal( + error.message, + "Já existe um projeto ativo com este nome.", + ); + + return true; + }, + ); + }, +); + +test( + "deve permitir criar projeto com mesmo nome se o anterior estiver arquivado", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + const initial = await service.create({ - nome: "Projeto Válido", - cliente: "", + nome: + "Projeto Antigo", + + cliente: + "Cliente A", }); - }, - (err: Error) => { - assert.ok(err instanceof ValidationError); - assert.match(err.message, /cliente.*obrigat[óo]rio/i); - return true; - }, - ); -}); - -test("PBI-01.1.1 Cenário 3: deve impedir criação com nome duplicado de projeto ativo (case-insensitive)", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - await service.create({ - nome: "Sinapse Memory", - cliente: "PRO4TECH", - }); - - await assert.rejects( - async () => { + + await service.archive( + initial.id, + "user-1", + "Projeto arquivado por fim de contrato", + ); + + const recreated = await service.create({ - nome: " sinapse memory ", - cliente: "Outro Cliente", + nome: + "Projeto Antigo", + + cliente: + "Cliente B", + }); + + assert.equal( + recreated.nome, + "Projeto Antigo", + ); + + assert.equal( + recreated.status, + "ativo", + ); + }, +); + +test( + "deve listar projetos com paginação e filtro por busca", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + await service.create({ + nome: "Alpha Core", + cliente: "Cliente 1", + }); + + await service.create({ + nome: "Beta App", + cliente: "Cliente 2", + descricao: "Mobile", + }); + + await service.create({ + nome: "Gamma Web", + cliente: "Cliente 1", + }); + + const listAll = + await service.list({ + limit: 10, + offset: 0, + }); + + assert.equal( + listAll.total, + 3, + ); + + assert.equal( + listAll.items.length, + 3, + ); + + const listSearch = + await service.list({ + busca: "beta", + limit: 10, + offset: 0, }); - }, - (err: Error) => { - assert.ok(err instanceof ConflictError); - assert.equal(err.message, "Já existe um projeto ativo com este nome."); - return true; - }, - ); -}); - -test("deve permitir criar projeto com mesmo nome se o anterior estiver arquivado", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - const initial = await service.create({ - nome: "Projeto Antigo", - cliente: "Cliente A", - }); - - await service.archive(initial.id, "user-1", "Projeto arquivado por fim de contrato"); - - // Agora deve ser possível criar com o mesmo nome - const recreated = await service.create({ - nome: "Projeto Antigo", - cliente: "Cliente B", - }); - - assert.equal(recreated.nome, "Projeto Antigo"); - assert.equal(recreated.status, "ativo"); -}); - -test("deve listar projetos com paginação e filtro por busca", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - await service.create({ nome: "Alpha Core", cliente: "Cliente 1" }); - await service.create({ nome: "Beta App", cliente: "Cliente 2", descricao: "Mobile" }); - await service.create({ nome: "Gamma Web", cliente: "Cliente 1" }); - - const listAll = await service.list({ limit: 10, offset: 0 }); - assert.equal(listAll.total, 3); - assert.equal(listAll.items.length, 3); - - const listSearch = await service.list({ busca: "beta", limit: 10, offset: 0 }); - assert.equal(listSearch.total, 1); - assert.equal(listSearch.items[0].nome, "Beta App"); -}); - -test("deve consultar projeto por ID e lançar NotFound se não existir", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - const created = await service.create({ nome: "Projeto X", cliente: "Cliente X" }); - const found = await service.getById(created.id); - assert.equal(found.id, created.id); - - await assert.rejects( - async () => { - await service.getById("99999999-9999-4999-8999-999999999999"); - }, - (err: Error) => { - assert.ok(err instanceof NotFoundError); - return true; - }, - ); -}); - -test("deve validar formato UUID no getById", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - await assert.rejects( - async () => { - await service.getById("id-invalido"); - }, - (err: Error) => { - assert.ok(err instanceof ValidationError); - return true; - }, - ); -}); + + assert.equal( + listSearch.total, + 1, + ); + + assert.equal( + listSearch.items[0].nome, + "Beta App", + ); + }, +); + +test( + "deve consultar projeto por ID e lançar NotFound se não existir", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + const created = + await service.create({ + nome: "Projeto X", + cliente: "Cliente X", + }); + + const found = + await service.getById( + created.id, + ); + + assert.equal( + found.id, + created.id, + ); + + await assert.rejects( + async () => { + await service.getById( + "99999999-9999-4999-8999-999999999999", + ); + }, + + (error: Error) => { + assert.ok( + error + instanceof NotFoundError, + ); + + return true; + }, + ); + }, +); + +test( + "deve validar formato UUID no getById", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + await assert.rejects( + async () => { + await service.getById( + "id-invalido", + ); + }, + + (error: Error) => { + assert.ok( + error + instanceof ValidationError, + ); + + return true; + }, + ); + }, +); + +test( + "S1-16: consulta a árvore do projeto e valida projeto ausente ou ID inválido", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + const created = + await service.create({ + nome: "Projeto árvore", + cliente: "PRO4TECH", + }); + + const tree = + await service + .getBacklogTree( + created.id, + ); + + assert.equal( + tree.project.id, + created.id, + ); + + assert.deepEqual( + tree.epics, + [], + ); + + await assert.rejects( + () => + service.getBacklogTree( + "invalido", + ), + ValidationError, + ); + + await assert.rejects( + () => + service.getBacklogTree( + "99999999-9999-4999-8999-999999999999", + ), + NotFoundError, + ); + }, +); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts index 9e9ba96..f091600 100644 --- a/backend/src/modules/projects/projects.service.ts +++ b/backend/src/modules/projects/projects.service.ts @@ -7,100 +7,225 @@ import { Project, ProjectWithStats, PaginatedProjects, + ProjectBacklogTree, } from "./projects.types.js"; -import { ProjectsRepository, projectsRepository } from "./projects.repository.js"; -import { ConflictError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { + ProjectsRepository, + projectsRepository, +} from "./projects.repository.js"; +import { + ConflictError, + NotFoundError, + ValidationError, + validateUuid, +} from "../../shared/errors.js"; export class ProjectsService { - constructor(private readonly repository: ProjectsRepository = projectsRepository) {} + constructor( + private readonly repository: + ProjectsRepository = projectsRepository, + ) {} private validateUuid(id: string): void { validateUuid(id, "ID do projeto"); } - async create(input: unknown, usuarioId?: string | null): Promise { - const parseResult = createProjectSchema.safeParse(input); + async create( + input: unknown, + usuarioId?: string | null, + ): Promise { + const parseResult = + createProjectSchema.safeParse(input); + if (!parseResult.success) { - const issue = parseResult.error.issues[0]; - throw new ValidationError(issue.message, parseResult.error.format()); + const issue = + parseResult.error.issues[0]; + + throw new ValidationError( + issue.message, + parseResult.error.format(), + ); } const dto = parseResult.data; - // Cenário 3 — Impedir nome duplicado entre projetos ativos - const existing = await this.repository.findActiveByName(dto.nome); + // Impedir nome duplicado entre projetos ativos. + const existing = + await this.repository.findActiveByName( + dto.nome, + ); + if (existing) { - throw new ConflictError("Já existe um projeto ativo com este nome."); + throw new ConflictError( + "Já existe um projeto ativo com este nome.", + ); } - return await this.repository.create(dto, usuarioId); + return await this.repository.create( + dto, + usuarioId, + ); } - async list(queryInput: unknown): Promise { - const parseResult = projectQuerySchema.safeParse(queryInput); + async list( + queryInput: unknown, + ): Promise { + const parseResult = + projectQuerySchema.safeParse( + queryInput, + ); + if (!parseResult.success) { - const issue = parseResult.error.issues[0]; - throw new ValidationError(issue.message, parseResult.error.format()); + const issue = + parseResult.error.issues[0]; + + throw new ValidationError( + issue.message, + parseResult.error.format(), + ); } - return await this.repository.findAll(parseResult.data); + return await this.repository.findAll( + parseResult.data, + ); } - async getById(id: string): Promise { + async getById( + id: string, + ): Promise { this.validateUuid(id); - const project = await this.repository.findById(id); + const project = + await this.repository.findById(id); + if (!project) { - throw new NotFoundError("Projeto não encontrado."); + throw new NotFoundError( + "Projeto não encontrado.", + ); } return project; } - async archiveImpact(id: string): Promise<{ projeto: number; epicos: number; features: number; pbis: number }> { + async getBacklogTree( + id: string, + ): Promise { + this.validateUuid(id); + + const tree = + await this.repository.findBacklogTree( + id, + ); + + if (!tree) { + throw new NotFoundError( + "Projeto não encontrado.", + ); + } + + return tree; + } + + async archiveImpact( + id: string, + ): Promise<{ + projeto: number; + epicos: number; + features: number; + pbis: number; + }> { this.validateUuid(id); - const impact = await this.repository.archiveImpact(id); - if (!impact) throw new NotFoundError("Projeto não encontrado."); + + const impact = + await this.repository.archiveImpact(id); + + if (!impact) { + throw new NotFoundError( + "Projeto não encontrado.", + ); + } + return impact; } - async update(id: string, input: unknown, usuarioId?: string | null): Promise { + async update( + id: string, + input: unknown, + usuarioId?: string | null, + ): Promise { this.validateUuid(id); - const parseResult = updateProjectSchema.safeParse(input); + const parseResult = + updateProjectSchema.safeParse(input); + if (!parseResult.success) { - const issue = parseResult.error.issues[0]; - throw new ValidationError(issue.message, parseResult.error.format()); + const issue = + parseResult.error.issues[0]; + + throw new ValidationError( + issue.message, + parseResult.error.format(), + ); } const dto = parseResult.data; - // Se estiver atualizando o nome, verificar duplicidade em outros projetos ativos if (dto.nome) { - const existing = await this.repository.findActiveByName(dto.nome, id); + const existing = + await this.repository + .findActiveByName( + dto.nome, + id, + ); + if (existing) { - throw new ConflictError("Já existe um projeto ativo com este nome."); + throw new ConflictError( + "Já existe um projeto ativo com este nome.", + ); } } - const updated = await this.repository.update(id, dto, usuarioId); + const updated = + await this.repository.update( + id, + dto, + usuarioId, + ); + if (!updated) { - throw new NotFoundError("Projeto não encontrado."); + throw new NotFoundError( + "Projeto não encontrado.", + ); } return updated; } - async archive(id: string, usuarioId?: string | null, justificativa?: string, expected?: ArchiveImpact): Promise { + async archive( + id: string, + usuarioId?: string | null, + justificativa?: string, + expected?: ArchiveImpact, + ): Promise { this.validateUuid(id); - const archived = await this.repository.archive(id, usuarioId, justificativa, expected); + const archived = + await this.repository.archive( + id, + usuarioId, + justificativa, + expected, + ); + if (!archived) { - throw new NotFoundError("Projeto não encontrado."); + throw new NotFoundError( + "Projeto não encontrado.", + ); } return archived; } } -export const projectsService = new ProjectsService(); +export const projectsService = + new ProjectsService(); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.types.ts b/backend/src/modules/projects/projects.types.ts index 4354857..69cebdd 100644 --- a/backend/src/modules/projects/projects.types.ts +++ b/backend/src/modules/projects/projects.types.ts @@ -1,68 +1,177 @@ import { z } from "zod"; -export const PROJECT_STATUSES = ["ativo", "em_andamento", "concluido", "arquivado"] as const; -export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; +export const PROJECT_STATUSES = [ + "ativo", + "em_andamento", + "concluido", + "arquivado", +] as const; + +export type ProjectStatus = + (typeof PROJECT_STATUSES)[number]; export const createProjectSchema = z.object({ nome: z - .string({ required_error: "O nome do projeto é obrigatório." }) + .string({ + required_error: + "O nome do projeto é obrigatório.", + }) .trim() - .min(1, "O nome do projeto é obrigatório.") - .max(255, "O nome não pode exceder 255 caracteres."), + .min( + 1, + "O nome do projeto é obrigatório.", + ) + .max( + 255, + "O nome não pode exceder 255 caracteres.", + ), + cliente: z - .string({ required_error: "O nome do cliente é obrigatório." }) + .string({ + required_error: + "O nome do cliente é obrigatório.", + }) + .trim() + .min( + 1, + "O nome do cliente é obrigatório.", + ) + .max( + 255, + "O cliente não pode exceder 255 caracteres.", + ), + + descricao: z + .string() .trim() - .min(1, "O nome do cliente é obrigatório.") - .max(255, "O cliente não pode exceder 255 caracteres."), - descricao: z.string().trim().optional().nullable(), - status: z.enum(["ativo", "em_andamento", "concluido"]).default("ativo"), + .optional() + .nullable(), + + status: z + .enum([ + "ativo", + "em_andamento", + "concluido", + ]) + .default("ativo"), + data_inicio: z .string() - .refine((val) => !isNaN(Date.parse(val)), { - message: "Data de início deve ser uma data válida.", - }) + .refine( + (val) => !isNaN(Date.parse(val)), + { + message: + "Data de início deve ser uma data válida.", + }, + ) .optional() .nullable(), }); -export type CreateProjectDTO = z.infer; +export type CreateProjectDTO = + z.infer; export const updateProjectSchema = z.object({ nome: z .string() .trim() - .min(1, "O nome do projeto não pode ser vazio.") - .max(255, "O nome não pode exceder 255 caracteres.") + .min( + 1, + "O nome do projeto não pode ser vazio.", + ) + .max( + 255, + "O nome não pode exceder 255 caracteres.", + ) .optional(), + cliente: z .string() .trim() - .min(1, "O cliente não pode ser vazio.") - .max(255, "O cliente não pode exceder 255 caracteres.") + .min( + 1, + "O cliente não pode ser vazio.", + ) + .max( + 255, + "O cliente não pode exceder 255 caracteres.", + ) .optional(), - descricao: z.string().trim().optional().nullable(), - status: z.enum(["ativo", "em_andamento", "concluido"]).optional(), + + descricao: z + .string() + .trim() + .optional() + .nullable(), + + status: z + .enum([ + "ativo", + "em_andamento", + "concluido", + ]) + .optional(), + data_inicio: z .string() - .refine((val) => !isNaN(Date.parse(val)), { - message: "Data de início deve ser uma data válida.", - }) + .refine( + (val) => !isNaN(Date.parse(val)), + { + message: + "Data de início deve ser uma data válida.", + }, + ) + .optional() + .nullable(), + + justificativa: z + .string() + .trim() .optional() .nullable(), - justificativa: z.string().trim().optional().nullable(), }); -export type UpdateProjectDTO = z.infer; +export type UpdateProjectDTO = + z.infer; export const projectQuerySchema = z.object({ - status: z.enum([...PROJECT_STATUSES, "todos"]).optional(), - busca: z.string().trim().optional(), - limit: z.coerce.number().int().min(1).max(100).default(50), - offset: z.coerce.number().int().min(0).default(0), - order: z.enum(["created_at_desc", "created_at_asc", "nome_asc", "nome_desc"]).default("created_at_desc"), + status: z + .enum([ + ...PROJECT_STATUSES, + "todos", + ]) + .optional(), + + busca: z + .string() + .trim() + .optional(), + + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .default(50), + + offset: z.coerce + .number() + .int() + .min(0) + .default(0), + + order: z + .enum([ + "created_at_desc", + "created_at_asc", + "nome_asc", + "nome_desc", + ]) + .default("created_at_desc"), }); -export type ProjectQueryDTO = z.infer; +export type ProjectQueryDTO = + z.infer; export interface Project { id: string; @@ -76,7 +185,8 @@ export interface Project { archived_at?: Date | string | null; } -export interface ProjectWithStats extends Project { +export interface ProjectWithStats + extends Project { epicos_count?: number; documentos_count?: number; } @@ -87,3 +197,42 @@ export interface PaginatedProjects { limit: number; offset: number; } + +export interface BacklogTechnology { + id: string; + nome: string; +} + +export interface BacklogPbiNode { + id: string; + codigo: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; +} + +export interface BacklogFeatureNode { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + pbis: BacklogPbiNode[]; +} + +export interface BacklogEpicNode { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + features: BacklogFeatureNode[]; +} + +export interface ProjectBacklogTree { + project: Pick< + Project, + "id" | "nome" | "status" + >; + + epics: BacklogEpicNode[]; + technologies: BacklogTechnology[]; +} \ No newline at end of file diff --git a/backend/src/modules/technologies/entity-technologies.ts b/backend/src/modules/technologies/entity-technologies.ts new file mode 100644 index 0000000..7ce0102 --- /dev/null +++ b/backend/src/modules/technologies/entity-technologies.ts @@ -0,0 +1,52 @@ +import type { PoolClient } from "pg"; +import { ValidationError } from "../../shared/errors.js"; + +export type BacklogEntityType = "epico" | "feature" | "pbi"; + +export async function replaceEntityTechnologies( + client: PoolClient, + entityType: BacklogEntityType, + entityId: string, + technologyIds: string[] | undefined, +): Promise { + if (technologyIds === undefined) return; + + const uniqueIds = [...new Set(technologyIds)]; + if (uniqueIds.length > 0) { + const existing = await client.query<{ id: string }>( + "SELECT id FROM tecnologia WHERE id = ANY($1::uuid[])", + [uniqueIds], + ); + if (existing.rowCount !== uniqueIds.length) { + throw new ValidationError("Uma ou mais tecnologias selecionadas não existem."); + } + } + + await client.query( + "DELETE FROM entidade_tecnologia WHERE entidade_tipo = $1 AND entidade_id = $2", + [entityType, entityId], + ); + + if (uniqueIds.length > 0) { + await client.query( + `INSERT INTO entidade_tecnologia (entidade_tipo, entidade_id, tecnologia_id) + SELECT $1, $2, input.technology_id + FROM unnest($3::uuid[]) AS input(technology_id)`, + [entityType, entityId, uniqueIds], + ); + } +} + +export async function getEntityTechnologyIds( + client: PoolClient, + entityType: BacklogEntityType, + entityId: string, +): Promise { + const result = await client.query<{ tecnologias_ids: string[] }>( + `SELECT COALESCE(array_agg(tecnologia_id ORDER BY tecnologia_id), ARRAY[]::uuid[]) AS tecnologias_ids + FROM entidade_tecnologia + WHERE entidade_tipo = $1 AND entidade_id = $2`, + [entityType, entityId], + ); + return result.rows[0]?.tecnologias_ids ?? []; +} diff --git a/backend/src/modules/technologies/technologies.routes.test.ts b/backend/src/modules/technologies/technologies.routes.test.ts new file mode 100644 index 0000000..4e8dd24 --- /dev/null +++ b/backend/src/modules/technologies/technologies.routes.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import type { Pool } from "pg"; +import { createTechnologiesRouter } from "./technologies.routes.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; + +test("GET /technologies expõe só o catálogo necessário, em ordem estável", async () => { + let queryText = ""; + const router = createTechnologiesRouter({ + async query(sql: string) { + queryText = sql; + return { rows: [ + { id: "tech-a", nome: "Node.js" }, + { id: "tech-b", nome: "React" }, + ] } as never; + }, + } as unknown as Pick); + const app = express(); + app.use("/api/v1/technologies", router); + app.use(errorHandler); + const server = app.listen(0, "127.0.0.1"); + + try { + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/technologies`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { items: [ + { id: "tech-a", nome: "Node.js" }, + { id: "tech-b", nome: "React" }, + ] }); + assert.match(queryText, /SELECT id, nome FROM tecnologia ORDER BY nome ASC, id ASC/); + assert.doesNotMatch(queryText, /desenvolvedor|competencia|email|bio/i); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); diff --git a/backend/src/modules/technologies/technologies.routes.ts b/backend/src/modules/technologies/technologies.routes.ts new file mode 100644 index 0000000..1830e18 --- /dev/null +++ b/backend/src/modules/technologies/technologies.routes.ts @@ -0,0 +1,22 @@ +import { Router, type NextFunction, type Request, type Response } from "express"; +import type { Pool } from "pg"; +import { pool } from "../../database/db.js"; + +export function createTechnologiesRouter(db: Pick = pool) { + const router = Router(); + + router.get("/", async (_req: Request, res: Response, next: NextFunction) => { + try { + const result = await db.query<{ id: string; nome: string }>( + "SELECT id, nome FROM tecnologia ORDER BY nome ASC, id ASC", + ); + res.json({ items: result.rows }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export const technologiesRouter = createTechnologiesRouter(); diff --git a/database/migrations/011_unique_entity_technology.sql b/database/migrations/011_unique_entity_technology.sql new file mode 100644 index 0000000..9a81cf4 --- /dev/null +++ b/database/migrations/011_unique_entity_technology.sql @@ -0,0 +1,11 @@ +-- Backlog technology tags are replaced atomically by the API. Normalize any +-- historical duplicates before enforcing the association's natural key. +DELETE FROM entidade_tecnologia duplicate +USING entidade_tecnologia retained +WHERE duplicate.entidade_tipo = retained.entidade_tipo + AND duplicate.entidade_id = retained.entidade_id + AND duplicate.tecnologia_id = retained.tecnologia_id + AND duplicate.ctid > retained.ctid; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_entidade_tecnologia_association + ON entidade_tecnologia (entidade_tipo, entidade_id, tecnologia_id); diff --git a/database/migrations/README.md b/database/migrations/README.md index acc0fc7..bb2f940 100644 --- a/database/migrations/README.md +++ b/database/migrations/README.md @@ -39,6 +39,14 @@ histórico backlog e ambas as migrations, com repetição e rollback de auditori Exige `S105_TEST_DATABASE_URL` apontando para PostgreSQL local descartável cujo nome termina em `_s105_test`. O teste cria/remove somente schemas próprios. +## S1-16: vínculos de tecnologia + +`011_unique_entity_technology.sql` normaliza associações duplicadas e cria a +chave única de `(entidade_tipo, entidade_id, tecnologia_id)`. A API atualiza os +vínculos dentro da mesma transação que cria/edita épicos, features e PBIs. +`BACKLOG_TREE_TEST_DATABASE_URL` habilita os testes reais da árvore e dos +vínculos; use somente um banco descartável cujo nome termine em `_test`. + ## Retomada do #22 após o #23 `007_criteria_polymorphic_format.sql` torna `criterio_aceitacao.texto` opcional e diff --git a/docs/PR37_CORRECTION_PLAN.md b/docs/PR37_CORRECTION_PLAN.md new file mode 100644 index 0000000..a7492e6 --- /dev/null +++ b/docs/PR37_CORRECTION_PLAN.md @@ -0,0 +1,81 @@ +# Plano de correção da PR #37 (S1-16) + +## Escopo e regra de segurança + +Correções feitas na branch isolada `codex/pr37-corrections`, iniciada no commit +`b6e6949` da PR #37 e integrada à `main` `ce30a2d` (merge da PR #36). O checkout +`codex/s1-28-frontend-source` e suas alterações locais foram preservados. Este +trabalho não faz merge da PR. + +Cada etapa só avança depois de revisar o diff e executar os testes pertinentes. + +## Etapa 0 — Confirmar os contratos + +**Concluída.** `pronto` não existe nas constraints atuais. O catálogo de +tecnologias existe, mas não havia fluxo da aplicação para associar tecnologias +a épicos, features ou PBIs. O contrato adotado usa os estados persistidos e o +catálogo já existente, sem criar outro vocabulário ou fonte de dados. + +## Etapa 1 — Integrar a main sem perder comportamento + +**Concluída localmente.** Os conflitos de `EpicsView.tsx`, `FeaturesView.tsx` e +`PbisView.tsx` foram resolvidos usando as implementações atuais da `main` para +justificativas, configuração de qualidade e histórico de auditoria. Os +breadcrumbs de S1-16 foram reaplicados nas telas de detalhe. Builds e testes +frontend passaram na validação local com Node 20. + +## Etapa 2 — Tornar o filtro de tecnologia funcional + +**Concluída no código.** Foi adicionada uma rota autenticada de catálogo que +retorna somente ID e nome, um seletor reutilizável nos formulários dos três +níveis e persistência atômica de vínculos em criação/edição. IDs são validados, +duplicatas são normalizadas, lista vazia remove vínculos e falhas revertem a +transação. A migration `011_unique_entity_technology.sql` remove duplicatas +legadas e impõe a chave natural. Associações também são registradas na auditoria. + +## Etapa 3 — Corrigir status do filtro + +**Concluída.** `pronto` foi removido das opções e da lista aceita na sessão. +Valores antigos inválidos são descartados. As opções restantes correspondem à +união dos status válidos para épico, feature e PBI nas migrations atuais. + +## Etapa 4 — Snapshot consistente e testes PostgreSQL + +**Concluída no código.** `findBacklogTree` agora lê projeto, épicos, features e +PBIs na mesma conexão e transação `REPEATABLE READ READ ONLY`, com ordenação +estável, commit/rollback e liberação garantida. Foram adicionados testes para a +transação, leitura real da árvore, isolamento de projeto, tags nos três níveis, +remoção, rejeição de IDs inexistentes, rollback e migração sobre associações +duplicadas. + +## Etapa 5 — Avaliação de escala + +**Concluída e medida na CI.** O teste de integração adiciona +25 épicos, 125 features e 625 PBIs (além da fixture funcional mínima) e exige +resposta em menos de 2 s e menor que 5 MiB. Os índices +de hierarquia publicados e o novo índice único cobrem os caminhos de junção e +associação. + +No PostgreSQL da CI, a consulta retornou 26 épicos, 126 features e 626 PBIs em +10,8 ms, com payload de 105.546 bytes. Isso valida o baseline sintético, não +substitui monitoramento de projetos reais em volumes maiores. + +A árvore continua sendo retornada integralmente. Se o baseline falhar ou os +projetos reais forem maiores que esse volume, a correção apropriada é carregar +features/PBIs progressivamente com filtros no servidor; limitar silenciosamente +os resultados no cliente faria status/tecnologia parecerem filtros globais +quando só considerariam os dados carregados. A máquina Windows atual não tem +Docker nem PostgreSQL local; o gate foi executado no PostgreSQL da CI. + +## Etapa 6 — QA final + +**Concluída no commit `8f4e9f5`.** Com Node 20: + +- Backend: build TypeScript aprovado; 168 testes passaram, 8 testes PostgreSQL + foram ignorados porque não há banco local disponível. +- Frontend: build de produção aprovado; 102 testes passaram. +- OpenAPI: YAML parseado sem erro. + +CI da PR: todos os checks passaram no commit `8f4e9f5`, incluindo migration +011 com duplicatas legadas, integração da árvore e baseline PostgreSQL. Nenhum +merge está autorizado por este plano; a PR permanece aberta para nova revisão. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cac5f9b..5643a73 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -347,6 +347,55 @@ paths: schema: $ref: '#/components/schemas/Error' + /api/v1/projects/{id}/backlog-tree: + get: + summary: Retorna a hierarquia do backlog e tecnologias associadas (S1-16) + operationId: getProjectBacklogTree + security: + - cookieAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Árvore ordenada dentro de um snapshot consistente do banco + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectBacklogTreeResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Projeto não encontrado + + /api/v1/technologies: + get: + summary: Lista o catálogo de tecnologias para associação ao backlog + operationId: listTechnologies + security: + - cookieAuth: [] + responses: + '200': + description: Catálogo ordenado sem dados pessoais de desenvolvedores + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: '#/components/schemas/TechnologyOption' + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/epics: post: summary: Cadastra um épico dentro de um projeto (PBI-01.1.2 / S1-05) @@ -1287,6 +1336,76 @@ components: name: sinapse_session schemas: + TechnologyOption: + type: object + required: [id, nome] + properties: + id: + type: string + format: uuid + nome: + type: string + + ProjectBacklogTreeResponse: + type: object + required: [project, epics, technologies] + properties: + project: + type: object + required: [id, nome, status] + properties: + id: {type: string, format: uuid} + nome: {type: string} + status: {type: string} + technologies: + type: array + items: + $ref: '#/components/schemas/TechnologyOption' + epics: + type: array + items: + $ref: '#/components/schemas/BacklogEpicNode' + + BacklogEpicNode: + type: object + required: [id, titulo, status, tecnologias, features] + properties: + id: {type: string, format: uuid} + titulo: {type: string} + status: {type: string} + tecnologias: + type: array + items: {$ref: '#/components/schemas/TechnologyOption'} + features: + type: array + items: {$ref: '#/components/schemas/BacklogFeatureNode'} + + BacklogFeatureNode: + type: object + required: [id, titulo, status, tecnologias, pbis] + properties: + id: {type: string, format: uuid} + titulo: {type: string} + status: {type: string} + tecnologias: + type: array + items: {$ref: '#/components/schemas/TechnologyOption'} + pbis: + type: array + items: {$ref: '#/components/schemas/BacklogPbiNode'} + + BacklogPbiNode: + type: object + required: [id, codigo, titulo, status, tecnologias] + properties: + id: {type: string, format: uuid} + codigo: {type: string} + titulo: {type: string} + status: {type: string} + tecnologias: + type: array + items: {$ref: '#/components/schemas/TechnologyOption'} + HierarchyArchiveImpact: type: object required: [epicos, features, pbis] @@ -1480,6 +1599,13 @@ components: type: string enum: [Must, Should, Could] default: Must + tecnologias_ids: + type: array + maxItems: 50 + items: + type: string + format: uuid + description: IDs do catálogo; associações são substituídas atomicamente. IDs repetidos são normalizados. UpdateEpicRequest: type: object @@ -1506,6 +1632,13 @@ components: enum: [Must, Should, Could] justificativa: type: string + tecnologias_ids: + type: array + maxItems: 50 + items: + type: string + format: uuid + description: Quando omitido, preserva as associações atuais; lista vazia remove todas. EpicResponse: type: object @@ -1548,6 +1681,11 @@ components: type: integer criterios_count: type: integer + tecnologias_ids: + type: array + items: + type: string + format: uuid PaginatedEpicsResponse: type: object @@ -1585,6 +1723,12 @@ components: type: string enum: [Must, Should, Could] default: Must + tecnologias_ids: + type: array + maxItems: 50 + items: + type: string + format: uuid UpdateFeatureRequest: type: object @@ -1604,6 +1748,12 @@ components: enum: [Must, Should, Could] justificativa: type: string + tecnologias_ids: + type: array + maxItems: 50 + items: + type: string + format: uuid FeatureResponse: type: object @@ -1644,6 +1794,11 @@ components: projeto_id: type: string format: uuid + tecnologias_ids: + type: array + items: + type: string + format: uuid PaginatedFeaturesResponse: type: object @@ -1694,6 +1849,12 @@ components: type: boolean default: false description: Define explicitamente se a verificação de protótipo deve ser aplicada ao PBI. + tecnologias_ids: + type: array + maxItems: 50 + items: + type: string + format: uuid UpdatePbiRequest: type: object @@ -1723,6 +1884,12 @@ components: type: boolean justificativa: type: string + tecnologias_ids: + type: array + maxItems: 50 + items: + type: string + format: uuid PbiResponse: type: object @@ -1755,6 +1922,11 @@ components: requer_interface: type: boolean description: Indica se a regra de protótipo é aplicável a este PBI. + tecnologias_ids: + type: array + items: + type: string + format: uuid prototipo_vinculado: type: boolean description: Indica se há ao menos um protótipo registrado para este PBI. diff --git a/frontend/src/api/api_backlog.ts b/frontend/src/api/api_backlog.ts index 96d7678..b675413 100644 --- a/frontend/src/api/api_backlog.ts +++ b/frontend/src/api/api_backlog.ts @@ -8,6 +8,11 @@ export type BacklogStatus = | "concluido" | "arquivado"; +export interface TechnologyOption { + id: string; + nome: string; +} + export type Priority = | "Must" | "Should" @@ -20,6 +25,7 @@ export interface EpicInput { objetivo: string; escopo_macro: string; resultado_esperado: string; + tecnologias_ids?: string[]; justificativa?: string | null; } @@ -31,6 +37,7 @@ export interface Epic extends EpicInput { criterios_count: number; projeto_status: string; archived_at: string | null; + tecnologias_ids?: string[]; } export interface FeatureInput { @@ -38,6 +45,7 @@ export interface FeatureInput { titulo: string; descricao: string; objetivo: string; + tecnologias_ids?: string[]; justificativa?: string | null; } @@ -51,6 +59,7 @@ export interface Feature extends FeatureInput { projeto_id: string; projeto_status: string; archived_at: string | null; + tecnologias_ids?: string[]; } export interface PbiInput { @@ -62,6 +71,7 @@ export interface PbiInput { requer_interface: boolean; regras_observacoes?: string | null; justificativa?: string | null; + tecnologias_ids?: string[]; } export interface Pbi extends PbiInput { @@ -76,6 +86,7 @@ export interface Pbi extends PbiInput { projeto_status: string; score_completude: number | null; prototipo_vinculado?: boolean; + tecnologias_ids?: string[]; } export interface CompletionError { @@ -148,6 +159,22 @@ function asText(value: unknown): string { : ""; } +function parseTechnologyIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((id): id is string => typeof id === "string"); +} + +export async function listTechnologies(signal?: AbortSignal): Promise { + const response = await apiRequest("/technologies", { signal }); + const data = await response.json(); + if (!Array.isArray(data.items)) throw new Error("Catálogo de tecnologias inválido"); + return data.items.filter((item: unknown): item is TechnologyOption => { + if (!item || typeof item !== "object") return false; + const candidate = item as Record; + return typeof candidate.id === "string" && typeof candidate.nome === "string"; + }); +} + function parseEpic(value: unknown): Epic { const epic = value as Record; @@ -176,6 +203,7 @@ function parseEpic(value: unknown): Epic { resultado_esperado: asText( epic.resultado_esperado, ), + tecnologias_ids: parseTechnologyIds(epic.tecnologias_ids), prioridade: (epic.prioridade as Priority) ?? "Must", @@ -224,6 +252,7 @@ function parseFeature( feature.descricao, ), objetivo: asText(feature.objetivo), + tecnologias_ids: parseTechnologyIds(feature.tecnologias_ids), prioridade: (feature.prioridade as Priority) ?? "Must", @@ -285,6 +314,7 @@ function parsePbi(value: unknown): Pbi { ), requer_interface: pbi.requer_interface === true, + tecnologias_ids: parseTechnologyIds(pbi.tecnologias_ids), prototipo_vinculado: pbi.prototipo_vinculado === true, status: diff --git a/frontend/src/api/api_backlog_tree.ts b/frontend/src/api/api_backlog_tree.ts new file mode 100644 index 0000000..8497a92 --- /dev/null +++ b/frontend/src/api/api_backlog_tree.ts @@ -0,0 +1,258 @@ +import { apiRequest } from "./api_auth"; + +export interface BacklogTechnology { + id: string; + nome: string; +} + +export interface BacklogPbiNode { + id: string; + codigo: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; +} + +export interface BacklogFeatureNode { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + pbis: BacklogPbiNode[]; +} + +export interface BacklogEpicNode { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + features: BacklogFeatureNode[]; +} + +export interface ProjectBacklogTree { + project: { + id: string; + nome: string; + status: string; + }; + + epics: BacklogEpicNode[]; + technologies: BacklogTechnology[]; +} + +function record( + value: unknown, +): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) + ) { + throw new Error( + "Resposta da árvore do backlog inválida", + ); + } + + return value as Record< + string, + unknown + >; +} + +function text( + value: unknown, + field: string, +): string { + if ( + typeof value !== "string" || + value.length === 0 + ) { + throw new Error( + `Campo ${field} inválido na árvore do backlog`, + ); + } + + return value; +} + +function technologies( + value: unknown, +): BacklogTechnology[] { + if (!Array.isArray(value)) { + throw new Error( + "Tecnologias inválidas na árvore do backlog", + ); + } + + return value.map((item) => { + const technology = record(item); + + return { + id: text( + technology.id, + "tecnologia.id", + ), + + nome: text( + technology.nome, + "tecnologia.nome", + ), + }; + }); +} + +function parsePbi( + value: unknown, +): BacklogPbiNode { + const item = record(value); + + return { + id: text(item.id, "pbi.id"), + + codigo: + typeof item.codigo === "string" + ? item.codigo + : "", + + titulo: text( + item.titulo, + "pbi.titulo", + ), + + status: text( + item.status, + "pbi.status", + ), + + tecnologias: technologies( + item.tecnologias, + ), + }; +} + +function parseFeature( + value: unknown, +): BacklogFeatureNode { + const item = record(value); + + if (!Array.isArray(item.pbis)) { + throw new Error( + "PBIs inválidos na árvore do backlog", + ); + } + + return { + id: text( + item.id, + "feature.id", + ), + + titulo: text( + item.titulo, + "feature.titulo", + ), + + status: text( + item.status, + "feature.status", + ), + + tecnologias: technologies( + item.tecnologias, + ), + + pbis: item.pbis.map(parsePbi), + }; +} + +function parseEpic( + value: unknown, +): BacklogEpicNode { + const item = record(value); + + if (!Array.isArray(item.features)) { + throw new Error( + "Features inválidas na árvore do backlog", + ); + } + + return { + id: text( + item.id, + "epico.id", + ), + + titulo: text( + item.titulo, + "epico.titulo", + ), + + status: text( + item.status, + "epico.status", + ), + + tecnologias: technologies( + item.tecnologias, + ), + + features: + item.features.map( + parseFeature, + ), + }; +} + +export async function getProjectBacklogTree( + projectId: string, + signal: AbortSignal, +): Promise { + const response = + await apiRequest( + `/projects/${encodeURIComponent( + projectId, + )}/backlog-tree`, + { signal }, + ); + + const data = record( + await response.json(), + ); + + const project = record( + data.project, + ); + + if (!Array.isArray(data.epics)) { + throw new Error( + "Épicos inválidos na árvore do backlog", + ); + } + + return { + project: { + id: text( + project.id, + "project.id", + ), + + nome: text( + project.nome, + "project.nome", + ), + + status: text( + project.status, + "project.status", + ), + }, + + epics: + data.epics.map(parseEpic), + + technologies: + technologies( + data.technologies, + ), + }; +} \ No newline at end of file diff --git a/frontend/src/assets/styles/projects.css b/frontend/src/assets/styles/projects.css index d1e061a..13c7622 100644 --- a/frontend/src/assets/styles/projects.css +++ b/frontend/src/assets/styles/projects.css @@ -1,35 +1,449 @@ -.projects-page { display: grid; gap: 24px; } -.project-filter { display: grid; gap: 8px; max-width: 260px; } -.project-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-card); } -.search-field { position: relative; display: flex; flex: 1 1 260px; align-items: center; min-width: 0; } -.search-field svg { position: absolute; left: 12px; color: var(--text-muted); pointer-events: none; } -.search-field input { width: 100%; min-height: 42px; padding: 10px 12px 10px 38px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-secondary); color: var(--text-primary); font: inherit; } -.project-filter select { padding: 12px; font: inherit; border: 1px solid var(--border-subtle); border-radius: 8px; color: var(--text-primary); background: var(--bg-secondary); } -.archive-dialog { padding: 28px; max-width: 560px; width: calc(100% - 32px); border: 1px solid var(--border-subtle); border-radius: 12px; color: var(--text-primary); background: var(--bg-secondary); } -.archive-dialog::backdrop { background: #0009; } -.archive-dialog p { margin: 20px 0; line-height: 1.6; } -.projects-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; } -.projects-heading h2 { font-size: 1.8rem; } -.projects-heading p, .project-card p, .projects-state p, .project-form > p { color: var(--text-secondary); } -.projects-eyebrow { color: var(--accent-secondary) !important; font-size: .8rem; } -.projects-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); gap: 20px; } -.project-card { padding: 24px; display: grid; gap: 12px; align-content: start; overflow-wrap: anywhere; } -.project-card .badge, .project-card button { justify-self: start; } -.project-card h3 { font-size: 1.3rem; } -.project-card dt { color: var(--text-secondary); margin-top: 16px; } -.project-description { white-space: pre-wrap; } -.project-excerpt { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } -.projects-state { padding: 40px 24px; display: grid; justify-items: center; gap: 16px; text-align: center; } -.project-form { max-width: 720px; padding: 28px; display: grid; gap: 20px; } -.project-field { display: grid; gap: 8px; } -.project-field input, .project-field textarea { width: 100%; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 12px; font: inherit; } -.project-field input[type="checkbox"] { width: auto; padding: 0; accent-color: var(--accent-primary); } -.project-field textarea { resize: vertical; } -.project-field textarea:focus-visible { outline: 2px solid var(--accent-secondary); outline-offset: 3px; } -.project-field [aria-invalid="true"] { border-color: #fca5a5; } -.projects-page [role="alert"] { color: #fca5a5; } -.project-actions { display: flex; flex-wrap: wrap; gap: 12px; } -.badge-error { background: rgba(244, 67, 54, 0.2); color: #fca5a5; } +.projects-page { + display: grid; + gap: 24px; +} + +.project-filter { + display: grid; + gap: 8px; + max-width: 260px; +} + +.project-filters { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 12px; + padding: 16px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-card); +} + +.search-field { + position: relative; + display: flex; + flex: 1 1 260px; + align-items: center; + min-width: 0; +} + +.search-field svg { + position: absolute; + left: 12px; + color: var(--text-muted); + pointer-events: none; +} + +.search-field input { + width: 100%; + min-height: 42px; + padding: 10px 12px 10px 38px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-primary); + font: inherit; +} + +.project-filter select { + padding: 12px; + font: inherit; + border: 1px solid var(--border-subtle); + border-radius: 8px; + color: var(--text-primary); + background: var(--bg-secondary); +} + +.archive-dialog { + padding: 28px; + max-width: 560px; + width: calc(100% - 32px); + border: 1px solid var(--border-subtle); + border-radius: 12px; + color: var(--text-primary); + background: var(--bg-secondary); +} + +.archive-dialog::backdrop { + background: #0009; +} + +.archive-dialog p { + margin: 20px 0; + line-height: 1.6; +} + +.projects-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.projects-heading h2 { + font-size: 1.8rem; +} + +.projects-heading p, +.project-card p, +.projects-state p, +.project-form > p { + color: var(--text-secondary); +} + +.projects-eyebrow { + color: var(--accent-secondary) !important; + font-size: 0.8rem; +} + +.projects-grid { + display: grid; + grid-template-columns: + repeat( + auto-fit, + minmax(min(100%, 290px), 1fr) + ); + gap: 20px; +} + +.project-card { + padding: 24px; + display: grid; + gap: 12px; + align-content: start; + overflow-wrap: anywhere; +} + +.project-card .badge, +.project-card button { + justify-self: start; +} + +.project-card h3 { + font-size: 1.3rem; +} + +.project-card dt { + color: var(--text-secondary); + margin-top: 16px; +} + +.project-description { + white-space: pre-wrap; +} + +.project-excerpt { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.projects-state { + padding: 40px 24px; + display: grid; + justify-items: center; + gap: 16px; + text-align: center; +} + +.project-form { + max-width: 720px; + padding: 28px; + display: grid; + gap: 20px; +} + +.project-field { + display: grid; + gap: 8px; +} + +.backlog-technology-selector { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.backlog-technology-selector legend { + margin-bottom: 8px; +} + +.backlog-technology-options { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.backlog-technology-options label { + display: inline-flex; + align-items: center; + gap: 8px; + overflow-wrap: anywhere; +} + +.project-field input, +.project-field textarea { + width: 100%; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 12px; + font: inherit; +} + +.project-field input[type="checkbox"] { + width: auto; + padding: 0; + accent-color: var(--accent-primary); +} + +.project-field textarea { + resize: vertical; +} + +.project-field textarea:focus-visible { + outline: 2px solid var(--accent-secondary); + outline-offset: 3px; +} + +.project-field [aria-invalid="true"] { + border-color: #fca5a5; +} + +.projects-page [role="alert"] { + color: #fca5a5; +} + +.project-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.badge-error { + background: rgba(244, 67, 54, 0.2); + color: #fca5a5; +} + +/* + * Árvore hierárquica, filtros + * e breadcrumb do backlog (S1-16). + */ + +.backlog-tree { + display: grid; + gap: 20px; +} + +.backlog-tree .projects-heading p { + margin: 4px 0 0; + color: var(--text-secondary); +} + +.backlog-tree-filters { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 12px; + padding: 16px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-card); +} + +.backlog-tree-filter-count { + min-height: 42px; + display: inline-flex; + align-items: center; + padding: 0 14px; + border-radius: 9999px; + color: var(--text-primary); + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + font-size: 0.9rem; +} + +.backlog-tree-list, +.backlog-tree-children { + list-style: none; + margin: 0; + padding: 0; +} + +.backlog-tree-list { + display: grid; + gap: 12px; +} + +.backlog-tree-epic { + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-card); +} + +.backlog-tree-feature { + margin: 0 14px 12px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-secondary); +} + +.backlog-tree-row, +.backlog-tree-pbi { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + min-height: 58px; + padding: 10px 14px; +} + +.backlog-tree-epic + > .backlog-tree-row { + min-height: 68px; +} + +.backlog-tree-pbi { + margin: 0 14px 10px 38px; + border-left: + 2px solid var(--accent-secondary); + background: var(--bg-tertiary); + border-radius: + 0 + var(--radius-sm) + var(--radius-sm) + 0; +} + +.backlog-tree-children { + display: grid; + gap: 0; + padding-bottom: 2px; +} + +.backlog-tree-toggle { + width: 34px; + height: 34px; + flex: 0 0 34px; + border: 1px solid var(--border-subtle); + border-radius: 50%; + color: var(--text-primary); + background: transparent; + cursor: pointer; + font-size: 1rem; +} + +.backlog-tree-toggle:hover:not(:disabled), +.backlog-tree-toggle:focus-visible { + border-color: var(--accent-secondary); + color: var(--accent-secondary); +} + +.backlog-tree-toggle:disabled { + cursor: default; + opacity: 0.8; +} + +.backlog-tree-marker { + flex: 0 0 auto; + padding: 4px 8px; + border-radius: 9999px; + color: var(--accent-secondary); + background: + color-mix( + in srgb, + var(--accent-secondary) 12%, + transparent + ); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.backlog-tree-link { + min-width: 180px; + flex: 1 1 260px; + padding: 5px 0; + border: 0; + text-align: left; + color: var(--text-primary); + background: transparent; + cursor: pointer; + font: inherit; + font-weight: 700; + overflow-wrap: anywhere; +} + +.backlog-tree-link:hover, +.backlog-tree-link:focus-visible { + color: var(--accent-secondary); + text-decoration: underline; + text-underline-offset: 4px; +} + +.backlog-tree-meta, +.backlog-tree-technologies { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.backlog-tree-technologies > span { + padding: 3px 7px; + border: 1px solid var(--border-subtle); + border-radius: 9999px; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.backlog-tree-empty-branch { + margin: 0 16px 14px 58px; + color: var(--text-secondary); + font-size: 0.88rem; +} + +.backlog-breadcrumb { + flex-wrap: wrap; +} + +.backlog-breadcrumb-segment { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.backlog-breadcrumb strong { + color: var(--text-primary); + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .backlog-tree-row, + .backlog-tree-pbi { + align-items: flex-start; + } + + .backlog-tree-pbi { + margin-left: 20px; + } + + .backlog-tree-meta { + width: 100%; + padding-left: 44px; + } + + .backlog-tree-pbi + .backlog-tree-meta { + padding-left: 0; + } +} @media (max-width: 900px) { .projects-heading { @@ -42,12 +456,17 @@ } } -/* Painel de Qualidade em Tempo Real (S1-14 / PBI-01.3.5) */ +/* + * Painel de Qualidade em Tempo Real + * (S1-14 / PBI-01.3.5). + */ + .quality-panel { padding: 20px 24px; display: grid; gap: 16px; - border-left: 4px solid var(--accent-secondary); + border-left: + 4px solid var(--accent-secondary); background: var(--bg-card); } @@ -111,7 +530,8 @@ .quality-check-item { display: grid; - grid-template-columns: 24px 1fr auto; + grid-template-columns: + 24px 1fr auto; align-items: start; gap: 12px; padding: 12px 14px; @@ -124,17 +544,22 @@ } .quality-check-item.passed { - border-color: rgba(34, 197, 94, 0.3); + border-color: + rgba(34, 197, 94, 0.3); } .quality-check-item.failed { - border-color: rgba(239, 68, 68, 0.4); - background: rgba(239, 68, 68, 0.04); + border-color: + rgba(239, 68, 68, 0.4); + background: + rgba(239, 68, 68, 0.04); } .quality-check-item.warning { - border-color: rgba(245, 158, 11, 0.4); - background: rgba(245, 158, 11, 0.04); + border-color: + rgba(245, 158, 11, 0.4); + background: + rgba(245, 158, 11, 0.04); } .quality-check-indicator { @@ -153,17 +578,20 @@ } .quality-icon.success { - background: rgba(34, 197, 94, 0.2); + background: + rgba(34, 197, 94, 0.2); color: #4ade80; } .quality-icon.warning { - background: rgba(245, 158, 11, 0.2); + background: + rgba(245, 158, 11, 0.2); color: #fbbf24; } .quality-icon.danger { - background: rgba(239, 68, 68, 0.2); + background: + rgba(239, 68, 68, 0.2); color: #f87171; } @@ -194,15 +622,19 @@ } .quality-badge-tag.blocking { - background: rgba(239, 68, 68, 0.15); + background: + rgba(239, 68, 68, 0.15); color: #fca5a5; - border: 1px solid rgba(239, 68, 68, 0.3); + border: + 1px solid rgba(239, 68, 68, 0.3); } .quality-badge-tag.alert { - background: rgba(245, 158, 11, 0.15); + background: + rgba(245, 158, 11, 0.15); color: #fde68a; - border: 1px solid rgba(245, 158, 11, 0.3); + border: + 1px solid rgba(245, 158, 11, 0.3); } .quality-check-message { @@ -218,24 +650,34 @@ white-space: nowrap; } -/* Efeito visual ao navegar para o campo com foco */ .field-target-highlight { - outline: 3px solid var(--accent-primary) !important; + outline: + 3px solid var(--accent-primary) + !important; outline-offset: 3px !important; - animation: fieldPulse 1.8s ease-in-out; - border-color: var(--accent-primary) !important; + animation: + fieldPulse 1.8s ease-in-out; + border-color: + var(--accent-primary) + !important; } @keyframes fieldPulse { 0% { - box-shadow: 0 0 0 0 rgba(36, 190, 219, 0.7); + box-shadow: + 0 0 0 0 + rgba(36, 190, 219, 0.7); } 50% { - box-shadow: 0 0 0 10px rgba(36, 190, 219, 0); + box-shadow: + 0 0 0 10px + rgba(36, 190, 219, 0); } 100% { - box-shadow: 0 0 0 0 rgba(36, 190, 219, 0); + box-shadow: + 0 0 0 0 + rgba(36, 190, 219, 0); } } diff --git a/frontend/src/backlog/Backlog.test.tsx b/frontend/src/backlog/Backlog.test.tsx index cf0253b..62b3dcb 100644 --- a/frontend/src/backlog/Backlog.test.tsx +++ b/frontend/src/backlog/Backlog.test.tsx @@ -125,6 +125,9 @@ it("PBI-01.1.2 Cenário 1: cria épico e navega ao seu detalhe", async () => { ) { return response(epic, 201); } + if (url === "/api/v1/technologies") { + return response({ items: [{ id: "technology-1", nome: "React" }] }); + } throw new Error( `Requisição inesperada: ${url}`, @@ -138,6 +141,8 @@ it("PBI-01.1.2 Cenário 1: cria épico e navega ao seu detalhe", async () => { , ); + fireEvent.click(await screen.findByLabelText("React")); + fireEvent.change( screen.getByLabelText( "Título (obrigatório)", @@ -161,14 +166,16 @@ it("PBI-01.1.2 Cenário 1: cria épico e navega ao seu detalhe", async () => { ), ); - const body = JSON.parse( - request.mock.calls[0][1]?.body as string, + const createCall = request.mock.calls.find( + ([url, init]) => url === "/api/v1/epics" && init?.method === "POST", ); + const body = JSON.parse(createCall?.[1]?.body as string); expect(body.projeto_id).toBe("project-1"); expect(body.titulo).toBe( "Especificar o backlog", ); + expect(body.tecnologias_ids).toEqual(["technology-1"]); }); it("PBI-01.1.2 Cenário 3: mostra os campos faltantes quando a conclusão é recusada", async () => { @@ -442,4 +449,4 @@ it("registra se um PBI exige interface para aplicar a regra de protótipo", asyn ); expect(body.requer_interface).toBe(true); -}); \ No newline at end of file +}); diff --git a/frontend/src/backlog/BacklogTechnologySelector.test.tsx b/frontend/src/backlog/BacklogTechnologySelector.test.tsx new file mode 100644 index 0000000..41bebf3 --- /dev/null +++ b/frontend/src/backlog/BacklogTechnologySelector.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom + +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { BacklogTechnologySelector } from "../views/backlog/BacklogTechnologySelector"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +it("carrega o catálogo e propaga associações sem duplicatas", async () => { + vi.stubGlobal("fetch", vi.fn(async (url: string) => { + if (url !== "/api/v1/technologies") throw new Error(`Requisição inesperada: ${url}`); + return new Response(JSON.stringify({ items: [ + { id: "tech-react", nome: "React" }, + { id: "tech-node", nome: "Node.js" }, + ] }), { status: 200 }); + })); + const onChange = vi.fn(); + + render(); + + expect(await screen.findByLabelText("React")).toBeTruthy(); + const react = screen.getByLabelText("React") as HTMLInputElement; + const node = screen.getByLabelText("Node.js") as HTMLInputElement; + expect(react.checked).toBe(true); + fireEvent.click(node); + expect(onChange).toHaveBeenCalledWith(["tech-react", "tech-node"]); + fireEvent.click(react); + expect(onChange).toHaveBeenLastCalledWith([]); +}); + +it("expõe erro do catálogo e permite tentar novamente", async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(new Error("indisponível")) + .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + render( undefined} />); + expect(await screen.findByRole("alert")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Tentar novamente" })); + expect(await screen.findByText("Nenhuma tecnologia cadastrada no catálogo.")).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); diff --git a/frontend/src/backlog/Epics.test.tsx b/frontend/src/backlog/Epics.test.tsx index f84514f..2954c90 100644 --- a/frontend/src/backlog/Epics.test.tsx +++ b/frontend/src/backlog/Epics.test.tsx @@ -29,7 +29,7 @@ it("formulário recusa título vazio antes da chamada", () => { render(); fireEvent.click(screen.getByRole("button", { name: "Criar épico" })); expect(screen.getByRole("alert")).toHaveTextContent("Informe o título"); - expect(request).not.toHaveBeenCalled(); + expect(request.mock.calls.some(([url]) => url === "/api/v1/epics")).toBe(false); }); it("detalhe canônico conclui quando a API aceita os campos e critérios", async () => { diff --git a/frontend/src/backlog/Hierarchy.test.tsx b/frontend/src/backlog/Hierarchy.test.tsx new file mode 100644 index 0000000..5178b7d --- /dev/null +++ b/frontend/src/backlog/Hierarchy.test.tsx @@ -0,0 +1,556 @@ +// @vitest-environment jsdom + +import { + afterEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +import { + BacklogBreadcrumb, +} from "../views/backlog/BacklogBreadcrumb"; + +import { + BacklogTreeView, + filterBacklogTree, +} from "../views/backlog/BacklogTreeView"; + +import type { + ProjectBacklogTree, +} from "../api/api_backlog_tree"; + +const tree: ProjectBacklogTree = { + project: { + id: "project-1", + nome: "Sinapse", + status: "ativo", + }, + + technologies: [ + { + id: "react", + nome: "React", + }, + { + id: "node", + nome: "Node.js", + }, + ], + + epics: [ + { + id: "epic-1", + titulo: "Organizar requisitos", + status: "ativo", + tecnologias: [], + + features: [ + { + id: "feature-1", + titulo: + "Navegação do backlog", + status: "concluido", + + tecnologias: [ + { + id: "react", + nome: "React", + }, + ], + + pbis: [ + { + id: "pbi-1", + codigo: "PBI-01.4.1", + titulo: + "Expandir árvore", + status: "rascunho", + + tecnologias: [ + { + id: "react", + nome: "React", + }, + ], + }, + ], + }, + + { + id: "feature-2", + titulo: "API antiga", + status: "arquivado", + + tecnologias: [ + { + id: "node", + nome: "Node.js", + }, + ], + + pbis: [], + }, + ], + }, + ], +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + + window.sessionStorage.clear(); + + window.history.replaceState( + null, + "", + "/", + ); +}); + +function mockTree( + value: ProjectBacklogTree = tree, +) { + vi.stubGlobal( + "fetch", + vi.fn( + async (url: string) => { + if ( + url + === "/api/v1/projects/project-1/backlog-tree" + ) { + return new Response( + JSON.stringify(value), + { + status: 200, + }, + ); + } + + throw new Error( + `Requisição inesperada: ${url}`, + ); + }, + ), + ); +} + +describe( + "S1-16 — árvore e filtros do backlog", + () => { + it( + "expande progressivamente épico e feature até o PBI", + async () => { + mockTree(); + + render( + , + ); + + await screen.findByText( + "Organizar requisitos", + ); + + expect( + screen.queryByText( + "Navegação do backlog", + ), + ).toBeNull(); + + fireEvent.click( + screen.getByRole( + "button", + { + name: + "Expandir épico Organizar requisitos", + }, + ), + ); + + expect( + screen.getByText( + "Navegação do backlog", + ), + ).toBeTruthy(); + + expect( + screen.queryByText( + /Expandir árvore/, + ), + ).toBeNull(); + + fireEvent.click( + screen.getByRole( + "button", + { + name: + "Expandir feature Navegação do backlog", + }, + ), + ); + + expect( + screen.getByText( + /PBI-01\.4\.1 — Expandir árvore/, + ), + ).toBeTruthy(); + }, + ); + + it( + "combina status e tecnologia, preserva ancestrais e persiste na sessão", + async () => { + mockTree(); + + const view = render( + , + ); + + await screen.findByText( + "Organizar requisitos", + ); + + fireEvent.change( + screen.getByLabelText( + "Status", + ), + { + target: { + value: "rascunho", + }, + }, + ); + + fireEvent.change( + screen.getByLabelText( + "Tecnologia", + ), + { + target: { + value: "react", + }, + }, + ); + + expect( + screen.getByText( + "2 filtros ativos", + ), + ).toBeTruthy(); + + expect( + screen.getByText( + "Organizar requisitos", + ), + ).toBeTruthy(); + + expect( + screen.getByText( + "Navegação do backlog", + ), + ).toBeTruthy(); + + expect( + screen.getByText( + /PBI-01\.4\.1 — Expandir árvore/, + ), + ).toBeTruthy(); + + expect( + screen.queryByText( + "API antiga", + ), + ).toBeNull(); + + expect( + JSON.parse( + window.sessionStorage + .getItem( + "sinapse.backlog.filters.project-1", + )!, + ), + ).toEqual({ + status: "rascunho", + technologyId: "react", + }); + + view.unmount(); + + render( + , + ); + + await screen.findByText( + "2 filtros ativos", + ); + + expect( + ( + screen.getByLabelText( + "Status", + ) as HTMLSelectElement + ).value, + ).toBe("rascunho"); + + expect( + ( + screen.getByLabelText( + "Tecnologia", + ) as HTMLSelectElement + ).value, + ).toBe("react"); + }, + ); + + it( + "descarta o status legado 'pronto' e não o oferece no filtro", + async () => { + window.sessionStorage.setItem( + "sinapse.backlog.filters.project-1", + JSON.stringify({ status: "pronto", technologyId: "" }), + ); + mockTree(); + render(); + + await screen.findByText("Organizar requisitos"); + const status = screen.getByLabelText("Status") as HTMLSelectElement; + expect(status.value).toBe(""); + expect(screen.queryByRole("option", { name: "Pronto" })).toBeNull(); + expect(status.querySelectorAll("option")).toHaveLength(5); + }, + ); + + it( + "oferece limpar filtros quando a combinação não tem resultado", + async () => { + mockTree(); + + render( + , + ); + + await screen.findByText( + "Organizar requisitos", + ); + + fireEvent.change( + screen.getByLabelText( + "Status", + ), + { + target: { + value: "concluido", + }, + }, + ); + + fireEvent.change( + screen.getByLabelText( + "Tecnologia", + ), + { + target: { + value: "node", + }, + }, + ); + + expect( + screen.getByText( + "Nenhum item corresponde aos filtros", + ), + ).toBeTruthy(); + + fireEvent.click( + screen.getAllByRole( + "button", + { + name: + "Limpar filtros", + }, + )[0], + ); + + expect( + screen.getByText( + "0 filtros ativos", + ), + ).toBeTruthy(); + + expect( + screen.getByText( + "Organizar requisitos", + ), + ).toBeTruthy(); + }, + ); + + it( + "orienta a criação do primeiro épico em projeto vazio", + async () => { + mockTree({ + ...tree, + epics: [], + technologies: [], + }); + + render( + , + ); + + expect( + await screen.findByText( + "Este projeto ainda não possui épicos", + ), + ).toBeTruthy(); + + expect( + screen.getByRole( + "button", + { + name: + "Criar primeiro épico", + }, + ), + ).toBeTruthy(); + }, + ); + + it( + "mantém apenas correspondências e seus ancestrais na função de filtragem", + () => { + const filtered = + filterBacklogTree( + tree, + { + status: "rascunho", + technologyId: + "react", + }, + ); + + expect( + filtered.epics, + ).toHaveLength(1); + + expect( + filtered.epics[0] + .features, + ).toHaveLength(1); + + expect( + filtered.epics[0] + .features[0] + .pbis, + ).toHaveLength(1); + }, + ); + }, +); + +it( + "S1-16 — breadcrumb apresenta o caminho e navega pelos ancestrais", + async () => { + const onNavigate = vi.fn(); + + render( + , + ); + + fireEvent.click( + screen.getByRole( + "button", + { + name: "Projeto", + }, + ), + ); + + fireEvent.click( + screen.getByRole( + "button", + { + name: "Feature", + }, + ), + ); + + const current = + screen.getByRole( + "button", + { + name: "PBI-01.4.1", + }, + ); + + fireEvent.click(current); + + await waitFor(() => + expect( + onNavigate, + ).toHaveBeenCalledTimes(3), + ); + + expect( + onNavigate, + ).toHaveBeenLastCalledWith( + "/projects/project-1/epics/epic-1/features/feature-1/pbis/pbi-1", + ); + + expect( + current.getAttribute( + "aria-current", + ), + ).toBe("page"); + }, +); diff --git a/frontend/src/backlog/Justificativa.test.tsx b/frontend/src/backlog/Justificativa.test.tsx index 7b400b9..e6b0201 100644 --- a/frontend/src/backlog/Justificativa.test.tsx +++ b/frontend/src/backlog/Justificativa.test.tsx @@ -136,7 +136,7 @@ it("ItemHistoryView carrega páginas seguintes sem duplicar eventos", async () = it("EpicDetail exige justificativa ao alterar épico concluído", async () => { render(); - + const editBtn = await screen.findByText("Editar"); fireEvent.click(editBtn); diff --git a/frontend/src/backlog/QualityPanel.test.tsx b/frontend/src/backlog/QualityPanel.test.tsx index 0817c76..56cc5c3 100644 --- a/frontend/src/backlog/QualityPanel.test.tsx +++ b/frontend/src/backlog/QualityPanel.test.tsx @@ -231,9 +231,9 @@ it("mostra erro e permite tentar novamente sem aplicar regras padrão silenciosa ).toBeNull(); fireEvent.click( - screen.getByRole("button", { + screen.getAllByRole("button", { name: "Tentar novamente", - }), + })[0], ); expect( @@ -546,4 +546,4 @@ it("salva rascunho mesmo com verificações de qualidade reprovadas", async () = init?.method === "POST", ), ).toBe(true); -}); \ No newline at end of file +}); diff --git a/frontend/src/views/backlog/BacklogBreadcrumb.tsx b/frontend/src/views/backlog/BacklogBreadcrumb.tsx new file mode 100644 index 0000000..86438aa --- /dev/null +++ b/frontend/src/views/backlog/BacklogBreadcrumb.tsx @@ -0,0 +1,67 @@ +import { navigate } from "../../models/navigation"; + +export interface BacklogBreadcrumbSegment { + label: string; + path?: string; +} + +export function BacklogBreadcrumb({ + segments, + onNavigate = navigate, +}: { + segments: BacklogBreadcrumbSegment[]; + onNavigate?: (path: string) => void; +}) { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/views/backlog/BacklogTechnologySelector.tsx b/frontend/src/views/backlog/BacklogTechnologySelector.tsx new file mode 100644 index 0000000..a068779 --- /dev/null +++ b/frontend/src/views/backlog/BacklogTechnologySelector.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { listTechnologies, type TechnologyOption } from "../../api/api_backlog"; + +type LoadState = + | { state: "loading" } + | { state: "error" } + | { state: "ready"; items: TechnologyOption[] }; + +export function BacklogTechnologySelector({ + value, + onChange, + disabled = false, +}: { + value: string[]; + onChange: (next: string[]) => void; + disabled?: boolean; +}) { + const [attempt, setAttempt] = useState(0); + const [result, setResult] = useState({ state: "loading" }); + + useEffect(() => { + const controller = new AbortController(); + setResult({ state: "loading" }); + listTechnologies(controller.signal) + .then((items) => { + if (!controller.signal.aborted) setResult({ state: "ready", items }); + }) + .catch(() => { + if (!controller.signal.aborted) setResult({ state: "error" }); + }); + return () => controller.abort(); + }, [attempt]); + + function toggle(id: string, checked: boolean) { + onChange( + checked + ? [...new Set([...value, id])] + : value.filter((current) => current !== id), + ); + } + + return ( +
+ Tecnologias + {result.state === "loading" &&

Carregando tecnologias…

} + {result.state === "error" && ( +
+

Não foi possível carregar o catálogo de tecnologias.

+ +
+ )} + {result.state === "ready" && result.items.length === 0 && ( +

Nenhuma tecnologia cadastrada no catálogo.

+ )} + {result.state === "ready" && result.items.length > 0 && ( +
+ {result.items.map((technology) => ( + + ))} +
+ )} + {result.state === "ready" && result.items.length > 0 && ( + {value.length}/50 tecnologias selecionadas + )} +
+ ); +} diff --git a/frontend/src/views/backlog/BacklogTreeView.tsx b/frontend/src/views/backlog/BacklogTreeView.tsx new file mode 100644 index 0000000..e76da36 --- /dev/null +++ b/frontend/src/views/backlog/BacklogTreeView.tsx @@ -0,0 +1,973 @@ +import { + useEffect, + useMemo, + useState, +} from "react"; + +import { + ApiError, +} from "../../api/api_auth"; + +import { + getProjectBacklogTree, + type BacklogEpicNode, + type BacklogFeatureNode, + type BacklogPbiNode, + type ProjectBacklogTree, +} from "../../api/api_backlog_tree"; + +import { + navigate, +} from "../../models/navigation"; + +export interface BacklogFilters { + status: string; + technologyId: string; +} + +type Result = + | { + state: "loading"; + } + | { + state: "error"; + message: string; + } + | { + state: "ready"; + tree: ProjectBacklogTree; + }; + +const EMPTY_FILTERS: + BacklogFilters = { + status: "", + technologyId: "", + }; + +const STATUS_OPTIONS = [ + { value: "rascunho", label: "Rascunho" }, + { value: "ativo", label: "Ativo" }, + { value: "concluido", label: "Concluído" }, + { value: "arquivado", label: "Arquivado" }, +] as const; +const VALID_STATUSES = new Set([ + "", + ...STATUS_OPTIONS.map(({ value }) => value), +]); + +function storageKey( + projectId: string, +) { + return ( + "sinapse.backlog.filters." + + projectId + ); +} + +function readFilters( + projectId: string, +): BacklogFilters { + try { + const stored = + window.sessionStorage + .getItem( + storageKey(projectId), + ); + + if (!stored) { + return EMPTY_FILTERS; + } + + const value = + JSON.parse( + stored, + ) as Partial; + + return { + status: + typeof value.status + === "string" + && VALID_STATUSES.has( + value.status, + ) + ? value.status + : "", + + technologyId: + typeof value.technologyId + === "string" + ? value.technologyId + : "", + }; + } catch { + return EMPTY_FILTERS; + } +} + +function matches( + item: { + status: string; + + tecnologias: + Array<{ + id: string; + }>; + }, + + filters: BacklogFilters, +) { + return ( + ( + !filters.status + || item.status + === filters.status + ) + && ( + !filters.technologyId + || item.tecnologias.some( + (technology) => + technology.id + === filters.technologyId, + ) + ) + ); +} + +export function filterBacklogTree( + tree: ProjectBacklogTree, + filters: BacklogFilters, +): ProjectBacklogTree { + if ( + !filters.status + && !filters.technologyId + ) { + return tree; + } + + const epics = + tree.epics.flatMap( + (epic) => { + const features = + epic.features.flatMap( + (feature) => { + const pbis = + feature.pbis.filter( + (pbi) => + matches( + pbi, + filters, + ), + ); + + return ( + matches( + feature, + filters, + ) + || pbis.length > 0 + ) + ? [ + { + ...feature, + pbis, + }, + ] + : []; + }, + ); + + return ( + matches( + epic, + filters, + ) + || features.length > 0 + ) + ? [ + { + ...epic, + features, + }, + ] + : []; + }, + ); + + return { + ...tree, + epics, + }; +} + +const statusLabels: + Record = { + rascunho: "Rascunho", + ativo: "Ativo", + pronto: "Pronto", + concluido: "Concluído", + arquivado: "Arquivado", + }; + +function Technologies({ + values, +}: { + values: + Array<{ + id: string; + nome: string; + }>; +}) { + if (values.length === 0) { + return null; + } + + return ( + + {values.map( + (item) => ( + + {item.nome} + + ), + )} + + ); +} + +function ItemMeta({ + item, +}: { + item: { + status: string; + + tecnologias: + Array<{ + id: string; + nome: string; + }>; + }; +}) { + return ( + + + {statusLabels[item.status] + ?? item.status} + + + + + ); +} + +function TreePbi({ + projectId, + epicId, + featureId, + pbi, +}: { + projectId: string; + epicId: string; + featureId: string; + pbi: BacklogPbiNode; +}) { + const path = + `/projects/${projectId}` + + `/epics/${epicId}` + + `/features/${featureId}` + + `/pbis/${pbi.id}`; + + return ( +
  • + + + + + +
  • + ); +} + +function TreeFeature({ + projectId, + epicId, + feature, + open, + forcedOpen, + onToggle, +}: { + projectId: string; + epicId: string; + feature: BacklogFeatureNode; + open: boolean; + forcedOpen: boolean; + onToggle: () => void; +}) { + const isOpen = + forcedOpen || open; + + const path = + `/projects/${projectId}` + + `/epics/${epicId}` + + `/features/${feature.id}`; + + return ( +
  • +
    + + + + + + + +
    + + {isOpen && ( + feature.pbis.length > 0 + ? ( +
      + {feature.pbis.map( + (pbi) => ( + + ), + )} +
    + ) + : ( +

    + Nenhum PBI corresponde + nesta feature. +

    + ) + )} +
  • + ); +} + +function TreeEpic({ + projectId, + epic, + open, + forcedOpen, + expandedFeatures, + onToggle, + onToggleFeature, +}: { + projectId: string; + epic: BacklogEpicNode; + open: boolean; + forcedOpen: boolean; + expandedFeatures: Set; + onToggle: () => void; + onToggleFeature: + (id: string) => void; +}) { + const isOpen = + forcedOpen || open; + + return ( +
  • +
    + + + + + + + +
    + + {isOpen && ( + epic.features.length > 0 + ? ( +
      + {epic.features.map( + (feature) => ( + + onToggleFeature( + feature.id, + ) + } + /> + ), + )} +
    + ) + : ( +

    + Nenhuma feature + corresponde neste épico. +

    + ) + )} +
  • + ); +} + +export function BacklogTreeView({ + projectId, + canCreate, +}: { + projectId: string; + canCreate: boolean; +}) { + const [ + result, + setResult, + ] = useState({ + state: "loading", + }); + + const [ + attempt, + setAttempt, + ] = useState(0); + + const [ + filters, + setFilters, + ] = useState( + () => readFilters(projectId), + ); + + const [ + expandedEpics, + setExpandedEpics, + ] = useState>( + () => new Set(), + ); + + const [ + expandedFeatures, + setExpandedFeatures, + ] = useState>( + () => new Set(), + ); + + useEffect(() => { + const controller = + new AbortController(); + + setResult({ + state: "loading", + }); + + getProjectBacklogTree( + projectId, + controller.signal, + ) + .then((tree) => { + if ( + !controller.signal.aborted + ) { + setResult({ + state: "ready", + tree, + }); + } + }) + .catch((error) => { + if ( + controller.signal.aborted + ) { + return; + } + + setResult({ + state: "error", + + message: + error instanceof ApiError + && error.status === 404 + ? "Projeto não encontrado." + : "Não foi possível carregar a árvore do backlog.", + }); + }); + + return () => + controller.abort(); + }, [ + projectId, + attempt, + ]); + + useEffect(() => { + try { + window.sessionStorage + .setItem( + storageKey(projectId), + JSON.stringify(filters), + ); + } catch { + // A navegação continua funcional + // quando o navegador bloqueia + // sessionStorage. + } + }, [ + filters, + projectId, + ]); + + const filteredTree = + useMemo( + () => + result.state === "ready" + ? filterBacklogTree( + result.tree, + filters, + ) + : null, + [ + result, + filters, + ], + ); + + const activeFilterCount = + Number( + Boolean(filters.status), + ) + + Number( + Boolean( + filters.technologyId, + ), + ); + + const filtersActive = + activeFilterCount > 0; + + const toggle = ( + set: Set, + id: string, + ) => { + const next = + new Set(set); + + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + + return next; + }; + + const clearFilters = () => + setFilters( + EMPTY_FILTERS, + ); + + return ( +
    +
    +
    +

    + ÁRVORE DO PROJETO +

    + +

    + Backlog hierárquico +

    + +

    + Expanda épicos e features + para navegar até os PBIs. +

    +
    + + {canCreate && ( + + )} +
    + + {result.state === "ready" + && ( +
    + + + + + + {activeFilterCount} + {" "} + {activeFilterCount === 1 + ? "filtro ativo" + : "filtros ativos"} + + + {filtersActive && ( + + )} +
    + )} + + {result.state === "loading" + && ( +
    + Carregando árvore + do backlog… +
    + )} + + {result.state === "error" + && ( +
    +

    + {result.message} +

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

    + Este projeto ainda + não possui épicos +

    + +

    + Crie o primeiro épico + para começar a organizar + features e PBIs. +

    + + {canCreate && ( + + )} +
    + )} + + {result.state === "ready" + && result.tree.epics + .length > 0 + && filteredTree?.epics + .length === 0 + && ( +
    +

    + Nenhum item corresponde + aos filtros +

    + +

    + Altere a combinação + de status e tecnologia + ou limpe os critérios. +

    + + +
    + )} + + {filteredTree + && filteredTree.epics + .length > 0 + && ( +
      + {filteredTree.epics.map( + (epic) => ( + + setExpandedEpics( + (current) => + toggle( + current, + epic.id, + ), + ) + } + onToggleFeature={ + (id) => + setExpandedFeatures( + (current) => + toggle( + current, + id, + ), + ) + } + /> + ), + )} +
    + )} +
    + ); +} diff --git a/frontend/src/views/backlog/EpicsView.tsx b/frontend/src/views/backlog/EpicsView.tsx index 7c4280c..4b55b96 100644 --- a/frontend/src/views/backlog/EpicsView.tsx +++ b/frontend/src/views/backlog/EpicsView.tsx @@ -8,10 +8,12 @@ import { descreverCamposFaltantes } from "../../models/fields"; import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; import { usePbiQualityConfiguration } from "../../viewmodels/usePbiQualityConfiguration"; import { CriteriaEditor } from "./CriteriaView"; +import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; +import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; import "../../assets/styles/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: "" }; +const emptyInput: EpicInput = { projeto_id: "", titulo: "", descricao: "", objetivo: "", escopo_macro: "", resultado_esperado: "", tecnologias_ids: [] }; export function EpicList({ projetoId, canCreate: allowedToCreate }: { projetoId: string; canCreate: boolean }) { const inheritedReadOnly = useContext(ReadOnlyContext); @@ -64,7 +66,7 @@ export function EpicForm({ projetoId }: { projetoId: string }) { const submitting = useRef(false); const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); - const isDirty = [values.titulo, values.descricao, values.objetivo, values.escopo_macro, values.resultado_esperado].some((value) => value.trim().length > 0); + const isDirty = [values.titulo, values.descricao, values.objetivo, values.escopo_macro, values.resultado_esperado].some((value) => value.trim().length > 0) || (values.tecnologias_ids?.length ?? 0) > 0; const { confirmLeave } = useUnsavedChangesGuard(isDirty); return ( @@ -98,6 +100,11 @@ export function EpicForm({ projetoId }: { projetoId: string }) { : setValues((v) => ({ ...v, [field]: e.target.value }))} />} ))} + setValues((current) => ({ ...current, tecnologias_ids }))} + disabled={busy} + /> {message &&

    {message}

    }
    @@ -110,10 +117,10 @@ export function EpicForm({ projetoId }: { projetoId: string }) { import { ItemHistoryView } from "./ItemHistoryView"; -type EpicFields = Pick & { justificativa?: string }; +type EpicFields = Pick & { justificativa?: string }; function toFields(epic: Epic): EpicFields { - return { titulo: epic.titulo, descricao: epic.descricao, objetivo: epic.objetivo, escopo_macro: epic.escopo_macro, resultado_esperado: epic.resultado_esperado, justificativa: "" }; + return { titulo: epic.titulo, descricao: epic.descricao, objetivo: epic.objetivo, escopo_macro: epic.escopo_macro, resultado_esperado: epic.resultado_esperado, tecnologias_ids: [...(epic.tecnologias_ids ?? [])], justificativa: "" }; } export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId: string; epicId: string; canEdit: boolean; children?: ReactNode }) { @@ -160,6 +167,21 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId return (
    + { + if (confirmLeave()) navigate(path); + }} + />

    Épico

    {epic.titulo}

    @@ -188,6 +210,11 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId : setFormValues((v) => v && { ...v, [field]: e.target.value })} />}
    ))} + setFormValues((current) => current && { ...current, tecnologias_ids })} + disabled={saving} + /> {justificationRequired && (
    diff --git a/frontend/src/views/backlog/FeaturesView.tsx b/frontend/src/views/backlog/FeaturesView.tsx index 33428a7..efba320 100644 --- a/frontend/src/views/backlog/FeaturesView.tsx +++ b/frontend/src/views/backlog/FeaturesView.tsx @@ -8,10 +8,12 @@ import { descreverCamposFaltantes } from "../../models/fields"; import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; import { usePbiQualityConfiguration } from "../../viewmodels/usePbiQualityConfiguration"; import { CriteriaEditor } from "./CriteriaView"; +import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; +import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; import "../../assets/styles/projects.css"; type ListResult = { state: "loading" } | { state: "error"; message: string } | { state: "ready"; features: Feature[] }; -const emptyInput: FeatureInput = { epico_id: "", titulo: "", descricao: "", objetivo: "" }; +const emptyInput: FeatureInput = { epico_id: "", titulo: "", descricao: "", objetivo: "", tecnologias_ids: [] }; export function FeatureList({ projectId, epicoId, canCreate: allowedToCreate }: { projectId: string; epicoId: string; canCreate: boolean }) { const inheritedReadOnly = useContext(ReadOnlyContext); @@ -64,7 +66,7 @@ export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId const submitting = useRef(false); const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); - const isDirty = [values.titulo, values.descricao, values.objetivo].some((value) => value.trim().length > 0); + const isDirty = [values.titulo, values.descricao, values.objetivo].some((value) => value.trim().length > 0) || (values.tecnologias_ids?.length ?? 0) > 0; const { confirmLeave } = useUnsavedChangesGuard(isDirty); return ( @@ -95,6 +97,11 @@ export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId : setValues((v) => ({ ...v, [field]: e.target.value }))} />}
    ))} + setValues((current) => ({ ...current, tecnologias_ids }))} + disabled={busy} + /> {message &&

    {message}

    }
    @@ -107,10 +114,10 @@ export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId import { ItemHistoryView } from "./ItemHistoryView"; -type FeatureFields = Pick & { justificativa?: string }; +type FeatureFields = Pick & { justificativa?: string }; function toFields(feature: Feature): FeatureFields { - return { titulo: feature.titulo, descricao: feature.descricao, objetivo: feature.objetivo, justificativa: "" }; + return { titulo: feature.titulo, descricao: feature.descricao, objetivo: feature.objetivo, tecnologias_ids: [...(feature.tecnologias_ids ?? [])], justificativa: "" }; } export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children }: { projectId: string; epicoId: string; featureId: string; canEdit: boolean; children?: ReactNode }) { @@ -153,6 +160,25 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children return (
    + { + if (confirmLeave()) navigate(path); + }} + />

    Épico: {feature!.epico_titulo}

    {feature!.titulo}

    @@ -170,6 +196,11 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children : setFormValues((v) => v && { ...v, [field]: e.target.value })} />}
    ))} + setFormValues((current) => current && { ...current, tecnologias_ids })} + disabled={saving} + /> {justificationRequired && (
    diff --git a/frontend/src/views/backlog/PbisView.tsx b/frontend/src/views/backlog/PbisView.tsx index a651cbe..e108a19 100644 --- a/frontend/src/views/backlog/PbisView.tsx +++ b/frontend/src/views/backlog/PbisView.tsx @@ -20,6 +20,8 @@ import { CriteriaEditor } from "./CriteriaView"; import { ItemHistoryView } from "./ItemHistoryView"; import { QualityPanelView as QualityPanel } from "./QualityPanelView"; import { evaluatePbiRealtime } from "../../models/qualityEngine"; +import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; +import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; import "../../assets/styles/projects.css"; type ListResult = @@ -34,6 +36,7 @@ const emptyInput: PbiInput = { historia_eu_quero: "", historia_para_que: "", requer_interface: false, + tecnologias_ids: [], }; export function PbiList({ @@ -298,6 +301,7 @@ export function PbiForm({ const isDirty = values.requer_interface + || (values.tecnologias_ids?.length ?? 0) > 0 || [ values.titulo, values.historia_como_um, @@ -548,6 +552,12 @@ export function PbiForm({
    + setValues((current) => ({ ...current, tecnologias_ids }))} + disabled={busy} + /> + {message && (

    {message} @@ -608,6 +618,7 @@ type PbiFields = Pick< | "historia_eu_quero" | "historia_para_que" | "requer_interface" + | "tecnologias_ids" > & { justificativa?: string }; function toFields( @@ -623,6 +634,7 @@ function toFields( pbi.historia_para_que, requer_interface: pbi.requer_interface, + tecnologias_ids: [...(pbi.tecnologias_ids ?? [])], justificativa: "", }; } @@ -795,6 +807,29 @@ export function PbiDetail({ return (

    + { + if (confirmLeave()) navigate(path); + }} + />

    @@ -1059,6 +1094,12 @@ export function PbiDetail({

    + setFormValues((current) => current && { ...current, tecnologias_ids })} + disabled={saving} + /> + {justificationRequired && (