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(), recordingUrl: r.recordingUrl, transcript: r.transcript, summary: r.summary, summaryStatus: r.summaryStatus, summaryNoteId: r.summaryNoteId, summaryError: r.summaryError, })); } 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 recordingUrl = String(event.recordingUrl ?? '').trim() || null; const transcript = String(event.transcript ?? '').trim() || 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; } const nextRecordingUrl = recordingUrl ?? existing?.recordingUrl ?? null; const nextTranscript = transcript ?? existing?.transcript ?? null; const canQueueSummary = ['completed', 'answered'].includes(status) && Boolean(nextRecordingUrl || nextTranscript) && !['done', 'pending', 'processing'].includes( existing?.summaryStatus ?? 'none', ); const summaryPatch: Prisma.VoipCallLogUpdateInput = {}; if (recordingUrl) summaryPatch.recordingUrl = recordingUrl; if (transcript) summaryPatch.transcript = transcript; if (canQueueSummary) { summaryPatch.summaryStatus = 'pending'; summaryPatch.summaryError = null; summaryPatch.summaryNextAttemptAt = new Date(); } 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(), ...summaryPatch, }, }); } 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, recordingUrl, transcript, summaryStatus: canQueueSummary ? 'pending' : 'none', summaryNextAttemptAt: canQueueSummary ? new Date() : null, }, }); } 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, }); } } }