calls: call history API and pages, CSV export, missed calls

/api/calls (admin): filters by company-time-zone days, direction,
status, user, phone number and number search; newest first with a
keyset cursor; /:id adds the raw CDR legs; /export.csv with the same
filters (RFC 4180, spreadsheet-formula cells quoted). /api/me/calls
lists my calls with my side (in/out) and whether I missed it.

Web: admin Call history (filters, Load more, a row expands to its legs,
Export CSV), My Phone → Calls (click a number to call back: softphone
or desk phone first), and Missed calls on Home.

e2e m7-t2 with real calls found that a call going straight to voicemail
(DND) wasn't in the user's history: the mailbox owner of a VoiceMail
leg now counts as rang (and the IVR fixture's expectation follows).

Task: M7-T2
This commit is contained in:
iamdoubz committed 2026-10-18 14:46:32 -05:00
1 parent 71440ac85d
commit 8abcea9533
16 files changed
+1008 -3

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Call history (02 calls, call_extensions; 04 §5.4, §7.3).
import { z } from 'zod';
export const CALL_DIRECTIONS = ['inbound', 'outbound', 'internal'] as const;
export const CALL_STATUSES = [
'answered',
'missed',
'voicemail',
'abandoned',
'busy',
'failed',
] as const;
export type CallDirection = (typeof CALL_DIRECTIONS)[number];
export type CallStatus = (typeof CALL_STATUSES)[number];
/** GET /api/calls and /api/calls/export.csv. Dates are YYYY-MM-DD in the company time zone. */
export const CallFilterSchema = z.object({
from: z.iso.date().optional(),
to: z.iso.date().optional(),
direction: z.enum(CALL_DIRECTIONS).optional(),
status: z.enum(CALL_STATUSES).optional(),
extensionId: z.uuid().optional(),
didId: z.uuid().optional(),
/** Part of a number (digits only are compared). */
q: z.string().trim().max(40).optional(),
cursor: z.string().max(100).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type CallFilter = z.input<typeof CallFilterSchema>;
export type CallDto = {
id: string;
direction: CallDirection;
status: CallStatus;
startedAt: string;
answeredAt: string | null;
endedAt: string;
fromNumber: string | null;
fromName: string | null;
toNumber: string | null;
did: { id: string; number: string; label: string | null } | null;
answeredBy: { id: string; number: string; name: string } | null;
talkSeconds: number;
recordingId: string | null;
};
/** One raw CDR leg, for the expanded row. */
export type CallLegDto = {
startAt: string | null;
channel: string | null;
dstchannel: string | null;
lastapp: string | null;
disposition: string | null;
billsec: number | null;
};
export type CallDetailDto = CallDto & { legs: CallLegDto[]; linkedid: string };
/** /api/me/calls: my part in it. */
export type MyCallDto = CallDto & {
/** in = someone called me (or I was rung), out = I called. */
myRole: 'in' | 'out';
/** Someone called me and nobody answered (a colleague answering in a group isn't missed). */
missedByMe: boolean;
};
+1
View File
@@ -18,3 +18,4 @@ export * from './timeconditions.ts';
export * from './announcements.ts';
export * from './me.ts';
export * from './blocked.ts';
export * from './calls.ts';