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),
+1
View File
@@ -69,6 +69,7 @@ const settingsActive = computed(() =>
'/settings/currencies',
'/settings/tahesab',
'/settings/woocommerce',
'/settings/voip',
'/settings/faq',
]),
);
+11 -1
View File
@@ -34,7 +34,8 @@ export type NavIconName =
| 'lightbulb'
| 'plus'
| 'user-plus'
| 'mail';
| 'mail'
| 'phone';
export type NavHubTint = 'mint' | 'violet' | 'peach' | 'sky' | 'rose' | 'amber';
@@ -410,6 +411,15 @@ export const NAV_HUBS: NavHubDef[] = [
module: 'woocommerce',
admin: true,
},
{
to: '/settings/voip',
title: 'تلفن سازمانی',
desc: 'ثبت تماس سانترال و اعلان ورودی',
icon: 'phone',
tint: 'sky',
module: 'voip',
admin: true,
},
{
to: '/settings/faq',
title: 'FAQ',
+6
View File
@@ -312,6 +312,12 @@ export const router = createRouter({
component: () => import('@/views/WooCommerceSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/voip',
name: 'voip-settings',
component: () => import('@/views/VoipSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/faq',
name: 'faq-settings',
+15 -2
View File
@@ -447,9 +447,22 @@ function closeMenus() {
filterOpen.value = false;
}
function goCall() {
async function goCall() {
closeMenus();
if (telHref.value) window.location.href = telHref.value;
const number = phoneNumber.value;
if (!number) return;
try {
const { data } = await api.post<{ ok: boolean; mode: string; dialUrl: string }>(
'/integrations/voip/click-to-call',
{ number, personId: id.value },
);
if (data.mode === 'tel' && data.dialUrl) {
window.location.href = data.dialUrl;
}
// mode url: سانترال تماس را شروع کرده؛ نیازی به tel نیست
} catch {
if (telHref.value) window.location.href = telHref.value;
}
}
function goMail() {
+323
View File
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateVoipConnectionRequest,
VoipCallLogDto,
VoipConnectionDto,
} from '@mento/shared';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const items = ref<VoipConnectionDto[]>([]);
const calls = ref<VoipCallLogDto[]>([]);
const form = reactive({
label: 'سانترال اصلی',
webhookSecret: '',
clickToCallUrl: '',
enabled: true,
});
const webhookUrl = computed(() => {
if (!items.value[0]) return '';
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `${origin}/api/public/integrations/voip/${items.value[0].id}/webhook`;
});
const statusLabel: Record<string, string> = {
ringing: 'زنگ',
answered: 'پاسخ',
missed: 'از دست‌رفته',
completed: 'تمام‌شده',
failed: 'ناموفق',
};
async function load() {
loading.value = true;
error.value = '';
try {
const [connRes, callRes] = await Promise.all([
api.get<VoipConnectionDto[]>('/integrations/voip/connections'),
api.get<VoipCallLogDto[]>('/integrations/voip/calls'),
]);
items.value = connRes.data;
calls.value = callRes.data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول تلفن سازمانی برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function create() {
if (!form.label.trim()) {
error.value = 'نام اتصال را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const payload: CreateVoipConnectionRequest = {
label: form.label.trim(),
webhookSecret: form.webhookSecret.trim() || undefined,
clickToCallUrl: form.clickToCallUrl.trim() || null,
enabled: form.enabled,
};
await api.post('/integrations/voip/connections', payload);
form.webhookSecret = '';
ok.value = 'اتصال ذخیره شد.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.')
: 'ذخیره ناموفق بود.';
} finally {
saving.value = false;
}
}
async function remove(id: string) {
if (!confirm('این اتصال حذف شود؟')) return;
try {
await api.delete(`/integrations/voip/connections/${id}`);
await load();
} catch {
error.value = 'حذف ناموفق بود.';
}
}
async function copyWebhook() {
if (!webhookUrl.value) return;
try {
await navigator.clipboard.writeText(webhookUrl.value);
ok.value = 'آدرس وب‌هوک کپی شد.';
} catch {
error.value = 'کپی ممکن نشد.';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<UiPageHeader
title="تلفن سازمانی"
description="ثبت خودکار تماس‌های سانترال روی پرونده مشتری و اعلان تماس ورودی"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="اتصال جدید">
<p class="hint">
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وب‌هوک بفرستد.
فیلدهای اصلی:
<code>callId</code>،
<code>direction</code> (inbound/outbound)،
<code>status</code>،
<code>from</code>،
<code>to</code>.
امضا اختیاری با هدر
<code>X-Voip-Signature</code>
(HMAC-SHA256 هگز).
</p>
<div class="form-grid">
<UiField label="نام">
<UiInput v-model="form.label" placeholder="سانترال اصلی" />
</UiField>
<UiField label="رمز وب‌هوک (اختیاری)">
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" />
</UiField>
<UiField label="آدرس Click-to-Call (اختیاری)">
<UiInput
v-model="form.clickToCallUrl"
placeholder="https://pbx.example/originate?num={number}"
/>
</UiField>
</div>
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton>
</UiCard>
<UiCard title="اتصال‌ها">
<UiEmptyState
v-if="!loading && !items.length"
title="اتصالی نیست"
description="یک اتصال برای دریافت رویداد تماس بسازید."
/>
<div v-else class="list">
<article v-for="c in items" :key="c.id" class="item">
<div>
<strong>{{ c.label }}</strong>
<p class="muted">
تماس‌های ثبت‌شده {{ toFaDigits(c.callCount) }}
·
{{
c.lastEventAt
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
: 'هنوز رویدادی نیامده'
}}
</p>
<p v-if="c.clickToCallUrl" class="muted">Click-to-Call فعال</p>
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
</div>
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
</article>
</div>
<div v-if="webhookUrl" class="webhook">
<p class="hint">آدرس وب‌هوک:</p>
<code>{{ webhookUrl }}</code>
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton>
</div>
</UiCard>
<UiCard title="آخرین تماس‌ها">
<UiEmptyState
v-if="!calls.length"
title="تماسی نیست"
description="پس از ارسال رویداد از سانترال اینجا دیده می‌شوند."
/>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th>جهت</th>
<th>وضعیت</th>
<th>از / به</th>
<th>مشتری</th>
<th>مدت</th>
<th>زمان</th>
</tr>
</thead>
<tbody>
<tr v-for="c in calls" :key="c.id">
<td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td>
<td>{{ statusLabel[c.status] ?? c.status }}</td>
<td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td>
<td>{{ c.personName || '—' }}</td>
<td>
{{
c.durationSec != null
? `${toFaDigits(c.durationSec)} ثانیه`
: '—'
}}
</td>
<td>{{ formatJalaliNumeric(new Date(c.startedAt)) }}</td>
</tr>
</tbody>
</table>
</div>
</UiCard>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.hint,
.muted {
color: #64748b;
font-size: 0.88rem;
line-height: 1.7;
}
.hint code {
background: #f1f5f9;
padding: 0.1rem 0.35rem;
border-radius: 6px;
font-size: 0.8rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 0.75rem 0 1rem;
}
.list {
display: grid;
gap: 0.75rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.webhook {
margin-top: 1rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: var(--radius);
display: grid;
gap: 0.5rem;
}
.webhook code {
word-break: break-all;
font-size: 0.85rem;
}
.warn {
color: #b45309;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: right;
padding: 0.55rem;
border-bottom: 1px solid #eef2f7;
}
th {
color: #64748b;
font-size: 0.8rem;
}
@media (max-width: 800px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+6 -6
View File
@@ -6,18 +6,18 @@
export const brand = {
id: 'montact',
name: 'Montact',
nameFa: 'منتکت',
nameFa: 'مُنتَکت',
fullName: 'Montact CRM',
company: 'Mentech',
taglineFa: 'نرم‌افزار مدیریت ارتباط با مشتری',
logo: '/brand/logo.png',
askPlaceholderFa: 'از منتکت بپرس...',
askLabelFa: 'از منتکت بپرس',
askPlaceholderFa: 'از مُنتَکت بپرس...',
askLabelFa: 'از مُنتَکت بپرس',
memoryEn: 'Montact Memory',
aiName: 'Montact AI',
aiNameFa: 'دستیار هوشمند منتکت',
suggestionPrefixFa: 'پیشنهاد منتکت:',
reminderPrefixFa: 'یادآوری منتکت:',
aiNameFa: 'دستیار هوشمند مُنتَکت',
suggestionPrefixFa: 'پیشنهاد مُنتَکت:',
reminderPrefixFa: 'یادآوری مُنتَکت:',
filePrefix: 'montact',
} as const;
+78 -1
View File
@@ -122,7 +122,8 @@ export type OrgModuleKey =
| "public_api"
| "sms_campaigns"
| "teams"
| "woocommerce";
| "woocommerce"
| "voip";
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"customers",
@@ -155,6 +156,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"sms_campaigns",
"teams",
"woocommerce",
"voip",
];
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
@@ -188,6 +190,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
sms_campaigns: "پیامک گروهی",
teams: "تیم‌ها و دیدپذیری",
woocommerce: "ووکامرس",
voip: "تلفن سازمانی",
};
export const ORG_MODULE_GROUPS: Array<{
@@ -228,6 +231,7 @@ export const ORG_MODULE_GROUPS: Array<{
"reports",
"saved_views",
"woocommerce",
"voip",
],
},
{
@@ -3203,3 +3207,76 @@ export interface WooCommerceOrderLinkDto {
currency: string;
syncedAt: string;
}
/* ——— تلفن سازمانی / VoIP ——— */
export type VoipCallDirection = "inbound" | "outbound";
export type VoipCallStatus =
| "ringing"
| "answered"
| "missed"
| "completed"
| "failed";
export interface VoipConnectionDto {
id: string;
label: string;
enabled: boolean;
webhookSecretSet: boolean;
/** قالب URL برای شروع تماس؛ {number} جایگزین می‌شود */
clickToCallUrl: string | null;
lastEventAt: string | null;
lastError: string | null;
callCount: number;
createdAt: string;
updatedAt: string;
}
export interface CreateVoipConnectionRequest {
label: string;
webhookSecret?: string;
clickToCallUrl?: string | null;
enabled?: boolean;
}
export interface UpdateVoipConnectionRequest {
label?: string;
webhookSecret?: string | null;
clickToCallUrl?: string | null;
enabled?: boolean;
}
export interface VoipCallLogDto {
id: string;
direction: VoipCallDirection;
status: VoipCallStatus | string;
fromNumber: string | null;
toNumber: string | null;
personId: string | null;
personName: string | null;
activityId: string | null;
durationSec: number | null;
startedAt: string;
syncedAt: string;
}
export interface VoipClickToCallRequest {
number: string;
personId?: string;
}
export interface VoipWebhookEvent {
/** شناسه یکتای تماس در سانترال */
callId: string;
direction: VoipCallDirection;
status: VoipCallStatus | string;
from?: string;
to?: string;
startedAt?: string;
endedAt?: string;
durationSec?: number;
/** برای سازگاری با فیلدهای رایج دیگر */
caller?: string;
callee?: string;
}