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:
1 parent
71440ac85d
commit
8abcea9533
16 files changed
+1008
-3
No files matched your search
@@ -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;
|
||||
};
|
||||
@@ -18,3 +18,4 @@ export * from './timeconditions.ts';
|
||||
export * from './announcements.ts';
|
||||
export * from './me.ts';
|
||||
export * from './blocked.ts';
|
||||
export * from './calls.ts';
|
||||
Reference in new issue
Block a user