From f36711174e036348753b84731d3e51631b0bcdf7 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 7 Nov 2026 13:58:42 +0330 Subject: [PATCH] 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 --- .../migration.sql | 8 ++ apps/api/prisma/schema.prisma | 4 + .../src/integrations/voip/calls.service.ts | 24 +++- .../integrations/voip/connections.service.ts | 9 +- .../integrations/voip/daftareshoma-webhook.ts | 32 +++-- .../src/integrations/voip/voip.controller.ts | 10 +- apps/web/src/views/VoipSettingsView.vue | 127 +++++++++++++++--- packages/shared/src/index.ts | 4 + 8 files changed, 176 insertions(+), 42 deletions(-) create mode 100644 apps/api/prisma/migrations/20261107140000_voip_call_line_extension/migration.sql diff --git a/apps/api/prisma/migrations/20261107140000_voip_call_line_extension/migration.sql b/apps/api/prisma/migrations/20261107140000_voip_call_line_extension/migration.sql new file mode 100644 index 0000000..7045074 --- /dev/null +++ b/apps/api/prisma/migrations/20261107140000_voip_call_line_extension/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 77de609..f4afa3a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) diff --git a/apps/api/src/integrations/voip/calls.service.ts b/apps/api/src/integrations/voip/calls.service.ts index ccc7f24..97bc54a 100644 --- a/apps/api/src/integrations/voip/calls.service.ts +++ b/apps/api/src/integrations/voip/calls.service.ts @@ -67,15 +67,27 @@ export class VoipCallsService { async listRecent( organizationId: string, - connectionId?: string, + opts?: { + connectionId?: string; + direction?: string; + status?: string; + lineExtension?: string; + }, ): Promise { 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, diff --git a/apps/api/src/integrations/voip/connections.service.ts b/apps/api/src/integrations/voip/connections.service.ts index e52efda..6fc17b4 100644 --- a/apps/api/src/integrations/voip/connections.service.ts +++ b/apps/api/src/integrations/voip/connections.service.ts @@ -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(); diff --git a/apps/api/src/integrations/voip/daftareshoma-webhook.ts b/apps/api/src/integrations/voip/daftareshoma-webhook.ts index 054e1c1..b432140 100644 --- a/apps/api/src/integrations/voip/daftareshoma-webhook.ts +++ b/apps/api/src/integrations/voip/daftareshoma-webhook.ts @@ -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); diff --git a/apps/api/src/integrations/voip/voip.controller.ts b/apps/api/src/integrations/voip/voip.controller.ts index bb2e7af..4835d82 100644 --- a/apps/api/src/integrations/voip/voip.controller.ts +++ b/apps/api/src/integrations/voip/voip.controller.ts @@ -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') diff --git a/apps/web/src/views/VoipSettingsView.vue b/apps/web/src/views/VoipSettingsView.vue index 9762ea2..57e54d6 100644 --- a/apps/web/src/views/VoipSettingsView.vue +++ b/apps/web/src/views/VoipSettingsView.vue @@ -29,6 +29,11 @@ const ok = ref(''); const items = ref([]); const calls = ref([]); const tab = ref('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([]); +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 = {}; + 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('/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('/integrations/voip/connections'), - api.get('/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 () => { +
+ + + + + + + + + +
{ جهت وضعیت + خط از / به مشتری مدت @@ -452,6 +522,7 @@ onMounted(async () => { {{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }} {{ statusLabel[c.status] ?? c.status }} + {{ c.lineExtension || '—' }} {{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }} {{ c.personName || '—' }} @@ -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; } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c4dfcc9..f3fcbc7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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;