feat: Administração > Configurações, remover usuário do tenant, Discador > Callbacks

Fecha os últimos gaps do módulo Administração (agente.md secao 169): tela de
Configurações self-service do próprio tenant (GET/PATCH /tenant-settings, nunca
aceita tenantId arbitrário — só user.tenantId das claims), e DELETE /users/:id pra
remover alguém do tenant, com duas proteções que não existiam antes (não deixa
remover a si mesmo, não deixa remover/rebaixar o último Tenant Admin).

Corrige um bug real achado testando a remoção: o delete de TenantMembership (FORCE
RLS) rodava dentro de um prisma.$transaction([...]) em forma de array, que nunca
seta app.current_tenant_id — Prisma devolvia P2025 "not found" com a linha
existindo (500 pro cliente). Mesma classe de bug já corrigida antes em
TenantsController.create; corrigido com $transaction(async (tx) => ...) + set_config
explícito.

Adiciona Discador > Callbacks (GET/PATCH /leads/callbacks, tenant-wide): reagendar,
tentar de novo sem esperar, ou desistir de um lead que pediu retorno em outro
horário. Remove "Importações" do menu — decisão já registrada na PHASE 39 de não
duplicar uma tela pro que já existe (CSV em lote no wizard/detalhe da campanha).

Testado ponta a ponta via curl e Puppeteer contra o tenant Acme real: tenant-settings
GET/PATCH, proteção de último-admin nos dois endpoints que a usam, convite+remoção
de um admin temporário, e o fluxo completo de callback (lead forçado pra CALLBACK
via SQL, reagendar rejeitado pro passado/aceito pro futuro, requeue confirmado).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 08:16:38 -03:00
parent 52e1b4f3b7
commit 2fb5010283
24 changed files with 798 additions and 11 deletions

View File

@@ -0,0 +1,76 @@
import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Patch, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { UpdateCallbackDto } from "./dto/update-callback.dto";
/**
* "Discador > Callbacks" (agente.md secao 169) — visão tenant-wide (todas
* as campanhas) dos leads que o `PredictiveDialerEngine` marcou como
* `CALLBACK` (secao 78-79: agente pediu pra ligar de volta em outro
* horário). Antes desta tela não existia forma nenhuma de ver ou agir
* sobre esses leads — só ficavam esperando `nextAttemptAt` chegar, sem
* ninguém poder adiantar, adiar ou cancelar.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("leads/callbacks")
export class CallbacksController {
@RequirePermission("campaigns.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
return withTenantContext(prisma, tenantId, (tx) =>
tx.lead.findMany({
where: { tenantId, status: "CALLBACK" },
include: { campaign: { select: { id: true, name: true } } },
orderBy: { nextAttemptAt: "asc" },
take: 500,
}),
);
}
@RequirePermission("campaigns.update")
@Patch(":id")
async update(
@CurrentUser() user: AccessTokenClaims,
@Param("id") id: string,
@Body() dto: UpdateCallbackDto,
): Promise<Record<string, unknown>> {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const lead = await withTenantContext(prisma, tenantId, (tx) =>
tx.lead.findFirst({ where: { id, tenantId, status: "CALLBACK" } }),
);
if (!lead) throw new NotFoundException("Callback nao encontrado (ou o lead ja saiu do status CALLBACK)");
if (dto.action === "RESCHEDULE" && new Date(dto.nextAttemptAt!).getTime() <= Date.now()) {
throw new BadRequestException("nextAttemptAt precisa ser no futuro");
}
const data =
dto.action === "RESCHEDULE"
? { nextAttemptAt: new Date(dto.nextAttemptAt!) }
: dto.action === "REQUEUE"
? { status: "READY" as const, nextAttemptAt: null }
: { status: "DO_NOT_CALL" as const, nextAttemptAt: null };
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.lead.update({ where: { id }, data }));
await recordAuditEvent(prisma, {
action: "LEAD_CALLBACK_UPDATE",
tenantId,
userId: user.sub,
entityType: "lead",
entityId: id,
after: { action: dto.action, nextAttemptAt: dto.nextAttemptAt ?? null },
});
return updated;
}
}