diff --git a/apps/api/prisma/migrations/20260927100000_voip_connection/migration.sql b/apps/api/prisma/migrations/20260927100000_voip_connection/migration.sql new file mode 100644 index 0000000..4a2264b --- /dev/null +++ b/apps/api/prisma/migrations/20260927100000_voip_connection/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 03c8488..59ecd97 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9c4664f..9381dd8 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/integrations/voip/calls.service.ts b/apps/api/src/integrations/voip/calls.service.ts new file mode 100644 index 0000000..f07eeb4 --- /dev/null +++ b/apps/api/src/integrations/voip/calls.service.ts @@ -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([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 { + 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 { + 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 { + 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(); + 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, + }); + } + } +} diff --git a/apps/api/src/integrations/voip/connections.service.ts b/apps/api/src/integrations/voip/connections.service.ts new file mode 100644 index 0000000..59deef0 --- /dev/null +++ b/apps/api/src/integrations/voip/connections.service.ts @@ -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 { + 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(), + }; + } +} diff --git a/apps/api/src/integrations/voip/dto.ts b/apps/api/src/integrations/voip/dto.ts new file mode 100644 index 0000000..4caf909 --- /dev/null +++ b/apps/api/src/integrations/voip/dto.ts @@ -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; +} diff --git a/apps/api/src/integrations/voip/voip.controller.ts b/apps/api/src/integrations/voip/voip.controller.ts new file mode 100644 index 0000000..c0fecd5 --- /dev/null +++ b/apps/api/src/integrations/voip/voip.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/integrations/voip/voip.module.ts b/apps/api/src/integrations/voip/voip.module.ts new file mode 100644 index 0000000..7fab511 --- /dev/null +++ b/apps/api/src/integrations/voip/voip.module.ts @@ -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 {} diff --git a/apps/api/src/integrations/woocommerce/woo-client.ts b/apps/api/src/integrations/woocommerce/woo-client.ts index 5b77022..2d72c18 100644 --- a/apps/api/src/integrations/woocommerce/woo-client.ts +++ b/apps/api/src/integrations/woocommerce/woo-client.ts @@ -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), }); diff --git a/apps/api/src/public-api/webhooks.service.ts b/apps/api/src/public-api/webhooks.service.ts index 42ac0d7..e7ffd65 100644 --- a/apps/api/src/public-api/webhooks.service.ts +++ b/apps/api/src/public-api/webhooks.service.ts @@ -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), diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index c435252..83b909a 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -69,6 +69,7 @@ const settingsActive = computed(() => '/settings/currencies', '/settings/tahesab', '/settings/woocommerce', + '/settings/voip', '/settings/faq', ]), ); diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index ce40834..2fd7404 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -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', diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 3882705..5da4e87 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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', diff --git a/apps/web/src/views/PersonProfileView.vue b/apps/web/src/views/PersonProfileView.vue index 63acd6e..9bea53c 100644 --- a/apps/web/src/views/PersonProfileView.vue +++ b/apps/web/src/views/PersonProfileView.vue @@ -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() { diff --git a/apps/web/src/views/VoipSettingsView.vue b/apps/web/src/views/VoipSettingsView.vue new file mode 100644 index 0000000..09526d5 --- /dev/null +++ b/apps/web/src/views/VoipSettingsView.vue @@ -0,0 +1,323 @@ + + + + + diff --git a/packages/shared/src/brand.ts b/packages/shared/src/brand.ts index 11145c2..0f54fa8 100644 --- a/packages/shared/src/brand.ts +++ b/packages/shared/src/brand.ts @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d170d9c..f420aac 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 = { @@ -188,6 +190,7 @@ export const ORG_MODULE_LABELS: Record = { 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; +}