Add VoIP webhook connector for call logging, notifications, and optional click-to-call; align brand to Montact.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user