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:
@@ -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");
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user