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

+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'],
});