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'],
});
+3
View File
@@ -7,6 +7,7 @@ import type {
DiagnosticsDto,
NetworkTestDto,
QueueReportRow,
ProviderBalanceDto,
ReportRange,
ReportSummary,
ApiTokenDto,
@@ -430,6 +431,8 @@ export const reportsApi = {
api<ListResponse<QueueReportRow>>('GET', `/api/reports/queues?${callQuery(r)}`).then(
(x) => x.items,
),
balance: () =>
api<ListResponse<ProviderBalanceDto>>('GET', '/api/reports/balance').then((x) => x.items),
};
export const recordingsApi = {
+34 -5
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Reports (05 §2): calls per day by direction, missed per day, busiest hours, queue table.
// Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per
// day, missed per day, busiest hours, queue table.
// ponytail: bars and the heatmap are plain divs and a table, no chart library.
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
@@ -14,6 +15,11 @@ const SERIES = [
['internal', 'Internal', 'bg-zinc-400'],
] as const;
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
/** 75 → "1 min", 4000 → "1 h 7 min": totals, rounded to the minute. */
const fmtMinutes = (s: number) => {
const m = Math.round(s / 60);
return m >= 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m} min`;
};
const shortDate = (d: string) =>
new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, {
month: 'short',
@@ -121,6 +127,7 @@ export function ReportsPage() {
queryKey: ['reports', 'queues', range],
queryFn: () => reportsApi.queues(range),
});
const balance = useQuery({ queryKey: ['reports', 'balance'], queryFn: reportsApi.balance });
const s = summary.data;
const date = (k: 'from' | 'to', label: string) => (
<Field label={label}>
@@ -163,24 +170,46 @@ export function ReportsPage() {
{s && (
<>
<dl aria-label="Totals" className="grid grid-cols-3 gap-3 text-sm sm:max-w-xl">
<dl aria-label="Totals" className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
{(
[
['Calls', s.totals.calls],
['Missed', s.totals.missed],
['Calls', s.totals.calls, null],
['Answered', s.totals.answered, null],
['Missed', s.totals.missed, 'missed, voicemail or hung up waiting'],
[
'Average talk time',
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
null,
],
['Talk time', fmtMinutes(s.totals.talkSeconds), 'people talking, inside calls too'],
[
'Outside minutes',
fmtMinutes(s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound),
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
],
...(balance.data ?? []).map(
(b) =>
[
balance.data!.length > 1 ? `Balance (${b.trunkName})` : 'voip.ms balance',
b.balance === null ? '—' : b.balance.toFixed(2),
b.error ?? "now, in your voip.ms account's currency",
] as const,
),
] as const
).map(([k, v]) => (
).map(([k, v, note]) => (
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<dt className="text-zinc-500">{k}</dt>
<dd className="text-lg font-semibold tabular-nums">{v}</dd>
{note && <dd className="text-xs text-zinc-500">{note}</dd>}
</div>
))}
</dl>
<Bars label="Calls per day" days={s.days} segments={SERIES} />
<Bars
label="Outside minutes per day"
days={s.days}
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
/>
<Bars
label="Missed per day"
days={s.days}
+1 -1
View File
@@ -43,7 +43,7 @@ export function SettingsPage() {
<h1 className="text-xl font-semibold">Settings</h1>
<nav
aria-label="Settings"
className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800"
className="flex flex-wrap gap-1 border-b border-zinc-200 dark:border-zinc-800"
>
{TABS.map((t) => (
<NavLink
+3 -3
View File
@@ -327,14 +327,14 @@ function MohClassCard({
<ol aria-label={`Play order of ${cls.name}`} className="flex flex-col gap-1">
{cls.files.map((f, i) => (
<li key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{cls.files.length > 1 && (
{cls.files.length > 1 && !cls.shuffle && (
<span className="flex items-center gap-0.5">
<span className="w-5 text-right text-zinc-500 tabular-nums">{i + 1}.</span>
<Button
variant="ghost"
aria-label={`Move ${f.name} up`}
title="Play earlier"
disabled={i === 0 || cls.shuffle || update.isPending}
disabled={i === 0 || update.isPending}
onClick={() => move(i, -1)}
>
↑
@@ -343,7 +343,7 @@ function MohClassCard({
variant="ghost"
aria-label={`Move ${f.name} down`}
title="Play later"
disabled={i === cls.files.length - 1 || cls.shuffle || update.isPending}
disabled={i === cls.files.length - 1 || update.isPending}
onClick={() => move(i, 1)}
>
↓
+1 -1
View File
@@ -44,7 +44,7 @@ Left sidebar navigation, in this order:
| Contacts | `/admin/contacts` | Company phonebook, CSV import |
| Call history | `/admin/calls` | Filters + table + CSV export; a row expands to show the CDR legs and plays the recording |
| Recordings | `/admin/recordings` | Searchable list, play, download, delete |
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page |
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page. Owner follow-up (2026-10-21, "total minutes, calltime, missed calls"): totals also have answered, talk time and outside minutes (in and out: inbound from arrival, since a receptionist or voicemail answers too; outbound from the answer; roughly what the provider bills), an outside-minutes-per-day chart, and one tile per voip.ms trunk with its account balance (`GET /api/reports/balance`, `getBalance`; a trunk without the API login says what to add) |
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| Blocked numbers | `/admin/blocked` | |
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS |
+4
View File
@@ -86,6 +86,10 @@ GET https://voip.ms/api/v1/rest.php
- Rate limit our sends to 1 per second per DID (queue in memory).
- Other methods used: `getDIDsInfo` (DID import), `getServersInfo` (server dropdown),
`getSMS` (optional polling fallback). All behind `voipmsApi(method, params)` with a 15 s timeout.
- `getBalance` (owner, 2026-10-21: the balance on Reports): `balance.current_balance`, a string
like `"12.3456"` in the account's currency **(verify the shape and that the API login needs
nothing more, with a real account)**. Each Reports load asks once per voip.ms trunk that has an
API login.
### 5.2 Receiving
Endpoint: `GET|POST /hooks/voipms/sms/:token`. `:token` is a 32-char random value stored
+34 -3
View File
@@ -88,9 +88,40 @@ export type ReportRange = z.input<typeof ReportRangeSchema>;
export type ReportSummary = {
from: string;
to: string;
/** Every day in the range, empty ones too. Missed = missed, voicemail or abandoned. */
days: { date: string; inbound: number; outbound: number; internal: number; missed: number }[];
totals: { calls: number; missed: number; avgTalkSeconds: number | null };
/**
* Every day in the range, empty ones too. Missed = missed, voicemail or abandoned.
* `outsideMinutes`: that day's outside line time (see `totals.outsideSeconds`), whole minutes.
*/
days: {
date: string;
inbound: number;
outbound: number;
internal: number;
missed: number;
outsideMinutes: number;
}[];
totals: {
calls: number;
answered: number;
missed: number;
avgTalkSeconds: number | null;
/** People talking, every direction. */
talkSeconds: number;
/**
* Time on the outside line, roughly what the provider bills (owner, 2026-10-21): inbound from
* arrival to hang-up (a receptionist or voicemail answers too), outbound from the answer.
*/
outsideSeconds: { inbound: number; outbound: number };
};
/** Calls by weekday (0 = Sunday) and hour, in the company time zone: hours[day][hour]. */
hours: number[][];
};
/** GET /api/reports/balance: each voip.ms trunk's account balance, or why it can't be shown. */
export type ProviderBalanceDto = {
trunkId: string;
trunkName: string;
/** In the voip.ms account's currency. */
balance: number | null;
error: string | null;
};
+6
View File
@@ -276,3 +276,9 @@ database without `network.probeUrl`) checks System → Diagnostics: the server's
network tests wait for a probe, then it sets one in Settings → Network (a probe it starts on
127.0.0.1:18090, so the results also warn that it's inside the network) and runs a quick and a
complete test. Asterisk isn't needed.
`numbers-music-reports.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>` (after `m1-t4.mjs`;
`PG_CONTAINER` and `PG_DB` name the Postgres container and database, for the calls it inserts)
checks the owner's 2026-10-21 features: Settings → Numbering (users from 1, Next free suggests 1),
the music-on-hold play order (move up, Shuffle), and the Reports totals (answered, talk time,
outside minutes in and out, the voip.ms balance tile). Needs ffmpeg for the uploads; no Asterisk.
+181
View File
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The owner's 2026-10-21 features in a browser:
// - Settings → Numbering: the users range starts at 1, so Next free suggests 1.
// - Sounds & music: three files play in upload order; "Move … up" reorders them; Shuffle.
// - Reports: answered, talk time, outside minutes (in and out), and the voip.ms balance tile
// (a trunk without API login says what to add).
// Calls for Reports go straight into the database (docker exec psql).
// Runs after m1-t4.mjs. Usage:
// PG_CONTAINER=selfphoneng-postgres-1 PG_DB=<db> node scripts/e2e/numbers-music-reports.mjs \
// <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { launch } from './cdp.mjs';
const [base, email, pw, shotsDir] = process.argv.slice(2);
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: pw }),
});
const cookie = login.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const api = async (method, url, body, type = 'application/json') => {
const r = await fetch(`${base}${url}`, {
method,
headers: { cookie, 'x-requested-with': 'selfphoneng', ...(body && { 'content-type': type }) },
body: body && type === 'application/json' ? JSON.stringify(body) : body,
});
const json = await r.json();
assert.ok(r.ok, `${method} ${url}: ${JSON.stringify(json)}`);
return json;
};
/** One second of a sine tone, 8 kHz mono 16-bit PCM. */
const tone = (hz) => {
const n = 8000;
const b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0);
b.writeUInt32LE(36 + n * 2, 4);
b.write('WAVEfmt ', 8);
b.writeUInt32LE(16, 16);
b.writeUInt16LE(1, 20);
b.writeUInt16LE(1, 22);
b.writeUInt32LE(8000, 24);
b.writeUInt32LE(16000, 28);
b.writeUInt16LE(2, 32);
b.writeUInt16LE(16, 34);
b.write('data', 36);
b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++)
b.writeInt16LE(Math.round(8000 * Math.sin((2 * Math.PI * hz * i) / 8000)), 44 + i * 2);
return b;
};
const psql = (sql) => {
const r = spawnSync(
'docker',
[
'exec',
process.env.PG_CONTAINER,
'psql',
'-U',
'selfphoneng',
'-d',
process.env.PG_DB,
'-c',
sql,
],
{ encoding: 'utf8' },
);
assert.equal(r.status, 0, r.stderr);
};
const text = (s) => `document.body.innerText.includes(${JSON.stringify(s)})`;
/** Types into an input found by its aria-label (the range grid has no <label>s). */
const fillAria = (p, label, value) =>
p.evaluate(`(() => {
const el = document.querySelector('input[aria-label=${JSON.stringify(label)}]');
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, ${JSON.stringify(value)});
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
})()`);
// Setup: a music set with three files, a voip.ms trunk without API login, three calls today.
const cls = await api('POST', '/api/moh-classes', { name: 'lobby' });
for (const [name, hz] of [
['First', 440],
['Second', 550],
['Third', 660],
])
await api('POST', `/api/moh-classes/${cls.id}/files?name=${name}`, tone(hz), 'audio/wav');
await api('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: 'chicago1.voip.ms',
username: '123456_pbx',
password: 'pw',
});
psql(`insert into calls (linkedid, direction, status, started_at, ended_at, talk_seconds) values
('e2e-1', 'inbound', 'answered', now() - interval '2 hours', now() - interval '2 hours' + interval '300 seconds', 270),
('e2e-2', 'outbound', 'answered', now() - interval '1 hour', now() - interval '1 hour' + interval '130 seconds', 120),
('e2e-3', 'inbound', 'missed', now() - interval '30 minutes', now() - interval '30 minutes' + interval '20 seconds', 0)`);
const p = await launch({ shotsDir });
try {
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/admin'`, 'signed in');
// Numbering
await p.goto(`${base}/admin/settings/numbering`);
await p.waitFor(text('Where Next free looks'), 'numbering tab');
await fillAria(p, 'Users (extensions) from', '1');
await p.click('Save');
await p.waitFor(text('Saved.'), 'saved');
await p.shot('o21-01-numbering');
assert.equal((await api('GET', '/api/numbering/next?kind=user')).number, '1');
console.log('numbering: users from 1, Next free suggests 1');
// Music order and shuffle
await p.goto(`${base}/admin/sounds`);
const order = () =>
p.evaluate(
`[...document.querySelectorAll('ol[aria-label="Play order of lobby"] li')].map(l => l.innerText.match(/(First|Second|Third)/)[1]).join(' ')`,
);
await p.waitFor(`!!document.querySelector('ol[aria-label="Play order of lobby"]')`, 'play order');
assert.equal(await order(), 'First Second Third');
await p.click('Move Third up');
await p.waitFor(
`${JSON.stringify('First Third Second')} === [...document.querySelectorAll('ol[aria-label="Play order of lobby"] li')].map(l => l.innerText.match(/(First|Second|Third)/)[1]).join(' ')`,
'moved',
);
const after = (await api('GET', '/api/moh-classes')).items.find((c) => c.name === 'lobby');
assert.deepEqual(
after.files.map((f) => f.name),
['First', 'Third', 'Second'],
);
await p.evaluate(
`[...document.querySelectorAll('label')].find(l => l.textContent.trim().startsWith('Shuffle')).querySelector('input').click()`,
);
await p.waitFor(text('(plays in random order)'), 'shuffled');
assert.equal(
(await api('GET', '/api/moh-classes')).items.find((c) => c.name === 'lobby').shuffle,
true,
);
assert.equal(
await p.evaluate(`!!document.querySelector('[aria-label="Move First down"]')`),
false,
); // order doesn't matter now
await p.evaluate(
`document.querySelector('ol[aria-label="Play order of lobby"]').scrollIntoView({ block: 'center' })`,
);
await p.shot('o21-02-music-order');
console.log('music: reordered (First Third Second), shuffle on');
// Reports
await p.goto(`${base}/admin/reports`);
await p.waitFor(`!!document.querySelector('dl[aria-label="Totals"]')`, 'totals');
for (const s of [
'Answered',
'Talk time',
'Outside minutes',
'voip.ms balance',
'API username and password',
'Outside minutes per day',
])
await p.waitFor(text(s), s);
const tile = (k) =>
p.evaluate(
`[...document.querySelectorAll('dl[aria-label="Totals"] > div')].find(d => d.querySelector('dt').textContent === ${JSON.stringify(k)}).innerText`,
);
// 300 + 20 s in (from arrival), 120 s out (from the answer): 7 min; talk 270 + 120 s: 7 min
assert.match(await tile('Outside minutes'), /7 min[\s\S]*5 min in, 2 min out/);
assert.match(await tile('Talk time'), /7 min/);
assert.match(await tile('Answered'), /2/);
await p.shot('o21-03-reports');
console.log('reports: answered, talk time, outside minutes in/out, balance tile');
} finally {
await p.close();
}