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:
2026-08-28 09:15:11 -03:00
parent 0720a0efe3
commit 6628578c42
15 changed files with 674 additions and 5 deletions

View File

@@ -0,0 +1,44 @@
-- CreateEnum
CREATE TYPE "queue_strategy" AS ENUM ('LONGEST_IDLE_AGENT', 'ROUND_ROBIN', 'TOP_DOWN', 'AGENT_WITH_LEAST_TALK_TIME', 'AGENT_WITH_FEWEST_CALLS', 'SEQUENTIALLY_BY_AGENT_ORDER', 'RING_ALL', 'RING_PROGRESSIVELY');
-- CreateTable
CREATE TABLE "queues" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"strategy" "queue_strategy" NOT NULL DEFAULT 'LONGEST_IDLE_AGENT',
"moh_sound" TEXT,
"announce_sound" TEXT,
"announce_frequency" INTEGER NOT NULL DEFAULT 0,
"max_wait_time" INTEGER NOT NULL DEFAULT 0,
"max_wait_time_with_no_agent" INTEGER NOT NULL DEFAULT 0,
"agent_no_answer_status" TEXT,
"tier_rules_apply" BOOLEAN NOT NULL DEFAULT false,
"tier_rule_wait_second" INTEGER NOT NULL DEFAULT 300,
"discard_abandoned_after" INTEGER NOT NULL DEFAULT 60,
"abandoned_resume_allowed" BOOLEAN NOT NULL DEFAULT false,
"skip_agents_with_external_calls" BOOLEAN NOT NULL DEFAULT true,
"recording_enabled" BOOLEAN NOT NULL DEFAULT false,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "queues_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "queues_tenant_id_idx" ON "queues"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "queues_tenant_id_name_key" ON "queues"("tenant_id", "name");
-- AddForeignKey
ALTER TABLE "queues" ADD CONSTRAINT "queues_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Tabela de negocio tenant-scoped: RLS obrigatorio (ver docs/TENANT_ISOLATION.md).
ALTER TABLE "queues" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "queues" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "queues"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

View File

@@ -38,6 +38,7 @@ model Tenant {
trunks Trunk[]
dialplanExtensions DialplanExtension[]
dialplanVersions DialplanVersion[]
queues Queue[]
@@map("tenants")
}
@@ -379,3 +380,61 @@ model DialplanVersion {
@@index([tenantId, context, status])
@@map("dialplan_versions")
}
enum QueueStrategy {
LONGEST_IDLE_AGENT
ROUND_ROBIN
TOP_DOWN
AGENT_WITH_LEAST_TALK_TIME
AGENT_WITH_FEWEST_CALLS
SEQUENTIALLY_BY_AGENT_ORDER
RING_ALL
RING_PROGRESSIVELY
@@map("queue_strategy")
}
// Tabela tenant-scoped protegida por RLS (agente.md secao 50-51). O nome no
// FreeSWITCH é `<queue.id>@<tenant.telephonyDomain>` — mod_callcenter usa
// um namespace unico compartilhado entre tenants (nao ha equivalente ao
// diretorio por-arquivo do Sofia pra isolar por tenant, ver docs/QUEUES.md).
model Queue {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String
description String?
strategy QueueStrategy @default(LONGEST_IDLE_AGENT)
mohSound String? @map("moh_sound")
announceSound String? @map("announce_sound")
announceFrequency Int @default(0) @map("announce_frequency")
maxWaitTime Int @default(0) @map("max_wait_time")
maxWaitTimeWithNoAgent Int @default(0) @map("max_wait_time_with_no_agent")
agentNoAnswerStatus String? @map("agent_no_answer_status")
tierRulesApply Boolean @default(false) @map("tier_rules_apply")
tierRuleWaitSecond Int @default(300) @map("tier_rule_wait_second")
discardAbandonedAfter Int @default(60) @map("discard_abandoned_after")
abandonedResumeAllowed Boolean @default(false) @map("abandoned_resume_allowed")
skipAgentsWithExternalCalls Boolean @default(true) @map("skip_agents_with_external_calls")
recordingEnabled Boolean @default(false) @map("recording_enabled")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, name])
@@index([tenantId])
@@map("queues")
}

View File

@@ -4,3 +4,4 @@ export * from "./freeswitch-provider";
export * from "./directory-xml";
export * from "./gateway-xml";
export * from "./dialplan-xml";
export * from "./queue-xml";

View File

@@ -0,0 +1,78 @@
function xmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
const STRATEGY_VALUES: Record<string, string> = {
LONGEST_IDLE_AGENT: "longest-idle-agent",
ROUND_ROBIN: "round-robin",
TOP_DOWN: "top-down",
AGENT_WITH_LEAST_TALK_TIME: "agent-with-least-talk-time",
AGENT_WITH_FEWEST_CALLS: "agent-with-fewest-calls",
SEQUENTIALLY_BY_AGENT_ORDER: "sequentially-by-agent-order",
RING_ALL: "ring-all",
RING_PROGRESSIVELY: "ring-progressively",
};
export interface QueueXmlParams {
queueName: string; // ja no formato "<id>@<domain>"
strategy: keyof typeof STRATEGY_VALUES;
mohSound?: string;
announceSound?: string;
announceFrequency: number;
maxWaitTime: number;
maxWaitTimeWithNoAgent: number;
agentNoAnswerStatus?: string;
tierRulesApply: boolean;
tierRuleWaitSecond: number;
discardAbandonedAfter: number;
abandonedResumeAllowed: boolean;
skipAgentsWithExternalCalls: boolean;
recordingEnabled: boolean;
}
/**
* XML de fila do mod_callcenter (agente.md secao 50-51). Escrito em
* autoload_configs/callcenter_queues.conf.d/<queue_id>.xml (volume Docker
* compartilhado, incluído via X-PRE-PROCESS no callcenter.conf.xml —
* mesmo padrão dos gateways Sofia). Carregado com `callcenter_config queue
* reload <nome>` via ESL, não é resolvido dinamicamente por chamada como o
* dialplan.
*/
export function buildQueueXml(params: QueueXmlParams): string {
const lines: string[] = [];
const param = (name: string, value: string | number | boolean | undefined) => {
if (value === undefined || value === "") return;
lines.push(` <param name="${name}" value="${xmlEscape(String(value))}"/>`);
};
param("strategy", STRATEGY_VALUES[params.strategy]);
param("moh-sound", params.mohSound);
param("announce-sound", params.announceSound);
param("announce-frequency", params.announceFrequency);
param("time-base-score", "system");
param("max-wait-time", params.maxWaitTime);
param("max-wait-time-with-no-agent", params.maxWaitTimeWithNoAgent);
param("agent-no-answer-status", params.agentNoAnswerStatus);
param("tier-rules-apply", params.tierRulesApply);
param("tier-rule-wait-second", params.tierRuleWaitSecond);
param("discard-abandoned-after", params.discardAbandonedAfter);
param("abandoned-resume-allowed", params.abandonedResumeAllowed);
param("skip-agents-with-external-calls", params.skipAgentsWithExternalCalls);
if (params.recordingEnabled) {
param(
"record-template",
"$${recordings_dir}/${strftime(%Y-%m-%d-%H-%M-%S)}.${destination_number}.${caller_id_number}.${uuid}.wav",
);
}
return `<include>
<queue name="${xmlEscape(params.queueName)}">
${lines.join("\n")}
</queue>
</include>`;
}