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;
|
||||
}
|
||||
}
|
||||
12
apps/api/src/leads/dto/update-callback.dto.ts
Normal file
12
apps/api/src/leads/dto/update-callback.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsIn, IsISO8601, ValidateIf } from "class-validator";
|
||||
|
||||
const CALLBACK_ACTIONS = ["RESCHEDULE", "REQUEUE", "CANCEL"];
|
||||
|
||||
export class UpdateCallbackDto {
|
||||
@IsIn(CALLBACK_ACTIONS)
|
||||
action!: "RESCHEDULE" | "REQUEUE" | "CANCEL";
|
||||
|
||||
@ValidateIf((dto) => dto.action === "RESCHEDULE")
|
||||
@IsISO8601()
|
||||
nextAttemptAt?: string;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { LeadsController } from "./leads.controller";
|
||||
import { CallbacksController } from "./callbacks.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [LeadsController],
|
||||
controllers: [LeadsController, CallbacksController],
|
||||
})
|
||||
export class LeadsModule {}
|
||||
|
||||
29
apps/api/src/tenants/dto/update-tenant-settings.dto.ts
Normal file
29
apps/api/src/tenants/dto/update-tenant-settings.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
const AI_PRIVACY_LEVELS = ["AI_OFF", "TRANSCRIPTION_ONLY", "TRANSCRIPTION_AND_ANALYSIS"];
|
||||
|
||||
export class UpdateTenantSettingsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
tradeName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
taxId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
timezone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
locale?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(AI_PRIVACY_LEVELS)
|
||||
aiPrivacyLevel?: string;
|
||||
}
|
||||
65
apps/api/src/tenants/tenant-settings.controller.ts
Normal file
65
apps/api/src/tenants/tenant-settings.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Body, Controller, Get, NotFoundException, Patch, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, type AIPrivacyLevel } 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 { UpdateTenantSettingsDto } from "./dto/update-tenant-settings.dto";
|
||||
|
||||
/**
|
||||
* "Administração > Configurações" (agente.md secao 169) — self-service do
|
||||
* próprio tenant, diferente de `TenantsController` (que é platform-only e
|
||||
* mexe em campos que só a plataforma controla: status, plano, code).
|
||||
* Nunca recebe `:id` — sempre `user.tenantId` das claims, então não existe
|
||||
* forma de um Tenant Admin editar outro tenant por aqui (a checagem
|
||||
* `isPlatformUser` do `TenantsController` não se aplica porque este
|
||||
* controller nem aceita um tenantId arbitrário).
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("tenant-settings")
|
||||
export class TenantSettingsController {
|
||||
@RequirePermission("users.manage")
|
||||
@Get()
|
||||
async get(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { id: user.tenantId!, deletedAt: null },
|
||||
include: { plan: { select: { key: true, name: true } } },
|
||||
});
|
||||
if (!tenant) throw new NotFoundException();
|
||||
const { priceBookId: _priceBookId, rateDeckId: _rateDeckId, ...rest } = tenant;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@RequirePermission("users.manage")
|
||||
@Patch()
|
||||
async update(@CurrentUser() user: AccessTokenClaims, @Body() dto: UpdateTenantSettingsDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const tenant = await prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
...(dto.tradeName !== undefined ? { tradeName: dto.tradeName } : {}),
|
||||
...(dto.taxId !== undefined ? { taxId: dto.taxId } : {}),
|
||||
...(dto.timezone !== undefined ? { timezone: dto.timezone } : {}),
|
||||
...(dto.locale !== undefined ? { locale: dto.locale } : {}),
|
||||
...(dto.aiPrivacyLevel !== undefined ? { aiPrivacyLevel: dto.aiPrivacyLevel as AIPrivacyLevel } : {}),
|
||||
},
|
||||
include: { plan: { select: { key: true, name: true } } },
|
||||
});
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "TENANT_SETTINGS_UPDATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "tenant",
|
||||
entityId: tenantId,
|
||||
after: { ...dto },
|
||||
});
|
||||
|
||||
const { priceBookId: _priceBookId, rateDeckId: _rateDeckId, ...rest } = tenant;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TenantsController } from "./tenants.controller";
|
||||
import { TenantSettingsController } from "./tenant-settings.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [TenantsController],
|
||||
controllers: [TenantsController, TenantSettingsController],
|
||||
})
|
||||
export class TenantsModule {}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user