Files
CRM_Montzar/apps/api/src/integrations/voip/calls.service.ts
T
MahdiandCursor f36711174e feat(voip): make Daftaresoma setup inbound-first and filter calls by line
Drop required from_number for webhook logging, persist CalleeExtension as lineExtension, and add direction/status/line filters on the calls list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 13:58:42 +03:30

617 lines
19 KiB
TypeScript

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 { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service';
import { DaftaresomaClient } from './daftareshoma-client';
import {
daftareshomaRecordingRef,
parseDaftaresomaRecordingRef,
} from './daftareshoma-webhook';
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}`);
}
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
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,
private readonly daftareshoma: DaftaresomaClient,
) {}
async listRecent(
organizationId: string,
opts?: {
connectionId?: string;
direction?: string;
status?: string;
lineExtension?: string;
},
): Promise<VoipCallLogDto[]> {
const rows = await this.prisma.voipCallLog.findMany({
where: {
organizationId,
...(opts?.connectionId ? { connectionId: opts.connectionId } : {}),
...(opts?.direction === 'inbound' || opts?.direction === 'outbound'
? { direction: opts.direction }
: {}),
...(opts?.status?.trim() ? { status: opts.status.trim() } : {}),
...(opts?.lineExtension?.trim()
? { lineExtension: opts.lineExtension.trim() }
: {}),
},
orderBy: { syncedAt: 'desc' },
take: 100,
});
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,
lineExtension: r.lineExtension ?? null,
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 lineRaw = String(event.lineExtension ?? '').trim();
const lineExtension =
normalizePhone(lineRaw) || digitsOnly(lineRaw) || lineRaw || 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,
lineExtension: lineExtension ?? existing.lineExtension,
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,
lineExtension,
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);
}
if (
conn.provider === 'daftareshoma' &&
['completed', 'answered', 'missed'].includes(status)
) {
void this.enrichDaftaresomaRecording(connectionId, callId).catch((e) => {
this.logger.warn(
`daftareshoma recording enrich failed: ${e instanceof Error ? e.message : e}`,
);
});
}
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' | 'daftareshoma'; dialUrl: string }> {
const phone = normalizePhone(number) ?? digitsOnly(number);
if (!phone) throw new BadRequestException('شماره معتبر نیست');
const daftareshomaConn = await this.prisma.voipConnection.findFirst({
where: {
organizationId,
enabled: true,
provider: 'daftareshoma',
},
orderBy: { createdAt: 'asc' },
});
if (daftareshomaConn) {
const token = await this.connections.decryptApiToken(daftareshomaConn);
const fromNumber = daftareshomaConn.fromNumber?.trim();
const callerExtension = daftareshomaConn.callerExtension?.trim();
if (!token || !fromNumber || !callerExtension) {
throw new BadRequestException(
'اتصال دفتر شما ناقص است؛ توکن، شماره مبدأ و خط را تکمیل کنید',
);
}
const toE164 = this.toPlusDigits(phone);
const fromE164 = this.toPlusDigits(fromNumber);
const extE164 = this.toPlusDigits(callerExtension);
try {
await this.daftareshoma.outgoingCall(token, {
fromNumber: fromE164,
toNumber: toE164,
callerExtension: extE164,
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'خطا';
await this.prisma.voipConnection.update({
where: { id: daftareshomaConn.id },
data: { lastError: msg.slice(0, 400) },
});
throw new BadRequestException(msg);
}
await this.prisma.voipCallLog.create({
data: {
organizationId,
connectionId: daftareshomaConn.id,
externalCallId: `ctc-${Date.now()}-${userId.slice(0, 6)}`,
direction: 'outbound',
status: 'ringing',
fromNumber: fromE164,
toNumber: toE164,
personId: personId ?? null,
startedAt: new Date(),
},
});
return {
ok: true,
mode: 'daftareshoma',
dialUrl: `daftareshoma:${toE164}`,
};
}
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 toPlusDigits(raw: string): string {
const d = digitsOnly(raw);
if (!d) return raw.trim();
if (raw.trim().startsWith('+')) return `+${d}`;
if (d.startsWith('98')) return `+${d}`;
if (d.startsWith('0') && d.length >= 10) return `+98${d.slice(1)}`;
if (d.length === 10 && d.startsWith('9')) return `+98${d}`;
return `+${d}`;
}
private async enrichDaftaresomaRecording(
connectionId: string,
callId: string,
): Promise<void> {
const conn = await this.prisma.voipConnection.findFirst({
where: { id: connectionId, provider: 'daftareshoma', enabled: true },
});
if (!conn) return;
const log = await this.prisma.voipCallLog.findUnique({
where: {
connectionId_externalCallId: { connectionId, externalCallId: callId },
},
});
if (!log) return;
if (parseDaftaresomaRecordingRef(log.recordingUrl) || log.recordingUrl) {
if (
parseDaftaresomaRecordingRef(log.recordingUrl) &&
!['done', 'pending', 'processing'].includes(log.summaryStatus)
) {
await this.prisma.voipCallLog.update({
where: { id: log.id },
data: {
summaryStatus: 'pending',
summaryError: null,
summaryNextAttemptAt: new Date(),
},
});
}
return;
}
const token = await this.connections.decryptApiToken(conn);
if (!token) return;
const row = await this.daftareshoma.findCallById(token, callId);
const recordingId = row?.recordingId?.trim();
if (!recordingId) return;
const ref = daftareshomaRecordingRef(recordingId);
await this.prisma.voipCallLog.update({
where: { id: log.id },
data: {
recordingUrl: ref,
summaryStatus: ['done', 'pending', 'processing'].includes(
log.summaryStatus,
)
? log.summaryStatus
: 'pending',
summaryError: null,
summaryNextAttemptAt: new Date(),
},
});
}
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,
});
}
}
}