Chore/qol #7

Merged
iamdoubz merged 2 commits from chore/qol into main 2026-10-28 15:18:49 -05:00
7 changed files with 218 additions and 77 deletions
Showing only changes of commit 2bc7d5cde5 - Show all commits

No files matched your search

+39 -13
View File
@@ -1,6 +1,7 @@
// 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, eq, sql } from 'drizzle-orm';
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only,
// but /api/me/reports/summary: the same summary of the calls made to me.
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import {
CALL_DIRECTIONS,
@@ -10,12 +11,12 @@ import {
type ProviderBalanceDto,
type ReportSummary,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { decrypt } from '../crypto/secrets.ts';
import { calls, queues, trunks } from '../db/schema.ts';
import { callExtensions, calls, extensions, queues, trunks } from '../db/schema.ts';
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
import { badRequest } from '../errors.ts';
import { badRequest, notFound } from '../errors.ts';
import { todayIn } from '../generator/load.ts';
import { queueStats } from '../services/queuestats.ts';
import { getSetting } from '../services/settings.ts';
@@ -35,14 +36,28 @@ async function range(db: Db, query: unknown) {
export async function reportRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
const admin = { preHandler: requireAdmin };
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
const { tz, from, to } = await range(db, req.query);
/** `toExt`: only the calls that rang or were answered by that extension, not the ones it made. */
async function summary(query: unknown, toExt?: string): Promise<ReportSummary> {
const { tz, from, to } = await range(db, query);
const where = and(
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
toExt
? inArray(
calls.id,
db
.select({ id: callExtensions.callId })
.from(callExtensions)
.where(and(eq(callExtensions.extensionId, toExt), ne(callExtensions.role, 'caller'))),
)
: undefined,
);
// Mine: answered and talk time are the calls I picked up, not a coworker in my ring group.
const answered = toExt
? sql`${calls.answeredByExtensionId} = ${toExt}`
: sql`${calls.status} = 'answered'`;
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),
@@ -73,14 +88,14 @@ export async function reportRoutes(app: FastifyInstance) {
db
.select({
calls: sql<number>`count(*)::int`,
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`,
answered: sql<number>`count(*) filter (where ${answered})::int`,
missed: sql<number>`count(*) filter (where ${missed})::int`,
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`,
talk: sql<number>`coalesce(sum(${calls.talkSeconds}) filter (where ${toExt ? answered : sql`true`}), 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`,
>`round(avg(${calls.talkSeconds}) filter (where ${answered}))::int`,
})
.from(calls)
.where(where),
@@ -113,10 +128,21 @@ export async function reportRoutes(app: FastifyInstance) {
},
hours,
};
}
app.get('/api/reports/summary', admin, (req) => summary(req.query));
app.get('/api/me/reports/summary', { preHandler: requireAuth }, async (req) => {
const [ext] = await db
.select({ id: extensions.id })
.from(extensions)
.where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
return summary(req.query, ext.id);
});
/** 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>> => {
app.get('/api/reports/balance', admin, async (): Promise<ListResponse<ProviderBalanceDto>> => {
const rows = await db
.select()
.from(trunks)
@@ -150,7 +176,7 @@ export async function reportRoutes(app: FastifyInstance) {
return { items, nextCursor: null };
});
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
app.get('/api/reports/queues', admin, async (req): Promise<ListResponse<QueueReportRow>> => {
const { from, to } = await range(db, req.query);
const qs = await db.select().from(queues).orderBy(asc(queues.number));
return {
+74 -2
View File
@@ -1,8 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone.
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone;
// /api/me/reports/summary, the calls made to me.
import type { LightMyRequestResponse } from 'fastify';
import { inArray } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { calls, queueCalls, queues } from '../src/db/schema.ts';
import { callExtensions, calls, extensions, queueCalls, queues } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -125,5 +128,74 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
await t.addUser('pat@example.com', 'pat-password-123');
const pat = await t.login('pat@example.com', 'pat-password-123');
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
expect((await get('/api/me/reports/summary', pat)).statusCode).toBe(404); // no extension
});
it('mine: only calls made to me; answered and talk time are the ones I picked up', async () => {
const sam = await t.addUser('sam@example.com', 'sam-password-123');
const [mine, other] = await t.db
.insert(extensions)
.values(
(['103', '104'] as const).map((number) => ({
number,
name: number,
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
userId: number === '103' ? sam.id : null,
})),
)
.returning();
await t.db.insert(calls).values({
linkedid: 'g',
direction: 'internal',
status: 'answered',
startedAt: new Date('2026-05-05T15:00:00Z'), // Tue 10:00
endedAt: new Date('2026-05-05T15:00:45Z'),
talkSeconds: 40,
answeredByExtensionId: mine!.id,
});
const ids = Object.fromEntries(
(
await t.db
.select({ id: calls.id, linkedid: calls.linkedid })
.from(calls)
.where(inArray(calls.linkedid, ['a', 'b', 'c', 'g']))
).map((c) => [c.linkedid, c.id]),
);
// a: rang me and a coworker, the coworker answered. b: rang me, missed. c: I made it. g: mine.
await t.db
.update(calls)
.set({ answeredByExtensionId: other!.id })
.where(inArray(calls.id, [ids.a!]));
await t.db.insert(callExtensions).values([
{ callId: ids.a!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.a!, extensionId: other!.id, role: 'answered' },
{ callId: ids.b!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.c!, extensionId: mine!.id, role: 'caller' },
{ callId: ids.g!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.g!, extensionId: mine!.id, role: 'answered' },
]);
const cookie = await t.login('sam@example.com', 'sam-password-123');
const r = await get('/api/me/reports/summary?from=2026-05-04&to=2026-05-06', cookie);
expect(r.statusCode, r.body).toBe(200);
const s = r.json();
expect(s.days).toEqual([
{ date: '2026-05-04', inbound: 2, outbound: 0, internal: 0, missed: 1, outsideMinutes: 2 },
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
]);
expect(s.totals).toEqual({
calls: 3,
answered: 1,
missed: 1,
avgTalkSeconds: 40,
talkSeconds: 40,
outsideSeconds: { inbound: 95, outbound: 0 },
});
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(3);
// The company summary is unchanged by who rang.
expect(
(await get('/api/reports/summary?from=2026-05-04&to=2026-05-04')).json().totals.answered,
).toBe(2);
});
});
+2
View File
@@ -306,6 +306,8 @@ export const systemApi = {
export const meApi = {
calls: (cursor?: string) =>
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
/** The summary of the calls made to me. */
report: (r: ReportRange) => api<ReportSummary>('GET', `/api/me/reports/summary?${callQuery(r)}`),
get: () => api<MeDto>('GET', '/api/me'),
/** 14 §5.1: only for a caller ringing me now. */
callerCheck: (number: string) =>
+90 -61
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per
// day, missed per day, busiest hours, queue table.
// day, missed per day, busiest hours, queue table. `mine` (05 §4): the calls made to me, without
// the company's outside minutes, balance and queues.
// ponytail: bars and the heatmap are plain divs and a table, no chart library.
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import type { ReportRange, ReportSummary } from '@selfphoneng/shared';
import { reportsApi } from '../../api/client.ts';
import { meApi, reportsApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { fmtDuration } from './CallsPage.tsx';
@@ -116,18 +117,23 @@ function Heatmap({ hours }: { hours: number[][] }) {
);
}
export function ReportsPage() {
export function ReportsPage({ mine = false }: { mine?: boolean }) {
const [draft, setDraft] = useState<ReportRange>({});
const [range, setRange] = useState<ReportRange>({});
const summary = useQuery({
queryKey: ['reports', 'summary', range],
queryFn: () => reportsApi.summary(range),
queryKey: [mine ? 'me' : 'reports', 'summary', range],
queryFn: () => (mine ? meApi.report(range) : reportsApi.summary(range)),
});
const queues = useQuery({
queryKey: ['reports', 'queues', range],
queryFn: () => reportsApi.queues(range),
enabled: !mine,
});
const balance = useQuery({
queryKey: ['reports', 'balance'],
queryFn: reportsApi.balance,
enabled: !mine,
});
const balance = useQuery({ queryKey: ['reports', 'balance'], queryFn: reportsApi.balance });
const s = summary.data;
const date = (k: 'from' | 'to', label: string) => (
<Field label={label}>
@@ -145,7 +151,8 @@ export function ReportsPage() {
return (
<section className="flex flex-col gap-6">
<h1 className="text-xl font-semibold">Reports</h1>
<h1 className="text-xl font-semibold">{mine ? 'My reports' : 'Reports'}</h1>
{mine && <p className="text-sm text-muted">Calls that rang you, not the ones you made.</p>}
<form
aria-label="Dates"
className="flex flex-wrap items-end gap-3"
@@ -174,19 +181,33 @@ export function ReportsPage() {
{(
[
['Calls', s.totals.calls, null],
['Answered', s.totals.answered, null],
[
mine ? 'You answered' : 'Answered',
s.totals.answered,
mine ? 'not the ones a coworker picked up' : 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`,
'Talk time',
fmtMinutes(s.totals.talkSeconds),
mine ? null : 'people talking, inside calls too',
],
...(mine
? []
: ([
[
'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`,
],
] as const)),
...(balance.data ?? []).map(
(b) =>
[
@@ -204,12 +225,18 @@ export function ReportsPage() {
</div>
))}
</dl>
<Bars label="Calls per day" days={s.days} segments={SERIES} />
<Bars
label="Outside minutes per day"
label="Calls per day"
days={s.days}
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
segments={mine ? SERIES.filter(([k]) => k !== 'outbound') : SERIES}
/>
{!mine && (
<Bars
label="Outside minutes per day"
days={s.days}
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
/>
)}
<Bars
label="Missed per day"
days={s.days}
@@ -222,53 +249,55 @@ export function ReportsPage() {
</>
)}
<div className="flex flex-col gap-2">
<h2 className="font-medium">Queues</h2>
{queues.error && <Alert>{queues.error.message}</Alert>}
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
{!!queues.data?.length && (
<div className="overflow-x-auto">
<table aria-label="Queue report" className="w-full text-left text-sm">
<thead className="text-muted">
<tr>
{[
'Queue',
'Calls',
'Answered',
'Hung up waiting',
'Timed out',
'Average wait',
'Answered in time',
].map((h) => (
<th key={h} scope="col" className="px-2 py-1 font-normal">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{queues.data.map((q) => (
<tr key={q.id} className="tabular-nums">
<td className="px-2 py-1">
<span className="font-mono">{q.number}</span> {q.name}
</td>
<td className="px-2 py-1">{q.offered}</td>
<td className="px-2 py-1">{q.answered}</td>
<td className="px-2 py-1">{q.abandoned}</td>
<td className="px-2 py-1">{q.timedOut}</td>
<td className="px-2 py-1">
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
</td>
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
</td>
{!mine && (
<div className="flex flex-col gap-2">
<h2 className="font-medium">Queues</h2>
{queues.error && <Alert>{queues.error.message}</Alert>}
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
{!!queues.data?.length && (
<div className="overflow-x-auto">
<table aria-label="Queue report" className="w-full text-left text-sm">
<thead className="text-muted">
<tr>
{[
'Queue',
'Calls',
'Answered',
'Hung up waiting',
'Timed out',
'Average wait',
'Answered in time',
].map((h) => (
<th key={h} scope="col" className="px-2 py-1 font-normal">
{h}
</th>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</thead>
<tbody className="divide-y divide-line">
{queues.data.map((q) => (
<tr key={q.id} className="tabular-nums">
<td className="px-2 py-1">
<span className="font-mono">{q.number}</span> {q.name}
</td>
<td className="px-2 py-1">{q.offered}</td>
<td className="px-2 py-1">{q.answered}</td>
<td className="px-2 py-1">{q.abandoned}</td>
<td className="px-2 py-1">{q.timedOut}</td>
<td className="px-2 py-1">
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
</td>
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</section>
);
}
+11 -1
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Reports, Settings, People, and the
// softphone.
import {
BookUser,
ChartColumn,
House,
Info,
LogOut,
@@ -30,6 +32,7 @@ import { fmtNumber } from '../admin/CallsPage.tsx';
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
import { ReportsPage } from '../admin/ReportsPage.tsx';
const link = 'text-link underline';
@@ -124,6 +127,12 @@ export function MePage({ user }: { user: AuthUser }) {
</span>
)}
</NavLink>
{user.extension && (
<NavLink to="/me/reports" className={tab} onClick={close}>
<ChartColumn className={icon} />
Reports
</NavLink>
)}
<NavLink to="/me/settings" className={tab} onClick={close}>
<Settings className={icon} />
Settings
@@ -180,6 +189,7 @@ export function MePage({ user }: { user: AuthUser }) {
<Route path="calls" element={<MyCallsPage />} />
<Route path="voicemail" element={<VoicemailPage />} />
<Route path="contacts" element={<ContactsPage />} />
<Route path="reports" element={<ReportsPage mine />} />
<Route path="settings" element={<MeSettingsPage />} />
<Route path="people" element={<PresencePanel me={me} />} />
</Routes>
+1
View File
@@ -383,6 +383,7 @@ Auth & self:
| GET | `/api/me/softphone-qr` | user | `{url, qr, expiresAt}`: Linphone setup link and its QR (PNG data URL), 24 h (07 §5) |
| GET | `/api/me/webrtc` | user | `{ wsUrl: "wss://<host>/ws", sipUri: "sip:101-web@<host>", authUser: "101-web", password, displayName, iceServers: [...] }` |
| GET | `/api/me/calls` | user | own call history (via `call_extensions`) |
| GET | `/api/me/reports/summary?from&to` | user | `/api/reports/summary` of the calls that rang or were answered by me; `answered` and talk time are the ones I picked up |
| GET | `/api/me/voicemail` | user | list |
| GET | `/api/me/voicemail/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) |
| PATCH | `/api/me/voicemail/:id` | user | `{folder: 'Old'|'INBOX'}` |
+1
View File
@@ -105,6 +105,7 @@ Implementation notes (M3):
| Calls | `/me/calls` | Own call history, click a number to call back, add to contacts |
| Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete, search (transcript, caller). Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name |
| Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call |
| Reports | `/me/reports` | The admin Reports' summary of the calls made to me (rang or answered me, not the ones I made): calls, answered by me, missed, talk time, calls and missed per day, busiest hours. No outside minutes, balance or queues. Shown only with an extension |
| Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) |
| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5), "Mobile app" (pair a phone, list and remove paired phones, 13 §2) |