import { BadRequestException, Body, Controller, ForbiddenException, Get, NotFoundException, Post, UseGuards, } from "@nestjs/common"; import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { decryptSecret } from "@b2bcall/shared"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { CurrentUser } from "../common/decorators/current-user.decorator"; import { PauseDto } from "./dto/pause.dto"; import { notifyAgentChanged, notifyTierChanged } from "./agent-sync.helper"; import { publishAgentStateChanged } from "../realtime/realtime-publish.helper"; import { WEBRTC_PROXY_SETTING_KEY } from "../platform/platform-webrtc-proxy.controller"; async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) { const agent = await tx.agent.findFirst({ where: { tenantId, userId, deletedAt: null }, include: { extension: true, tiers: true }, }); if (!agent) { throw new NotFoundException("Nenhum agente vinculado a este usuario neste tenant"); } return agent; } /** * "Tela do Agente" (agente.md secao 49): DISPONÍVEL / PAUSA / FINALIZAR * PAUSA / LOGOUT. Opera sempre sobre o agente do PRÓPRIO usuário * autenticado — nunca aceita um agentId arbitrário do client (mesmo * principio de nunca confiar em tenant_id do frontend, secao 31). */ @UseGuards(JwtAuthGuard) @Controller("agents/me") export class AgentsMeController { /** * Achado real reportado pelo usuário: "não achei como deixar o agente * online" — os endpoints de login/pausa/logout sempre existiram, mas * não tinha nenhum jeito do frontend saber SE o usuário logado tem um * Agent vinculado (pra mostrar o controle) nem qual o estado atual. * 404 aqui = usuário sem Agent neste tenant, não um erro — é assim que * o widget da topbar decide se aparece ou não. */ @Get() async me(@CurrentUser() user: AccessTokenClaims) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub)); return { id: agent.id, name: agent.name, state: agent.state, enabled: agent.enabled, hasExtension: agent.extensionId != null, }; } /** * Credenciais SIP + endereço do proxy WebRTC pro softphone embutido * (Handphone, PHASE 66) — sempre o ramal do PRÓPRIO agente, nunca um * agentId/extensionId arbitrário do client (mesmo principio de * `findMyAgent`). Diferente de `POST /extensions/:id/reveal-password` * (que exige `extensions.manage`, permissão que um agente comum nunca * tem): aqui não há permissão nenhuma além de "sou um agente logado * com ramal vinculado" — é o próprio agente pegando a própria senha * pra usar no softphone, não uma ação administrativa sobre o ramal de * outra pessoa. Cada acesso fica no audit log (mesma lógica de * `revealPassword`: decifrar de novo é sensível mesmo sem trocar nada). */ @Get("softphone-config") async softphoneConfig(@CurrentUser() user: AccessTokenClaims) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const agent = await withTenantContext(prisma, tenantId, (tx) => tx.agent.findFirst({ where: { tenantId, userId: user.sub, deletedAt: null }, include: { extension: true } }), ); if (!agent) { throw new NotFoundException("Nenhum agente vinculado a este usuario neste tenant"); } if (!agent.extension) { return { hasExtension: false as const }; } const setting = await prisma.platformSetting.findUnique({ where: { key: WEBRTC_PROXY_SETTING_KEY } }); await recordAuditEvent(prisma, { action: "AGENT_SOFTPHONE_CONFIG_ACCESSED", tenantId, userId: user.sub, entityType: "extension", entityId: agent.extension.id, }); return { hasExtension: true as const, username: agent.extension.number, domain: agent.extension.domain, password: decryptSecret(agent.extension.sipPasswordEnc), displayName: agent.name, proxyUrl: setting?.value ?? null, }; } /** Motivos de pausa pro próprio agente escolher — sem exigir * `agents.view` (que listaria TODOS os agentes do tenant, permissão * que o role "agent" nunca precisou ter até aqui). */ @Get("pause-reasons") async pauseReasons(@CurrentUser() user: AccessTokenClaims) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub)); return withTenantContext(prisma, tenantId, (tx) => tx.pauseReason.findMany({ where: { tenantId, enabled: true }, orderBy: { name: "asc" } }), ); } /** Fluxo de login (agente.md secao 47): valida usuário (JWT) e ramal, * cria sessão, configura contact/tiers no FreeSWITCH, fica AVAILABLE. */ @Post("login") async login(@CurrentUser() user: AccessTokenClaims) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub)); if (!agent.extension) { throw new BadRequestException("Agente sem ramal configurado — nao e' possivel logar"); } if (!agent.enabled) { throw new ForbiddenException("Agente desabilitado"); } await withTenantContext(prisma, tenantId, async (tx) => { await tx.agentSession.create({ data: { tenantId, agentId: agent.id } }); await tx.agentStateEvent.createMany({ data: [ { tenantId, agentId: agent.id, state: "LOGGED_IN" }, { tenantId, agentId: agent.id, state: "AVAILABLE" }, ], }); await tx.agent.update({ where: { id: agent.id }, data: { state: "AVAILABLE", stateUpdatedAt: new Date() }, }); }); await recordAuditEvent(prisma, { action: "AGENT_LOGIN", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id }); await notifyAgentChanged(tenantId, agent.id, "upsert"); for (const tier of agent.tiers) { await notifyTierChanged(tenantId, tier.queueId, agent.id, "upsert", tier.level, tier.position); } await publishAgentStateChanged(tenantId, agent.id, "AVAILABLE"); return { state: "AVAILABLE" }; } @Post("logout") async logout(@CurrentUser() user: AccessTokenClaims) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub)); await withTenantContext(prisma, tenantId, async (tx) => { await tx.agentSession.updateMany({ where: { tenantId, agentId: agent.id, endedAt: null }, data: { endedAt: new Date() }, }); await tx.agentPauseEvent.updateMany({ where: { tenantId, agentId: agent.id, endedAt: null }, data: { endedAt: new Date() }, }); await tx.agentStateEvent.create({ data: { tenantId, agentId: agent.id, state: "OFFLINE" } }); await tx.agent.update({ where: { id: agent.id }, data: { state: "OFFLINE", stateUpdatedAt: new Date() }, }); }); await recordAuditEvent(prisma, { action: "AGENT_LOGOUT", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id }); await notifyAgentChanged(tenantId, agent.id, "upsert"); await publishAgentStateChanged(tenantId, agent.id, "OFFLINE"); return { state: "OFFLINE" }; } @Post("pause") async pause(@CurrentUser() user: AccessTokenClaims, @Body() dto: PauseDto) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub)); if (agent.state === "OFFLINE") { throw new BadRequestException("Agente precisa estar logado pra entrar em pausa"); } const pauseReason = await withTenantContext(prisma, tenantId, (tx) => tx.pauseReason.findFirst({ where: { id: dto.pauseReasonId, tenantId, enabled: true } }), ); if (!pauseReason) { throw new NotFoundException("Motivo de pausa nao encontrado"); } await withTenantContext(prisma, tenantId, async (tx) => { await tx.agentPauseEvent.create({ data: { tenantId, agentId: agent.id, pauseReasonId: pauseReason.id }, }); await tx.agentStateEvent.create({ data: { tenantId, agentId: agent.id, state: "PAUSED" } }); await tx.agent.update({ where: { id: agent.id }, data: { state: "PAUSED", stateUpdatedAt: new Date() }, }); }); await recordAuditEvent(prisma, { action: "AGENT_PAUSE", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id, after: { pauseReason: pauseReason.name }, }); await notifyAgentChanged(tenantId, agent.id, "upsert"); await publishAgentStateChanged(tenantId, agent.id, "PAUSED"); return { state: "PAUSED" }; } @Post("resume") async resume(@CurrentUser() user: AccessTokenClaims) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub)); await withTenantContext(prisma, tenantId, async (tx) => { await tx.agentPauseEvent.updateMany({ where: { tenantId, agentId: agent.id, endedAt: null }, data: { endedAt: new Date() }, }); await tx.agentStateEvent.create({ data: { tenantId, agentId: agent.id, state: "AVAILABLE" } }); await tx.agent.update({ where: { id: agent.id }, data: { state: "AVAILABLE", stateUpdatedAt: new Date() }, }); }); await recordAuditEvent(prisma, { action: "AGENT_RESUME", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id }); await notifyAgentChanged(tenantId, agent.id, "upsert"); await publishAgentStateChanged(tenantId, agent.id, "AVAILABLE"); return { state: "AVAILABLE" }; } }