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:
76
apps/api/src/leads/callbacks.controller.ts
Normal file
76
apps/api/src/leads/callbacks.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user