queues: queues, agents, *45 login, live tiles, stats

Queues per 03 §6.8: queues_selfphoneng.conf (members as Local agents
following their hint), sp-queue-{id} (answer, prefix, record, Queue with
max wait, timeout destination), the number in sp-local-numbers and
destApp queue. Golden case queue; the Asterisk load test now checks
`queue show` lists each queue's members.

*45<queue> toggles login and checks PQMSTATUS/UPQMSTATUS, so a code for
a queue the caller isn't an agent of plays ss-noservice and stores
nothing (spec snippet fixed). Pauses live in queue_member_state, AstDB
SP/QPAUSE (resynced on connect) and app_queue; QueuePause is re-sent
after every `queue reload all` and rollback, which unpause everyone.

/api/queues CRUD with members [{extensionId, penalty}], admin
pause/unpause, GET /:id/stats; /api/me/queues/:id/pause|unpause for
Home. Stats come from queue_calls (new table), filled from app_queue's
AMI events plus UserEvent SpQueueExit in the fail branch: a real call
showed that when no agent rings, the Queue part of a call vanishes into
the next leg's CDR, and Asterisk 24 sends QueueCallerAbandon on
timeouts too.

Live: QueueTracker (QueueStatus on connect and after applies, queue
events); admins see every queue, users the queues they're agents of.
Web: Queues page (list, form with every column, detail page with
waiting callers, agents and pause buttons, today's stats), queue tiles
on the dashboard, queue login switches on Home, Queue in the
DestinationPicker.

CDR fixtures for queue answered/abandoned/timeout calls. e2e m8-t1 with
real phones: 3 callers wait with 2 agents, one paused by *45550 and one
in the UI, unpause connects the longest waiting caller, one times out
to the overflow phone, stats count it all. The whole e2e chain passes.

Task: M8-T1
This commit is contained in:
iamdoubz committed 2026-10-18 20:15:51 -05:00
1 parent 6ddd8d3ea9
commit 2d9f8ed99e
75 files changed
+7623 -56

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ export * from './outbound.ts';
export * from './voicemail.ts';
export * from './sounds.ts';
export * from './ringgroups.ts';
export * from './queues.ts';
export * from './ivrs.ts';
export * from './timeconditions.ts';
export * from './announcements.ts';
+23 -2
View File
@@ -62,14 +62,32 @@ export type ActiveCall = {
recording: boolean;
};
/** One queue right now (04 §5.1). */
export type QueueLive = {
id: string;
number: string;
name: string;
/** Callers waiting, longest wait first. */
waiting: { linkedid: string; callerNumber: string; callerName?: string; since: string }[];
members: {
ext: string;
paused: boolean;
state: ExtensionState;
callsTaken: number;
/** ISO time of the last call this agent finished. */
lastCall?: string;
}[];
};
export type LiveSnapshot = {
extensions: ExtensionLive[];
/** Admins only. */
trunks?: TrunkLive[];
/** Admins only. */
calls?: ActiveCall[];
/** Queues and parked calls arrive with M8. */
queues?: never[];
/** Admins: every queue; users: the queues they are a member of. */
queues: QueueLive[];
/** Parked calls arrive with M8-T2. */
parked?: never[];
ami: { connected: boolean };
};
@@ -85,6 +103,9 @@ export type LiveMessage =
/** Admins only. */
| { type: 'call.updated'; data: ActiveCall }
| { type: 'call.removed'; data: { linkedid: string } }
/** Admins and the queue's members. */
| { type: 'queue.updated'; data: QueueLive }
| { type: 'queue.removed'; data: { number: string } }
/** Only to the mailbox owner. */
| { type: 'voicemail.new'; data: VoicemailDto & { userId: string } }
/** A short message for one user (e.g. "forwarding refused"). */
+80
View File
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Queues (02 queues, 03 §6.8): callers wait with music until an agent is free.
import { z } from 'zod';
import { DestinationSchema, type Destination } from './destination.ts';
import { InternalNumber } from './extensions.ts';
export const QUEUE_STRATEGIES = [
'ringall',
'leastrecent',
'fewestcalls',
'random',
'rrmemory',
'linear',
] as const;
const QueueFields = z.object({
number: InternalNumber,
name: z.string().trim().min(1, 'Required').max(40),
strategy: z.enum(QUEUE_STRATEGIES),
memberRingSeconds: z.number().int().min(5).max(120),
retrySeconds: z.number().int().min(0).max(60),
wrapupSeconds: z.number().int().min(0).max(600),
maxWaitSeconds: z.number().int().min(10).max(7200),
/** 0 = unlimited. */
maxCallers: z.number().int().min(0).max(500),
/** M8-T5 adds classes; until then only "default" exists. */
mohClass: z.string().regex(/^[a-z0-9-]{1,40}$/, 'Lowercase letters, digits and - only'),
announcePosition: z.boolean(),
announceEverySeconds: z.number().int().min(15).max(600),
joinWhenNoAgents: z.boolean(),
record: z.boolean(),
slaSeconds: z.number().int().min(1).max(3600),
/** Shown before the caller's name. Same rules as caller ID names (03 §2). */
callerIdNamePrefix: z
.string()
.max(20)
.regex(/^[^"'\\;,()[\]{}$<>|]*$/, 'No quotes, commas, brackets or ; $ < > |')
.nullable(),
/** Agents; a lower penalty gets calls first. */
members: z
.array(z.object({ extensionId: z.uuid(), penalty: z.number().int().min(0).max(10) }))
.max(100)
.refine(
(ms) => new Set(ms.map((m) => m.extensionId)).size === ms.length,
'Someone is listed twice',
),
timeoutDestination: DestinationSchema,
});
export const QueueCreateSchema = QueueFields.partial().required({
number: true,
name: true,
timeoutDestination: true,
});
export const QueueUpdateSchema = QueueFields.partial();
export type QueueCreate = z.input<typeof QueueCreateSchema>;
export type QueueUpdate = z.input<typeof QueueUpdateSchema>;
export type QueueMemberDto = { extensionId: string; penalty: number; paused: boolean };
export type QueueDto = Omit<z.output<typeof QueueFields>, 'members'> & {
id: string;
members: QueueMemberDto[];
timeoutDestination: Destination;
timeoutSummary: string;
createdAt: string;
};
/** GET /api/queues/:id/stats (and the reports queue table later). */
export type QueueStats = {
offered: number;
answered: number;
abandoned: number;
/** Went to the timeout destination (waited too long, full, no agents). */
timedOut: number;
/** Seconds, over answered calls; null when none. */
avgWaitSeconds: number | null;
/** % of answered calls picked up within sla_seconds; null when none. */
withinSlaPercent: number | null;
};