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")
fromNumber String?
toNumber String?
/// خط/داخلی سازمان که تماس روی آن بوده (مثلاً CalleeExtension دفتر شما)
lineExtension String?
personId String?
activityId String?
durationSec Int?
@@ -3021,6 +3023,8 @@ model VoipCallLog {
@@unique([connectionId, externalCallId])
@@index([organizationId, syncedAt])
@@index([organizationId, lineExtension])
@@index([organizationId, status])
@@index([personId])
@@index([activityId])
@@index([summaryStatus, summaryNextAttemptAt])
@@ -67,15 +67,27 @@ export class VoipCallsService {
async listRecent(
organizationId: string,
connectionId?: string,
opts?: {
connectionId?: string;
direction?: string;
status?: string;
lineExtension?: string;
},
): Promise<VoipCallLogDto[]> {
const rows = await this.prisma.voipCallLog.findMany({
where: {
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' },
take: 50,
take: 100,
});
const personIds = rows
.map((r) => r.personId)
@@ -97,6 +109,7 @@ export class VoipCallsService {
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,
@@ -133,6 +146,9 @@ export class VoipCallsService {
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,
@@ -199,6 +215,7 @@ export class VoipCallsService {
status,
fromNumber,
toNumber,
lineExtension: lineExtension ?? existing.lineExtension,
personId: personId ?? existing.personId,
activityId: activityId ?? existing.activityId,
durationSec,
@@ -219,6 +236,7 @@ export class VoipCallsService {
status,
fromNumber,
toNumber,
lineExtension,
personId,
activityId,
durationSec,
@@ -52,14 +52,7 @@ export class VoipConnectionsService {
if (provider === 'daftareshoma') {
const token = dto.apiToken?.trim();
if (!token) throw new BadRequestException('توکن API دفتر شما الزامی است');
if (!dto.callerExtension?.trim()) {
throw new BadRequestException('خط / داخلی سازمان الزامی است');
}
if (!dto.fromNumber?.trim()) {
throw new BadRequestException(
'شماره مبدأ (from_number) برای تماس خروجی الزامی است',
);
}
// fromNumber / callerExtension فقط برای تماس خروجی از CRM لازم‌اند؛ ثبت وب‌هوک بدون آن‌ها ممکن است
}
const secret = dto.webhookSecret?.trim();
@@ -76,16 +76,17 @@ export function mapDaftaresomaWebhook(
'From',
'fromNumber',
);
const callee = pick(
/** خط سازمانی که تماس روی آن آمده/رفته */
const lineExtension =
pick(obj, 'CalleeExtension', 'calleeExtension', 'Extension', 'extension') ||
undefined;
/** مقصد انتقال / شماره طرف مقابل روی خط */
const transferred = pick(
obj,
'TransferredToNumber',
'transferredToNumber',
'CalleeExtension',
'calleeExtension',
'to',
'To',
'toNumber',
);
const startedAt =
pick(obj, 'StartDateTime', 'startDateTime', 'startedAt') || undefined;
const endedAt =
@@ -128,17 +129,28 @@ export function mapDaftaresomaWebhook(
'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 = {
callId,
direction: isOutbound ? 'outbound' : 'inbound',
status,
from: caller || undefined,
to: callee || undefined,
from,
to,
lineExtension,
startedAt,
endedAt,
durationSec,
caller: caller || undefined,
callee: callee || undefined,
caller: from,
callee: to,
};
if (recordingId) {
event.recordingUrl = daftareshomaRecordingRef(recordingId);
@@ -82,8 +82,16 @@ export class VoipController {
recent(
@CurrentUser() user: { organizationId: 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')
+107 -20
View File
@@ -29,6 +29,11 @@ const ok = ref('');
const items = ref<VoipConnectionDto[]>([]);
const calls = ref<VoipCallLogDto[]>([]);
const tab = ref<TabKey>('daftareshoma');
const callFilters = reactive({
direction: '',
status: '',
lineExtension: '',
});
const form = reactive({
label: 'سانترال اصلی',
@@ -54,6 +59,16 @@ const daftaItems = computed(() =>
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) => {
if (!id) return '';
const origin = typeof window !== 'undefined' ? window.location.origin : '';
@@ -88,16 +103,31 @@ function noteLink(c: VoipCallLogDto): string | null {
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() {
loading.value = true;
error.value = '';
try {
const [connRes, callRes] = await Promise.all([
const [connRes] = await Promise.all([
api.get<VoipConnectionDto[]>('/integrations/voip/connections'),
api.get<VoipCallLogDto[]>('/integrations/voip/calls'),
loadCalls(),
]);
items.value = connRes.data;
calls.value = callRes.data;
fillDaftaFromActive();
} catch (e) {
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() {
if (!form.label.trim()) {
error.value = 'نام اتصال را وارد کنید.';
@@ -148,10 +190,6 @@ async function saveDafta() {
error.value = 'توکن API دفتر شما را وارد کنید.';
return;
}
if (!daftaForm.fromNumber.trim() || !daftaForm.callerExtension.trim()) {
error.value = 'شماره مبدأ و خط / داخلی را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
@@ -159,8 +197,8 @@ async function saveDafta() {
if (activeDafta.value) {
await api.patch(`/integrations/voip/connections/${activeDafta.value.id}`, {
label: daftaForm.label.trim(),
fromNumber: daftaForm.fromNumber.trim(),
callerExtension: daftaForm.callerExtension.trim(),
fromNumber: daftaForm.fromNumber.trim() || null,
callerExtension: daftaForm.callerExtension.trim() || null,
enabled: daftaForm.enabled,
...(daftaForm.apiToken.trim()
? { apiToken: daftaForm.apiToken.trim() }
@@ -173,8 +211,8 @@ async function saveDafta() {
label: daftaForm.label.trim(),
provider: 'daftareshoma',
apiToken: daftaForm.apiToken.trim(),
fromNumber: daftaForm.fromNumber.trim(),
callerExtension: daftaForm.callerExtension.trim(),
fromNumber: daftaForm.fromNumber.trim() || null,
callerExtension: daftaForm.callerExtension.trim() || null,
enabled: daftaForm.enabled,
};
await api.post('/integrations/voip/connections', payload);
@@ -265,11 +303,11 @@ onMounted(async () => {
<template v-if="tab === 'daftareshoma'">
<UiCard title="اتصال دفتر شما">
<p class="hint">
توکن API را از پرتال دفتر شما (توسعه‌دهندگان → API) بگیرید.
پس از ذخیره، آدرس وب‌هوک زیر را در
برای ثبت همه تماس‌های ورودی و خروجی فقط توکن و وب‌هوک لازم است.
توکن را از پرتال (توسعه‌دهندگان → API) بگیرید؛ بعد آدرس وب‌هوک را در
<strong>تنظیمات → هوک</strong>
پرتال ثبت کنید و رویدادهای شروع/پایان تماس ورودی و پایان تماس خروجی را فعال کنید.
برای تماس خروجی از CRM، توکن باید دسترسی «تماس خروجی» داشته باشد؛ برای خلاصه خودکار، «گزارش تماس» و «دریافت فایل تماس» هم لازم است.
با رویدادهای شروع/پایان ورودی و پایان خروجی ثبت کنید.
فیلدهای شماره مبدأ و خط فقط برای «تماس از داخل CRM» اختیاری‌اند و بعداً قابل تکمیل‌اند.
</p>
<div class="form-grid">
<UiField label="نام">
@@ -285,17 +323,17 @@ onMounted(async () => {
autocomplete="off"
/>
</UiField>
<UiField label="شماره مبدأ (from_number)">
<UiField label="شماره مبدأ — اختیاری (فقط تماس خروجی از CRM)">
<UiInput
v-model="daftaForm.fromNumber"
placeholder="مثلاً 0912…"
placeholder="موبایل همکار برای click-to-call"
dir="ltr"
/>
</UiField>
<UiField label="خط / داخلی (caller_extension)">
<UiField label="خط پیش‌فرض — اختیاری (فقط تماس خروجی از CRM)">
<UiInput
v-model="daftaForm.callerExtension"
placeholder="مثلاً 02128…"
placeholder="مثلاً 02128425990"
dir="ltr"
/>
</UiField>
@@ -427,6 +465,37 @@ onMounted(async () => {
</template>
<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
v-if="!calls.length"
title="تماسی نیست"
@@ -441,6 +510,7 @@ onMounted(async () => {
<tr>
<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">
<td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td>
<td>{{ statusLabel[c.status] ?? c.status }}</td>
<td dir="ltr">{{ c.lineExtension || '—' }}</td>
<td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td>
<td>{{ c.personName || '—' }}</td>
<td>
@@ -536,6 +607,21 @@ onMounted(async () => {
gap: 0.75rem;
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 {
display: grid;
gap: 0.75rem;
@@ -628,7 +714,8 @@ th {
color: #0f766e;
}
@media (max-width: 800px) {
.form-grid {
.form-grid,
.call-filters {
grid-template-columns: 1fr;
}
}
+4
View File
@@ -5569,6 +5569,8 @@ export interface VoipCallLogDto {
status: VoipCallStatus | string;
fromNumber: string | null;
toNumber: string | null;
/** خط/داخلی سازمانی که تماس روی آن بوده */
lineExtension: string | null;
personId: string | null;
personName: string | null;
activityId: string | null;
@@ -5595,6 +5597,8 @@ export interface VoipWebhookEvent {
status: VoipCallStatus | string;
from?: string;
to?: string;
/** خط/داخلی سازمان */
lineExtension?: string;
startedAt?: string;
endedAt?: string;
durationSec?: number;