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