Add VoIP webhook connector for call logging, notifications, and optional click-to-call; align brand to Montact.

This commit is contained in:
2026-09-27 08:47:11 +03:30
parent 26085b54a9
commit db65b55660
17 changed files with 1385 additions and 14 deletions
@@ -0,0 +1,429 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
VoipCallLogDto,
VoipWebhookEvent,
} from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service';
import { normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service';
function digitsOnly(value: string | null | undefined): string {
return (value ?? '').replace(/\D/g, '');
}
function phoneVariants(raw: string | null | undefined): string[] {
const n = normalizePhone(raw);
const d = digitsOnly(n ?? raw);
if (!d) return [];
const variants = new Set<string>([d]);
if (d.startsWith('98') && d.length >= 12) {
variants.add(`0${d.slice(2)}`);
variants.add(d.slice(2));
}
if (d.startsWith('0') && d.length >= 11) {
variants.add(`98${d.slice(1)}`);
variants.add(d.slice(1));
}
if (d.length === 10 && d.startsWith('9')) {
variants.add(`0${d}`);
variants.add(`98${d}`);
}
return [...variants];
}
function normalizeStatus(raw: string | undefined): string {
const s = (raw ?? 'completed').toLowerCase();
if (['ringing', 'answered', 'missed', 'completed', 'failed'].includes(s)) {
return s;
}
if (s === 'hangup' || s === 'ended') return 'completed';
if (s === 'no-answer' || s === 'busy') return 'missed';
return s || 'completed';
}
@Injectable()
export class VoipCallsService {
private readonly logger = new Logger(VoipCallsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly connections: VoipConnectionsService,
private readonly notifications: NotificationsService,
) {}
async listRecent(
organizationId: string,
connectionId?: string,
): Promise<VoipCallLogDto[]> {
const rows = await this.prisma.voipCallLog.findMany({
where: {
organizationId,
...(connectionId ? { connectionId } : {}),
},
orderBy: { syncedAt: 'desc' },
take: 50,
});
const personIds = rows
.map((r) => r.personId)
.filter((id): id is string => Boolean(id));
const people = personIds.length
? await this.prisma.person.findMany({
where: { id: { in: personIds } },
select: { id: true, firstName: true, lastName: true },
})
: [];
const nameById = new Map(
people.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()]),
);
return rows.map((r) => ({
id: r.id,
direction: (r.direction === 'outbound' ? 'outbound' : 'inbound') as
| 'inbound'
| 'outbound',
status: r.status,
fromNumber: r.fromNumber,
toNumber: r.toNumber,
personId: r.personId,
personName: r.personId ? (nameById.get(r.personId) ?? null) : null,
activityId: r.activityId,
durationSec: r.durationSec,
startedAt: r.startedAt.toISOString(),
syncedAt: r.syncedAt.toISOString(),
}));
}
async ingestWebhook(
connectionId: string,
event: VoipWebhookEvent,
): Promise<{ callLogId: string; activityId: string | null; personId: string | null }> {
const conn = await this.prisma.voipConnection.findFirst({
where: { id: connectionId, enabled: true },
});
if (!conn) throw new NotFoundException('اتصال پیدا نشد');
const callId = String(event.callId ?? '').trim();
if (!callId) throw new BadRequestException('callId الزامی است');
const direction =
event.direction === 'outbound' ? 'outbound' : 'inbound';
const status = normalizeStatus(event.status);
const fromNormalized = normalizePhone(event.from ?? event.caller ?? null);
const toNormalized = normalizePhone(event.to ?? event.callee ?? null);
const fromDigits = digitsOnly(event.from ?? event.caller);
const toDigits = digitsOnly(event.to ?? event.callee);
const fromNumber = fromNormalized || fromDigits || null;
const toNumber = toNormalized || toDigits || null;
const matchNumber = direction === 'inbound' ? fromNumber : toNumber;
const personId = await this.findPersonId(
conn.organizationId,
matchNumber,
);
const startedAt = event.startedAt
? new Date(event.startedAt)
: new Date();
const endedAt = event.endedAt ? new Date(event.endedAt) : null;
const durationSec =
event.durationSec != null
? Math.max(0, Math.round(Number(event.durationSec)))
: null;
const existing = await this.prisma.voipCallLog.findUnique({
where: {
connectionId_externalCallId: {
connectionId,
externalCallId: callId,
},
},
});
let activityId = existing?.activityId ?? null;
if (!activityId && this.shouldCreateActivity(status)) {
const createdId = await this.createCallActivity({
organizationId: conn.organizationId,
direction,
status,
personId,
fromNumber,
toNumber,
durationSec,
startedAt,
});
activityId = createdId || null;
}
if (existing) {
await this.prisma.voipCallLog.update({
where: { id: existing.id },
data: {
direction,
status,
fromNumber,
toNumber,
personId: personId ?? existing.personId,
activityId: activityId ?? existing.activityId,
durationSec,
startedAt,
endedAt,
rawPayload: event as unknown as Prisma.InputJsonValue,
syncedAt: new Date(),
},
});
} else {
await this.prisma.voipCallLog.create({
data: {
organizationId: conn.organizationId,
connectionId,
externalCallId: callId,
direction,
status,
fromNumber,
toNumber,
personId,
activityId,
durationSec,
startedAt,
endedAt,
rawPayload: event as unknown as Prisma.InputJsonValue,
},
});
}
await this.prisma.voipConnection.update({
where: { id: connectionId },
data: { lastEventAt: new Date(), lastError: null },
});
if (
direction === 'inbound' &&
(status === 'ringing' || status === 'answered') &&
personId
) {
await this.notifyIncoming(conn.organizationId, personId, fromNumber);
}
const link = await this.prisma.voipCallLog.findUnique({
where: {
connectionId_externalCallId: {
connectionId,
externalCallId: callId,
},
},
});
return {
callLogId: link!.id,
activityId: link!.activityId,
personId: link!.personId,
};
}
async clickToCall(
organizationId: string,
userId: string,
number: string,
personId?: string,
): Promise<{ ok: true; mode: 'url' | 'tel'; dialUrl: string }> {
const phone = normalizePhone(number) ?? digitsOnly(number);
if (!phone) throw new BadRequestException('شماره معتبر نیست');
const conn = await this.prisma.voipConnection.findFirst({
where: {
organizationId,
enabled: true,
clickToCallUrl: { not: null },
},
orderBy: { createdAt: 'asc' },
});
if (conn?.clickToCallUrl) {
const dialUrl = conn.clickToCallUrl.replace(
/\{number\}/gi,
encodeURIComponent(phone),
);
try {
await fetch(dialUrl, {
method: 'GET',
signal: AbortSignal.timeout(10000),
headers: { 'User-Agent': 'MontactCRM-VoIP/1.0' },
});
} catch (e) {
this.logger.warn(
`click-to-call failed: ${e instanceof Error ? e.message : e}`,
);
await this.prisma.voipConnection.update({
where: { id: conn.id },
data: {
lastError: e instanceof Error ? e.message.slice(0, 400) : 'خطا',
},
});
throw new BadRequestException('شروع تماس از سانترال ناموفق بود');
}
await this.prisma.voipCallLog.create({
data: {
organizationId,
connectionId: conn.id,
externalCallId: `ctc-${Date.now()}-${userId.slice(0, 6)}`,
direction: 'outbound',
status: 'ringing',
fromNumber: null,
toNumber: phone,
personId: personId ?? null,
startedAt: new Date(),
},
});
return { ok: true, mode: 'url', dialUrl };
}
return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` };
}
private shouldCreateActivity(status: string): boolean {
return ['answered', 'completed', 'missed', 'failed'].includes(status);
}
private async createCallActivity(input: {
organizationId: string;
direction: string;
status: string;
personId: string | null;
fromNumber: string | null;
toNumber: string | null;
durationSec: number | null;
startedAt: Date;
}): Promise<string> {
const dirLabel = input.direction === 'outbound' ? 'خروجی' : 'ورودی';
const peer =
input.direction === 'outbound' ? input.toNumber : input.fromNumber;
const statusLabel =
input.status === 'missed'
? 'از دست‌رفته'
: input.status === 'failed'
? 'ناموفق'
: 'ثبت‌شده';
const title = `تماس ${dirLabel}${peer ? ` — ${peer}` : ''}`;
const durationMinutes =
input.durationSec != null
? Math.max(1, Math.round(input.durationSec / 60))
: null;
// createdBy: first active owner/admin of org
const actor = await this.prisma.membership.findFirst({
where: {
organizationId: input.organizationId,
isActive: true,
role: { in: ['owner', 'admin'] },
},
select: { userId: true },
orderBy: { createdAt: 'asc' },
});
const createdById = actor?.userId;
if (!createdById) {
this.logger.warn('No owner/admin to attach VoIP activity');
return '';
}
let assigneeId = createdById;
if (input.personId) {
const person = await this.prisma.person.findFirst({
where: { id: input.personId, organizationId: input.organizationId },
select: { ownerId: true },
});
if (person?.ownerId) assigneeId = person.ownerId;
}
const row = await this.prisma.activity.create({
data: {
organizationId: input.organizationId,
title,
type: 'call',
status: input.status === 'missed' ? 'open' : 'done',
priority: 'medium',
description: [
`جهت: ${dirLabel}`,
`وضعیت: ${statusLabel}`,
peer ? `شماره: ${peer}` : null,
input.durationSec != null
? `مدت: ${input.durationSec} ثانیه`
: null,
'منبع: تلفن سازمانی (VoIP)',
]
.filter(Boolean)
.join('\n'),
dueAt: input.startedAt,
durationMinutes,
personId: input.personId,
assigneeId,
createdById,
tags: ['voip'],
},
});
return row.id;
}
private async findPersonId(
organizationId: string,
phone: string | null,
): Promise<string | null> {
const variants = phoneVariants(phone);
if (!variants.length) return null;
const person = await this.prisma.person.findFirst({
where: {
organizationId,
OR: [
{ mobile: { in: variants } },
{ phone: { in: variants } },
],
},
select: { id: true },
});
return person?.id ?? null;
}
private async notifyIncoming(
organizationId: string,
personId: string,
fromNumber: string | null,
) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { firstName: true, lastName: true, ownerId: true },
});
if (!person) return;
const name = `${person.firstName} ${person.lastName}`.trim();
const userIds = new Set<string>();
if (person.ownerId) userIds.add(person.ownerId);
const admins = await this.prisma.membership.findMany({
where: {
organizationId,
isActive: true,
role: { in: ['owner', 'admin'] },
},
select: { userId: true },
take: 10,
});
for (const a of admins) userIds.add(a.userId);
for (const userId of userIds) {
await this.notifications.create({
organizationId,
userId,
type: 'voip_incoming',
title: `تماس ورودی: ${name}`,
body: fromNumber ? `از شماره ${fromNumber}` : 'تماس ورودی',
link: `/contacts/people/${personId}`,
entityType: 'person',
entityId: personId,
dedupe: true,
});
}
}
}
@@ -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(),
};
}
}
+61
View File
@@ -0,0 +1,61 @@
import {
IsBoolean,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateVoipConnectionDto {
@IsString()
@MinLength(2)
@MaxLength(80)
label!: string;
@IsOptional()
@IsString()
@MaxLength(200)
webhookSecret?: string;
@IsOptional()
@IsString()
@MaxLength(500)
clickToCallUrl?: string | null;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class UpdateVoipConnectionDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(80)
label?: string;
@IsOptional()
@IsString()
@MaxLength(200)
webhookSecret?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
clickToCallUrl?: string | null;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class VoipClickToCallDto {
@IsString()
@MinLength(3)
@MaxLength(32)
number!: string;
@IsOptional()
@IsString()
personId?: string;
}
@@ -0,0 +1,139 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
Param,
Patch,
Post,
Query,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { CurrentUser } from '../../auth/current-user.decorator';
import { Roles } from '../../auth/roles.decorator';
import { RolesGuard } from '../../auth/roles.guard';
import { OrgModuleGuard } from '../../billing/org-module.guard';
import { RequireOrgModule } from '../../billing/org-module.decorator';
import { PrismaService } from '../../prisma/prisma.service';
import type { VoipWebhookEvent } from '@mento/shared';
import { VoipConnectionsService } from './connections.service';
import { VoipCallsService } from './calls.service';
import {
CreateVoipConnectionDto,
UpdateVoipConnectionDto,
VoipClickToCallDto,
} from './dto';
@Controller('integrations/voip')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('voip')
export class VoipController {
constructor(
private readonly connections: VoipConnectionsService,
private readonly calls: VoipCallsService,
) {}
@Get('connections')
@Roles('owner', 'admin')
list(@CurrentUser() user: { organizationId: string }) {
return this.connections.list(user.organizationId);
}
@Post('connections')
@Roles('owner', 'admin')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateVoipConnectionDto,
) {
return this.connections.create(user.organizationId, dto);
}
@Patch('connections/:id')
@Roles('owner', 'admin')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateVoipConnectionDto,
) {
return this.connections.update(user.organizationId, id, dto);
}
@Delete('connections/:id')
@Roles('owner', 'admin')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.connections.remove(user.organizationId, id);
return { ok: true };
}
@Get('calls')
@Roles('owner', 'admin')
recent(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
) {
return this.calls.listRecent(user.organizationId, connectionId);
}
@Post('click-to-call')
clickToCall(
@CurrentUser() user: { organizationId: string; userId: string },
@Body() dto: VoipClickToCallDto,
) {
return this.calls.clickToCall(
user.organizationId,
user.userId,
dto.number,
dto.personId,
);
}
}
@Controller('public/integrations/voip')
export class VoipWebhookController {
constructor(
private readonly prisma: PrismaService,
private readonly connections: VoipConnectionsService,
private readonly calls: VoipCallsService,
) {}
@Post(':connectionId/webhook')
async webhook(
@Param('connectionId') connectionId: string,
@Headers('x-voip-signature') signature: string | undefined,
@Headers('x-montact-signature') montactSignature: string | undefined,
@Req() req: Request & { rawBody?: Buffer },
@Body() body: VoipWebhookEvent,
) {
const row = await this.prisma.voipConnection.findFirst({
where: { id: connectionId, enabled: true },
});
if (!row) throw new UnauthorizedException('اتصال نامعتبر است');
const secret = await this.connections.decryptWebhookSecret(row);
if (secret) {
const raw =
req.rawBody ?? Buffer.from(JSON.stringify(body ?? {}), 'utf8');
const ok = this.connections.verifySignature(
raw,
signature ?? montactSignature,
secret,
);
if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است');
}
if (!body?.callId) {
return { ok: false, error: 'callId required' };
}
const result = await this.calls.ingestWebhook(connectionId, body);
return { ok: true, ...result };
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { BillingModule } from '../../billing/billing.module';
import { NotificationsModule } from '../../notifications/notifications.module';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { VoipConnectionsService } from './connections.service';
import { VoipCallsService } from './calls.service';
import { VoipController, VoipWebhookController } from './voip.controller';
@Module({
imports: [AuthModule, BillingModule, NotificationsModule],
controllers: [VoipController, VoipWebhookController],
providers: [
TahesabCryptoService,
VoipConnectionsService,
VoipCallsService,
],
exports: [VoipConnectionsService, VoipCallsService],
})
export class VoipModule {}
@@ -99,7 +99,7 @@ export class WooCommerceClient {
Authorization: this.authHeader,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'MentoCRM-WooCommerce/1.0',
'User-Agent': 'MontactCRM-WooCommerce/1.0',
},
signal: AbortSignal.timeout(30000),
});