feat: implement Call Center queues (mod_callcenter)
- Investigated the real callcenter_config command surface via
'help callcenter_config' on the running FreeSWITCH before writing any
code: queues only have load/unload/reload (static XML + reload, no
'queue add' exists), while agents and tiers are fully dynamic via ESL
commands (agent add, tier add) -- no file involved. This shapes the next
phase (Agents/Tiers) differently from this one.
- queues table (tenant-scoped, RLS): strategy, moh/announce, wait times,
tier rules, discard/abandoned handling, skip-agents-with-external-calls,
recording_enabled
- packages/telephony: buildQueueXml()
- infrastructure/freeswitch: our own callcenter.conf.xml override (empties
the vanilla static agents/tiers -- those become fully dynamic in the next
phase) that includes callcenter_queues.conf.d/*.xml via X-PRE-PROCESS,
same pattern as the Sofia gateway directory
- apps/api/src/queues: CRUD (POST/GET/GET:id/DELETE) using the queues.view/
.manage permissions already in the seed
- b2bcall-fs-config (queue-sync.ts): one XML file per queue on a shared
volume, synced via Redis pub/sub (b2bcall:queues:sync) on create/delete
and once at boot -- same shape as trunk-sync.ts
- confirmed manually against the real FreeSWITCH, before coding the sync
logic: 'queue load <name>' fails ('Invalid Queue not found!') for a file
added after boot -- needs 'reloadxml' first to repopulate the in-memory
XML tree from disk; after that, 'queue reload <name>' alone handles both
create and update, no need to distinguish load vs reload
- verified end-to-end: created a queue (ROUND_ROBIN, maxWaitTime=120,
discardAbandonedAfter=90), 'callcenter_config queue list' showed the
correct values on the FreeSWITCH side; deleted it, list went back empty
docs/QUEUES.md
This commit is contained in:
88
apps/api/src/queues/dto/create-queue.dto.ts
Normal file
88
apps/api/src/queues/dto/create-queue.dto.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator";
|
||||
|
||||
const STRATEGIES = [
|
||||
"LONGEST_IDLE_AGENT",
|
||||
"ROUND_ROBIN",
|
||||
"TOP_DOWN",
|
||||
"AGENT_WITH_LEAST_TALK_TIME",
|
||||
"AGENT_WITH_FEWEST_CALLS",
|
||||
"SEQUENTIALLY_BY_AGENT_ORDER",
|
||||
"RING_ALL",
|
||||
"RING_PROGRESSIVELY",
|
||||
] as const;
|
||||
|
||||
export class CreateQueueDto {
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STRATEGIES)
|
||||
strategy?: (typeof STRATEGIES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
mohSound?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
announceSound?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(3600)
|
||||
announceFrequency?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(3600)
|
||||
maxWaitTime?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(3600)
|
||||
maxWaitTimeWithNoAgent?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
agentNoAnswerStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
tierRulesApply?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(3600)
|
||||
tierRuleWaitSecond?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(3600)
|
||||
discardAbandonedAfter?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
abandonedResumeAllowed?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
skipAgentsWithExternalCalls?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
recordingEnabled?: boolean;
|
||||
}
|
||||
125
apps/api/src/queues/queues.controller.ts
Normal file
125
apps/api/src/queues/queues.controller.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
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 { getRedisClient } from "../common/redis";
|
||||
import { CreateQueueDto } from "./dto/create-queue.dto";
|
||||
|
||||
const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync";
|
||||
|
||||
async function notifyQueuesChanged(): Promise<void> {
|
||||
await getRedisClient().publish(QUEUES_SYNC_CHANNEL, "sync");
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("queues")
|
||||
export class QueuesController {
|
||||
@RequirePermission("queues.manage")
|
||||
@Post()
|
||||
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateQueueDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const queue = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.queue.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
strategy: dto.strategy ?? "LONGEST_IDLE_AGENT",
|
||||
mohSound: dto.mohSound,
|
||||
announceSound: dto.announceSound,
|
||||
announceFrequency: dto.announceFrequency ?? 0,
|
||||
maxWaitTime: dto.maxWaitTime ?? 0,
|
||||
maxWaitTimeWithNoAgent: dto.maxWaitTimeWithNoAgent ?? 0,
|
||||
agentNoAnswerStatus: dto.agentNoAnswerStatus,
|
||||
tierRulesApply: dto.tierRulesApply ?? false,
|
||||
tierRuleWaitSecond: dto.tierRuleWaitSecond ?? 300,
|
||||
discardAbandonedAfter: dto.discardAbandonedAfter ?? 60,
|
||||
abandonedResumeAllowed: dto.abandonedResumeAllowed ?? false,
|
||||
skipAgentsWithExternalCalls: dto.skipAgentsWithExternalCalls ?? true,
|
||||
recordingEnabled: dto.recordingEnabled ?? false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "QUEUE_CREATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "queue",
|
||||
entityId: queue.id,
|
||||
after: { name: queue.name, strategy: queue.strategy },
|
||||
});
|
||||
|
||||
await notifyQueuesChanged();
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
@RequirePermission("queues.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
return withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.queue.findMany({ where: { deletedAt: null }, orderBy: { name: "asc" } }),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission("queues.view")
|
||||
@Get(":id")
|
||||
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const queue = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.queue.findFirst({ where: { id, deletedAt: null } }),
|
||||
);
|
||||
if (!queue) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
@RequirePermission("queues.manage")
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.queue.updateMany({
|
||||
where: { id, deletedAt: null },
|
||||
data: { deletedAt: new Date(), enabled: false },
|
||||
}),
|
||||
);
|
||||
if (result.count === 0) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "QUEUE_DELETE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "queue",
|
||||
entityId: id,
|
||||
});
|
||||
|
||||
await notifyQueuesChanged();
|
||||
}
|
||||
}
|
||||
7
apps/api/src/queues/queues.module.ts
Normal file
7
apps/api/src/queues/queues.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { QueuesController } from "./queues.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [QueuesController],
|
||||
})
|
||||
export class QueuesModule {}
|
||||
Reference in New Issue
Block a user