feat: optimize UI queries and seed number library
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { RuntimeConfig } from '../../shared/config.js';
|
||||
import { OpenSipsMiClient } from './opensips-mi.client.js';
|
||||
|
||||
function client() {
|
||||
const config = {
|
||||
get: () => ({
|
||||
mode: 'http',
|
||||
httpUrl: 'http://127.0.0.1:8888/mi',
|
||||
sshHost: 'unused',
|
||||
remoteCommand: 'unused',
|
||||
timeoutMs: 3000
|
||||
})
|
||||
} as unknown as ConfigService<RuntimeConfig, true>;
|
||||
return new OpenSipsMiClient(config);
|
||||
}
|
||||
|
||||
describe('OpenSipsMiClient HTTP MI', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('lists dialogs through the local JSON-RPC endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { Dialogs: [] } }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(client().listDialogs()).resolves.toEqual({ Dialogs: [] });
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:8888/mi', expect.objectContaining({ method: 'POST' }));
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ jsonrpc: '2.0', id: 1, method: 'dlg_list', params: [] });
|
||||
});
|
||||
|
||||
it('preserves OpenSIPS MI errors for the API layer', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: 500, message: 'Operation failed' } }), { status: 200 })));
|
||||
await expect(client().endDialog('dlg-1')).rejects.toMatchObject({ response: expect.objectContaining({ code: 'ACTIVE_CALLS_MI_ERROR' }) });
|
||||
});
|
||||
});
|
||||
@@ -63,17 +63,13 @@ export class OpenSipsMiClient {
|
||||
|
||||
private async call(method: string, params: string[] = []): Promise<unknown> {
|
||||
const config = this.configService.get('activeCalls', { infer: true });
|
||||
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
|
||||
const args = [];
|
||||
if (config.sshConfig) {
|
||||
args.push('-F', config.sshConfig);
|
||||
}
|
||||
args.push(config.sshHost, remoteCommand);
|
||||
|
||||
let output: string;
|
||||
let response: MiResponse;
|
||||
try {
|
||||
output = await this.executor.run(args, config.timeoutMs);
|
||||
response = config.mode === 'http'
|
||||
? await this.callHttp(config.httpUrl, method, params, config.timeoutMs)
|
||||
: await this.callSsh(config, method, params);
|
||||
} catch (error) {
|
||||
if (error instanceof BadGatewayException) throw error;
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_UNAVAILABLE',
|
||||
message: 'OpenSIPS control plane is unavailable.',
|
||||
@@ -81,16 +77,6 @@ export class OpenSipsMiClient {
|
||||
});
|
||||
}
|
||||
|
||||
let response: MiResponse;
|
||||
try {
|
||||
response = JSON.parse(output) as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_ERROR',
|
||||
@@ -101,6 +87,42 @@ export class OpenSipsMiClient {
|
||||
|
||||
return response.result;
|
||||
}
|
||||
|
||||
private async callHttp(url: string, method: string, params: string[], timeoutMs: number): Promise<MiResponse> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenSIPS MI HTTP returned ${response.status}`);
|
||||
}
|
||||
try {
|
||||
return await response.json() as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async callSsh(config: RuntimeConfig['activeCalls'], method: string, params: string[]): Promise<MiResponse> {
|
||||
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
|
||||
const args: string[] = [];
|
||||
if (config.sshConfig) args.push('-F', config.sshConfig);
|
||||
args.push(config.sshHost, remoteCommand);
|
||||
const output = await this.executor.run(args, config.timeoutMs);
|
||||
try {
|
||||
return JSON.parse(output) as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export interface AuditLogQuery {
|
||||
@@ -8,6 +9,8 @@ export interface AuditLogQuery {
|
||||
objectType?: string;
|
||||
objectId?: string;
|
||||
result?: 'SUCCESS' | 'FAILURE';
|
||||
createdFrom?: Date;
|
||||
createdTo?: Date;
|
||||
take: number;
|
||||
skip: number;
|
||||
}
|
||||
@@ -46,13 +49,14 @@ export class PrismaAuditLogsRepository implements AuditLogsRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> {
|
||||
const where = {
|
||||
const where: Prisma.AuditLogWhereInput = {
|
||||
module: query.module,
|
||||
action: query.action,
|
||||
userId: query.userId,
|
||||
objectType: query.objectType,
|
||||
objectId: query.objectId,
|
||||
result: query.result
|
||||
result: query.result,
|
||||
createdAt: query.createdFrom || query.createdTo ? { gte: query.createdFrom, lte: query.createdTo } : undefined
|
||||
};
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
|
||||
@@ -13,6 +13,8 @@ export class AuditLogsService {
|
||||
objectType: this.optionalString(rawQuery.objectType),
|
||||
objectId: this.optionalString(rawQuery.objectId),
|
||||
result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result),
|
||||
createdFrom: this.optionalDate(rawQuery.createdFrom, 'createdFrom'),
|
||||
createdTo: this.optionalDate(rawQuery.createdTo, 'createdTo'),
|
||||
take: this.positiveInt(rawQuery.take, 50, 100),
|
||||
skip: this.positiveInt(rawQuery.skip, 0, 10_000)
|
||||
};
|
||||
@@ -56,4 +58,16 @@ export class AuditLogsService {
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private optionalDate(value: unknown, field: string): Date | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,16 @@ export interface SaveReviewInput {
|
||||
|
||||
export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY');
|
||||
|
||||
export interface RecordingListQuery {
|
||||
status?: string;
|
||||
reviewStatus?: 'PENDING' | 'REVIEWED';
|
||||
limit?: number;
|
||||
startedFrom?: Date;
|
||||
startedTo?: Date;
|
||||
}
|
||||
|
||||
export interface RecordingsRepository {
|
||||
list(query?: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number }): Promise<RecordingListItem[]>;
|
||||
list(query?: RecordingListQuery): Promise<RecordingListItem[]>;
|
||||
getDetail(id: string): Promise<RecordingDetail>;
|
||||
getReadyForPlayback(id: string): Promise<RecordingPlayback>;
|
||||
saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>;
|
||||
@@ -99,12 +107,17 @@ type ReviewRecord = Prisma.QualityReviewGetPayload<{
|
||||
export class PrismaRecordingsRepository implements RecordingsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number } = {}): Promise<RecordingListItem[]> {
|
||||
async list(query: RecordingListQuery = {}): Promise<RecordingListItem[]> {
|
||||
const take = Math.min(query.limit ?? 100, 500);
|
||||
const startedAt = query.startedFrom || query.startedTo ? { gte: query.startedFrom, lte: query.startedTo } : undefined;
|
||||
const recordings = await this.prisma.recording.findMany({
|
||||
where: {
|
||||
status: query.status ? (query.status as never) : 'READY',
|
||||
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined
|
||||
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined,
|
||||
OR: startedAt ? [
|
||||
{ rawCdr: { is: { startedAt } } },
|
||||
{ rawCdrId: null, createdAt: startedAt }
|
||||
] : undefined
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
take,
|
||||
|
||||
@@ -26,11 +26,13 @@ export class RecordingsService {
|
||||
@Inject(QualityService) private readonly qualityService: QualityService
|
||||
) {}
|
||||
|
||||
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown } = {}) {
|
||||
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown; startedFrom?: unknown; startedTo?: unknown } = {}) {
|
||||
const recordings = await this.recordings.list({
|
||||
status: query.status === undefined ? undefined : this.recordingStatus(query.status),
|
||||
reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus),
|
||||
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500)
|
||||
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500),
|
||||
startedFrom: query.startedFrom === undefined ? undefined : this.date(query.startedFrom, 'startedFrom'),
|
||||
startedTo: query.startedTo === undefined ? undefined : this.date(query.startedTo, 'startedTo')
|
||||
});
|
||||
return Promise.all(recordings.map((recording) => this.withSampling(recording)));
|
||||
}
|
||||
@@ -123,6 +125,17 @@ export class RecordingsService {
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private date(value: unknown, field: string): Date {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
export function internalRecordingPath(storageKey: string): string {
|
||||
|
||||
Reference in New Issue
Block a user