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
10 changes: 10 additions & 0 deletions backend/src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,14 @@ export class AdminController {
next(error);
}
};

purgeProject = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const projectId = typeof req.params.id === "string" ? req.params.id : "";
res.json(await this.service.purgeProject(projectId, req.body, req.auth!.id));
} catch (error) {
next(error);
}
};
}

189 changes: 189 additions & 0 deletions backend/src/modules/admin/admin.purge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import test from "node:test";
import assert from "node:assert/strict";
import { AdminService } from "./admin.service.js";
import { createAdminRouter } from "./admin.routes.js";
import { AdminController } from "./admin.controller.js";
import { NotFoundError, ValidationError } from "../../shared/errors.js";
import type { Request, Response } from "express";

test("S2-05: Expurgo transacional do contexto de um projeto", async (t) => {
const adminUserId = "11111111-1111-4111-8111-111111111111";
const targetProjectId = "22222222-2222-4222-8222-222222222222";
const otherProjectId = "33333333-3333-4333-8333-333333333333";

await t.test("valida UUID do projeto", async () => {
const service = new AdminService({
counts: async () => ({} as any),
insertDemoChunks: async () => 0,
});

await assert.rejects(
() => service.purgeProject("id-invalido", { confirmacao_nome: "Projeto Alpha" }, adminUserId),
(err: unknown) => err instanceof ValidationError && err.message.includes("ID do projeto"),
);
});

await t.test("exige digitação do nome para confirmar o expurgo (Cenário 3)", async () => {
const service = new AdminService({
counts: async () => ({} as any),
insertDemoChunks: async () => 0,
});

await assert.rejects(
() => service.purgeProject(targetProjectId, {}, adminUserId),
(err: unknown) => err instanceof ValidationError,
);

await assert.rejects(
() => service.purgeProject(targetProjectId, { confirmacao_nome: " " }, adminUserId),
(err: unknown) => err instanceof ValidationError,
);
});

await t.test("rejeita quando o nome digitado diverge do nome real do projeto", async () => {
const repoMock = {
counts: async () => ({} as any),
insertDemoChunks: async () => 0,
purgeProjectContext: async () => {
throw new ValidationError("O nome informado não confere com o nome do projeto.");
},
};

const service = new AdminService(repoMock);

await assert.rejects(
() => service.purgeProject(targetProjectId, { confirmacao_nome: "Nome Errado" }, adminUserId),
(err: unknown) => err instanceof ValidationError && err.message === "O nome informado não confere com o nome do projeto.",
);
});

await t.test("lança NotFoundError quando o projeto não existe", async () => {
const repoMock = {
counts: async () => ({} as any),
insertDemoChunks: async () => 0,
purgeProjectContext: async () => {
throw new NotFoundError("Projeto não encontrado.");
},
};

const service = new AdminService(repoMock);

await assert.rejects(
() => service.purgeProject(targetProjectId, { confirmacao_nome: "Projeto Inexistente" }, adminUserId),
(err: unknown) => err instanceof NotFoundError && err.message === "Projeto não encontrado.",
);
});

await t.test("remove o contexto indexado com sucesso e audita (Cenário 1 e 2 - preservação dos demais)", async () => {
let purgedTargetCalled = false;
let otherProjectTouched = false;

// Simulação do repositório garantindo isolamento entre projetos
const chunksDatabase = new Map<string, Array<{ id: string; texto: string }>>([
[targetProjectId, [{ id: "c-1", texto: "Chunk do projeto alvo" }, { id: "c-2", texto: "Outro chunk" }]],
[otherProjectId, [{ id: "c-3", texto: "Chunk do projeto preservado" }]],
]);

const auditLog: Array<{ acao: string; usuario_id: string; entidade_id: string; dados_json: any }> = [];

const repoMock = {
counts: async () => ({} as any),
insertDemoChunks: async () => 0,
purgeProjectContext: async (projectId: string, confirmacaoNome: string, userId: string, justificativa?: string) => {
if (projectId !== targetProjectId) {
otherProjectTouched = true;
}

const projectChunks = chunksDatabase.get(projectId) ?? [];
const count = projectChunks.length;

// Limpa somente o projeto alvo
chunksDatabase.delete(projectId);
purgedTargetCalled = true;

auditLog.push({
acao: "EXPURGAR_CONTEXTO",
usuario_id: userId,
entidade_id: projectId,
dados_json: {
projeto_nome: confirmacaoNome,
chunks_removidos: count,
eventos_removidos: 0,
},
});

return {
projeto_id: projectId,
projeto_nome: confirmacaoNome,
chunks_removidos: count,
eventos_removidos: 0,
status: "sucesso" as const,
message: "Contexto indexado do projeto expurgado com sucesso.",
};
},
};

const service = new AdminService(repoMock);
const result = await service.purgeProject(
targetProjectId,
{ confirmacao_nome: "Projeto Alpha", justificativa: "Carga duplicada" },
adminUserId,
);

assert.equal(purgedTargetCalled, true);
assert.equal(otherProjectTouched, false);
assert.equal(result.status, "sucesso");
assert.equal(result.chunks_removidos, 2);

// Verificação de preservação estrita do outro projeto (Cenário 2)
assert.equal(chunksDatabase.has(targetProjectId), false, "Projeto alvo deve ter os chunks removidos");
assert.equal(chunksDatabase.has(otherProjectId), true, "Outro projeto deve permanecer intacto");
assert.equal(chunksDatabase.get(otherProjectId)?.length, 1);

// Verificação de auditoria
assert.equal(auditLog.length, 1);
assert.equal(auditLog[0]!.acao, "EXPURGAR_CONTEXTO");
assert.equal(auditLog[0]!.entidade_id, targetProjectId);
assert.equal(auditLog[0]!.usuario_id, adminUserId);
});

await t.test("controller responde com JSON do expurgo", async () => {
let handled = false;
const fakeService = {
stats: async () => ({} as any),
loadDemo: async () => ({} as any),
purgeProject: async (id: string, body: any, userId: string) => {
handled = true;
return {
projeto_id: id,
projeto_nome: "Projeto Beta",
chunks_removidos: 5,
eventos_removidos: 2,
status: "sucesso" as const,
message: "Contexto indexado do projeto expurgado com sucesso.",
};
},
};

const controller = new AdminController(fakeService as any, {} as any);

let jsonResponse: any = null;
const req = {
params: { id: targetProjectId },
body: { confirmacao_nome: "Projeto Beta" },
auth: { id: adminUserId, role: "admin" },
} as unknown as Request;

const res = {
json: (data: any) => {
jsonResponse = data;
},
} as unknown as Response;

await controller.purgeProject(req, res, () => {});

assert.equal(handled, true);
assert.equal(jsonResponse.status, "sucesso");
assert.equal(jsonResponse.chunks_removidos, 5);
});
});
69 changes: 69 additions & 0 deletions backend/src/modules/admin/admin.repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Pool } from "pg";
import { pool } from "../../database/db.js";
import { withTransaction } from "../../database/transaction.js";
import { NotFoundError, ValidationError } from "../../shared/errors.js";
import { auditService } from "../audit/audit.service.js";
import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js";

Expand All @@ -13,6 +14,15 @@ export interface AdminCounts {
chunksIndexados: number;
}

export interface PurgeProjectResult {
projeto_id: string;
projeto_nome: string;
chunks_removidos: number;
eventos_removidos: number;
status: "sucesso";
message: string;
}

export class AdminRepository {
constructor(private readonly db: Pool = pool) { }

Expand Down Expand Up @@ -56,4 +66,63 @@ export class AdminRepository {

});
}

async purgeProjectContext(
projectId: string,
confirmacaoNome: string,
userId: string,
justificativa?: string,
): Promise<PurgeProjectResult> {
return withTransaction(this.db, async (client) => {
await lockHierarchy(client, "projeto", projectId);

const projectRes = await client.query<{ id: string; nome: string }>(
"SELECT id, nome FROM projeto WHERE id = $1 FOR UPDATE",
[projectId],
);
const project = projectRes.rows[0];
if (!project) throw new NotFoundError("Projeto não encontrado.");

if (confirmacaoNome.trim() !== project.nome.trim()) {
throw new ValidationError("O nome informado não confere com o nome do projeto.");
}

const countRes = await client.query<{ count: number }>(
"SELECT COUNT(*)::int AS count FROM chunk WHERE projeto_id = $1",
[projectId],
);
const chunksRemovidos = countRes.rows[0]?.count ?? 0;

await client.query("DELETE FROM chunk WHERE projeto_id = $1", [projectId]);

const eventsCountRes = await client.query(
"DELETE FROM indice_evento WHERE projeto_id = $1",
[projectId],
);
const eventosRemovidos = eventsCountRes.rowCount ?? 0;

await auditService.record({
usuario_id: userId,
entidade_tipo: "projeto",
entidade_id: projectId,
acao: "EXPURGAR_CONTEXTO",
justificativa: justificativa ?? null,
dados_json: {
projeto_nome: project.nome,
chunks_removidos: chunksRemovidos,
eventos_removidos: eventosRemovidos,
},
}, client);

return {
projeto_id: projectId,
projeto_nome: project.nome,
chunks_removidos: chunksRemovidos,
eventos_removidos: eventosRemovidos,
status: "sucesso",
message: "Contexto indexado do projeto expurgado com sucesso.",
};
});
}
}

3 changes: 3 additions & 0 deletions backend/src/modules/admin/admin.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ export function createAdminRouter(controller = new AdminController(), authentica
// Observabilidade do pipeline de ingestao (substitui o editor do n8n para
// o admin acompanhar o worker). Query opcional: limit=1..100 (default 25).
router.get("/ingestion", controller.ingestionSnapshot);
// S2-05: Expurgo transacional do contexto indexado por projeto
router.post("/projects/:id/purge", controller.purgeProject);
return router;
}

export const adminRouter = createAdminRouter();

49 changes: 43 additions & 6 deletions backend/src/modules/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,60 @@
import { z } from "zod";
import { ValidationError } from "../../shared/errors.js";
import { AdminRepository } from "./admin.repository.js";
import { ValidationError, validateUuid } from "../../shared/errors.js";
import { AdminRepository, type PurgeProjectResult } from "./admin.repository.js";

const demoSeedSchema = z.object({ projeto_id: z.string().uuid("Selecione um projeto válido para a carga demonstrativa.") }).strict();

const purgeProjectSchema = z
.object({
confirmacao_nome: z.string().trim().min(1, "Informe o nome do projeto para confirmar o expurgo.").optional(),
nome_confirmacao: z.string().trim().min(1, "Informe o nome do projeto para confirmar o expurgo.").optional(),
justificativa: z.string().trim().max(2000).optional(),
})
.refine((data) => !!(data.confirmacao_nome || data.nome_confirmacao), {
message: "Informe o nome do projeto para confirmar o expurgo.",
path: ["confirmacao_nome"],
});

export class AdminService {
constructor(private readonly repository: Pick<AdminRepository, "counts" | "insertDemoChunks"> = new AdminRepository()) {}
constructor(
private readonly repository: Pick<AdminRepository, "counts" | "insertDemoChunks"> &
Partial<Pick<AdminRepository, "purgeProjectContext">> = new AdminRepository(),
) {}

async stats() {
return { ...await this.repository.counts(), statusSistema: "operacional" };

return { ...(await this.repository.counts()), statusSistema: "operacional" };
}

async loadDemo(input: unknown, userId: string) {
const parsed = demoSeedSchema.safeParse(input);
if (!parsed.success) throw new ValidationError("Informe o projeto de destino da carga demonstrativa.", parsed.error.flatten());
const inseridos = await this.repository.insertDemoChunks(parsed.data.projeto_id, userId);
return {
projeto_id: parsed.data.projeto_id, inseridos, status: "sucesso",
message: inseridos ? "Dados demonstrativos carregados. Nenhuma indexação vetorial foi executada." : "Este projeto já possui a carga demonstrativa. Nenhum trecho foi duplicado.",
projeto_id: parsed.data.projeto_id,
inseridos,
status: "sucesso",
message: inseridos
? "Dados demonstrativos carregados. Nenhuma indexação vetorial foi executada."
: "Este projeto já possui a carga demonstrativa. Nenhum trecho foi duplicado.",
};
}

async purgeProject(projectId: string, input: unknown, userId: string): Promise<PurgeProjectResult> {
validateUuid(projectId, "ID do projeto");

const parsed = purgeProjectSchema.safeParse(input);
if (!parsed.success) {
const issue = parsed.error.issues[0];
throw new ValidationError(issue?.message ?? "Informe o nome do projeto para confirmar o expurgo.", parsed.error.flatten());
}

const confirmacaoNome = (parsed.data.confirmacao_nome || parsed.data.nome_confirmacao)!;
if (!this.repository.purgeProjectContext) {
throw new Error("purgeProjectContext não configurado.");
}
return await this.repository.purgeProjectContext(projectId, confirmacaoNome, userId, parsed.data.justificativa);
}
}


Loading
Loading