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,61 @@
-- CreateTable
CREATE TABLE "VoipConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"webhookSecretCiphertext" TEXT,
"webhookSecretIv" TEXT,
"webhookSecretTag" TEXT,
"clickToCallUrl" TEXT,
"lastEventAt" TIMESTAMP(3),
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VoipConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VoipCallLog" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalCallId" TEXT NOT NULL,
"direction" TEXT NOT NULL DEFAULT 'inbound',
"status" TEXT NOT NULL DEFAULT 'completed',
"fromNumber" TEXT,
"toNumber" TEXT,
"personId" TEXT,
"activityId" TEXT,
"durationSec" INTEGER,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endedAt" TIMESTAMP(3),
"rawPayload" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VoipCallLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "VoipConnection_organizationId_enabled_idx" ON "VoipConnection"("organizationId", "enabled");
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_syncedAt_idx" ON "VoipCallLog"("organizationId", "syncedAt");
-- CreateIndex
CREATE INDEX "VoipCallLog_personId_idx" ON "VoipCallLog"("personId");
-- CreateIndex
CREATE INDEX "VoipCallLog_activityId_idx" ON "VoipCallLog"("activityId");
-- CreateIndex
CREATE UNIQUE INDEX "VoipCallLog_connectionId_externalCallId_key" ON "VoipCallLog"("connectionId", "externalCallId");
-- AddForeignKey
ALTER TABLE "VoipConnection" ADD CONSTRAINT "VoipConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoipCallLog" ADD CONSTRAINT "VoipCallLog_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "VoipConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+50
View File
@@ -199,6 +199,7 @@ model Organization {
webhookEndpoints WebhookEndpoint[]
smsCampaigns SmsCampaign[]
wooCommerceConnections WooCommerceConnection[]
voipConnections VoipConnection[]
}
model User {
@@ -1780,3 +1781,52 @@ model WooCommerceOrderLink {
@@index([organizationId, syncedAt])
@@index([invoiceId])
}
/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی)
model VoipConnection {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
label String
enabled Boolean @default(true)
webhookSecretCiphertext String?
webhookSecretIv String?
webhookSecretTag String?
/// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند
clickToCallUrl String?
lastEventAt DateTime?
lastError String?
calls VoipCallLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, enabled])
}
model VoipCallLog {
id String @id @default(cuid())
organizationId String
connectionId String
connection VoipConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
externalCallId String
/// inbound | outbound
direction String @default("inbound")
/// ringing | answered | missed | completed | failed
status String @default("completed")
fromNumber String?
toNumber String?
personId String?
activityId String?
durationSec Int?
startedAt DateTime @default(now())
endedAt DateTime?
rawPayload Json?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([connectionId, externalCallId])
@@index([organizationId, syncedAt])
@@index([personId])
@@index([activityId])
}
+2
View File
@@ -40,6 +40,7 @@ import { PublicApiModule } from './public-api/public-api.module';
import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
import { TeamsModule } from './teams/teams.module';
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
import { VoipModule } from './integrations/voip/voip.module';
@Module({
imports: [
@@ -87,6 +88,7 @@ import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module
SmsCampaignsModule,
TeamsModule,
WooCommerceModule,
VoipModule,
],
})
export class AppModule {}
@@ -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),
});
+3 -3
View File
@@ -221,9 +221,9 @@ export class WebhooksService {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Mento-Event': row.event,
'X-Mento-Signature': `sha256=${signature}`,
'User-Agent': 'MentoCRM-Webhook/1.0',
'X-Montact-Event': row.event,
'X-Montact-Signature': `sha256=${signature}`,
'User-Agent': 'MontactCRM-Webhook/1.0',
},
body,
signal: AbortSignal.timeout(15000),