Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions backend/src/database/migration-011.db.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 12 additions & 2 deletions backend/src/modules/epics/epics.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -53,14 +55,15 @@ export class EpicsRepository {

const result = await client.query<Epic>(insertQuery, values);
const created = result.rows[0];
await replaceEntityTechnologies(client, "epico", created.id, data.tecnologias_ids);

await auditService.record(
{
usuario_id: usuarioId ?? null,
entidade_tipo: "epico",
entidade_id: created.id,
acao: "CRIAR_EPICO",
dados_json: { titulo: created.titulo, projeto_id: created.projeto_id, status: created.status },
dados_json: { titulo: created.titulo, projeto_id: created.projeto_id, status: created.status, tecnologias_ids: data.tecnologias_ids ?? [] },
},
client,
);
Expand Down Expand Up @@ -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(
{
Expand All @@ -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,
);
Expand Down
3 changes: 3 additions & 0 deletions backend/src/modules/epics/epics.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand All @@ -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(),
});

Expand All @@ -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;
Expand Down
14 changes: 12 additions & 2 deletions backend/src/modules/features/features.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -54,14 +56,15 @@ export class FeaturesRepository {

const result = await client.query<Feature>(insertQuery, values);
const created = result.rows[0];
await replaceEntityTechnologies(client, "feature", created.id, data.tecnologias_ids);

await auditService.record(
{
usuario_id: usuarioId ?? null,
entidade_tipo: "feature",
entidade_id: created.id,
acao: "CRIAR_FEATURE",
dados_json: { titulo: created.titulo, epico_id: created.epico_id, status: created.status },
dados_json: { titulo: created.titulo, epico_id: created.epico_id, status: created.status, tecnologias_ids: data.tecnologias_ids ?? [] },
},
client,
);
Expand Down Expand Up @@ -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(
{
Expand All @@ -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,
);
Expand Down
3 changes: 3 additions & 0 deletions backend/src/modules/features/features.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});

Expand All @@ -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<typeof updateFeatureSchema>;
Expand All @@ -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;
Expand Down
14 changes: 12 additions & 2 deletions backend/src/modules/pbis/pbis.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -69,14 +71,15 @@ export class PbisRepository {

const result = await client.query<Pbi>(insertQuery, values);
const created = result.rows[0];
await replaceEntityTechnologies(client, "pbi", created.id, data.tecnologias_ids);

await auditService.record(
{
usuario_id: usuarioId ?? null,
entidade_tipo: "pbi",
entidade_id: created.id,
acao: "CRIAR_PBI",
dados_json: { codigo: created.codigo, titulo: created.titulo, feature_id: created.feature_id, status: created.status, 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,
);
Expand Down Expand Up @@ -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(
{
Expand All @@ -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,
);
Expand Down
3 changes: 3 additions & 0 deletions backend/src/modules/pbis/pbis.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createPbiSchema>;
Expand All @@ -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<typeof updatePbiSchema>;
Expand All @@ -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;
Expand Down
Loading
Loading