180 lines
5.3 KiB
TypeScript
180 lines
5.3 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import type {
|
|
CreateVoipConnectionRequest,
|
|
UpdateVoipConnectionRequest,
|
|
VoipConnectionDto,
|
|
} from '@mento/shared';
|
|
import { Prisma } from '@prisma/client';
|
|
import { createHmac } from 'crypto';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
|
|
|
@Injectable()
|
|
export class VoipConnectionsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly crypto: TahesabCryptoService,
|
|
) {}
|
|
|
|
async list(organizationId: string): Promise<VoipConnectionDto[]> {
|
|
const rows = await this.prisma.voipConnection.findMany({
|
|
where: { organizationId },
|
|
include: { _count: { select: { calls: true } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
return rows.map((r) => this.toDto(r, r._count.calls));
|
|
}
|
|
|
|
async getOrThrow(organizationId: string, id: string) {
|
|
const row = await this.prisma.voipConnection.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
if (!row) throw new NotFoundException('اتصال تلفن پیدا نشد');
|
|
return row;
|
|
}
|
|
|
|
async create(
|
|
organizationId: string,
|
|
dto: CreateVoipConnectionRequest,
|
|
): Promise<VoipConnectionDto> {
|
|
const label = dto.label?.trim();
|
|
if (!label) throw new BadRequestException('نام اتصال الزامی است');
|
|
const secret = dto.webhookSecret?.trim();
|
|
const secretEnc = secret ? this.crypto.encrypt(secret) : null;
|
|
const clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
|
|
|
|
const row = await this.prisma.voipConnection.create({
|
|
data: {
|
|
organizationId,
|
|
label,
|
|
enabled: dto.enabled ?? true,
|
|
clickToCallUrl,
|
|
webhookSecretCiphertext: secretEnc?.ciphertext,
|
|
webhookSecretIv: secretEnc?.iv,
|
|
webhookSecretTag: secretEnc?.tag,
|
|
},
|
|
});
|
|
return this.toDto(row, 0);
|
|
}
|
|
|
|
async update(
|
|
organizationId: string,
|
|
id: string,
|
|
dto: UpdateVoipConnectionRequest,
|
|
): Promise<VoipConnectionDto> {
|
|
const existing = await this.getOrThrow(organizationId, id);
|
|
const data: Prisma.VoipConnectionUpdateInput = {};
|
|
if (dto.label !== undefined) {
|
|
const label = dto.label.trim();
|
|
if (!label) throw new BadRequestException('نام اتصال الزامی است');
|
|
data.label = label;
|
|
}
|
|
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
|
if (dto.clickToCallUrl !== undefined) {
|
|
data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
|
|
}
|
|
if (dto.webhookSecret !== undefined) {
|
|
const wh = dto.webhookSecret?.trim();
|
|
if (!wh) {
|
|
data.webhookSecretCiphertext = null;
|
|
data.webhookSecretIv = null;
|
|
data.webhookSecretTag = null;
|
|
} else {
|
|
const enc = this.crypto.encrypt(wh);
|
|
data.webhookSecretCiphertext = enc.ciphertext;
|
|
data.webhookSecretIv = enc.iv;
|
|
data.webhookSecretTag = enc.tag;
|
|
}
|
|
}
|
|
const row = await this.prisma.voipConnection.update({
|
|
where: { id: existing.id },
|
|
data,
|
|
});
|
|
const count = await this.prisma.voipCallLog.count({
|
|
where: { connectionId: id },
|
|
});
|
|
return this.toDto(row, count);
|
|
}
|
|
|
|
async remove(organizationId: string, id: string): Promise<void> {
|
|
await this.getOrThrow(organizationId, id);
|
|
await this.prisma.voipConnection.delete({ where: { id } });
|
|
}
|
|
|
|
async decryptWebhookSecret(row: {
|
|
webhookSecretCiphertext: string | null;
|
|
webhookSecretIv: string | null;
|
|
webhookSecretTag: string | null;
|
|
}): Promise<string | null> {
|
|
if (
|
|
!row.webhookSecretCiphertext ||
|
|
!row.webhookSecretIv ||
|
|
!row.webhookSecretTag
|
|
) {
|
|
return null;
|
|
}
|
|
return this.crypto.decrypt({
|
|
ciphertext: row.webhookSecretCiphertext,
|
|
iv: row.webhookSecretIv,
|
|
tag: row.webhookSecretTag,
|
|
});
|
|
}
|
|
|
|
verifySignature(
|
|
rawBody: string | Buffer,
|
|
signature: string | undefined,
|
|
secret: string,
|
|
): boolean {
|
|
if (!signature?.trim() || !secret.trim()) return false;
|
|
const digest = createHmac('sha256', secret)
|
|
.update(typeof rawBody === 'string' ? rawBody : rawBody)
|
|
.digest('hex');
|
|
const sig = signature.trim().replace(/^sha256=/i, '').toLowerCase();
|
|
return digest === sig;
|
|
}
|
|
|
|
private normalizeClickUrl(value?: string | null): string | null {
|
|
if (value == null) return null;
|
|
const url = value.trim();
|
|
if (!url) return null;
|
|
if (!/^https?:\/\//i.test(url)) {
|
|
throw new BadRequestException(
|
|
'آدرس Click-to-Call باید با http یا https شروع شود',
|
|
);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
private toDto(
|
|
row: {
|
|
id: string;
|
|
label: string;
|
|
enabled: boolean;
|
|
webhookSecretCiphertext: string | null;
|
|
clickToCallUrl: string | null;
|
|
lastEventAt: Date | null;
|
|
lastError: string | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
},
|
|
callCount: number,
|
|
): VoipConnectionDto {
|
|
return {
|
|
id: row.id,
|
|
label: row.label,
|
|
enabled: row.enabled,
|
|
webhookSecretSet: Boolean(row.webhookSecretCiphertext),
|
|
clickToCallUrl: row.clickToCallUrl,
|
|
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
|
lastError: row.lastError,
|
|
callCount,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
}
|