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:
iamdoubz committed 2026-10-21 13:38:23 -05:00
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;
}
+58 -3
View File
@@ -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));
+19 -10
View File
@@ -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);
+23
View File
@@ -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'],
});