reports: total minutes, talk time, answered; outside minutes per day; voip.ms balance
Owner asked for total minutes, call time and missed calls in one place, and the money left on voip.ms. Reports now totals answered calls, talk time and outside minutes (in from arrival, out from the answer: roughly what the provider bills), charts outside minutes per day, and shows each voip.ms trunk's balance (getBalance; verify with a real account).
This commit is contained in:
1 parent
175d5a2c73
commit
338ab8d7f1
13 files changed
+380
-26
No files matched your search
@@ -105,3 +105,16 @@ export async function getDids(baseUrl: string, creds: VoipmsCreds): Promise<Voip
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's balance (getBalance → balance.current_balance, in the account's currency).
|
||||
* (verify) against a real account: the shape is from voip.ms's API docs (06 §5.1).
|
||||
*/
|
||||
export async function getBalance(baseUrl: string, creds: VoipmsCreds): Promise<number> {
|
||||
const r = await voipmsApi(baseUrl, creds, 'getBalance');
|
||||
const b = r.balance as Record<string, unknown> | undefined;
|
||||
const n = Number(str(b?.current_balance));
|
||||
if (!b || str(b.current_balance) === '' || !Number.isFinite(n))
|
||||
throw new VoipmsError('bad_response');
|
||||
return n;
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
|
||||
import { and, asc, sql } from 'drizzle-orm';
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
CALL_DIRECTIONS,
|
||||
ReportRangeSchema,
|
||||
type ListResponse,
|
||||
type QueueReportRow,
|
||||
type ProviderBalanceDto,
|
||||
type ReportSummary,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { calls, queues } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { calls, queues, trunks } from '../db/schema.ts';
|
||||
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
|
||||
import { badRequest } from '../errors.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { queueStats } from '../services/queuestats.ts';
|
||||
@@ -42,6 +45,10 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
);
|
||||
const local = sql`(${calls.startedAt} at time zone ${tz})`;
|
||||
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
||||
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
|
||||
// outbound from the answer (talk time).
|
||||
const lineIn = sql`coalesce(sum(extract(epoch from ${calls.endedAt} - ${calls.startedAt})) filter (where ${calls.direction} = 'inbound'), 0)`;
|
||||
const lineOut = sql`coalesce(sum(${calls.talkSeconds}) filter (where ${calls.direction} = 'outbound'), 0)`;
|
||||
const [perDay, perHour, [t]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
@@ -49,6 +56,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
direction: calls.direction,
|
||||
n: sql<number>`count(*)::int`,
|
||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||
outside: sql<number>`round(${lineIn} + ${lineOut})::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where)
|
||||
@@ -65,7 +73,11 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
db
|
||||
.select({
|
||||
calls: sql<number>`count(*)::int`,
|
||||
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`,
|
||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`,
|
||||
lineIn: sql<number>`round(${lineIn})::int`,
|
||||
lineOut: sql<number>`${lineOut}::int`,
|
||||
avgTalk: sql<
|
||||
number | null
|
||||
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
|
||||
@@ -82,6 +94,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
CALL_DIRECTIONS.map((dir) => [dir, rows.find((r) => r.direction === dir)?.n ?? 0]),
|
||||
) as Record<(typeof CALL_DIRECTIONS)[number], number>),
|
||||
missed: rows.reduce((s, r) => s + r.missed, 0),
|
||||
outsideMinutes: Math.round(rows.reduce((s, r) => s + r.outside, 0) / 60),
|
||||
});
|
||||
}
|
||||
const hours = Array.from({ length: 7 }, () => Array<number>(24).fill(0));
|
||||
@@ -90,11 +103,53 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
from,
|
||||
to,
|
||||
days,
|
||||
totals: { calls: t!.calls, missed: t!.missed, avgTalkSeconds: t!.avgTalk },
|
||||
totals: {
|
||||
calls: t!.calls,
|
||||
answered: t!.answered,
|
||||
missed: t!.missed,
|
||||
avgTalkSeconds: t!.avgTalk,
|
||||
talkSeconds: t!.talk,
|
||||
outsideSeconds: { inbound: t!.lineIn, outbound: t!.lineOut },
|
||||
},
|
||||
hours,
|
||||
};
|
||||
});
|
||||
|
||||
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
|
||||
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(trunks)
|
||||
.where(eq(trunks.provider, 'voipms'))
|
||||
.orderBy(asc(trunks.name));
|
||||
const items = await Promise.all(
|
||||
rows.map(async (t): Promise<ProviderBalanceDto> => {
|
||||
const base = { trunkId: t.id, trunkName: t.name };
|
||||
if (!t.voipmsApiUsername || !t.voipmsApiPasswordEnc)
|
||||
return {
|
||||
...base,
|
||||
balance: null,
|
||||
error: 'Add the voip.ms API username and password to this trunk (Trunks) to see it',
|
||||
};
|
||||
try {
|
||||
const creds = {
|
||||
username: t.voipmsApiUsername,
|
||||
password: decrypt(t.voipmsApiPasswordEnc),
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
balance: await getBalance(app.config.VOIPMS_API_URL, creds),
|
||||
error: null,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof VoipmsError) return { ...base, balance: null, error: e.message };
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { items, nextCursor: null };
|
||||
});
|
||||
|
||||
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
|
||||
const { from, to } = await range(db, req.query);
|
||||
const qs = await db.select().from(queues).orderBy(asc(queues.number));
|
||||
|
||||
@@ -24,20 +24,21 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
|
||||
direction: 'inbound' | 'outbound' | 'internal',
|
||||
status: 'answered' | 'missed' | 'voicemail' | 'busy',
|
||||
talkSeconds = 0,
|
||||
lineSeconds = talkSeconds, // arrival to hang-up
|
||||
) => ({
|
||||
linkedid,
|
||||
direction,
|
||||
status,
|
||||
startedAt: new Date(at),
|
||||
endedAt: new Date(at),
|
||||
endedAt: new Date(Date.parse(at) + lineSeconds * 1000),
|
||||
talkSeconds,
|
||||
});
|
||||
await t.db.insert(calls).values([
|
||||
call('a', '2026-05-04T14:00:00Z', 'inbound', 'answered', 60), // Mon 09:00 local
|
||||
call('b', '2026-05-04T14:30:00Z', 'inbound', 'missed'), // Mon 09:30
|
||||
call('c', '2026-05-04T15:00:00Z', 'outbound', 'answered', 120), // Mon 10:00
|
||||
call('d', '2026-05-05T03:00:00Z', 'inbound', 'voicemail'), // still Mon, 22:00
|
||||
call('e', '2026-05-06T13:00:00Z', 'internal', 'busy'), // Wed 08:00
|
||||
call('a', '2026-05-04T14:00:00Z', 'inbound', 'answered', 60, 75), // Mon 09:00 local
|
||||
call('b', '2026-05-04T14:30:00Z', 'inbound', 'missed', 0, 20), // Mon 09:30
|
||||
call('c', '2026-05-04T15:00:00Z', 'outbound', 'answered', 120, 130), // Mon 10:00
|
||||
call('d', '2026-05-05T03:00:00Z', 'inbound', 'voicemail', 0, 40), // still Mon, 22:00
|
||||
call('e', '2026-05-06T13:00:00Z', 'internal', 'busy', 0, 5), // Wed 08:00
|
||||
call('f', '2026-05-09T12:00:00Z', 'inbound', 'answered', 30), // outside the range
|
||||
]);
|
||||
const [q] = await t.db
|
||||
@@ -78,11 +79,19 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
const s = r.json();
|
||||
expect(s.days).toEqual([
|
||||
{ date: '2026-05-04', inbound: 3, outbound: 1, internal: 0, missed: 2 },
|
||||
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 0, missed: 0 },
|
||||
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 1, missed: 0 },
|
||||
// outside: inbound 75 + 20 + 40 s from arrival, outbound 120 s talk = 255 s, 4 minutes
|
||||
{ date: '2026-05-04', inbound: 3, outbound: 1, internal: 0, missed: 2, outsideMinutes: 4 },
|
||||
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
|
||||
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
|
||||
]);
|
||||
expect(s.totals).toEqual({ calls: 5, missed: 2, avgTalkSeconds: 90 });
|
||||
expect(s.totals).toEqual({
|
||||
calls: 5,
|
||||
answered: 2,
|
||||
missed: 2,
|
||||
avgTalkSeconds: 90,
|
||||
talkSeconds: 180,
|
||||
outsideSeconds: { inbound: 135, outbound: 120 },
|
||||
});
|
||||
expect(s.hours).toHaveLength(7);
|
||||
expect(s.hours[1][9]).toBe(2);
|
||||
expect(s.hours[1][10]).toBe(1);
|
||||
|
||||
@@ -19,6 +19,10 @@ function fakeVoipms() {
|
||||
{ server_name: 'Chicago 1', server_hostname: 'chicago1.voip.ms', server_ip: '1.2.3.5' },
|
||||
],
|
||||
},
|
||||
getBalance: {
|
||||
status: 'success',
|
||||
balance: { current_balance: '12.3456', spent_total: '7.89', calls_total: '42' },
|
||||
},
|
||||
getDIDsInfo: {
|
||||
status: 'success',
|
||||
dids: [
|
||||
@@ -124,6 +128,15 @@ describe.skipIf(!hasTestDb)('/api/trunks/:id voip.ms routes', () => {
|
||||
const r = await req('GET', `/api/trunks/${trunkId}/voipms-servers`);
|
||||
expect(r.statusCode).toBe(400);
|
||||
expect(r.json().error.message).toMatch(/API username and password/);
|
||||
// The balance on Reports says so instead of failing (owner, 2026-10-21).
|
||||
expect((await req('GET', '/api/reports/balance')).json().items).toEqual([
|
||||
{
|
||||
trunkId,
|
||||
trunkName: 'voip.ms',
|
||||
balance: null,
|
||||
error: expect.stringMatching(/API username and password/),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('a wrong API password is a 502 with voip.ms’s reason', async () => {
|
||||
@@ -135,10 +148,20 @@ describe.skipIf(!hasTestDb)('/api/trunks/:id voip.ms routes', () => {
|
||||
expect(r.statusCode).toBe(502);
|
||||
expect(r.json().error.message).toBe('voip.ms rejected the API username or password');
|
||||
expect(r.body).not.toContain('nope');
|
||||
const bal = await req('GET', '/api/reports/balance');
|
||||
expect(bal.json().items[0]).toMatchObject({
|
||||
balance: null,
|
||||
error: 'voip.ms rejected the API username or password',
|
||||
});
|
||||
expect(bal.body).not.toContain('nope');
|
||||
});
|
||||
|
||||
it('lists servers, previews and imports numbers (not routed, label from voip.ms)', async () => {
|
||||
await req('PATCH', `/api/trunks/${trunkId}`, { voipmsApiPassword: 'api-pw' });
|
||||
expect((await req('GET', '/api/reports/balance')).json().items).toEqual([
|
||||
{ trunkId, trunkName: 'voip.ms', balance: 12.3456, error: null },
|
||||
]);
|
||||
expect(fake.seen.at(-1)!.get('method')).toBe('getBalance');
|
||||
expect((await req('GET', `/api/trunks/${trunkId}/voipms-servers`)).json()).toEqual({
|
||||
servers: ['chicago1.voip.ms', 'seattle1.voip.ms'],
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user