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>
This commit is contained in:
2026-11-07 13:58:42 +03:30
co-authored by Cursor
parent abb1a3a18e
commit f36711174e
8 changed files with 176 additions and 42 deletions
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "VoipCallLog" ADD COLUMN "lineExtension" TEXT;
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_lineExtension_idx" ON "VoipCallLog"("organizationId", "lineExtension");
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_status_idx" ON "VoipCallLog"("organizationId", "status");
+4
View File
@@ -2997,6 +2997,8 @@ model VoipCallLog {
status String @default("completed") status String @default("completed")
fromNumber String? fromNumber String?
toNumber String? toNumber String?
/// خط/داخلی سازمان که تماس روی آن بوده (مثلاً CalleeExtension دفتر شما)
lineExtension String?
personId String? personId String?
activityId String? activityId String?
durationSec Int? durationSec Int?
@@ -3021,6 +3023,8 @@ model VoipCallLog {
@@unique([connectionId, externalCallId]) @@unique([connectionId, externalCallId])
@@index([organizationId, syncedAt]) @@index([organizationId, syncedAt])
@@index([organizationId, lineExtension])
@@index([organizationId, status])
@@index([personId]) @@index([personId])
@@index([activityId]) @@index([activityId])
@@index([summaryStatus, summaryNextAttemptAt]) @@index([summaryStatus, summaryNextAttemptAt])
@@ -67,15 +67,27 @@ export class VoipCallsService {
async listRecent( async listRecent(
organizationId: string, organizationId: string,
connectionId?: string, opts?: {
connectionId?: string;
direction?: string;
status?: string;
lineExtension?: string;
},
): Promise<VoipCallLogDto[]> { ): Promise<VoipCallLogDto[]> {
const rows = await this.prisma.voipCallLog.findMany({ const rows = await this.prisma.voipCallLog.findMany({
where: { where: {
organizationId, organizationId,
...(connectionId ? { connectionId } : {}), ...(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' }, orderBy: { syncedAt: 'desc' },
take: 50, take: 100,
}); });
const personIds = rows const personIds = rows
.map((r) => r.personId) .map((r) => r.personId)
@@ -97,6 +109,7 @@ export class VoipCallsService {
status: r.status, status: r.status,
fromNumber: r.fromNumber, fromNumber: r.fromNumber,
toNumber: r.toNumber, toNumber: r.toNumber,
lineExtension: r.lineExtension ?? null,
personId: r.personId, personId: r.personId,
personName: r.personId ? (nameById.get(r.personId) ?? null) : null, personName: r.personId ? (nameById.get(r.personId) ?? null) : null,
activityId: r.activityId, activityId: r.activityId,
@@ -133,6 +146,9 @@ export class VoipCallsService {
const toDigits = digitsOnly(event.to ?? event.callee); const toDigits = digitsOnly(event.to ?? event.callee);
const fromNumber = fromNormalized || fromDigits || null; const fromNumber = fromNormalized || fromDigits || null;
const toNumber = toNormalized || toDigits || 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 matchNumber = direction === 'inbound' ? fromNumber : toNumber;
const personId = await this.findPersonId( const personId = await this.findPersonId(
conn.organizationId, conn.organizationId,
@@ -199,6 +215,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension: lineExtension ?? existing.lineExtension,
personId: personId ?? existing.personId, personId: personId ?? existing.personId,
activityId: activityId ?? existing.activityId, activityId: activityId ?? existing.activityId,
durationSec, durationSec,
@@ -219,6 +236,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension,
personId, personId,
activityId, activityId,
durationSec, durationSec,
@@ -52,14 +52,7 @@ export class VoipConnectionsService {
if (provider === 'daftareshoma') { if (provider === 'daftareshoma') {
const token = dto.apiToken?.trim(); const token = dto.apiToken?.trim();
if (!token) throw new BadRequestException('توکن API دفتر شما الزامی است'); if (!token) throw new BadRequestException('توکن API دفتر شما الزامی است');
if (!dto.callerExtension?.trim()) { // fromNumber / callerExtension فقط برای تماس خروجی از CRM لازم‌اند؛ ثبت وب‌هوک بدون آن‌ها ممکن است
throw new BadRequestException('خط / داخلی سازمان الزامی است');
}
if (!dto.fromNumber?.trim()) {
throw new BadRequestException(
'شماره مبدأ (from_number) برای تماس خروجی الزامی است',
);
}
} }
const secret = dto.webhookSecret?.trim(); const secret = dto.webhookSecret?.trim();
@@ -76,16 +76,17 @@ export function mapDaftaresomaWebhook(
'From', 'From',
'fromNumber', 'fromNumber',
); );
const callee = pick( /** خط سازمانی که تماس روی آن آمده/رفته */
const lineExtension =
pick(obj, 'CalleeExtension', 'calleeExtension', 'Extension', 'extension') ||
undefined;
/** مقصد انتقال / شماره طرف مقابل روی خط */
const transferred = pick(
obj, obj,
'TransferredToNumber', 'TransferredToNumber',
'transferredToNumber', 'transferredToNumber',
'CalleeExtension',
'calleeExtension',
'to',
'To',
'toNumber',
); );
const startedAt = const startedAt =
pick(obj, 'StartDateTime', 'startDateTime', 'startedAt') || undefined; pick(obj, 'StartDateTime', 'startDateTime', 'startedAt') || undefined;
const endedAt = const endedAt =
@@ -128,17 +129,28 @@ export function mapDaftaresomaWebhook(
'recordId', 'recordId',
); );
// inbound: from=مشتری، to=همکار/مقصد انتقال یا خود خط
// outbound: from=همکار، to=مشتری (TransferredToNumber)
let from = caller || undefined;
let to: string | undefined;
if (isOutbound) {
to = transferred || undefined;
} else {
to = transferred || lineExtension || undefined;
}
const event: VoipWebhookEvent = { const event: VoipWebhookEvent = {
callId, callId,
direction: isOutbound ? 'outbound' : 'inbound', direction: isOutbound ? 'outbound' : 'inbound',
status, status,
from: caller || undefined, from,
to: callee || undefined, to,
lineExtension,
startedAt, startedAt,
endedAt, endedAt,
durationSec, durationSec,
caller: caller || undefined, caller: from,
callee: callee || undefined, callee: to,
}; };
if (recordingId) { if (recordingId) {
event.recordingUrl = daftareshomaRecordingRef(recordingId); event.recordingUrl = daftareshomaRecordingRef(recordingId);
@@ -82,8 +82,16 @@ export class VoipController {
recent( recent(
@CurrentUser() user: { organizationId: string }, @CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string, @Query('connectionId') connectionId?: string,
@Query('direction') direction?: string,
@Query('status') status?: string,
@Query('lineExtension') lineExtension?: string,
) { ) {
return this.calls.listRecent(user.organizationId, connectionId); return this.calls.listRecent(user.organizationId, {
connectionId,
direction,
status,
lineExtension,
});
} }
@Post('click-to-call') @Post('click-to-call')
+107 -20
View File
@@ -29,6 +29,11 @@ const ok = ref('');
const items = ref<VoipConnectionDto[]>([]); const items = ref<VoipConnectionDto[]>([]);
const calls = ref<VoipCallLogDto[]>([]); const calls = ref<VoipCallLogDto[]>([]);
const tab = ref<TabKey>('daftareshoma'); const tab = ref<TabKey>('daftareshoma');
const callFilters = reactive({
direction: '',
status: '',
lineExtension: '',
});
const form = reactive({ const form = reactive({
label: 'سانترال اصلی', label: 'سانترال اصلی',
@@ -54,6 +59,16 @@ const daftaItems = computed(() =>
const activeDafta = computed(() => daftaItems.value[0] ?? null); const activeDafta = computed(() => daftaItems.value[0] ?? null);
const knownLines = ref<string[]>([]);
const lineOptions = computed(() => {
const set = new Set(knownLines.value);
for (const c of calls.value) {
if (c.lineExtension) set.add(c.lineExtension);
}
if (callFilters.lineExtension) set.add(callFilters.lineExtension);
return [...set].sort();
});
const webhookUrlFor = (id: string | undefined) => { const webhookUrlFor = (id: string | undefined) => {
if (!id) return ''; if (!id) return '';
const origin = typeof window !== 'undefined' ? window.location.origin : ''; const origin = typeof window !== 'undefined' ? window.location.origin : '';
@@ -88,16 +103,31 @@ function noteLink(c: VoipCallLogDto): string | null {
return '/notes'; return '/notes';
} }
async function loadCalls() {
const params: Record<string, string> = {};
if (callFilters.direction) params.direction = callFilters.direction;
if (callFilters.status) params.status = callFilters.status;
if (callFilters.lineExtension) params.lineExtension = callFilters.lineExtension;
const callRes = await api.get<VoipCallLogDto[]>('/integrations/voip/calls', {
params,
});
calls.value = callRes.data;
const merged = new Set(knownLines.value);
for (const c of callRes.data) {
if (c.lineExtension) merged.add(c.lineExtension);
}
knownLines.value = [...merged].sort();
}
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const [connRes, callRes] = await Promise.all([ const [connRes] = await Promise.all([
api.get<VoipConnectionDto[]>('/integrations/voip/connections'), api.get<VoipConnectionDto[]>('/integrations/voip/connections'),
api.get<VoipCallLogDto[]>('/integrations/voip/calls'), loadCalls(),
]); ]);
items.value = connRes.data; items.value = connRes.data;
calls.value = callRes.data;
fillDaftaFromActive(); fillDaftaFromActive();
} catch (e) { } catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) { if (axios.isAxiosError(e) && e.response?.status === 403) {
@@ -110,6 +140,18 @@ async function load() {
} }
} }
async function applyCallFilters() {
loading.value = true;
error.value = '';
try {
await loadCalls();
} catch {
error.value = 'فیلتر تماس‌ها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function createGeneric() { async function createGeneric() {
if (!form.label.trim()) { if (!form.label.trim()) {
error.value = 'نام اتصال را وارد کنید.'; error.value = 'نام اتصال را وارد کنید.';
@@ -148,10 +190,6 @@ async function saveDafta() {
error.value = 'توکن API دفتر شما را وارد کنید.'; error.value = 'توکن API دفتر شما را وارد کنید.';
return; return;
} }
if (!daftaForm.fromNumber.trim() || !daftaForm.callerExtension.trim()) {
error.value = 'شماره مبدأ و خط / داخلی را وارد کنید.';
return;
}
saving.value = true; saving.value = true;
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
@@ -159,8 +197,8 @@ async function saveDafta() {
if (activeDafta.value) { if (activeDafta.value) {
await api.patch(`/integrations/voip/connections/${activeDafta.value.id}`, { await api.patch(`/integrations/voip/connections/${activeDafta.value.id}`, {
label: daftaForm.label.trim(), label: daftaForm.label.trim(),
fromNumber: daftaForm.fromNumber.trim(), fromNumber: daftaForm.fromNumber.trim() || null,
callerExtension: daftaForm.callerExtension.trim(), callerExtension: daftaForm.callerExtension.trim() || null,
enabled: daftaForm.enabled, enabled: daftaForm.enabled,
...(daftaForm.apiToken.trim() ...(daftaForm.apiToken.trim()
? { apiToken: daftaForm.apiToken.trim() } ? { apiToken: daftaForm.apiToken.trim() }
@@ -173,8 +211,8 @@ async function saveDafta() {
label: daftaForm.label.trim(), label: daftaForm.label.trim(),
provider: 'daftareshoma', provider: 'daftareshoma',
apiToken: daftaForm.apiToken.trim(), apiToken: daftaForm.apiToken.trim(),
fromNumber: daftaForm.fromNumber.trim(), fromNumber: daftaForm.fromNumber.trim() || null,
callerExtension: daftaForm.callerExtension.trim(), callerExtension: daftaForm.callerExtension.trim() || null,
enabled: daftaForm.enabled, enabled: daftaForm.enabled,
}; };
await api.post('/integrations/voip/connections', payload); await api.post('/integrations/voip/connections', payload);
@@ -265,11 +303,11 @@ onMounted(async () => {
<template v-if="tab === 'daftareshoma'"> <template v-if="tab === 'daftareshoma'">
<UiCard title="اتصال دفتر شما"> <UiCard title="اتصال دفتر شما">
<p class="hint"> <p class="hint">
توکن API را از پرتال دفتر شما (توسعه‌دهندگان → API) بگیرید. برای ثبت همه تماس‌های ورودی و خروجی فقط توکن و وب‌هوک لازم است.
پس از ذخیره، آدرس وب‌هوک زیر را در توکن را از پرتال (توسعه‌دهندگان → API) بگیرید؛ بعد آدرس وب‌هوک را در
<strong>تنظیمات → هوک</strong> <strong>تنظیمات → هوک</strong>
پرتال ثبت کنید و رویدادهای شروع/پایان تماس ورودی و پایان تماس خروجی را فعال کنید. با رویدادهای شروع/پایان ورودی و پایان خروجی ثبت کنید.
برای تماس خروجی از CRM، توکن باید دسترسی «تماس خروجی» داشته باشد؛ برای خلاصه خودکار، «گزارش تماس» و «دریافت فایل تماس» هم لازم است. فیلدهای شماره مبدأ و خط فقط برای «تماس از داخل CRM» اختیاری‌اند و بعداً قابل تکمیل‌اند.
</p> </p>
<div class="form-grid"> <div class="form-grid">
<UiField label="نام"> <UiField label="نام">
@@ -285,17 +323,17 @@ onMounted(async () => {
autocomplete="off" autocomplete="off"
/> />
</UiField> </UiField>
<UiField label="شماره مبدأ (from_number)"> <UiField label="شماره مبدأ — اختیاری (فقط تماس خروجی از CRM)">
<UiInput <UiInput
v-model="daftaForm.fromNumber" v-model="daftaForm.fromNumber"
placeholder="مثلاً 0912…" placeholder="موبایل همکار برای click-to-call"
dir="ltr" dir="ltr"
/> />
</UiField> </UiField>
<UiField label="خط / داخلی (caller_extension)"> <UiField label="خط پیش‌فرض — اختیاری (فقط تماس خروجی از CRM)">
<UiInput <UiInput
v-model="daftaForm.callerExtension" v-model="daftaForm.callerExtension"
placeholder="مثلاً 02128…" placeholder="مثلاً 02128425990"
dir="ltr" dir="ltr"
/> />
</UiField> </UiField>
@@ -427,6 +465,37 @@ onMounted(async () => {
</template> </template>
<UiCard title="آخرین تماس‌ها"> <UiCard title="آخرین تماس‌ها">
<div class="call-filters">
<UiField label="جهت">
<select v-model="callFilters.direction" class="filter-select" @change="applyCallFilters">
<option value="">همه</option>
<option value="inbound">ورودی</option>
<option value="outbound">خروجی</option>
</select>
</UiField>
<UiField label="وضعیت">
<select v-model="callFilters.status" class="filter-select" @change="applyCallFilters">
<option value="">همه</option>
<option value="ringing">زنگ</option>
<option value="answered">پاسخ</option>
<option value="missed">از دست‌رفته</option>
<option value="completed">تمام‌شده</option>
<option value="failed">ناموفق</option>
</select>
</UiField>
<UiField label="خط">
<select
v-model="callFilters.lineExtension"
class="filter-select"
@change="applyCallFilters"
>
<option value="">همه خطوط</option>
<option v-for="line in lineOptions" :key="line" :value="line">
{{ line }}
</option>
</select>
</UiField>
</div>
<UiEmptyState <UiEmptyState
v-if="!calls.length" v-if="!calls.length"
title="تماسی نیست" title="تماسی نیست"
@@ -441,6 +510,7 @@ onMounted(async () => {
<tr> <tr>
<th>جهت</th> <th>جهت</th>
<th>وضعیت</th> <th>وضعیت</th>
<th>خط</th>
<th>از / به</th> <th>از / به</th>
<th>مشتری</th> <th>مشتری</th>
<th>مدت</th> <th>مدت</th>
@@ -452,6 +522,7 @@ onMounted(async () => {
<tr v-for="c in calls" :key="c.id"> <tr v-for="c in calls" :key="c.id">
<td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td> <td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td>
<td>{{ statusLabel[c.status] ?? c.status }}</td> <td>{{ statusLabel[c.status] ?? c.status }}</td>
<td dir="ltr">{{ c.lineExtension || '—' }}</td>
<td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td> <td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td>
<td>{{ c.personName || '—' }}</td> <td>{{ c.personName || '—' }}</td>
<td> <td>
@@ -536,6 +607,21 @@ onMounted(async () => {
gap: 0.75rem; gap: 0.75rem;
margin: 0.75rem 0 1rem; margin: 0.75rem 0 1rem;
} }
.call-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.filter-select {
width: 100%;
min-height: 36px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.35rem 0.6rem;
background: #fff;
font: inherit;
}
.list { .list {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -628,7 +714,8 @@ th {
color: #0f766e; color: #0f766e;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.form-grid { .form-grid,
.call-filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
+4
View File
@@ -5569,6 +5569,8 @@ export interface VoipCallLogDto {
status: VoipCallStatus | string; status: VoipCallStatus | string;
fromNumber: string | null; fromNumber: string | null;
toNumber: string | null; toNumber: string | null;
/** خط/داخلی سازمانی که تماس روی آن بوده */
lineExtension: string | null;
personId: string | null; personId: string | null;
personName: string | null; personName: string | null;
activityId: string | null; activityId: string | null;
@@ -5595,6 +5597,8 @@ export interface VoipWebhookEvent {
status: VoipCallStatus | string; status: VoipCallStatus | string;
from?: string; from?: string;
to?: string; to?: string;
/** خط/داخلی سازمان */
lineExtension?: string;
startedAt?: string; startedAt?: string;
endedAt?: string; endedAt?: string;
durationSec?: number; durationSec?: number;