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

@@ -1,4 +1,4 @@
import { Body, ConflictException, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { Body, ConflictException, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { recordAuditEvent, hashPassword, type AccessTokenClaims } from "@b2bcall/auth";
import { generateStrongPassword } from "@b2bcall/shared";
@@ -15,6 +15,16 @@ import { InviteUserDto, UpdateUserRoleDto } from "./dto/invite-user.dto";
* (Platform > Clientes > Tenants) ou via script — nenhuma forma de um
* Tenant Admin adicionar um colega ao próprio tenant.
*/
async function isLastTenantAdmin(tenantId: string, userId: string): Promise<boolean> {
const prisma = getPrismaClient();
const currentRole = await prisma.userRole.findFirst({ where: { userId, tenantId }, include: { role: true } });
if (currentRole?.role.key !== "tenant_admin") return false;
const otherAdmins = await prisma.userRole.count({
where: { tenantId, userId: { not: userId }, role: { key: "tenant_admin" } },
});
return otherAdmins === 0;
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("users")
export class UsersController {
@@ -135,6 +145,10 @@ export class UsersController {
throw new ForbiddenException("roleKey precisa ser uma role de escopo TENANT");
}
if (role.key !== "tenant_admin" && (await isLastTenantAdmin(tenantId, id))) {
throw new ForbiddenException("Este e' o unico Tenant Admin do tenant — promova outra pessoa antes de trocar o papel dele(a)");
}
// Simplificação deliberada: 1 role por usuário por tenant — trocar
// substitui, não acumula (o schema permite várias, mas a UI não
// oferece combinar papéis nesta primeira versão).
@@ -154,4 +168,50 @@ export class UsersController {
return { id, role: { key: role.key, name: role.name } };
}
/** Remove só a membership+role deste tenant (secao 169) — nunca a conta
* `User` em si, que pode pertencer a outros tenants. Bloqueia remover a
* si mesmo (evita um Tenant Admin se trancar fora sem querer) e remover
* o último Tenant Admin (o tenant ficaria sem ninguém que possa
* convidar/gerenciar gente). */
@RequirePermission("users.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
if (id === user.sub) {
throw new ForbiddenException("Você não pode remover a si mesmo deste tenant");
}
const membership = await withTenantContext(prisma, tenantId, (tx) =>
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: id } } }),
);
if (!membership) throw new NotFoundException("Usuário não pertence a este tenant");
if (await isLastTenantAdmin(tenantId, id)) {
throw new ForbiddenException("Este e' o unico Tenant Admin do tenant — promova outra pessoa antes de remove-lo(a)");
}
// `tenant_memberships` tem FORCE RLS (secao 32) — precisa de
// `app.current_tenant_id` setado na mesma transação, senão o delete
// não enxerga a linha e Prisma devolve P2025 ("not found") mesmo com
// a linha existindo (mesma classe de bug já corrigida em
// TenantsController.create). `user_roles` não tem RLS, então o
// deleteMany funciona em qualquer contexto.
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
await tx.userRole.deleteMany({ where: { userId: id, tenantId } });
await tx.tenantMembership.delete({ where: { tenantId_userId: { tenantId, userId: id } } });
});
await recordAuditEvent(prisma, {
action: "USER_REMOVED_FROM_TENANT",
tenantId,
userId: user.sub,
entityType: "user",
entityId: id,
});
}
}