Files
CRM_Montzar/apps/api/src/integrations/voip/connections.service.ts
T

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(),
};
}
}