Três achados reportados pelo usuário numa mensagem só: (1) Rotas de Entrada não tinha edição depois de criada — implementada no mesmo padrão de Filas; (2) telas de Platform > Infraestrutura sempre davam "Timeout no ESL" nesta VM — não era limitação permanente como o comentário antigo dizia, e sim ESL_HOST=freeswitch (nome DNS que só existe dentro da rede do Docker) mais um segundo bug independente (`show gateways as json` não é comando válido nesta versão do FreeSWITCH); (3) ramal externo registrava mas sem áudio — diagnosticado com contadores de pacote do iptables: a VM está atrás de um roteador sem port-forward pra faixa de RTP, achado de infraestrutura de rede, não bug de código. Também integra o softphone WebRTC (handphone.js/OpenSIPS, já em produção): código-fonte encontrado em git.falehandix.com.br/Handix/handphone-2.0, patch mínimo pra aceitar o endereço do proxy em runtime (era build-time), nova config global (Platform > Infraestrutura > Softphone WebRTC) e widget na topbar do tenant que pega usuário/domínio/senha do ramal vinculado ao agente logado. Adiciona docs/QA_SETUP.md — runbook completo pra subir o ambiente do zero numa máquina nova (Docker, migrations, seed, systemd), e completa o .env.example que estava faltando a maioria das variáveis reais. Testado ponta a ponta com Playwright: edição de rota (criar/editar/F5), as 3 telas de Infraestrutura com dado real, e um tenant/ramal/agente de teste criados na hora confirmando que o script do softphone recebe as credenciais certas. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
263 lines
9.9 KiB
TypeScript
263 lines
9.9 KiB
TypeScript
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" };
|
|
}
|
|
}
|