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

@@ -0,0 +1,16 @@
CREATE TYPE "public"."queue_outcome" AS ENUM('answered', 'abandoned', 'timed_out');--> statement-breakpoint
CREATE TABLE "queue_calls" (
"id" bigserial PRIMARY KEY NOT NULL,
"queue_id" uuid NOT NULL,
"uniqueid" text NOT NULL,
"linkedid" text NOT NULL,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
"outcome" "queue_outcome",
"wait_seconds" integer,
"agent_extension_id" uuid
);
--> statement-breakpoint
ALTER TABLE "queue_calls" ADD CONSTRAINT "queue_calls_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "queue_calls" ADD CONSTRAINT "queue_calls_agent_extension_id_extensions_id_fk" FOREIGN KEY ("agent_extension_id") REFERENCES "public"."extensions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "queue_calls_queue_id_joined_at_index" ON "queue_calls" USING btree ("queue_id","joined_at");--> statement-breakpoint
CREATE INDEX "queue_calls_uniqueid_index" ON "queue_calls" USING btree ("uniqueid");
File diff suppressed because it is too large. Load diff
@@ -15,6 +15,13 @@
"when": 1792267225335,
"tag": "0001_initial",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1792371506100,
"tag": "0002_queue_calls",
"breakpoints": true
}
]
}
+22
View File
@@ -305,6 +305,28 @@ export const queueMemberState = pgTable(
(t) => [primaryKey({ columns: [t.queueId, t.extensionId] })],
);
export const queueOutcome = pgEnum('queue_outcome', ['answered', 'abandoned', 'timed_out']);
/** One caller's stay in a queue, from app_queue's AMI events (stats, 04 §7.3). */
export const queueCalls = pgTable(
'queue_calls',
{
id: bigserial({ mode: 'number' }).primaryKey(),
queueId: uuid()
.notNull()
.references(() => queues.id, { onDelete: 'cascade' }),
/** The caller's channel. */
uniqueid: text().notNull(),
linkedid: text().notNull(),
joinedAt: tstz().notNull().defaultNow(),
/** null while waiting. */
outcome: queueOutcome(),
waitSeconds: integer(),
agentExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
},
(t) => [index().on(t.queueId, t.joinedAt), index().on(t.uniqueid)],
);
export const mohClasses = pgTable('moh_classes', {
id: id(),
name: text().notNull().unique(),