From 2bc7d5cde5cf5399004d268ea481c2d5aa9a6f56 Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Wed, 28 Oct 2026 13:39:33 -0500 Subject: [PATCH] web: my reports, the calls made to me Users get a Reports tab: the admin summary limited to calls that rang or were answered by them. Answered and talk time are the calls they picked up. --- apps/api/src/routes/reports.ts | 52 ++++++-- apps/api/test/reports.test.ts | 76 +++++++++++- apps/web/src/api/client.ts | 2 + apps/web/src/pages/admin/ReportsPage.tsx | 151 ++++++++++++++--------- apps/web/src/pages/user/MePage.tsx | 12 +- docs/spec/04-backend-api.md | 1 + docs/spec/05-web-ui.md | 1 + 7 files changed, 218 insertions(+), 77 deletions(-) diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index e2d4e3e..eea4a71 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -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 => { - 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 { + 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`count(*)::int`, - answered: sql`count(*) filter (where ${calls.status} = 'answered')::int`, + answered: sql`count(*) filter (where ${answered})::int`, missed: sql`count(*) filter (where ${missed})::int`, - talk: sql`coalesce(sum(${calls.talkSeconds}), 0)::int`, + talk: sql`coalesce(sum(${calls.talkSeconds}) filter (where ${toExt ? answered : sql`true`}), 0)::int`, lineIn: sql`round(${lineIn})::int`, lineOut: sql`${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> => { + app.get('/api/reports/balance', admin, async (): Promise> => { 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> => { + app.get('/api/reports/queues', admin, async (req): Promise> => { const { from, to } = await range(db, req.query); const qs = await db.select().from(queues).orderBy(asc(queues.number)); return { diff --git a/apps/api/test/reports.test.ts b/apps/api/test/reports.test.ts index 080fddd..ead98f5 100644 --- a/apps/api/test/reports.test.ts +++ b/apps/api/test/reports.test.ts @@ -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); }); }); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 94c711e..546d57e 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -306,6 +306,8 @@ export const systemApi = { export const meApi = { calls: (cursor?: string) => api>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`), + /** The summary of the calls made to me. */ + report: (r: ReportRange) => api('GET', `/api/me/reports/summary?${callQuery(r)}`), get: () => api('GET', '/api/me'), /** 14 §5.1: only for a caller ringing me now. */ callerCheck: (number: string) => diff --git a/apps/web/src/pages/admin/ReportsPage.tsx b/apps/web/src/pages/admin/ReportsPage.tsx index fc11703..1165207 100644 --- a/apps/web/src/pages/admin/ReportsPage.tsx +++ b/apps/web/src/pages/admin/ReportsPage.tsx @@ -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({}); const [range, setRange] = useState({}); 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) => ( @@ -145,7 +151,8 @@ export function ReportsPage() { return (
-

Reports

+

{mine ? 'My reports' : 'Reports'}

+ {mine &&

Calls that rang you, not the ones you made.

}
[ @@ -204,12 +225,18 @@ export function ReportsPage() { ))} - k !== 'outbound') : SERIES} /> + {!mine && ( + + )} )} -
-

Queues

- {queues.error && {queues.error.message}} - {queues.data && !queues.data.length &&

No queues.

} - {!!queues.data?.length && ( -
- - - - {[ - 'Queue', - 'Calls', - 'Answered', - 'Hung up waiting', - 'Timed out', - 'Average wait', - 'Answered in time', - ].map((h) => ( - - ))} - - - - {queues.data.map((q) => ( - - - - - - - - + {!mine && ( +
+

Queues

+ {queues.error && {queues.error.message}} + {queues.data && !queues.data.length &&

No queues.

} + {!!queues.data?.length && ( +
+
- {h} -
- {q.number} {q.name} - {q.offered}{q.answered}{q.abandoned}{q.timedOut} - {q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`} - - {q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`} -
+ + + {[ + 'Queue', + 'Calls', + 'Answered', + 'Hung up waiting', + 'Timed out', + 'Average wait', + 'Answered in time', + ].map((h) => ( + + ))} - ))} - -
+ {h} +
-
- )} -
+ + + {queues.data.map((q) => ( + + + {q.number} {q.name} + + {q.offered} + {q.answered} + {q.abandoned} + {q.timedOut} + + {q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`} + + + {q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`} + + + ))} + + + + )} + + )}
); } diff --git a/apps/web/src/pages/user/MePage.tsx b/apps/web/src/pages/user/MePage.tsx index 4330a55..4d8cf53 100644 --- a/apps/web/src/pages/user/MePage.tsx +++ b/apps/web/src/pages/user/MePage.tsx @@ -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 }) { )} + {user.extension && ( + + + Reports + + )} Settings @@ -180,6 +189,7 @@ export function MePage({ user }: { user: AuthUser }) { } /> } /> } /> + } /> } /> } /> diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md index 1bcecd8..e5817da 100644 --- a/docs/spec/04-backend-api.md +++ b/docs/spec/04-backend-api.md @@ -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:///ws", sipUri: "sip:101-web@", 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'}` | diff --git a/docs/spec/05-web-ui.md b/docs/spec/05-web-ui.md index 76a0a99..9b05e4b 100644 --- a/docs/spec/05-web-ui.md +++ b/docs/spec/05-web-ui.md @@ -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) |