update project

مخزن کامیت contained در
مسعود نیک زاد 2026-07-25 15:18:46 +03:30
والد b246b94754
کامیت e1a86ead41
326فایلهای تغییر یافته به همراه20110 افزوده شده و 2009 حذف شده

104
.github/workflows/ci.yml فروخته شده normal فایل
مشاهده پرونده

@ -0,0 +1,104 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
backend:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- connection: sqlite
database: ':memory:'
host: 127.0.0.1
port: 0
username: root
password: ''
- connection: mysql
database: crm
host: 127.0.0.1
port: 3306
username: root
password: root
- connection: pgsql
database: crm
host: 127.0.0.1
port: 5432
username: postgres
password: postgres
services:
mysql:
image: mysql:8.4
env:
MYSQL_DATABASE: crm
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=10
postgres:
image: postgres:17
env:
POSTGRES_DB: crm
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres -d crm"
--health-interval=10s
--health-timeout=5s
--health-retries=10
defaults:
run:
working-directory: backend
env:
APP_ENV: testing
APP_KEY: base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
DB_CONNECTION: ${{ matrix.connection }}
DB_DATABASE: ${{ matrix.database }}
DB_HOST: ${{ matrix.host }}
DB_PORT: ${{ matrix.port }}
DB_USERNAME: ${{ matrix.username }}
DB_PASSWORD: ${{ matrix.password }}
CACHE_STORE: array
QUEUE_CONNECTION: sync
SESSION_DRIVER: array
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo_sqlite, pdo_mysql, pdo_pgsql
coverage: none
- run: composer install --no-interaction --prefer-dist --no-progress
- run: php artisan migrate:fresh --seed --force
- run: php artisan test
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run test:run
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e

3
.gitignore فروخته شده
مشاهده پرونده

@ -20,10 +20,13 @@ node_modules/
vendor/ vendor/
# Build output # Build output
artifacts/
backend/public/build/ backend/public/build/
backend/public/hot backend/public/hot
frontend/dist/ frontend/dist/
frontend/dist-ssr/ frontend/dist-ssr/
frontend/test-results/
frontend/playwright-report/
# Laravel runtime data # Laravel runtime data
backend/storage/app/* backend/storage/app/*

مشاهده پرونده

@ -1 +1,93 @@
# CRM
سامانه CRM با بک‌اند Laravel 12 و فرانت‌اند React/Vite. مجوزها در بک‌اند منبع حقیقت هستند و رابط کاربری نیز از همان permissionهای کاربر برای نمایش مسیرها و عملیات استفاده می‌کند.
## اجرای محلی در ویندوز
پیش‌نیازها: PHP 8.2 یا جدیدتر، Composer، Node.js و npm.
```powershell
cd backend
composer install
Copy-Item .env.example .env
php artisan key:generate
php artisan migrate --seed
cd ..\frontend
npm install
```
سپس از ریشه پروژه `run-project.bat` را اجرا کنید. بک‌اند روی `http://127.0.0.1:8887` و فرانت‌اند روی `http://127.0.0.1:8886` بالا می‌آیند. Vite درخواست‌های `/api`، `/sanctum` و `/storage` را به بک‌اند proxy می‌کند.
اگر پروژه را بدون فایل bat اجرا می‌کنید، مقادیر `APP_URL`، `FRONTEND_URL`، `SANCTUM_STATEFUL_DOMAINS` و `CORS_ALLOWED_ORIGINS` را متناسب با میزبان و پورت‌های خود تنظیم کنید.
## عملیات پس از استقرار
بعد از migrate، ماتریس مجوز نقش‌های پیش‌فرض را همگام کنید:
```powershell
cd backend
php artisan migrate --force
php artisan permissions:sync-defaults
```
برای reminderهای Follow-up و Task باید scheduler لاراول فعال باشد. در سرور، `php artisan schedule:run` را هر دقیقه اجرا کنید؛ برای اجرای دائمی در محیط توسعه می‌توان از `php artisan schedule:work` استفاده کرد. Task reminder به صف ارسال می‌شود، پس در production یک `php artisan queue:work` تحت process manager نیز اجرا کنید.
پس از استقرار P1، تبدیل idempotent یادداشت‌های قدیمی تماس را هم اجرا کنید (migration نیز همین تبدیل را انجام می‌دهد و تکرار فرمان امن است):
```powershell
php artisan call-notes:backfill
```
مرکز کارها در مسیر `/tasks` قرار دارد و Task را می‌توان به Lead، Contact، Company، Deal، Call یا Campaign متصل کرد. جزئیات schema، API، مجوزها و rollback در [راهنمای P1](docs/P1_TASKS_AND_CALL_NOTES_FA.md) آمده است.
فاز P2 برد چندپایپ‌لاین فرصت‌ها، workspace فروش، جست‌وجوی سراسری، نماهای ذخیره‌شده، scoring/SLA، اتوماسیون محدود، فیلدهای سفارشی و گزارش عملیات را اضافه می‌کند. داشبورد نقش‌محور در `/` صفحه پیش‌فرض باقی می‌ماند. جزئیات API، مجوزها، scheduler و rollback در [راهنمای P2](docs/P2_PROFESSIONAL_CRM_FA.md) آمده است.
پایش دستی SLA (اجرای تکراری امن است):
```powershell
cd backend
php artisan sla:monitor
```
## کنترل کیفیت
```powershell
cd backend
php artisan test
composer audit
cd ..\frontend
npm run lint
npm run test:run
npm run build
npm run test:e2e
npm audit --audit-level=high
```
برای فایل‌های PHP تغییرکرده نیز `vendor\bin\pint --test <files>` را اجرا کنید. CI تست‌های بک‌اند را روی SQLite، MySQL و PostgreSQL و lint، unit test، build و E2E فرانت‌اند را اجرا می‌کند.
## انتشار امن
ابتدا همه تغییرات مورد انتشار را commit کنید و مطمئن شوید working tree تمیز است. سپس:
```powershell
.\scripts\package-release.ps1
```
اسکریپت فقط فایل‌های tracked در commit فعلی را archive می‌کند، مسیرهای حساس/وابستگی‌ها را رد می‌کند و در صورت مشاهده الگوی secret متوقف می‌شود. خروجی پیش‌فرض `artifacts/crm-release.zip` است. فایل‌های `.env`، دیتابیس محلی، `vendor`، `node_modules`، log و build محلی وارد بسته نمی‌شوند.
قبل از انتشار واقعی، secretهای محیط مقصد را خارج از Git نگه دارید و اگر قبلاً جایی افشا شده‌اند آن‌ها را در سرویس مربوطه rotate کنید. سپس cacheهای production را با `php artisan optimize` بسازید.
## Rollback
قبل از migrate از دیتابیس نسخه پشتیبان بگیرید. برای برگشت آخرین batch:
```powershell
cd backend
php artisan migrate:rollback --step=1 --force
```
مهاجرت نرمال‌سازی Sales Script داده‌های رابطه‌ای قدیمی را به کلیدهای canonical در `campaigns` و `products` منتقل می‌کند؛ بنابراین rollback تولیدی باید همراه با backup و برنامه بازیابی داده انجام شود.
در rollback مهاجرت تاریخچه تماس، Noteهای تولیدشده با `source_key=legacy_call:*` حذف می‌شوند ولی ستون قدیمی `calls.notes` دست‌نخورده است. nullable شدن `notes.user_id` و رفتار `nullOnDelete` عمداً برگشت داده نمی‌شود تا حذف کاربر باعث نابودی تاریخچه نشود؛ برای rollback تولیدی P1 راهنمای بالا و backup الزامی است.

مشاهده پرونده

@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum TaskPriority: string
{
case Low = 'low';
case Normal = 'normal';
case High = 'high';
case Urgent = 'urgent';
}

مشاهده پرونده

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
enum TaskStatus: string
{
case Open = 'open';
case InProgress = 'in_progress';
case Done = 'done';
case Cancelled = 'cancelled';
public function isActive(): bool
{
return in_array($this, [self::Open, self::InProgress], true);
}
}

مشاهده پرونده

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum TaskVisibility: string
{
case Private = 'private';
case Team = 'team';
case Organization = 'organization';
}

مشاهده پرونده

@ -0,0 +1,13 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class TaskVersionConflictException extends RuntimeException
{
public function __construct()
{
parent::__construct('این کار توسط کاربر دیگری تغییر کرده است. اطلاعات را تازه‌سازی و دوباره تلاش کنید.');
}
}

مشاهده پرونده

@ -12,14 +12,15 @@ use App\Models\FollowUp;
use App\Models\Lead; use App\Models\Lead;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Services\CallService; use App\Services\CallService;
use Illuminate\Support\Facades\DB; use App\Services\FollowUpService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
class AgentMobileController extends Controller class AgentMobileController extends Controller
{ {
public function __construct(private CallService $callService) {} public function __construct(private CallService $callService, private FollowUpService $followUps) {}
public function bootstrap(Request $request): JsonResponse public function bootstrap(Request $request): JsonResponse
{ {
@ -29,7 +30,7 @@ class AgentMobileController extends Controller
'call_results' => CallResult::where('is_active', true) 'call_results' => CallResult::where('is_active', true)
->orderBy('sort_order') ->orderBy('sort_order')
->get(['name', 'slug', 'requires_follow_up', 'is_final']) ->get(['name', 'slug', 'requires_follow_up', 'is_final'])
->map(fn(CallResult $result) => [ ->map(fn (CallResult $result) => [
'name' => $result->name, 'name' => $result->name,
'slug' => $result->slug, 'slug' => $result->slug,
'requires_follow_up' => $result->requires_follow_up, 'requires_follow_up' => $result->requires_follow_up,
@ -71,7 +72,7 @@ class AgentMobileController extends Controller
$this->applyMobileFilter($query, $request->query('filter')); $this->applyMobileFilter($query, $request->query('filter'));
return response()->json([ return response()->json([
'data' => $query->limit(30)->get()->map(fn(Lead $lead) => $this->leadCard($lead))->values(), 'data' => $query->limit(30)->get()->map(fn (Lead $lead) => $this->leadCard($lead))->values(),
]); ]);
} }
@ -84,12 +85,12 @@ class AgentMobileController extends Controller
$query->where(function ($q) { $query->where(function ($q) {
$q->where('interest_level', 'hot') $q->where('interest_level', 'hot')
->orWhere('last_call_result', 'علاقه‌مند بود') ->orWhere('last_call_result', 'علاقه‌مند بود')
->orWhereHas('pipelineStage', fn($stage) => $stage->where('name', 'like', '%علاقه‌مند%')); ->orWhereHas('pipelineStage', fn ($stage) => $stage->where('name', 'like', '%علاقه‌مند%'));
}); });
} }
if ($request->query('filter') === 'proposal') { if ($request->query('filter') === 'proposal') {
$query->whereHas('pipelineStage', fn($stage) => $stage->where('name', 'like', '%پیشنهاد%')); $query->whereHas('pipelineStage', fn ($stage) => $stage->where('name', 'like', '%پیشنهاد%'));
} }
if ($request->query('filter') === 'closed') { if ($request->query('filter') === 'closed') {
@ -103,7 +104,7 @@ class AgentMobileController extends Controller
->orderBy('next_follow_up_at') ->orderBy('next_follow_up_at')
->limit(40) ->limit(40)
->get() ->get()
->map(fn(Lead $lead) => $this->leadCard($lead)) ->map(fn (Lead $lead) => $this->leadCard($lead))
->values(), ->values(),
]); ]);
} }
@ -120,9 +121,9 @@ class AgentMobileController extends Controller
'contacts.phones:id,contact_id,type,status,call_count,successful_call_count,failed_call_count,last_called_at,last_call_result', 'contacts.phones:id,contact_id,type,status,call_count,successful_call_count,failed_call_count,last_called_at,last_call_result',
'contactRelations.fromContact:id,name,role', 'contactRelations.fromContact:id,name,role',
'contactRelations.toContact:id,name,role', 'contactRelations.toContact:id,name,role',
'calls' => fn($q) => $q->with('contact:id,name,role')->latest()->limit(12), 'calls' => fn ($q) => $q->with('contact:id,name,role')->latest()->limit(12),
'callLogs' => fn($q) => $q->with('contact:id,name,role')->latest('called_at')->limit(12), 'callLogs' => fn ($q) => $q->with('contact:id,name,role')->latest('called_at')->limit(12),
'followUps' => fn($q) => $q->latest('scheduled_at')->limit(8), 'followUps' => fn ($q) => $q->latest('scheduled_at')->limit(8),
]); ]);
$primary = $this->primaryContact($lead); $primary = $this->primaryContact($lead);
@ -147,20 +148,20 @@ class AgentMobileController extends Controller
'tags' => $this->tags($lead), 'tags' => $this->tags($lead),
'final_result' => $lead->final_result, 'final_result' => $lead->final_result,
], ],
'calls' => $lead->calls->map(fn(Call $call) => [ 'calls' => $lead->calls->map(fn (Call $call) => [
'id' => $call->id, 'id' => $call->id,
'contact_name' => $call->contact?->name, 'contact_name' => $call->contact?->name,
'result' => $call->result, 'result' => $call->result,
'notes' => $call->notes, 'notes' => $call->notes,
'created_at' => optional($call->created_at)->toIso8601String(), 'created_at' => optional($call->created_at)->toIso8601String(),
])->values(), ])->values(),
'contacts' => $lead->contacts->map(fn($contact) => $this->contactSummary($contact))->values(), 'contacts' => $lead->contacts->map(fn ($contact) => $this->contactSummary($contact))->values(),
'notes' => array_values(array_filter([ 'notes' => array_values(array_filter([
$lead->notes ? ['id' => 'lead-notes', 'text' => $lead->notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null, $lead->notes ? ['id' => 'lead-notes', 'text' => $lead->notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null,
$lead->customer_notes ? ['id' => 'customer-notes', 'text' => $lead->customer_notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null, $lead->customer_notes ? ['id' => 'customer-notes', 'text' => $lead->customer_notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null,
])), ])),
'files' => [], 'files' => [],
'contact_routes' => $lead->contactRelations->map(fn($relation) => [ 'contact_routes' => $lead->contactRelations->map(fn ($relation) => [
'id' => $relation->id, 'id' => $relation->id,
'from' => $relation->fromContact?->name, 'from' => $relation->fromContact?->name,
'to' => $relation->toContact?->name, 'to' => $relation->toContact?->name,
@ -174,17 +175,17 @@ class AgentMobileController extends Controller
public function followUps(Request $request): JsonResponse public function followUps(Request $request): JsonResponse
{ {
$items = FollowUp::with([ $items = FollowUp::with([
'lead:id,first_name,last_name,company,lead_status_id,pipeline_stage_id,last_call_result,next_follow_up_at,assigned_to', 'lead:id,first_name,last_name,company,lead_status_id,pipeline_stage_id,last_call_result,next_follow_up_at,assigned_to',
'lead.leadStatus:id,name,color', 'lead.leadStatus:id,name,color',
'lead.pipelineStage:id,name,color', 'lead.pipelineStage:id,name,color',
'lead.contacts.phones:id,contact_id,type,status,call_count,last_call_result,last_called_at', 'lead.contacts.phones:id,contact_id,type,status,call_count,last_call_result,last_called_at',
]) ])
->where('user_id', $request->user()->id) ->where('user_id', $request->user()->id)
->where('scheduled_at', '<=', now()->addWeek()) ->where('scheduled_at', '<=', now()->addWeek())
->orderBy('scheduled_at') ->orderBy('scheduled_at')
->limit(80) ->limit(80)
->get() ->get()
->map(fn(FollowUp $followUp) => $this->followUpCard($followUp)); ->map(fn (FollowUp $followUp) => $this->followUpCard($followUp));
return response()->json([ return response()->json([
'data' => [ 'data' => [
@ -224,16 +225,16 @@ class AgentMobileController extends Controller
]); ]);
$lead = Lead::findOrFail($validated['lead_id']); $lead = Lead::findOrFail($validated['lead_id']);
if (!$this->canAccessLead($request, $lead)) { if (! $this->canAccessLead($request, $lead)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
if (!empty($validated['contact_phone_id'])) { if (! empty($validated['contact_phone_id'])) {
$belongsToLead = ContactPhone::where('id', $validated['contact_phone_id']) $belongsToLead = ContactPhone::where('id', $validated['contact_phone_id'])
->whereHas('contact', fn($query) => $query->where('lead_id', $lead->id)) ->whereHas('contact', fn ($query) => $query->where('lead_id', $lead->id))
->exists(); ->exists();
if (!$belongsToLead) { if (! $belongsToLead) {
return response()->json(['message' => 'شماره انتخاب‌شده برای این لید معتبر نیست'], 422); return response()->json(['message' => 'شماره انتخاب‌شده برای این لید معتبر نیست'], 422);
} }
} }
@ -271,13 +272,13 @@ class AgentMobileController extends Controller
]); ]);
$call = Call::with('lead')->findOrFail($validated['call_id']); $call = Call::with('lead')->findOrFail($validated['call_id']);
if ($call->user_id !== $request->user()->id || !$call->lead || !$this->canAccessLead($request, $call->lead)) { if ($call->user_id !== $request->user()->id || ! $call->lead || ! $this->canAccessLead($request, $call->lead)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
$followUpAt = $validated['next_follow_up_at'] ?? $validated['referral']['next_follow_up_at'] ?? null; $followUpAt = $validated['next_follow_up_at'] ?? $validated['referral']['next_follow_up_at'] ?? null;
$callResult = CallResult::where('name', $validated['result'])->first(); $callResult = CallResult::where('name', $validated['result'])->first();
if ($callResult?->requires_follow_up && !$followUpAt) { if ($callResult?->requires_follow_up && ! $followUpAt) {
return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422); return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422);
} }
@ -300,7 +301,7 @@ class AgentMobileController extends Controller
'call_id' => $registeredCall->id, 'call_id' => $registeredCall->id,
'lead_id' => $registeredCall->lead_id, 'lead_id' => $registeredCall->lead_id,
'result' => $registeredCall->result, 'result' => $registeredCall->result,
'next_call' => optional($this->queueQuery($request->user()->id)->where('id', '!=', $registeredCall->lead_id)->first(), fn($lead) => $this->leadCard($lead)), 'next_call' => optional($this->queueQuery($request->user()->id)->where('id', '!=', $registeredCall->lead_id)->first(), fn ($lead) => $this->leadCard($lead)),
], ],
]); ]);
} }
@ -315,25 +316,23 @@ class AgentMobileController extends Controller
]); ]);
$lead = Lead::findOrFail($validated['lead_id']); $lead = Lead::findOrFail($validated['lead_id']);
if (!$this->canAccessLead($request, $lead)) { if (! $this->canAccessLead($request, $lead)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
if (!empty($validated['call_id']) && !Call::where('id', $validated['call_id'])->where('user_id', $request->user()->id)->where('lead_id', $lead->id)->exists()) { if (! empty($validated['call_id']) && ! Call::where('id', $validated['call_id'])->where('user_id', $request->user()->id)->where('lead_id', $lead->id)->exists()) {
return response()->json(['message' => 'تماس انتخاب‌شده معتبر نیست'], 422); return response()->json(['message' => 'تماس انتخاب‌شده معتبر نیست'], 422);
} }
$followUp = FollowUp::create([ $followUp = $this->followUps->schedule(
'lead_id' => $lead->id, $lead,
'user_id' => $request->user()->id, $request->user()->id,
'call_id' => $validated['call_id'] ?? null, $validated['scheduled_at'],
'scheduled_at' => $validated['scheduled_at'], $request->user(),
'status' => 'pending', $validated['notes'] ?? null,
'notes' => $validated['notes'] ?? null, $validated['call_id'] ?? null,
]); 'agent_mobile',
$lead->update(['next_follow_up_at' => $validated['scheduled_at']]); );
ActivityLogger::log('agent_mobile_follow_up_created', "Mobile follow-up created for lead {$lead->id}", $followUp);
return response()->json(['data' => $this->followUpCard($followUp->load('lead.contacts.phones'))], 201); return response()->json(['data' => $this->followUpCard($followUp->load('lead.contacts.phones'))], 201);
} }
@ -353,11 +352,11 @@ class AgentMobileController extends Controller
]); ]);
$lead = Lead::findOrFail($validated['lead_id']); $lead = Lead::findOrFail($validated['lead_id']);
if (!$this->canAccessLead($request, $lead)) { if (! $this->canAccessLead($request, $lead)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
if (!empty($validated['from_contact_id']) && !Contact::where('id', $validated['from_contact_id'])->where('lead_id', $lead->id)->exists()) { if (! empty($validated['from_contact_id']) && ! Contact::where('id', $validated['from_contact_id'])->where('lead_id', $lead->id)->exists()) {
return response()->json(['message' => 'مخاطب معرف برای این لید معتبر نیست'], 422); return response()->json(['message' => 'مخاطب معرف برای این لید معتبر نیست'], 422);
} }
@ -440,7 +439,7 @@ class AgentMobileController extends Controller
->where(function ($query) { ->where(function ($query) {
$query->whereNull('last_call_at') $query->whereNull('last_call_at')
->orWhere('next_follow_up_at', '<=', now()) ->orWhere('next_follow_up_at', '<=', now())
->orWhereHas('followUps', fn($followUp) => $followUp->where('status', 'pending')->where('scheduled_at', '<=', now())); ->orWhereHas('followUps', fn ($followUp) => $followUp->where('status', 'pending')->where('scheduled_at', '<=', now()));
}) })
->orderByDesc('priority') ->orderByDesc('priority')
->orderByRaw('next_follow_up_at IS NULL') ->orderByRaw('next_follow_up_at IS NULL')
@ -533,6 +532,7 @@ class AgentMobileController extends Controller
private function primaryContact(Lead $lead) private function primaryContact(Lead $lead)
{ {
$contacts = $lead->relationLoaded('contacts') ? $lead->contacts : $lead->contacts()->with('phones')->get(); $contacts = $lead->relationLoaded('contacts') ? $lead->contacts : $lead->contacts()->with('phones')->get();
return $contacts->firstWhere('is_primary', true) ?? $contacts->first(); return $contacts->firstWhere('is_primary', true) ?? $contacts->first();
} }
@ -543,13 +543,13 @@ class AgentMobileController extends Controller
private function tags(Lead $lead): array private function tags(Lead $lead): array
{ {
if (!$lead->tags) { if (! $lead->tags) {
return []; return [];
} }
$decoded = json_decode($lead->tags, true); $decoded = json_decode($lead->tags, true);
if (is_array($decoded)) { if (is_array($decoded)) {
return array_values(array_filter($decoded, fn($tag) => is_string($tag) && $tag !== '')); return array_values(array_filter($decoded, fn ($tag) => is_string($tag) && $tag !== ''));
} }
return array_values(array_filter(array_map('trim', explode(',', $lead->tags)))); return array_values(array_filter(array_map('trim', explode(',', $lead->tags))));
@ -557,34 +557,64 @@ class AgentMobileController extends Controller
private function priorityLabel(int $priority): string private function priorityLabel(int $priority): string
{ {
if ($priority >= 8) return 'خیلی بالا'; if ($priority >= 8) {
if ($priority >= 5) return 'بالا'; return 'خیلی بالا';
if ($priority >= 2) return 'متوسط'; }
if ($priority >= 5) {
return 'بالا';
}
if ($priority >= 2) {
return 'متوسط';
}
return 'عادی'; return 'عادی';
} }
private function nextAction(Lead $lead): string private function nextAction(Lead $lead): string
{ {
if (!$lead->last_call_at) return 'تماس اول'; if (! $lead->last_call_at) {
if ($lead->next_follow_up_at && $lead->next_follow_up_at->isPast()) return 'پیگیری عقب‌افتاده'; return 'تماس اول';
if ($lead->next_follow_up_at && $lead->next_follow_up_at->isToday()) return 'پیگیری امروز'; }
if ($lead->next_follow_up_at && $lead->next_follow_up_at->isPast()) {
return 'پیگیری عقب‌افتاده';
}
if ($lead->next_follow_up_at && $lead->next_follow_up_at->isToday()) {
return 'پیگیری امروز';
}
return 'ادامه پیگیری'; return 'ادامه پیگیری';
} }
private function followUpStatus(Lead $lead): string private function followUpStatus(Lead $lead): string
{ {
if (!$lead->next_follow_up_at) return 'بدون زمان پیگیری'; if (! $lead->next_follow_up_at) {
if ($lead->next_follow_up_at->isPast()) return 'عقب‌افتاده'; return 'بدون زمان پیگیری';
if ($lead->next_follow_up_at->isToday()) return 'امروز'; }
if ($lead->next_follow_up_at->isPast()) {
return 'عقب‌افتاده';
}
if ($lead->next_follow_up_at->isToday()) {
return 'امروز';
}
return 'زمان‌بندی‌شده'; return 'زمان‌بندی‌شده';
} }
private function followUpGroup(FollowUp $followUp): string private function followUpGroup(FollowUp $followUp): string
{ {
if ($followUp->status === 'completed') return 'done'; if ($followUp->status === 'completed') {
if ($followUp->scheduled_at->isPast() && !$followUp->scheduled_at->isToday()) return 'overdue'; return 'done';
if ($followUp->scheduled_at->isToday()) return 'today'; }
if ($followUp->scheduled_at->isTomorrow()) return 'tomorrow'; if ($followUp->scheduled_at->isPast() && ! $followUp->scheduled_at->isToday()) {
return 'overdue';
}
if ($followUp->scheduled_at->isToday()) {
return 'today';
}
if ($followUp->scheduled_at->isTomorrow()) {
return 'tomorrow';
}
return 'week'; return 'week';
} }

مشاهده پرونده

@ -24,7 +24,7 @@ class AssignmentController extends Controller
]); ]);
$lead = Lead::findOrFail($validated['lead_id']); $lead = Lead::findOrFail($validated['lead_id']);
$agent = User::findOrFail($validated['agent_id']); $agent = User::findOrFail($validated['agent_id']);
if (!$this->canAssignToAgent($lead, $agent)) { if (! $this->canAssignToAgent($lead, $agent)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
@ -47,7 +47,7 @@ class AssignmentController extends Controller
]); ]);
$agent = User::findOrFail($validated['agent_id']); $agent = User::findOrFail($validated['agent_id']);
$leads = Lead::whereIn('id', $validated['lead_ids'])->get(); $leads = Lead::whereIn('id', $validated['lead_ids'])->get();
if ($leads->count() !== count(array_unique($validated['lead_ids'])) || $leads->contains(fn(Lead $lead) => !$this->canAssignToAgent($lead, $agent))) { if ($leads->count() !== count(array_unique($validated['lead_ids'])) || $leads->contains(fn (Lead $lead) => ! $this->canAssignToAgent($lead, $agent))) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
@ -56,7 +56,7 @@ class AssignmentController extends Controller
$validated['agent_id'], $validated['agent_id'],
auth()->id() auth()->id()
); );
ActivityLogger::log('lead_bulk_owner_changed', count($leads) . " leads assigned to user {$agent->id}"); ActivityLogger::log('lead_bulk_owner_changed', count($leads)." leads assigned to user {$agent->id}");
return response()->json(['assigned' => count($leads)]); return response()->json(['assigned' => count($leads)]);
} }
@ -74,8 +74,8 @@ class AssignmentController extends Controller
if ( if (
$agents->count() !== count(array_unique($validated['agent_ids'])) $agents->count() !== count(array_unique($validated['agent_ids']))
|| $leads->count() !== count(array_unique($validated['lead_ids'])) || $leads->count() !== count(array_unique($validated['lead_ids']))
|| $leads->contains(fn(Lead $lead) => Gate::denies('assign', $lead)) || $leads->contains(fn (Lead $lead) => Gate::denies('assign', $lead))
|| $agents->contains(fn(User $agent) => !$this->agentIsAssignable($agent)) || $agents->contains(fn (User $agent) => ! $this->agentIsAssignable($agent))
) { ) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
@ -85,7 +85,7 @@ class AssignmentController extends Controller
$validated['agent_ids'], $validated['agent_ids'],
auth()->id() auth()->id()
); );
ActivityLogger::log('lead_round_robin_owner_changed', count($leads) . ' leads assigned by round robin'); ActivityLogger::log('lead_round_robin_owner_changed', count($leads).' leads assigned by round robin');
return response()->json(['assigned' => count($leads)]); return response()->json(['assigned' => count($leads)]);
} }
@ -98,7 +98,7 @@ class AssignmentController extends Controller
]); ]);
$lead = Lead::findOrFail($validated['lead_id']); $lead = Lead::findOrFail($validated['lead_id']);
$agent = User::findOrFail($validated['agent_id']); $agent = User::findOrFail($validated['agent_id']);
if (!$this->canAssignToAgent($lead, $agent)) { if (! $this->canAssignToAgent($lead, $agent)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
@ -112,12 +112,36 @@ class AssignmentController extends Controller
return response()->json($lead->load('assignedAgent')); return response()->json($lead->load('assignedAgent'));
} }
public function refer(Request $request, Lead $lead): JsonResponse
{
$validated = $request->validate(['user_id' => 'required|integer|exists:users,id']);
$actor = $request->user();
abort_unless(AccessControl::canAccessLead($actor, $lead), 403, 'به این لید دسترسی ندارید.');
if ($actor->hasRole('agent')) {
abort_unless($lead->assigned_to === $actor->id, 403, 'فقط لید تحت مسئولیت خود را می‌توانید ارجاع دهید.');
} else {
Gate::authorize('assign', $lead);
}
$target = User::whereKey($validated['user_id'])->where('is_active', true)->firstOrFail();
abort_unless($target->hasAnyRole(['agent', 'supervisor']), 422, 'مقصد ارجاع باید کارشناس یا مدیر فروش باشد.');
if (! $actor->hasRole('admin')) {
abort_unless((bool) array_intersect(AccessControl::teamIds($actor), AccessControl::teamIds($target)), 403, 'مقصد ارجاع خارج از تیم شما است.');
}
$updated = $this->assignmentService->assignToAgent($lead->id, $target->id, $actor->id);
ActivityLogger::log('lead_referred', "Lead {$lead->id} referred from {$actor->id} to {$target->id}", $updated);
return response()->json($updated->load('assignedAgent'));
}
public function returnToPool(int $leadId): JsonResponse public function returnToPool(int $leadId): JsonResponse
{ {
$leadModel = Lead::findOrFail($leadId); $leadModel = Lead::findOrFail($leadId);
Gate::authorize('assign', $leadModel); Gate::authorize('assign', $leadModel);
$lead = $this->assignmentService->returnToPool($leadId); $lead = $this->assignmentService->returnToPool($leadId);
return response()->json($lead); return response()->json($lead);
} }
@ -130,7 +154,7 @@ class AssignmentController extends Controller
{ {
$user = auth()->user(); $user = auth()->user();
if (!$agent->hasRole('agent') || !$agent->is_active) { if (! $agent->hasRole('agent') || ! $agent->is_active) {
return false; return false;
} }

مشاهده پرونده

@ -4,13 +4,12 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Attachment; use App\Models\Attachment;
use App\Models\Company;
use App\Models\Deal;
use App\Models\Lead;
use App\Models\Setting; use App\Models\Setting;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Support\EntityResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class AttachmentController extends Controller class AttachmentController extends Controller
@ -18,7 +17,8 @@ class AttachmentController extends Controller
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$validated = $request->validate(['entity_type' => 'required|in:lead,company,deal', 'entity_id' => 'required|integer']); $validated = $request->validate(['entity_type' => 'required|in:lead,company,deal', 'entity_id' => 'required|integer']);
$model = $this->resolve($validated['entity_type'], $validated['entity_id']); $model = EntityResolver::authorize($validated['entity_type'], $validated['entity_id']);
return response()->json(['data' => $model->attachments()->with('uploader:id,name')->latest()->get()]); return response()->json(['data' => $model->attachments()->with('uploader:id,name')->latest()->get()]);
} }
@ -27,12 +27,12 @@ class AttachmentController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'entity_type' => 'required|in:lead,company,deal', 'entity_type' => 'required|in:lead,company,deal',
'entity_id' => 'required|integer', 'entity_id' => 'required|integer',
'file' => 'required|file|mimes:' . $this->allowedMimes() . '|max:' . ($this->maxFileMb() * 1024), 'file' => 'required|file|mimes:'.$this->allowedMimes().'|max:'.($this->maxFileMb() * 1024),
], [ ], [
'file.mimes' => 'نوع فایل پیوست مجاز نیست.', 'file.mimes' => 'نوع فایل پیوست مجاز نیست.',
'file.max' => 'حجم فایل پیوست بیش از حد مجاز است.', 'file.max' => 'حجم فایل پیوست بیش از حد مجاز است.',
]); ]);
$model = $this->resolve($validated['entity_type'], $validated['entity_id']); $model = EntityResolver::authorize($validated['entity_type'], $validated['entity_id'], 'update');
$file = $request->file('file'); $file = $request->file('file');
$path = $file->store('attachments'); $path = $file->store('attachments');
@ -45,31 +45,26 @@ class AttachmentController extends Controller
]); ]);
ActivityLogger::log('attachment_uploaded', "File {$attachment->original_name} uploaded", $model); ActivityLogger::log('attachment_uploaded', "File {$attachment->original_name} uploaded", $model);
return response()->json($attachment->load('uploader:id,name'), 201); return response()->json($attachment->load('uploader:id,name'), 201);
} }
public function download(Attachment $attachment) public function download(Attachment $attachment)
{ {
Gate::authorize('view', $attachment);
ActivityLogger::log('attachment_downloaded', "File {$attachment->original_name} downloaded", $attachment->attachable); ActivityLogger::log('attachment_downloaded', "File {$attachment->original_name} downloaded", $attachment->attachable);
return Storage::download($attachment->path, $attachment->original_name); return Storage::download($attachment->path, $attachment->original_name);
} }
public function destroy(Attachment $attachment): JsonResponse public function destroy(Attachment $attachment): JsonResponse
{ {
abort_unless(auth()->user()?->hasRole('admin') || auth()->id() === $attachment->uploaded_by, 403, 'دسترسی غیرمجاز'); Gate::authorize('delete', $attachment);
Storage::delete($attachment->path); Storage::delete($attachment->path);
$attachment->delete(); $attachment->delete();
ActivityLogger::log('attachment_deleted', "File {$attachment->id} deleted"); ActivityLogger::log('attachment_deleted', "File {$attachment->id} deleted");
return response()->json(['message' => 'فایل حذف شد']);
}
private function resolve(string $type, int $id): Lead|Company|Deal return response()->json(['message' => 'فایل حذف شد']);
{
return match ($type) {
'lead' => Lead::findOrFail($id),
'company' => Company::findOrFail($id),
'deal' => Deal::findOrFail($id),
};
} }
private function allowedMimes(): string private function allowedMimes(): string

مشاهده پرونده

@ -26,13 +26,13 @@ class AuthController extends Controller
? User::whereRaw('lower(email) = ?', [$login])->first() ? User::whereRaw('lower(email) = ?', [$login])->first()
: User::get()->first(fn (User $candidate) => $this->normalizeLoginIdentifier($candidate->phone) === $login); : User::get()->first(fn (User $candidate) => $this->normalizeLoginIdentifier($candidate->phone) === $login);
if (!$user || !Hash::check($request->password, $user->password)) { if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'phone' => ['شماره موبایل یا رمز عبور اشتباه است'], 'phone' => ['شماره موبایل یا رمز عبور اشتباه است'],
]); ]);
} }
if (!$user->is_active) { if (! $user->is_active) {
return response()->json(['message' => 'حساب کاربری شما غیرفعال است'], 403); return response()->json(['message' => 'حساب کاربری شما غیرفعال است'], 403);
} }
@ -66,7 +66,7 @@ class AuthController extends Controller
])->save(); ])->save();
} }
ActivityLogger::log('logout', "User logged out"); ActivityLogger::log('logout', 'User logged out');
Auth::guard('web')->logout(); Auth::guard('web')->logout();
if ($request->hasSession()) { if ($request->hasSession()) {
@ -79,10 +79,20 @@ class AuthController extends Controller
public function me(Request $request): JsonResponse public function me(Request $request): JsonResponse
{ {
$user = $request->user()->load('roles.permissions', 'teams'); $user = Auth::guard('web')->user();
if (! $user) {
return response()->json([
'authenticated' => false,
'data' => null,
]);
}
$user->load('roles.permissions', 'teams');
$user->permissions = $user->getAllPermissions()->pluck('name'); $user->permissions = $user->getAllPermissions()->pluck('name');
return response()->json([ return response()->json([
'authenticated' => true,
'data' => $user, 'data' => $user,
]); ]);
} }
@ -93,8 +103,8 @@ class AuthController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $user->id, 'email' => 'required|email|unique:users,email,'.$user->id,
'phone' => 'nullable|string|max:20|unique:users,phone,' . $user->id, 'phone' => 'nullable|string|max:20|unique:users,phone,'.$user->id,
'password' => ['nullable', ...PasswordPolicy::rules()], 'password' => ['nullable', ...PasswordPolicy::rules()],
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048', 'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048',
]); ]);
@ -104,7 +114,7 @@ class AuthController extends Controller
$validated['avatar'] = $path; $validated['avatar'] = $path;
} }
if (!empty($validated['password'])) { if (! empty($validated['password'])) {
$validated['password'] = Hash::make($validated['password']); $validated['password'] = Hash::make($validated['password']);
} else { } else {
unset($validated['password']); unset($validated['password']);

مشاهده پرونده

@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\FollowUp;
use App\Models\Task;
use App\Support\AccessControl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class CalendarController extends Controller
{
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'from' => 'required|date',
'to' => 'required|date|after_or_equal:from',
'type' => 'nullable|string|in:task,follow_up',
]);
$from = Carbon::parse($validated['from'])->startOfDay();
$to = Carbon::parse($validated['to'])->endOfDay();
abort_if($from->diffInDays($to) > 370, 422, 'بازه تقویم نمی‌تواند بیشتر از یک سال باشد.');
$events = collect();
$type = $validated['type'] ?? null;
if ((! $type || $type === 'task') && Gate::allows('viewAny', Task::class)) {
$tasks = Task::query()
->with(['assignee:id,name', 'taskable'])
->whereNotNull('due_at')
->whereBetween('due_at', [$from, $to]);
AccessControl::scopeTasks($tasks, $request->user());
$events->push(...$tasks->get()->map(fn (Task $task) => [
'id' => "task-{$task->id}",
'entity_id' => $task->id,
'type' => 'task',
'title' => $task->subject,
'starts_at' => $task->due_at,
'status' => is_object($task->status) ? $task->status->value : $task->status,
'priority' => is_object($task->priority) ? $task->priority->value : $task->priority,
'assignee' => $task->assignee?->only(['id', 'name']),
'related' => $task->taskable ? [
'type' => class_basename($task->taskable_type),
'id' => $task->taskable_id,
'label' => $task->taskable->name ?? $task->taskable->title ?? $task->taskable->company ?? null,
] : null,
'url' => "/tasks?task={$task->id}",
]));
}
if ((! $type || $type === 'follow_up') && Gate::allows('viewAny', FollowUp::class)) {
$followUps = FollowUp::query()
->with(['lead:id,first_name,last_name,company', 'user:id,name'])
->whereBetween('scheduled_at', [$from, $to]);
AccessControl::scopeFollowUps($followUps, $request->user());
$events->push(...$followUps->get()->map(fn (FollowUp $followUp) => [
'id' => "follow-up-{$followUp->id}",
'entity_id' => $followUp->id,
'type' => 'follow_up',
'title' => $followUp->notes ?: 'پیگیری لید',
'starts_at' => $followUp->scheduled_at,
'status' => $followUp->status,
'priority' => null,
'assignee' => $followUp->user?->only(['id', 'name']),
'related' => $followUp->lead ? [
'type' => 'Lead',
'id' => $followUp->lead_id,
'label' => $followUp->lead->company ?: trim("{$followUp->lead->first_name} {$followUp->lead->last_name}"),
] : null,
'url' => "/follow-ups?follow_up={$followUp->id}",
]));
}
abort_if($events->isEmpty() && ! Gate::allows('viewAny', Task::class) && ! Gate::allows('viewAny', FollowUp::class), 403);
return response()->json(['events' => $events->sortBy('starts_at')->values()]);
}
}

مشاهده پرونده

@ -3,6 +3,8 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\CallResource;
use App\Http\Responses\ApiResponse;
use App\Models\Call; use App\Models\Call;
use App\Models\CallResult; use App\Models\CallResult;
use App\Models\Lead; use App\Models\Lead;
@ -26,13 +28,19 @@ class CallController extends Controller
$user = auth()->user(); $user = auth()->user();
Gate::authorize('viewAny', Call::class); Gate::authorize('viewAny', Call::class);
$query = Call::with('lead:id,first_name,last_name,phone', 'user:id,name', 'contact:id,name,role', 'contactPhone:id,phone,type,status'); $query = Call::with('lead:id,first_name,last_name,company,phone', 'user:id,name', 'contact:id,name,role', 'contactPhone:id,phone,type,status');
AccessControl::scopeCalls($query, $user); AccessControl::scopeCalls($query, $user);
if ($request->lead_id) { if ($request->lead_id) {
$query->where('lead_id', $request->lead_id); $query->where('lead_id', $request->lead_id);
} }
if ($request->user_id) {
$query->where('user_id', $request->user_id);
}
if ($request->direction) {
$query->where('direction', $request->direction);
}
if ($request->result) { if ($request->result) {
$query->where('result', $request->result); $query->where('result', $request->result);
} }
@ -42,16 +50,30 @@ class CallController extends Controller
if ($request->date_to) { if ($request->date_to) {
$query->whereDate('created_at', '<=', $request->date_to); $query->whereDate('created_at', '<=', $request->date_to);
} }
if ($request->filled('search')) {
$search = trim((string) $request->get('search'));
$query->where(function ($searchQuery) use ($search): void {
$searchQuery->where('provider_call_id', 'like', "%{$search}%")
->orWhereHas('lead', fn ($leadQuery) => $leadQuery
->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('company', 'like', "%{$search}%"))
->orWhereHas('contact', fn ($contactQuery) => $contactQuery->where('name', 'like', "%{$search}%"));
});
}
return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15)); $paginator = $query->orderByDesc('created_at')->paginate(min((int) $request->get('per_page', 15), 100));
return ApiResponse::paginated($paginator, fn (Call $call) => $this->resource($call));
} }
public function results(): JsonResponse public function results(): JsonResponse
{ {
return response()->json( return ApiResponse::success(
CallResult::where('is_active', true) CallResult::where('is_active', true)
->orderBy('sort_order') ->orderBy('sort_order')
->get(['id', 'name', 'slug', 'color', 'requires_follow_up', 'is_positive', 'is_negative', 'is_final']) ->get(['id', 'name', 'slug', 'color', 'requires_follow_up', 'is_positive', 'is_negative', 'is_final'])
->toArray()
); );
} }
@ -67,7 +89,7 @@ class CallController extends Controller
$result = $this->callService->initiateCall($validated['lead_id'], auth()->id(), $validated['contact_phone_id'] ?? null); $result = $this->callService->initiateCall($validated['lead_id'], auth()->id(), $validated['contact_phone_id'] ?? null);
return response()->json($result, 201); return ApiResponse::success($result, 201, 'تماس آغاز شد.');
} }
public function show(Call $call): JsonResponse public function show(Call $call): JsonResponse
@ -77,7 +99,7 @@ class CallController extends Controller
ActivityLogger::log('recording_viewed', "Recording viewed for call {$call->id}", $call); ActivityLogger::log('recording_viewed', "Recording viewed for call {$call->id}", $call);
} }
return response()->json($call->load('lead', 'user', 'contact', 'contactPhone')); return ApiResponse::success($this->resource($call->load('lead', 'user', 'contact', 'contactPhone')));
} }
public function registerResult(Request $request): JsonResponse public function registerResult(Request $request): JsonResponse
@ -113,6 +135,54 @@ class CallController extends Controller
$validated['referral'] ?? null $validated['referral'] ?? null
); );
return response()->json($call); return ApiResponse::success($this->resource($call->loadMissing('lead', 'user', 'contact', 'contactPhone')), message: 'نتیجه تماس ثبت شد.');
}
public function manualResult(Request $request): JsonResponse
{
$validated = $request->validate([
'lead_id' => 'required|exists:leads,id',
'contact_phone_id' => 'required|exists:contact_phones,id',
'result' => 'required|string|max:50',
'notes' => 'nullable|string',
'next_follow_up_at' => 'nullable|date',
'referral' => 'nullable|array',
'referral.name' => 'required_with:referral|string|max:255',
'referral.phone' => 'required_with:referral|string|max:30',
'referral.phone_type' => 'nullable|string|in:mobile,landline,extension',
'referral.role' => 'nullable|string|max:80',
'referral.relation_description' => 'nullable|string|max:255',
'referral.description' => 'nullable|string',
'referral.make_primary' => 'nullable|boolean',
'referral.next_follow_up_at' => 'nullable|date',
]);
$lead = Lead::findOrFail($validated['lead_id']);
Gate::authorize('create', [Call::class, $lead]);
$callResult = CallResult::where('name', $validated['result'])->first();
if ($callResult?->requires_follow_up && empty($validated['next_follow_up_at']) && empty($validated['referral']['next_follow_up_at'])) {
return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422);
}
$call = $this->callService->recordManualResult(
$lead->id,
(int) $request->user()->id,
(int) $validated['contact_phone_id'],
$validated['result'],
$validated['notes'] ?? null,
$validated['next_follow_up_at'] ?? null,
$validated['referral'] ?? null,
);
return ApiResponse::success(
$this->resource($call->loadMissing('lead', 'user', 'contact', 'contactPhone')),
201,
'تماس و نتیجه آن ثبت شد.'
);
}
private function resource(Call $call): array
{
return (new CallResource($call))->resolve(request());
} }
} }

مشاهده پرونده

@ -3,18 +3,30 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\CampaignResource;
use App\Http\Responses\ApiResponse;
use App\Models\Campaign; use App\Models\Campaign;
use App\Models\User;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Support\AccessControl; use App\Support\AccessControl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
class CampaignController extends Controller class CampaignController extends Controller
{ {
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$query = Campaign::withCount('leads')->with('assignedAgents:id,name', 'assignedSupervisors:id,name'); $query = Campaign::with('assignedAgents:id,name', 'assignedSupervisors:id,name', 'salesScript:id,title', 'product:id,name,base_price')
->withCount([
'leads',
'leads as contacted_leads_count' => fn ($leadQuery) => $leadQuery->whereNotNull('last_call_at'),
'leads as won_leads_count' => fn ($leadQuery) => $leadQuery->where('final_result', 'موفق'),
])
->withSum([
'leads as won_value_sum' => fn ($leadQuery) => $leadQuery->where('final_result', 'موفق'),
], 'deal_value');
Gate::authorize('viewAny', Campaign::class); Gate::authorize('viewAny', Campaign::class);
AccessControl::scopeCampaigns($query, auth()->user()); AccessControl::scopeCampaigns($query, auth()->user());
@ -26,7 +38,9 @@ class CampaignController extends Controller
$query->where('status', $request->status); $query->where('status', $request->status);
} }
return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15)); $paginator = $query->orderByDesc('created_at')->paginate(min((int) $request->get('per_page', 15), 100));
return ApiResponse::paginated($paginator, fn (Campaign $campaign) => $this->resource($campaign));
} }
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
@ -37,40 +51,44 @@ class CampaignController extends Controller
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
'product_service' => 'nullable|string|max:255', 'product_service' => 'nullable|string|max:255',
'product_id' => 'nullable|exists:products,id',
'channel' => 'nullable|string|in:phone,sms,email,social,advertising,event,referral,other',
'start_date' => 'nullable|date', 'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date', 'end_date' => 'nullable|date|after_or_equal:start_date',
'target' => 'nullable|integer|min:0', 'target' => 'nullable|integer|min:0',
'budget' => 'nullable|numeric|min:0',
'actual_cost' => 'nullable|numeric|min:0',
'status' => 'nullable|string|in:draft,active,paused,completed,archived', 'status' => 'nullable|string|in:draft,active,paused,completed,archived',
'sales_script_id' => 'nullable|exists:sales_scripts,id',
'agent_ids' => 'nullable|array', 'agent_ids' => 'nullable|array',
'agent_ids.*' => 'exists:users,id', 'agent_ids.*' => 'exists:users,id',
'supervisor_ids' => 'nullable|array', 'supervisor_ids' => 'nullable|array',
'supervisor_ids.*' => 'exists:users,id', 'supervisor_ids.*' => 'exists:users,id',
]); ]);
$campaign = Campaign::create($validated); $this->authorizeAssignments($validated);
$campaign = DB::transaction(function () use ($validated): Campaign {
$campaign = Campaign::create(collect($validated)->except(['agent_ids', 'supervisor_ids'])->all());
$this->syncAssignments($campaign, $validated['agent_ids'] ?? [], $validated['supervisor_ids'] ?? []);
if ($request->agent_ids) { return $campaign;
$campaign->assignedAgents()->sync($request->agent_ids); });
}
if ($request->supervisor_ids) {
$campaign->assignedSupervisors()->sync($request->supervisor_ids);
}
ActivityLogger::log('campaign_created', "Campaign {$campaign->name} created", $campaign); ActivityLogger::log('campaign_created', "Campaign {$campaign->name} created", $campaign);
return response()->json($campaign->load('assignedAgents', 'assignedSupervisors'), 201); return ApiResponse::success($this->resource($campaign->load('assignedAgents', 'assignedSupervisors', 'salesScript', 'product')), 201, 'کمپین ایجاد شد.');
} }
public function show(Campaign $campaign): JsonResponse public function show(Campaign $campaign): JsonResponse
{ {
Gate::authorize('view', $campaign); Gate::authorize('view', $campaign);
return response()->json( return ApiResponse::success($this->resource(
$campaign->load([ $campaign->load([
'assignedAgents', 'assignedSupervisors', 'salesScript', 'assignedAgents', 'assignedSupervisors', 'salesScript', 'product',
'leads' => fn($q) => $q->with('leadStatus'), 'leads' => fn ($q) => $q->with('leadStatus'),
]) ])
); ));
} }
public function update(Request $request, Campaign $campaign): JsonResponse public function update(Request $request, Campaign $campaign): JsonResponse
@ -81,28 +99,34 @@ class CampaignController extends Controller
'name' => 'sometimes|string|max:255', 'name' => 'sometimes|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
'product_service' => 'nullable|string|max:255', 'product_service' => 'nullable|string|max:255',
'product_id' => 'nullable|exists:products,id',
'channel' => 'nullable|string|in:phone,sms,email,social,advertising,event,referral,other',
'start_date' => 'nullable|date', 'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date', 'end_date' => 'nullable|date|after_or_equal:start_date',
'target' => 'nullable|integer|min:0', 'target' => 'nullable|integer|min:0',
'budget' => 'nullable|numeric|min:0',
'actual_cost' => 'nullable|numeric|min:0',
'status' => 'nullable|string|in:draft,active,paused,completed,archived', 'status' => 'nullable|string|in:draft,active,paused,completed,archived',
'sales_script_id' => 'nullable|exists:sales_scripts,id',
'agent_ids' => 'nullable|array', 'agent_ids' => 'nullable|array',
'agent_ids.*' => 'exists:users,id', 'agent_ids.*' => 'exists:users,id',
'supervisor_ids' => 'nullable|array', 'supervisor_ids' => 'nullable|array',
'supervisor_ids.*' => 'exists:users,id', 'supervisor_ids.*' => 'exists:users,id',
]); ]);
$campaign->update($validated); $this->authorizeAssignments($validated);
DB::transaction(function () use ($campaign, $validated, $request): void {
if ($request->has('agent_ids')) { $campaign->update(collect($validated)->except(['agent_ids', 'supervisor_ids'])->all());
$campaign->assignedAgents()->sync($request->agent_ids); if ($request->has('agent_ids') || $request->has('supervisor_ids')) {
} $agentIds = $request->has('agent_ids') ? ($validated['agent_ids'] ?? []) : $campaign->assignedAgents()->pluck('users.id')->all();
if ($request->has('supervisor_ids')) { $supervisorIds = $request->has('supervisor_ids') ? ($validated['supervisor_ids'] ?? []) : $campaign->assignedSupervisors()->pluck('users.id')->all();
$campaign->assignedSupervisors()->sync($request->supervisor_ids); $this->syncAssignments($campaign, $agentIds, $supervisorIds);
} }
});
ActivityLogger::log('campaign_updated', "Campaign {$campaign->name} updated", $campaign); ActivityLogger::log('campaign_updated', "Campaign {$campaign->name} updated", $campaign);
return response()->json($campaign->load('assignedAgents', 'assignedSupervisors')); return ApiResponse::success($this->resource($campaign->load('assignedAgents', 'assignedSupervisors', 'salesScript', 'product')), message: 'کمپین ویرایش شد.');
} }
public function destroy(Campaign $campaign): JsonResponse public function destroy(Campaign $campaign): JsonResponse
@ -111,6 +135,38 @@ class CampaignController extends Controller
$campaign->delete(); $campaign->delete();
ActivityLogger::log('campaign_deleted', "Campaign {$campaign->name} deleted"); ActivityLogger::log('campaign_deleted', "Campaign {$campaign->name} deleted");
return response()->json(['message' => 'کمپین حذف شد']);
return ApiResponse::success(null, message: 'کمپین حذف شد.');
}
private function authorizeAssignments(array $validated): void
{
foreach (['agent_ids' => 'agent', 'supervisor_ids' => 'supervisor'] as $key => $role) {
foreach ($validated[$key] ?? [] as $userId) {
$user = User::findOrFail($userId);
abort_unless($user->hasRole($role), 422, 'نقش کاربر انتخاب‌شده با نوع تخصیص سازگار نیست.');
abort_unless(AccessControl::canAssignUser(auth()->user(), $user->id), 403, 'کاربر انتخاب‌شده خارج از محدوده تیم شما است.');
}
}
}
private function syncAssignments(Campaign $campaign, array $agentIds, array $supervisorIds): void
{
$pivot = [];
foreach ($agentIds as $id) {
$pivot[$id] = ['role' => 'agent'];
}
foreach ($supervisorIds as $id) {
$pivot[$id] = ['role' => 'supervisor'];
}
$campaign->assignedAgents()->newPivotQuery()->where('campaign_id', $campaign->id)->delete();
if ($pivot) {
$campaign->assignedAgents()->attach($pivot);
}
}
private function resource(Campaign $campaign): array
{
return (new CampaignResource($campaign))->resolve(request());
} }
} }

مشاهده پرونده

@ -9,6 +9,7 @@ use App\Services\DuplicateService;
use App\Support\AccessControl; use App\Support\AccessControl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class CompanyController extends Controller class CompanyController extends Controller
{ {
@ -16,23 +17,31 @@ class CompanyController extends Controller
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
Gate::authorize('viewAny', Company::class);
$query = Company::with('owner:id,name')->withCount(['contacts', 'leads', 'deals']); $query = Company::with('owner:id,name')->withCount(['contacts', 'leads', 'deals']);
$this->scope($query); AccessControl::scopeCompanies($query, auth()->user());
if ($request->search) { if ($request->search) {
$search = $request->search; $search = $request->search;
$query->where(fn($q) => $q->where('name', 'like', "%{$search}%")->orWhere('website', 'like', "%{$search}%")->orWhere('city', 'like', "%{$search}%")); $query->where(fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('website', 'like', "%{$search}%")->orWhere('city', 'like', "%{$search}%"));
}
if ($request->status) {
$query->where('status', $request->status);
}
if ($request->owner_id) {
$query->where('owner_id', $request->owner_id);
} }
if ($request->status) $query->where('status', $request->status);
if ($request->owner_id) $query->where('owner_id', $request->owner_id);
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100))); return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
} }
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
Gate::authorize('create', Company::class);
$validated = $this->validated($request); $validated = $this->validated($request);
$suggestions = $this->duplicates->companySuggestions($validated); $validated['owner_id'] ??= auth()->id();
abort_unless(AccessControl::canAssignUser(auth()->user(), $validated['owner_id']), 403, 'مالک انتخاب‌شده خارج از محدوده مجاز است.');
$suggestions = $this->duplicates->companySuggestions($validated, null, auth()->user());
if ($request->boolean('block_duplicates') && $suggestions) { if ($request->boolean('block_duplicates') && $suggestions) {
return response()->json(['message' => 'شرکت مشابهی در سیستم وجود دارد.', 'duplicates' => $suggestions], 422); return response()->json(['message' => 'شرکت مشابهی در سیستم وجود دارد.', 'duplicates' => $suggestions], 422);
} }
@ -41,13 +50,17 @@ class CompanyController extends Controller
ActivityLogger::log('company_created', "Company {$company->name} created", $company); ActivityLogger::log('company_created', "Company {$company->name} created", $company);
$payload = $company->load('owner:id,name')->toArray(); $payload = $company->load('owner:id,name')->toArray();
if ($suggestions) $payload['duplicate_suggestions'] = $suggestions; if ($suggestions) {
$payload['duplicate_suggestions'] = $suggestions;
}
return response()->json($payload, 201); return response()->json($payload, 201);
} }
public function show(Company $company): JsonResponse public function show(Company $company): JsonResponse
{ {
$this->authorizeAccess($company); Gate::authorize('view', $company);
return response()->json($company->load([ return response()->json($company->load([
'owner:id,name', 'contacts.phones', 'leads.pipelineStage', 'deals.product', 'notes.user:id,name', 'attachments.uploader:id,name', 'owner:id,name', 'contacts.phones', 'leads.pipelineStage', 'deals.product', 'notes.user:id,name', 'attachments.uploader:id,name',
])); ]));
@ -55,34 +68,43 @@ class CompanyController extends Controller
public function update(Request $request, Company $company): JsonResponse public function update(Request $request, Company $company): JsonResponse
{ {
$this->authorizeAccess($company); Gate::authorize('update', $company);
$validated = $this->validated($request, true); $validated = $this->validated($request, true);
if (array_key_exists('owner_id', $validated)) {
abort_unless(AccessControl::canAssignUser(auth()->user(), $validated['owner_id']), 403, 'مالک انتخاب‌شده خارج از محدوده مجاز است.');
}
$company->update($this->prepare($validated)); $company->update($this->prepare($validated));
ActivityLogger::log('company_updated', "Company {$company->name} updated", $company); ActivityLogger::log('company_updated', "Company {$company->name} updated", $company);
return response()->json($company->fresh('owner:id,name')); return response()->json($company->fresh('owner:id,name'));
} }
public function destroy(Company $company): JsonResponse public function destroy(Company $company): JsonResponse
{ {
$this->authorizeAccess($company, true); Gate::authorize('delete', $company);
$company->delete(); $company->delete();
ActivityLogger::log('company_deleted', "Company {$company->id} deleted"); ActivityLogger::log('company_deleted', "Company {$company->id} deleted");
return response()->json(['message' => 'شرکت حذف شد']); return response()->json(['message' => 'شرکت حذف شد']);
} }
public function merge(Request $request, Company $company): JsonResponse public function merge(Request $request, Company $company): JsonResponse
{ {
abort_unless(auth()->user()?->hasRole('admin') || auth()->user()?->can('merge_duplicates'), 403, 'شما مجوز ادغام رکوردهای تکراری را ندارید.'); Gate::authorize('update', $company);
$validated = $request->validate(['target_id' => 'required|exists:companies,id|different:' . $company->id]); abort_unless(auth()->user()?->can('merge_duplicates'), 403, 'شما مجوز ادغام رکوردهای تکراری را ندارید.');
$validated = $request->validate(['target_id' => 'required|exists:companies,id|different:'.$company->id]);
$target = Company::findOrFail($validated['target_id']); $target = Company::findOrFail($validated['target_id']);
Gate::authorize('update', $target);
return response()->json($this->duplicates->mergeCompanies($company, $target, auth()->id())); return response()->json($this->duplicates->mergeCompanies($company, $target, auth()->id()));
} }
private function validated(Request $request, bool $partial = false): array private function validated(Request $request, bool $partial = false): array
{ {
$sometimes = $partial ? 'sometimes|' : ''; $sometimes = $partial ? 'sometimes|' : '';
return $request->validate([ return $request->validate([
'name' => $sometimes . 'required|string|max:255', 'name' => $sometimes.'required|string|max:255',
'company_type' => 'nullable|string|max:80', 'company_type' => 'nullable|string|max:80',
'industry' => 'nullable|string|max:120', 'industry' => 'nullable|string|max:120',
'city' => 'nullable|string|max:120', 'city' => 'nullable|string|max:120',
@ -96,26 +118,13 @@ class CompanyController extends Controller
private function prepare(array $data): array private function prepare(array $data): array
{ {
if (array_key_exists('name', $data)) $data['normalized_name'] = DuplicateService::normalizeName($data['name']); if (array_key_exists('name', $data)) {
if (array_key_exists('website', $data)) $data['normalized_website'] = DuplicateService::normalizeWebsite($data['website']); $data['normalized_name'] = DuplicateService::normalizeName($data['name']);
}
if (array_key_exists('website', $data)) {
$data['normalized_website'] = DuplicateService::normalizeWebsite($data['website']);
}
return $data; return $data;
} }
private function scope($query): void
{
$user = auth()->user();
if ($user?->hasRole('admin')) return;
if ($user?->hasRole('agent')) $query->where('owner_id', $user->id);
if ($user?->hasRole('supervisor')) {
$query->where(function ($q) use ($user) {
$q->where('owner_id', $user->id)->orWhereIn('owner_id', AccessControl::teamMemberIds($user));
});
}
}
private function authorizeAccess(Company $company, bool $manage = false): void
{
$user = auth()->user();
abort_unless($user && ($user->hasRole('admin') || (!$manage && $company->owner_id === $user->id) || (!$manage && $user->hasRole('supervisor') && in_array($company->owner_id, AccessControl::teamMemberIds($user), true))), 403, 'دسترسی غیرمجاز');
}
} }

مشاهده پرونده

@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AutomationRule;
use App\Models\AutomationRun;
use App\Models\Company;
use App\Models\Contact;
use App\Models\CustomFieldDefinition;
use App\Models\CustomFieldValue;
use App\Models\Lead;
use App\Services\ActivityLogger;
use App\Services\AutomationEngine;
use App\Support\AccessControl;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class ConfigurationController extends Controller
{
private const ENTITIES = ['lead' => Lead::class, 'company' => Company::class, 'contact' => Contact::class];
public function __construct(private AutomationEngine $engine) {}
public function automations(Request $request): JsonResponse
{
abort_unless($request->user()->can('manage_automations') || $request->user()->can('view_automation_logs'), 403);
$query = AutomationRule::withCount('runs')->with('creator:id,name')
->where('trigger', '!=', 'deal_stage_changed')->latest();
if (! $request->user()->hasRole('admin')) {
$query->where(fn ($q) => $q->whereNull('team_id')->orWhereIn('team_id', AccessControl::teamIds($request->user())));
}
return response()->json($query->get());
}
public function storeAutomation(Request $request): JsonResponse
{
abort_unless($request->user()->can('manage_automations'), 403);
$data = $request->validate([
'name' => 'required|string|max:120', 'trigger' => 'required|in:manual,lead_created,lead_scored,sla_breached',
'conditions' => 'nullable|array|max:20', 'actions' => 'required|array|min:1|max:10',
'conditions.*.field' => 'required|string|max:80', 'conditions.*.operator' => 'required|in:equals,not_equals,greater_than,less_than,contains',
'conditions.*.value' => 'nullable',
'actions.*.type' => 'required|in:create_task,notify,set_lead_priority', 'team_id' => 'nullable|exists:teams,id',
'actions.*.subject' => 'nullable|string|max:255', 'actions.*.assigned_to' => 'nullable|exists:users,id',
'actions.*.priority' => 'nullable|in:low,normal,high,urgent', 'actions.*.due_in_minutes' => 'nullable|integer|min:1|max:525600',
'actions.*.user_id' => 'nullable|exists:users,id', 'actions.*.title' => 'nullable|string|max:255',
'actions.*.message' => 'nullable|string|max:1000', 'actions.*.value' => 'nullable|integer|min:0|max:4',
'is_active' => 'boolean', 'max_runs_per_record' => 'integer|min:1|max:20',
]);
if (! $request->user()->hasRole('admin') && ! empty($data['team_id'])) {
abort_unless(in_array((int) $data['team_id'], AccessControl::teamIds($request->user()), true), 403);
}
return response()->json(AutomationRule::create($data + ['created_by' => $request->user()->id]), 201);
}
public function runAutomation(Request $request, AutomationRule $automationRule): JsonResponse
{
abort_unless($request->user()->can('manage_automations'), 403);
$data = $request->validate(['entity_type' => ['required', Rule::in(array_keys(self::ENTITIES))], 'entity_id' => 'required|integer|min:1', 'event_key' => 'nullable|string|max:150']);
$subject = self::ENTITIES[$data['entity_type']]::findOrFail($data['entity_id']);
abort_unless(AccessControl::canAccessEntity($request->user(), $subject), 403);
$key = $data['event_key'] ?? "manual:{$automationRule->id}:{$data['entity_type']}:{$subject->id}:".Str::uuid();
return response()->json($this->engine->run($automationRule, $subject, $key), 202);
}
public function automationRuns(Request $request): JsonResponse
{
abort_unless($request->user()->can('view_automation_logs'), 403);
return response()->json(AutomationRun::with('rule:id,name')->latest()->paginate(min((int) $request->get('per_page', 20), 100)));
}
public function customFields(Request $request): JsonResponse
{
$data = $request->validate(['entity_type' => ['required', Rule::in(array_keys(self::ENTITIES))]]);
$role = $request->user()->roles->first()?->name;
$fields = CustomFieldDefinition::where('entity_type', $data['entity_type'])->where('is_active', true)
->where(fn ($q) => $q->whereNull('visible_to_roles')->orWhereJsonContains('visible_to_roles', $role))->orderBy('sort_order')->get();
return response()->json($fields);
}
public function storeCustomField(Request $request): JsonResponse
{
abort_unless($request->user()->can('manage_custom_fields'), 403);
$data = $request->validate([
'entity_type' => ['required', Rule::in(array_keys(self::ENTITIES))], 'key' => ['required', 'alpha_dash', 'max:80'],
'label' => 'required|string|max:120', 'type' => 'required|in:text,textarea,number,date,datetime,boolean,select,multiselect',
'options' => 'nullable|array|max:100', 'validation' => 'nullable|array|max:20', 'visible_to_roles' => 'nullable|array|max:3',
'default_value' => 'nullable|string|max:1000', 'sort_order' => 'integer|min:0|max:1000', 'is_required' => 'boolean',
'is_active' => 'boolean', 'is_filterable' => 'boolean', 'is_searchable' => 'boolean',
]);
abort_if(CustomFieldDefinition::where('entity_type', $data['entity_type'])->where('key', $data['key'])->exists(), 422, 'کلید فیلد تکراری است.');
return response()->json(CustomFieldDefinition::create($data + ['created_by' => $request->user()->id]), 201);
}
public function values(Request $request, string $entityType, int $entityId): JsonResponse
{
$subject = $this->subject($request, $entityType, $entityId);
return response()->json(CustomFieldValue::with('definition')->where('fieldable_type', $subject::class)->where('fieldable_id', $subject->id)->get());
}
public function updateValues(Request $request, string $entityType, int $entityId): JsonResponse
{
$subject = $this->subject($request, $entityType, $entityId);
$data = $request->validate(['values' => 'required|array|max:100']);
$definitions = CustomFieldDefinition::where('entity_type', $entityType)->where('is_active', true)->get()->keyBy('key');
foreach ($data['values'] as $key => $value) {
$definition = $definitions->get($key);
if (! $definition) {
continue;
}
$column = match ($definition->type) {
'number' => 'value_number', 'date' => 'value_date', 'datetime' => 'value_datetime', 'boolean' => 'value_boolean', 'multiselect' => 'value_json', 'textarea' => 'value_text', default => 'value_string'
};
CustomFieldValue::updateOrCreate([
'custom_field_definition_id' => $definition->id, 'fieldable_type' => $subject::class, 'fieldable_id' => $subject->id,
], [$column => $value, 'updated_by' => $request->user()->id]);
}
ActivityLogger::log('custom_fields_updated', "Custom fields updated for {$entityType} {$entityId}", $subject);
return $this->values($request, $entityType, $entityId);
}
private function subject(Request $request, string $type, int $id): Model
{
abort_unless(isset(self::ENTITIES[$type]), 404);
$subject = self::ENTITIES[$type]::findOrFail($id);
abort_unless(AccessControl::canAccessEntity($request->user(), $subject), 403);
return $subject;
}
}

مشاهده پرونده

@ -3,8 +3,10 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\Contact; use App\Models\Contact;
use App\Models\ContactPhone; use App\Models\ContactPhone;
use App\Models\Deal;
use App\Models\Lead; use App\Models\Lead;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Support\AccessControl; use App\Support\AccessControl;
@ -12,20 +14,31 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
class ContactController extends Controller class ContactController extends Controller
{ {
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
Gate::authorize('viewAny', Contact::class);
$query = Contact::with('phones', 'lead:id,company,first_name,last_name', 'company:id,name', 'deal:id,title'); $query = Contact::with('phones', 'lead:id,company,first_name,last_name', 'company:id,name', 'deal:id,title');
AccessControl::scopeContacts($query, auth()->user());
if ($request->lead_id) $query->where('lead_id', $request->lead_id); if ($request->lead_id) {
if ($request->company_id) $query->where('company_id', $request->company_id); $query->where('lead_id', $request->lead_id);
if ($request->deal_id) $query->where('deal_id', $request->deal_id); }
if ($request->status) $query->where('status', $request->status); if ($request->company_id) {
$query->where('company_id', $request->company_id);
}
if ($request->deal_id) {
$query->where('deal_id', $request->deal_id);
}
if ($request->status) {
$query->where('status', $request->status);
}
if ($request->search) { if ($request->search) {
$search = $request->search; $search = $request->search;
$query->where(fn($q) => $q->where('name', 'like', "%{$search}%")->orWhere('email', 'like', "%{$search}%")); $query->where(fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('email', 'like', "%{$search}%"));
} }
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100))); return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
@ -33,9 +46,12 @@ class ContactController extends Controller
public function storeStandalone(Request $request): JsonResponse public function storeStandalone(Request $request): JsonResponse
{ {
Gate::authorize('create', Contact::class);
$validated = $this->validateContact($request); $validated = $this->validateContact($request);
$contact = DB::transaction(fn() => $this->createContact($validated)); $this->authorizeParents($validated, 'update');
$contact = DB::transaction(fn () => $this->createContact($validated));
ActivityLogger::log('contact_created', "Contact {$contact->name} created", $contact); ActivityLogger::log('contact_created', "Contact {$contact->name} created", $contact);
return response()->json($contact->load('phones.lastCaller', 'company:id,name', 'lead:id,company'), 201); return response()->json($contact->load('phones.lastCaller', 'company:id,name', 'lead:id,company'), 201);
} }
@ -45,9 +61,10 @@ class ContactController extends Controller
$validated = $this->validateContact($request); $validated = $this->validateContact($request);
$validated['lead_id'] = $lead->id; $validated['lead_id'] = $lead->id;
$this->authorizeParents($validated, 'update');
$contact = DB::transaction(function () use ($lead, $validated) { $contact = DB::transaction(function () use ($lead, $validated) {
if (!empty($validated['is_primary'])) { if (! empty($validated['is_primary'])) {
Contact::where('lead_id', $lead->id)->update(['is_primary' => false]); Contact::where('lead_id', $lead->id)->update(['is_primary' => false]);
} }
@ -67,16 +84,14 @@ class ContactController extends Controller
public function setPrimary(Request $request, Contact $contact): JsonResponse public function setPrimary(Request $request, Contact $contact): JsonResponse
{ {
if ($contact->lead) { Gate::authorize('update', $contact);
$this->authorizeLeadAccess($contact->lead);
}
$validated = $request->validate([ $validated = $request->validate([
'reason' => 'nullable|string|max:255', 'reason' => 'nullable|string|max:255',
]); ]);
DB::transaction(function () use ($contact, $validated) { DB::transaction(function () use ($contact, $validated) {
Contact::where('lead_id', $contact->lead_id)->update(['is_primary' => false]); $this->primaryScope($contact)->whereKeyNot($contact->id)->update(['is_primary' => false]);
$contact->update([ $contact->update([
'is_primary' => true, 'is_primary' => true,
'primary_reason' => $validated['reason'] ?? 'انتخاب دستی توسط کاربر', 'primary_reason' => $validated['reason'] ?? 'انتخاب دستی توسط کاربر',
@ -90,27 +105,54 @@ class ContactController extends Controller
public function update(Request $request, Contact $contact): JsonResponse public function update(Request $request, Contact $contact): JsonResponse
{ {
Gate::authorize('update', $contact);
$validated = $this->validateContact($request, true); $validated = $this->validateContact($request, true);
$contact->update($validated); $this->authorizeParents($validated, 'update', $contact);
if ($request->has('phones')) { DB::transaction(function () use ($contact, $validated, $request): void {
$contact->phones()->delete(); $beforePhoneIds = $contact->phones()->withTrashed()->pluck('id')->all();
foreach ($validated['phones'] ?? [] as $phone) { $contact->update(collect($validated)->except('phones')->all());
ContactPhone::create([ if ($contact->is_primary) {
'contact_id' => $contact->id, $this->primaryScope($contact)->whereKeyNot($contact->id)->update(['is_primary' => false]);
'phone' => $phone['phone'],
'type' => $phone['type'] ?? 'mobile',
'status' => $phone['status'] ?? 'active',
]);
} }
} if ($request->has('phones')) {
$keptIds = [];
foreach ($validated['phones'] ?? [] as $phone) {
if (! empty($phone['id'])) {
$existing = $contact->phones()->withTrashed()->findOrFail($phone['id']);
$existing->restore();
$existing->update([
'phone' => $phone['phone'],
'type' => $phone['type'] ?? $existing->type,
'status' => $phone['status'] ?? 'active',
]);
$keptIds[] = $existing->id;
} else {
$keptIds[] = ContactPhone::create([
'contact_id' => $contact->id,
'phone' => $phone['phone'],
'type' => $phone['type'] ?? 'mobile',
'status' => $phone['status'] ?? 'active',
])->id;
}
}
$contact->phones()->whereNotIn('id', $keptIds)->get()->each(function (ContactPhone $phone): void {
$phone->update(['status' => 'inactive']);
$phone->delete();
});
ActivityLogger::log('contact_phones_updated', "Contact {$contact->id} phones updated", $contact, ['phone_ids' => $beforePhoneIds], ['phone_ids' => $keptIds]);
}
});
ActivityLogger::log('contact_updated', "Contact {$contact->name} updated", $contact); ActivityLogger::log('contact_updated', "Contact {$contact->name} updated", $contact);
return response()->json($contact->fresh('phones.lastCaller')); return response()->json($contact->fresh('phones.lastCaller'));
} }
public function destroy(Contact $contact): JsonResponse public function destroy(Contact $contact): JsonResponse
{ {
Gate::authorize('delete', $contact);
$contact->delete(); $contact->delete();
ActivityLogger::log('contact_deleted', "Contact {$contact->id} deleted"); ActivityLogger::log('contact_deleted', "Contact {$contact->id} deleted");
return response()->json(['message' => 'مخاطب حذف شد']); return response()->json(['message' => 'مخاطب حذف شد']);
} }
@ -119,14 +161,43 @@ class ContactController extends Controller
Gate::authorize('update', $lead); Gate::authorize('update', $lead);
} }
private function authorizeParents(array $validated, string $ability, ?Contact $contact = null): void
{
$parentIds = [];
foreach (['lead_id' => Lead::class, 'company_id' => Company::class, 'deal_id' => Deal::class] as $key => $model) {
$parentIds[$key] = array_key_exists($key, $validated)
? $validated[$key]
: $contact?->{$key};
if (! empty($parentIds[$key])) {
Gate::authorize($ability, $model::findOrFail($parentIds[$key]));
}
}
$lead = $parentIds['lead_id'] ? Lead::findOrFail($parentIds['lead_id']) : null;
$deal = $parentIds['deal_id'] ? Deal::findOrFail($parentIds['deal_id']) : null;
$companyId = $parentIds['company_id'] ? (int) $parentIds['company_id'] : null;
$incompatible = ($lead?->company_id && $companyId && (int) $lead->company_id !== $companyId)
|| ($deal?->company_id && $companyId && (int) $deal->company_id !== $companyId)
|| ($deal?->lead_id && $lead && (int) $deal->lead_id !== (int) $lead->id);
if ($incompatible) {
throw ValidationException::withMessages([
'relationships' => 'شرکت، سرنخ و معامله انتخاب‌شده با یکدیگر سازگار نیستند.',
]);
}
}
private function validateContact(Request $request, bool $partial = false): array private function validateContact(Request $request, bool $partial = false): array
{ {
$sometimes = $partial ? 'sometimes|' : ''; $sometimes = $partial ? 'sometimes|' : '';
return $request->validate([ return $request->validate([
'lead_id' => 'nullable|exists:leads,id', 'lead_id' => $sometimes.'nullable|exists:leads,id',
'company_id' => 'nullable|exists:companies,id', 'company_id' => $sometimes.'nullable|exists:companies,id',
'deal_id' => 'nullable|exists:deals,id', 'deal_id' => $sometimes.'nullable|exists:deals,id',
'name' => $sometimes . 'required|string|max:255', 'name' => $sometimes.'required|string|max:255',
'first_name' => 'nullable|string|max:120', 'first_name' => 'nullable|string|max:120',
'last_name' => 'nullable|string|max:120', 'last_name' => 'nullable|string|max:120',
'role' => 'nullable|string|max:80', 'role' => 'nullable|string|max:80',
@ -137,8 +208,9 @@ class ContactController extends Controller
'status' => 'nullable|string|max:30', 'status' => 'nullable|string|max:30',
'is_primary' => 'nullable|boolean', 'is_primary' => 'nullable|boolean',
'primary_reason' => 'nullable|string|max:255', 'primary_reason' => 'nullable|string|max:255',
'phones' => ($partial ? 'nullable' : 'required') . '|array|min:1', 'phones' => ($partial ? 'nullable' : 'required').'|array|min:1',
'phones.*.phone' => 'required|string|max:30', 'phones.*.phone' => 'required|string|max:30',
'phones.*.id' => 'nullable|integer|exists:contact_phones,id',
'phones.*.type' => 'nullable|string|in:mobile,landline,extension', 'phones.*.type' => 'nullable|string|in:mobile,landline,extension',
'phones.*.status' => 'nullable|string|max:30', 'phones.*.status' => 'nullable|string|max:30',
]); ]);
@ -160,10 +232,14 @@ class ContactController extends Controller
'description' => $validated['description'] ?? null, 'description' => $validated['description'] ?? null,
'status' => $validated['status'] ?? 'active', 'status' => $validated['status'] ?? 'active',
'is_primary' => (bool) ($validated['is_primary'] ?? false), 'is_primary' => (bool) ($validated['is_primary'] ?? false),
'primary_reason' => $validated['primary_reason'] ?? (!empty($validated['is_primary']) ? 'انتخاب دستی توسط کاربر' : null), 'primary_reason' => $validated['primary_reason'] ?? (! empty($validated['is_primary']) ? 'انتخاب دستی توسط کاربر' : null),
'created_by' => auth()->id(), 'created_by' => auth()->id(),
]); ]);
if ($contact->is_primary) {
$this->primaryScope($contact)->whereKeyNot($contact->id)->update(['is_primary' => false]);
}
foreach ($validated['phones'] as $phone) { foreach ($validated['phones'] as $phone) {
ContactPhone::create([ ContactPhone::create([
'contact_id' => $contact->id, 'contact_id' => $contact->id,
@ -175,4 +251,20 @@ class ContactController extends Controller
return $contact; return $contact;
} }
private function primaryScope(Contact $contact)
{
$query = Contact::query();
if ($contact->lead_id) {
return $query->where('lead_id', $contact->lead_id);
}
if ($contact->company_id) {
return $query->whereNull('lead_id')->where('company_id', $contact->company_id);
}
if ($contact->deal_id) {
return $query->whereNull('lead_id')->whereNull('company_id')->where('deal_id', $contact->deal_id);
}
return $query->whereNull('lead_id')->whereNull('company_id')->whereNull('deal_id')->where('created_by', $contact->created_by);
}
} }

مشاهده پرونده

@ -3,9 +3,11 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Team;
use App\Models\User;
use App\Services\DashboardService; use App\Services\DashboardService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate;
class DashboardController extends Controller class DashboardController extends Controller
{ {
@ -13,13 +15,14 @@ class DashboardController extends Controller
public function admin(): JsonResponse public function admin(): JsonResponse
{ {
Gate::authorize('view-admin-dashboard');
$stats = $this->dashboardService->admin(); $stats = $this->dashboardService->admin();
// Agent ranking // Agent ranking
$agents = \App\Models\User::role('agent') $agents = User::role('agent')
->withCount(['assignedLeads', 'calls']) ->withCount(['assignedLeads', 'calls'])
->get() ->get()
->map(fn($agent) => [ ->map(fn ($agent) => [
'id' => $agent->id, 'id' => $agent->id,
'name' => $agent->name, 'name' => $agent->name,
'leads_count' => $agent->assigned_leads_count, 'leads_count' => $agent->assigned_leads_count,
@ -35,12 +38,13 @@ class DashboardController extends Controller
public function supervisor(): JsonResponse public function supervisor(): JsonResponse
{ {
Gate::authorize('view-supervisor-dashboard');
$teamId = auth()->user()->teams->first()?->id; $teamId = auth()->user()->teams->first()?->id;
$stats = $this->dashboardService->supervisor($teamId); $stats = $this->dashboardService->supervisor($teamId);
// Team members // Team members
$team = \App\Models\Team::with('members')->find($teamId); $team = Team::with('members')->find($teamId);
$stats['team_members'] = $team?->members->map(fn($member) => [ $stats['team_members'] = $team?->members->map(fn ($member) => [
'id' => $member->id, 'id' => $member->id,
'name' => $member->name, 'name' => $member->name,
'is_online' => $member->last_login_at && $member->last_login_at->gt(now()->subMinutes(15)), 'is_online' => $member->last_login_at && $member->last_login_at->gt(now()->subMinutes(15)),
@ -52,7 +56,9 @@ class DashboardController extends Controller
public function agent(): JsonResponse public function agent(): JsonResponse
{ {
Gate::authorize('view-agent-dashboard');
$stats = $this->dashboardService->agent(); $stats = $this->dashboardService->agent();
return response()->json($stats); return response()->json($stats);
} }
} }

مشاهده پرونده

@ -3,85 +3,114 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\Contact;
use App\Models\Deal; use App\Models\Deal;
use App\Models\Lead;
use App\Models\Product;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Support\AccessControl; use App\Support\AccessControl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class DealController extends Controller class DealController extends Controller
{ {
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$query = Deal::with('company:id,name', 'lead:id,company,first_name,last_name', 'contact:id,name', 'product:id,name', 'owner:id,name'); Gate::authorize('viewAny', Deal::class);
$this->scope($query); $query = Deal::with('company:id,name', 'lead:id,company,first_name,last_name', 'contact:id,name', 'product:id,name', 'owner:id,name', 'pipeline:id,name', 'stage:id,name,color,probability');
foreach (['status', 'sales_stage', 'company_id', 'lead_id', 'owner_id', 'product_id'] as $filter) { AccessControl::scopeDeals($query, auth()->user());
if ($request->filled($filter)) $query->where($filter, $request->get($filter)); foreach (['status', 'sales_stage', 'company_id', 'lead_id', 'owner_id', 'product_id', 'pipeline_id', 'deal_stage_id', 'forecast_category'] as $filter) {
if ($request->filled($filter)) {
$query->where($filter, $request->get($filter));
}
} }
if ($request->search) $query->where('title', 'like', "%{$request->search}%"); if ($request->search) {
$query->where('title', 'like', "%{$request->search}%");
}
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100))); return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
} }
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$deal = Deal::create($this->validated($request) + ['created_by' => auth()->id()]); Gate::authorize('create', Deal::class);
$validated = $this->validated($request);
$validated['owner_id'] ??= auth()->id();
$this->authorizeRelations($validated);
$deal = Deal::create($validated + ['created_by' => auth()->id()]);
ActivityLogger::log('deal_created', "Deal {$deal->title} created", $deal); ActivityLogger::log('deal_created', "Deal {$deal->title} created", $deal);
return response()->json($deal->load('company', 'contact', 'product', 'owner'), 201); return response()->json($deal->load('company', 'contact', 'product', 'owner'), 201);
} }
public function show(Deal $deal): JsonResponse public function show(Deal $deal): JsonResponse
{ {
$this->authorizeAccess($deal); Gate::authorize('view', $deal);
return response()->json($deal->load('company', 'lead', 'contact.phones', 'product.salesScript.sections', 'owner:id,name', 'timelineNotes.user:id,name', 'attachments.uploader:id,name'));
return response()->json($deal->load('company', 'lead', 'contact.phones', 'product.salesScript.sections', 'owner:id,name', 'pipeline', 'stage', 'stageHistory.fromStage', 'stageHistory.toStage', 'stageHistory.actor:id,name', 'customFieldValues.definition', 'tasks.assignee:id,name', 'notes.user:id,name', 'attachments.uploader:id,name'));
} }
public function update(Request $request, Deal $deal): JsonResponse public function update(Request $request, Deal $deal): JsonResponse
{ {
$this->authorizeAccess($deal); Gate::authorize('update', $deal);
$deal->update($this->validated($request, true)); $validated = $this->validated($request, true);
$this->authorizeRelations($validated);
$deal->update($validated);
ActivityLogger::log('deal_updated', "Deal {$deal->title} updated", $deal); ActivityLogger::log('deal_updated', "Deal {$deal->title} updated", $deal);
return response()->json($deal->fresh('company', 'contact', 'product', 'owner')); return response()->json($deal->fresh('company', 'contact', 'product', 'owner'));
} }
public function destroy(Deal $deal): JsonResponse public function destroy(Deal $deal): JsonResponse
{ {
$this->authorizeAccess($deal, true); Gate::authorize('delete', $deal);
$deal->delete(); $deal->delete();
ActivityLogger::log('deal_deleted', "Deal {$deal->id} deleted"); ActivityLogger::log('deal_deleted', "Deal {$deal->id} deleted");
return response()->json(['message' => 'فرصت فروش حذف شد']); return response()->json(['message' => 'فرصت فروش حذف شد']);
} }
private function validated(Request $request, bool $partial = false): array private function validated(Request $request, bool $partial = false): array
{ {
$sometimes = $partial ? 'sometimes|' : ''; $sometimes = $partial ? 'sometimes|' : '';
return $request->validate([ return $request->validate([
'title' => $sometimes . 'required|string|max:255', 'title' => $sometimes.'required|string|max:255',
'company_id' => 'nullable|exists:companies,id', 'company_id' => 'nullable|exists:companies,id',
'lead_id' => 'nullable|exists:leads,id', 'lead_id' => 'nullable|exists:leads,id',
'contact_id' => 'nullable|exists:contacts,id', 'contact_id' => 'nullable|exists:contacts,id',
'product_id' => 'nullable|exists:products,id', 'product_id' => 'nullable|exists:products,id',
'pipeline_id' => 'nullable|exists:pipelines,id',
'deal_stage_id' => 'nullable|exists:deal_stages,id',
'estimated_value' => 'nullable|numeric|min:0', 'estimated_value' => 'nullable|numeric|min:0',
'final_amount' => 'nullable|numeric|min:0',
'win_probability' => 'nullable|integer|min:0|max:100', 'win_probability' => 'nullable|integer|min:0|max:100',
'sales_stage' => 'nullable|string|max:80', 'sales_stage' => 'nullable|string|max:80',
'expected_close_date' => 'nullable|date', 'expected_close_date' => 'nullable|date',
'owner_id' => 'nullable|exists:users,id', 'owner_id' => 'nullable|exists:users,id',
'status' => 'nullable|string|max:40', 'status' => 'nullable|string|max:40',
'won_lost_reason' => 'nullable|string|max:255', 'won_lost_reason' => 'nullable|string|max:255',
'competitor' => 'nullable|string|max:255',
'forecast_category' => 'nullable|in:pipeline,best_case,commit,closed',
'notes' => 'nullable|string', 'notes' => 'nullable|string',
]); ]);
} }
private function scope($query): void private function authorizeRelations(array $validated): void
{ {
$user = auth()->user(); $user = auth()->user();
if ($user?->hasRole('admin')) return; if (array_key_exists('owner_id', $validated)) {
if ($user?->hasRole('agent')) $query->where('owner_id', $user->id); abort_unless(AccessControl::canAssignUser($user, $validated['owner_id']), 403, 'مالک انتخاب‌شده خارج از محدوده مجاز است.');
if ($user?->hasRole('supervisor')) $query->whereIn('owner_id', array_merge([$user->id], AccessControl::teamMemberIds($user))); }
} foreach (['company_id' => Company::class, 'lead_id' => Lead::class, 'contact_id' => Contact::class] as $key => $model) {
if (! empty($validated[$key])) {
private function authorizeAccess(Deal $deal, bool $manage = false): void Gate::authorize('view', $model::findOrFail($validated[$key]));
{ }
$user = auth()->user(); }
abort_unless($user && ($user->hasRole('admin') || (!$manage && $deal->owner_id === $user->id) || (!$manage && $user->hasRole('supervisor') && in_array($deal->owner_id, AccessControl::teamMemberIds($user), true))), 403, 'دسترسی غیرمجاز'); if (! empty($validated['product_id'])) {
Gate::authorize('view', Product::findOrFail($validated['product_id']));
}
} }
} }

مشاهده پرونده

@ -24,8 +24,8 @@ class DuplicateController extends Controller
]); ]);
$items = $validated['entity_type'] === 'company' $items = $validated['entity_type'] === 'company'
? $this->duplicates->companySuggestions($validated, $validated['exclude_id'] ?? null) ? $this->duplicates->companySuggestions($validated, $validated['exclude_id'] ?? null, auth()->user())
: $this->duplicates->leadSuggestions($validated, $validated['exclude_id'] ?? null); : $this->duplicates->leadSuggestions($validated, $validated['exclude_id'] ?? null, auth()->user());
return response()->json(['data' => $items]); return response()->json(['data' => $items]);
} }

مشاهده پرونده

@ -3,184 +3,150 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\FollowUpResource;
use App\Http\Responses\ApiResponse;
use App\Models\FollowUp; use App\Models\FollowUp;
use App\Models\Lead; use App\Models\Lead;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Services\NotificationService; use App\Services\FollowUpService;
use App\Support\AccessControl; use App\Support\AccessControl;
use App\Support\WorkingHours;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
class FollowUpController extends Controller class FollowUpController extends Controller
{ {
public function __construct(private FollowUpService $service) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$user = auth()->user();
Gate::authorize('viewAny', FollowUp::class); Gate::authorize('viewAny', FollowUp::class);
$query = FollowUp::with('lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name');
AccessControl::scopeFollowUps($query, $request->user());
$this->applyFilters($query, $request);
$query = FollowUp::with('lead:id,first_name,last_name,phone', 'user:id,name'); $direction = $request->get('sort', '-created_at') === 'created_at' ? 'asc' : 'desc';
$paginator = $query->orderBy('created_at', $direction)->paginate(min((int) $request->get('per_page', 15), 100));
AccessControl::scopeFollowUps($query, $user); return ApiResponse::paginated($paginator, fn (FollowUp $followUp) => $this->resource($followUp));
if ($request->status) {
$query->where('status', $request->status);
}
if ($request->lead_id) {
$query->where('lead_id', $request->lead_id);
}
if ($request->date_from) {
$query->whereDate('scheduled_at', '>=', $request->date_from);
}
if ($request->date_to) {
$query->whereDate('scheduled_at', '<=', $request->date_to);
}
return response()->json($query->orderBy('scheduled_at')->paginate($request->per_page ?? 15));
} }
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'lead_id' => 'required|exists:leads,id', 'lead_id' => 'required|exists:leads,id',
'user_id' => 'nullable|exists:users,id',
'call_id' => 'nullable|exists:calls,id',
'scheduled_at' => 'required|date', 'scheduled_at' => 'required|date',
'notes' => 'nullable|string', 'notes' => 'nullable|string',
]); ]);
$lead = Lead::findOrFail($validated['lead_id']); $lead = Lead::findOrFail($validated['lead_id']);
Gate::authorize('create', [FollowUp::class, $lead]); Gate::authorize('create', [FollowUp::class, $lead]);
if (!WorkingHours::followUpAllowed($validated['scheduled_at'])) { $assigneeId = (int) ($validated['user_id'] ?? $request->user()->id);
return response()->json(['message' => 'زمان پیگیری خارج از روز یا ساعت کاری مجاز است.'], 422); abort_unless(AccessControl::canAssignUser($request->user(), $assigneeId), 403, 'مسئول انتخاب‌شده خارج از محدوده مجاز است.');
}
$followUp = FollowUp::create([ $followUp = $this->service->schedule(
'lead_id' => $validated['lead_id'], $lead,
'user_id' => auth()->id(), $assigneeId,
'scheduled_at' => $validated['scheduled_at'], $validated['scheduled_at'],
'notes' => $validated['notes'] ?? null, $request->user(),
'status' => 'pending', $validated['notes'] ?? null,
]); $validated['call_id'] ?? null,
);
ActivityLogger::log('follow_up_created', "Follow-up for lead {$validated['lead_id']} scheduled", $followUp); return ApiResponse::success($this->resource($followUp), 201, 'پیگیری ایجاد شد.');
NotificationService::notifyFollowUpReminder($followUp->user_id, $lead->full_name ?: ($lead->company ?? "لید {$lead->id}")); }
return response()->json($followUp->load('lead'), 201); public function show(FollowUp $followUp): JsonResponse
{
Gate::authorize('view', $followUp);
return ApiResponse::success($this->resource($followUp->load('lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name')));
} }
public function update(Request $request, FollowUp $followUp): JsonResponse public function update(Request $request, FollowUp $followUp): JsonResponse
{ {
Gate::authorize('update', $followUp); Gate::authorize('update', $followUp);
$validated = $request->validate([ $validated = $request->validate([
'user_id' => 'sometimes|exists:users,id',
'scheduled_at' => 'sometimes|date', 'scheduled_at' => 'sometimes|date',
'notes' => 'nullable|string', 'notes' => 'nullable|string',
'status' => 'sometimes|string|in:pending,completed,cancelled', 'status' => 'sometimes|string|in:pending,completed,cancelled',
]); ]);
if (isset($validated['user_id'])) {
abort_unless(AccessControl::canAssignUser($request->user(), (int) $validated['user_id']), 403, 'مسئول انتخاب‌شده خارج از محدوده مجاز است.');
}
$followUp = $this->service->update($followUp, $validated);
$followUp->update($validated); return ApiResponse::success($this->resource($followUp), message: 'پیگیری ویرایش شد.');
ActivityLogger::log('follow_up_updated', "Follow-up {$followUp->id} updated", $followUp);
return response()->json($followUp->load('lead'));
} }
public function markDone(FollowUp $followUp): JsonResponse public function markDone(FollowUp $followUp): JsonResponse
{ {
Gate::authorize('update', $followUp); Gate::authorize('complete', $followUp);
$followUp->update(['status' => 'completed', 'completed_at' => now(), 'is_overdue' => false]);
$followUp->update([
'status' => 'completed',
'completed_at' => now(),
]);
ActivityLogger::log('follow_up_completed', "Follow-up {$followUp->id} completed", $followUp); ActivityLogger::log('follow_up_completed', "Follow-up {$followUp->id} completed", $followUp);
return response()->json($followUp); return ApiResponse::success($this->resource($followUp->fresh(['lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name'])), message: 'پیگیری تکمیل شد.');
} }
public function today(): JsonResponse public function destroy(FollowUp $followUp): JsonResponse
{ {
$user = auth()->user(); Gate::authorize('delete', $followUp);
$query = FollowUp::with('lead:id,first_name,last_name,phone') $followUp->delete();
ActivityLogger::log('follow_up_deleted', "Follow-up {$followUp->id} deleted", $followUp);
return ApiResponse::success(null, message: 'پیگیری حذف شد.');
}
public function today(Request $request): JsonResponse
{
Gate::authorize('viewAny', FollowUp::class);
$query = FollowUp::with('lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name')
->whereDate('scheduled_at', now()->toDateString()) ->whereDate('scheduled_at', now()->toDateString())
->where('status', 'pending'); ->where('status', 'pending');
AccessControl::scopeFollowUps($query, $user); AccessControl::scopeFollowUps($query, $request->user());
$direction = $request->get('sort', '-created_at') === 'created_at' ? 'asc' : 'desc';
$paginator = $query->orderBy('created_at', $direction)->paginate(min((int) $request->get('per_page', 50), 100));
$followUps = $query->orderBy('scheduled_at')->get(); return ApiResponse::paginated($paginator, fn (FollowUp $followUp) => $this->resource($followUp));
foreach ($followUps as $followUp) {
NotificationService::sendOnce(
$followUp->user_id,
'پیگیری عقب‌افتاده',
'پیگیری لید ' . ($followUp->lead?->full_name ?: ($followUp->lead?->company ?? "لید {$followUp->lead_id}")) . ' عقب افتاده است',
'overdue_follow_up',
['follow_up_id' => $followUp->id, 'lead_id' => $followUp->lead_id]
);
}
return response()->json($followUps);
} }
public function overdue(): JsonResponse public function overdue(Request $request): JsonResponse
{ {
$user = auth()->user(); Gate::authorize('viewAny', FollowUp::class);
$query = FollowUp::with('lead:id,first_name,last_name,phone') $query = FollowUp::with('lead:id,first_name,last_name,company,phone', 'user:id,name', 'creator:id,name')
->where('status', 'pending') ->where('status', 'pending')
->where(function ($q) { ->where(fn ($overdue) => $overdue->where('is_overdue', true)->orWhere('scheduled_at', '<', now()));
$q->where('is_overdue', true) AccessControl::scopeFollowUps($query, $request->user());
->orWhere('scheduled_at', '<', now()); $direction = $request->get('sort', '-created_at') === 'created_at' ? 'asc' : 'desc';
}); $paginator = $query->orderBy('created_at', $direction)->paginate(min((int) $request->get('per_page', 50), 100));
AccessControl::scopeFollowUps($query, $user);
return response()->json($query->orderBy('scheduled_at')->get()); return ApiResponse::paginated($paginator, fn (FollowUp $followUp) => $this->resource($followUp));
} }
private function canManageFollowUp(FollowUp $followUp): bool private function applyFilters($query, Request $request): void
{ {
$user = auth()->user(); if ($request->filled('status')) {
$query->where('status', $request->get('status'));
if (!$user) {
return false;
} }
if ($request->filled('lead_id')) {
if ($user->hasRole('admin')) { $query->where('lead_id', $request->integer('lead_id'));
return true;
} }
if ($request->filled('user_id')) {
if ($user->hasRole('agent')) { $query->where('user_id', $request->integer('user_id'));
return $followUp->user_id === $user->id;
} }
if ($request->filled('date_from')) {
if ($user->hasRole('supervisor')) { $query->whereDate('scheduled_at', '>=', $request->get('date_from'));
$teamIds = $user->teams->pluck('id')->toArray(); }
$agentIds = \App\Models\Team::whereIn('id', $teamIds)->with('members')->get()->pluck('members.*.id')->flatten(); if ($request->filled('date_to')) {
return $agentIds->contains($followUp->user_id); $query->whereDate('scheduled_at', '<=', $request->get('date_to'));
} }
return false;
} }
private function canAccessLead(\App\Models\Lead $lead): bool private function resource(FollowUp $followUp): array
{ {
$user = auth()->user(); return (new FollowUpResource($followUp))->resolve(request());
if (!$user) {
return false;
}
if ($user->hasRole('admin')) {
return true;
}
if ($user->hasRole('agent')) {
return $lead->assigned_to === $user->id;
}
if ($user->hasRole('supervisor')) {
$teamIds = $user->teams->pluck('id')->toArray();
return in_array($lead->team_id, $teamIds, true) || $lead->assigned_to === $user->id;
}
return false;
} }
} }

مشاهده پرونده

@ -5,12 +5,13 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\ImportBatch; use App\Models\ImportBatch;
use App\Models\Setting; use App\Models\Setting;
use App\Services\ImportService;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Services\ImportService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
@ -25,7 +26,7 @@ class ImportController extends Controller
$allowedTypes = Setting::where('key', 'import_allowed_file_types')->value('value') ?: 'xlsx,xls,csv'; $allowedTypes = Setting::where('key', 'import_allowed_file_types')->value('value') ?: 'xlsx,xls,csv';
$maxMb = max(1, (int) (Setting::where('key', 'import_max_file_size_mb')->value('value') ?: 10)); $maxMb = max(1, (int) (Setting::where('key', 'import_max_file_size_mb')->value('value') ?: 10));
$request->validate([ $request->validate([
'file' => 'required|file|mimes:' . $allowedTypes . '|max:' . ($maxMb * 1024), 'file' => 'required|file|mimes:'.$allowedTypes.'|max:'.($maxMb * 1024),
], [ ], [
'file.mimes' => 'نوع فایل import مجاز نیست.', 'file.mimes' => 'نوع فایل import مجاز نیست.',
'file.max' => "حجم فایل import نباید بیشتر از {$maxMb} مگابایت باشد.", 'file.max' => "حجم فایل import نباید بیشتر از {$maxMb} مگابایت باشد.",
@ -46,7 +47,7 @@ class ImportController extends Controller
$preview = $this->importService->preview($fullPath); $preview = $this->importService->preview($fullPath);
// Store rows for later processing // Store rows for later processing
$rows = \Maatwebsite\Excel\Facades\Excel::toArray([], $fullPath)[0] ?? []; $rows = Excel::toArray([], $fullPath)[0] ?? [];
$dataRows = array_slice($rows, 1); $dataRows = array_slice($rows, 1);
foreach ($dataRows as $index => $row) { foreach ($dataRows as $index => $row) {
@ -68,6 +69,7 @@ class ImportController extends Controller
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
$batch->update(['status' => 'failed', 'errors' => $e->getMessage()]); $batch->update(['status' => 'failed', 'errors' => $e->getMessage()]);
return response()->json(['message' => $e->getMessage()], 422); return response()->json(['message' => $e->getMessage()], 422);
} }
} }
@ -127,6 +129,7 @@ class ImportController extends Controller
$this->importService->rollback($batchId); $this->importService->rollback($batchId);
ActivityLogger::log('lead_import_rolled_back', "Import batch {$batchId} rolled back", $batch); ActivityLogger::log('lead_import_rolled_back', "Import batch {$batchId} rolled back", $batch);
return response()->json(['message' => 'بازگشت import انجام شد']); return response()->json(['message' => 'بازگشت import انجام شد']);
} }
@ -145,7 +148,7 @@ class ImportController extends Controller
{ {
Gate::authorize('import', ImportBatch::class); Gate::authorize('import', ImportBatch::class);
$spreadsheet = new Spreadsheet(); $spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet(); $sheet = $spreadsheet->getActiveSheet();
$sheet->fromArray([ $sheet->fromArray([
['company', 'phone', 'phone_secondary', 'first_name', 'last_name', 'email', 'city', 'province', 'source', 'product_interest', 'priority', 'lead_score', 'interest_level', 'notes', 'tags'], ['company', 'phone', 'phone_secondary', 'first_name', 'last_name', 'email', 'city', 'province', 'source', 'product_interest', 'priority', 'lead_score', 'interest_level', 'notes', 'tags'],

مشاهده پرونده

@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Lead;
use App\Models\SlaBreach;
use App\Models\SlaRule;
use App\Services\ActivityLogger;
use App\Services\LeadScoringService;
use App\Services\SlaMonitorService;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class IntelligenceController extends Controller
{
public function __construct(private LeadScoringService $scoring, private SlaMonitorService $slaMonitor) {}
public function scoreLead(Request $request, Lead $lead): JsonResponse
{
abort_unless($request->user()->can('score_leads') && AccessControl::canAccessLead($request->user(), $lead), 403);
return response()->json($this->scoring->score($lead));
}
public function bulkScore(Request $request): JsonResponse
{
abort_unless($request->user()->can('score_leads'), 403);
$ids = $request->validate(['ids' => 'required|array|min:1|max:100', 'ids.*' => 'integer|exists:leads,id'])['ids'];
$query = Lead::whereKey($ids);
AccessControl::scopeLeads($query, $request->user());
$scored = $query->get()->map(fn (Lead $lead) => $this->scoring->score($lead));
return response()->json(['count' => $scored->count(), 'data' => $scored]);
}
public function slaRules(Request $request): JsonResponse
{
abort_unless($request->user()->can('view_sla'), 403);
return response()->json(SlaRule::where('event', '!=', 'stale_deal')->latest()->get());
}
public function storeSlaRule(Request $request): JsonResponse
{
abort_unless($request->user()->can('manage_sla'), 403);
$data = $request->validate([
'name' => 'required|string|max:120', 'event' => 'required|in:first_contact,follow_up',
'warning_minutes' => 'required|integer|min:1|max:525600', 'breach_minutes' => 'required|integer|gt:warning_minutes|max:525600',
'scope' => 'nullable|array|max:10', 'is_active' => 'boolean',
]);
return response()->json(SlaRule::create($data + ['created_by' => $request->user()->id]), 201);
}
public function breaches(Request $request): JsonResponse
{
abort_unless($request->user()->can('view_sla'), 403);
$query = SlaBreach::with('rule:id,name,event', 'assignee:id,name', 'breachable');
if ($request->user()->hasRole('agent')) {
$query->where('assigned_to', $request->user()->id);
} elseif ($request->user()->hasRole('supervisor')) {
$query->whereIn('assigned_to', AccessControl::teamMemberIds($request->user()));
}
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 20), 100)));
}
public function detect(Request $request): JsonResponse
{
abort_unless($request->user()->can('manage_sla'), 403);
return response()->json(['created' => $this->slaMonitor->run()]);
}
public function resolve(Request $request, SlaBreach $slaBreach): JsonResponse
{
abort_unless($request->user()->can('manage_sla') || $slaBreach->assigned_to === $request->user()->id, 403);
$slaBreach->update(['status' => 'resolved', 'resolved_at' => now()]);
ActivityLogger::log('sla_breach_resolved', "SLA breach {$slaBreach->id} resolved", $slaBreach);
return response()->json($slaBreach->fresh(['rule', 'assignee:id,name']));
}
}

مشاهده پرونده

@ -0,0 +1,383 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Invoice;
use App\Models\InvoiceTemplate;
use App\Models\Lead;
use App\Services\ActivityLogger;
use App\Services\InvoiceService;
use App\Services\InvoiceWordService;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class InvoiceController extends Controller
{
public function __construct(private InvoiceService $service, private InvoiceWordService $wordService) {}
public function index(Request $request): JsonResponse
{
Gate::authorize('viewAny', Invoice::class);
$user = auth()->user();
$query = Invoice::with('lead:id,first_name,last_name,company,assigned_to,final_result', 'template:id,name', 'creator:id,name', 'approver:id,name');
if (! $user->hasRole('admin')) {
if ($user->hasRole('supervisor') || $user->can('approve_invoices')) {
$query->whereHas('lead', fn ($leads) => AccessControl::scopeLeads($leads, $user, false));
} else {
$query->where(fn ($scope) => $scope->where('created_by', $user->id)->orWhereHas('lead', fn ($leads) => $leads->where('assigned_to', $user->id)));
}
}
foreach (['status', 'lead_id', 'created_by'] as $filter) {
if ($request->filled($filter)) {
$query->where($filter, $request->get($filter));
}
}
if ($request->filled('search')) {
$search = trim((string) $request->get('search'));
$query->where(fn ($scope) => $scope->where('number', 'like', "%{$search}%")
->orWhereHas('lead', fn ($leads) => $leads->where('company', 'like', "%{$search}%")->orWhere('phone', 'like', "%{$search}%")));
}
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
}
public function summary(): JsonResponse
{
Gate::authorize('viewAny', Invoice::class);
$user = auth()->user();
$query = Invoice::query();
if (! $user->hasRole('admin')) {
if ($user->hasRole('supervisor') || $user->can('approve_invoices')) {
$query->whereHas('lead', fn ($leads) => AccessControl::scopeLeads($leads, $user, false));
} else {
$query->where(fn ($scope) => $scope->where('created_by', $user->id)
->orWhereHas('lead', fn ($leads) => $leads->where('assigned_to', $user->id)));
}
}
$counts = (clone $query)->selectRaw('status, COUNT(*) as aggregate')->groupBy('status')->pluck('aggregate', 'status');
$issued = (clone $query)->where('status', 'issued');
$issuedTotal = (float) (clone $issued)->sum('total');
$paidTotal = (float) (clone $issued)->sum('paid_amount');
return response()->json([
'total' => (clone $query)->count(),
'counts' => [
'draft' => (int) ($counts['draft'] ?? 0),
'pending_approval' => (int) ($counts['pending_approval'] ?? 0),
'approved' => (int) ($counts['approved'] ?? 0),
'rejected' => (int) ($counts['rejected'] ?? 0),
'issued' => (int) ($counts['issued'] ?? 0),
'void' => (int) ($counts['void'] ?? 0),
],
'issued_total' => $issuedTotal,
'paid_total' => $paidTotal,
'outstanding_total' => max(0, $issuedTotal - $paidTotal),
'currency' => 'IRR',
]);
}
public function show(Invoice $invoice): JsonResponse
{
Gate::authorize('view', $invoice);
return response()->json($this->invoicePayload($invoice->load('lead', 'template', 'creator:id,name', 'approver:id,name')));
}
public function word(Invoice $invoice): BinaryFileResponse
{
Gate::authorize('view', $invoice);
$document = $this->wordService->create($invoice);
return response()->download(
$document['path'],
$document['filename'],
['Content-Type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
)->deleteFileAfterSend(true);
}
public function fromLead(Request $request, Lead $lead): JsonResponse
{
Gate::authorize('create', Invoice::class);
Gate::authorize('view', $lead);
if (auth()->user()->hasRole('agent')) {
abort_unless($lead->assigned_to === auth()->id(), 403, 'فقط مسئول این لید می‌تواند درخواست فاکتور ثبت کند.');
}
$payload = $request->validate($this->invoiceRules());
$invoice = $this->service->createFromLead($lead, auth()->user(), $payload);
return response()->json($this->invoicePayload($invoice), 201);
}
public function update(Request $request, Invoice $invoice): JsonResponse
{
Gate::authorize('update', $invoice);
$invoice = $this->service->updateDraft($invoice, $request->validate($this->invoiceRules(true)), auth()->user());
return response()->json($this->invoicePayload($invoice));
}
public function issue(Invoice $invoice): JsonResponse
{
Gate::authorize('issue', $invoice);
return response()->json($this->invoicePayload($this->service->issue($invoice, auth()->user())));
}
public function approve(Invoice $invoice): JsonResponse
{
Gate::authorize('approve', $invoice);
return response()->json($this->invoicePayload($this->service->approve($invoice, auth()->user())));
}
public function reject(Request $request, Invoice $invoice): JsonResponse
{
Gate::authorize('approve', $invoice);
$validated = $request->validate(['reason' => 'required|string|max:2000']);
return response()->json($this->invoicePayload($this->service->reject($invoice, auth()->user(), $validated['reason'])));
}
public function void(Invoice $invoice): JsonResponse
{
Gate::authorize('void', $invoice);
return response()->json($this->invoicePayload($this->service->void($invoice, auth()->user())));
}
public function templates(Request $request): JsonResponse
{
Gate::authorize('viewAny', Invoice::class);
$query = InvoiceTemplate::query();
if (! ($request->boolean('include_inactive') && Gate::allows('manageTemplates', Invoice::class))) {
$query->where('is_active', true);
}
return response()->json($query->orderByDesc('is_default')->orderBy('name')->get()->map(fn ($template) => $this->templatePayload($template)));
}
public function storeTemplate(Request $request): JsonResponse
{
Gate::authorize('manageTemplates', Invoice::class);
$data = $request->validate($this->templateRules());
if (! empty($data['is_default'])) {
InvoiceTemplate::query()->update(['is_default' => false]);
}
$template = InvoiceTemplate::create($data + ['created_by' => auth()->id(), 'layout' => $data['layout'] ?? $this->service->defaultLayout()]);
ActivityLogger::log('invoice_template_created', "Invoice template {$template->name} created", $template);
return response()->json($this->templatePayload($template), 201);
}
public function updateTemplate(Request $request, InvoiceTemplate $invoiceTemplate): JsonResponse
{
Gate::authorize('manageTemplates', Invoice::class);
$data = $request->validate($this->templateRules(true));
if (! empty($data['is_default'])) {
InvoiceTemplate::where('id', '!=', $invoiceTemplate->id)->update(['is_default' => false]);
}
$invoiceTemplate->update($data);
ActivityLogger::log('invoice_template_updated', "Invoice template {$invoiceTemplate->name} updated", $invoiceTemplate);
return response()->json($this->templatePayload($invoiceTemplate->fresh()));
}
public function destroyTemplate(InvoiceTemplate $invoiceTemplate): JsonResponse
{
Gate::authorize('manageTemplates', Invoice::class);
abort_if($invoiceTemplate->invoices()->exists(), 422, 'این قالب در فاکتورهای ثبت‌شده استفاده شده است؛ به‌جای حذف، آن را غیرفعال کنید.');
$paths = array_filter([$invoiceTemplate->background_path, $invoiceTemplate->source_path]);
DB::transaction(function () use ($invoiceTemplate): void {
$wasDefault = $invoiceTemplate->is_default;
$name = $invoiceTemplate->name;
$invoiceTemplate->delete();
if ($wasDefault) {
InvoiceTemplate::where('is_active', true)->orderBy('id')->first()?->update(['is_default' => true]);
}
ActivityLogger::log('invoice_template_deleted', "Invoice template {$name} deleted");
});
if ($paths) {
Storage::disk('local')->delete(array_values(array_unique($paths)));
}
return response()->json(['message' => 'قالب حذف شد.']);
}
public function uploadTemplateBackground(Request $request, InvoiceTemplate $invoiceTemplate): JsonResponse
{
Gate::authorize('manageTemplates', Invoice::class);
$data = $request->validate([
'file' => 'required|image|mimes:png,jpg,jpeg|max:20480',
'source_file' => 'nullable|file|mimes:png,jpg,jpeg,pdf|max:20480',
'source_name' => 'nullable|string|max:255',
'source_mime' => 'nullable|in:image/png,image/jpeg,application/pdf',
]);
$file = $request->file('file');
$sourceFile = $request->file('source_file');
if ($invoiceTemplate->background_path) {
Storage::disk('local')->delete($invoiceTemplate->background_path);
}
if ($invoiceTemplate->source_path) {
Storage::disk('local')->delete($invoiceTemplate->source_path);
}
$path = $file->store('invoice-templates', 'local');
$sourcePath = $sourceFile?->store('invoice-template-sources', 'local');
$invoiceTemplate->update([
'background_path' => $path,
'background_name' => $file->getClientOriginalName(),
'background_mime' => $file->getMimeType(),
'source_path' => $sourcePath,
'source_name' => $data['source_name'] ?? $sourceFile?->getClientOriginalName() ?? $file->getClientOriginalName(),
'source_mime' => $data['source_mime'] ?? $sourceFile?->getMimeType() ?? $file->getMimeType(),
'base_type' => $invoiceTemplate->base_type === 'blank' ? 'full_template' : $invoiceTemplate->base_type,
'background_settings' => $invoiceTemplate->background_settings ?: ['fit' => 'contain', 'top' => 0, 'height' => 100],
]);
ActivityLogger::log('invoice_template_background_uploaded', "Background uploaded for invoice template {$invoiceTemplate->id}", $invoiceTemplate);
return response()->json($this->templatePayload($invoiceTemplate->fresh()));
}
public function deleteTemplateBackground(InvoiceTemplate $invoiceTemplate): JsonResponse
{
Gate::authorize('manageTemplates', Invoice::class);
if ($invoiceTemplate->background_path) {
Storage::disk('local')->delete($invoiceTemplate->background_path);
}
if ($invoiceTemplate->source_path) {
Storage::disk('local')->delete($invoiceTemplate->source_path);
}
$invoiceTemplate->update([
'background_path' => null,
'background_name' => null,
'background_mime' => null,
'source_path' => null,
'source_name' => null,
'source_mime' => null,
'base_type' => 'blank',
'background_settings' => null,
]);
ActivityLogger::log('invoice_template_background_deleted', "Background deleted for invoice template {$invoiceTemplate->id}", $invoiceTemplate);
return response()->json($this->templatePayload($invoiceTemplate->fresh()));
}
public function templateBackground(InvoiceTemplate $invoiceTemplate)
{
Gate::authorize('viewAny', Invoice::class);
abort_unless($invoiceTemplate->background_path && Storage::disk('local')->exists($invoiceTemplate->background_path), 404);
return response()->file(Storage::disk('local')->path($invoiceTemplate->background_path), [
'Content-Type' => $invoiceTemplate->background_mime ?: 'application/octet-stream',
'Cache-Control' => 'private, max-age=300',
]);
}
public function fieldCatalog(): JsonResponse
{
Gate::authorize('viewAny', Invoice::class);
return response()->json(InvoiceService::FIELD_CATALOG);
}
private function invoiceRules(bool $partial = false): array
{
$sometimes = $partial ? 'sometimes|' : '';
return [
'invoice_template_id' => 'nullable|exists:invoice_templates,id',
'currency' => $sometimes.'nullable|string|max:8',
'customer_snapshot' => 'nullable|array',
'customer_snapshot.name' => 'nullable|string|max:255',
'customer_snapshot.company' => 'nullable|string|max:255',
'customer_snapshot.phone' => 'nullable|string|max:40',
'customer_snapshot.email' => 'nullable|email|max:255',
'customer_snapshot.address' => 'nullable|string|max:1000',
'customer_snapshot.national_code' => 'nullable|string|max:40',
'customer_snapshot.economic_code' => 'nullable|string|max:40',
'customer_snapshot.postal_code' => 'nullable|string|max:40',
'seller_snapshot' => 'nullable|array',
'seller_snapshot.name' => 'nullable|string|max:255',
'seller_snapshot.company' => 'nullable|string|max:255',
'seller_snapshot.phone' => 'nullable|string|max:40',
'seller_snapshot.email' => 'nullable|email|max:255',
'seller_snapshot.address' => 'nullable|string|max:1000',
'seller_snapshot.national_id' => 'nullable|string|max:40',
'seller_snapshot.economic_code' => 'nullable|string|max:40',
'seller_snapshot.postal_code' => 'nullable|string|max:40',
'items' => 'nullable|array|min:1',
'items.*.description' => 'required_with:items|string|max:500',
'items.*.quantity' => 'required_with:items|numeric|min:0.01',
'items.*.unit_price' => 'required_with:items|numeric|min:0',
'items.*.unit' => 'nullable|string|max:40',
'discount' => 'nullable|numeric|min:0',
'tax' => 'nullable|numeric|min:0',
'paid_amount' => 'nullable|numeric|min:0',
'notes' => 'nullable|string|max:2000',
'payment_terms' => 'nullable|string|max:2000',
'due_date' => 'nullable|date',
'resolved_fields' => 'nullable|array',
'page_width_mm' => 'nullable|integer|min:100|max:500',
'page_height_mm' => 'nullable|integer|min:100|max:500',
];
}
private function templateRules(bool $partial = false): array
{
$required = $partial ? 'sometimes|' : 'required|';
return [
'name' => $required.'string|max:255',
'layout' => 'nullable|array',
'layout.*.id' => 'required_with:layout|string|max:80',
'layout.*.label' => 'required_with:layout|string|max:255',
'layout.*.source' => 'required_with:layout|string|max:120',
'layout.*.x' => 'required_with:layout|numeric|min:0|max:100',
'layout.*.y' => 'required_with:layout|numeric|min:0|max:100',
'layout.*.width' => 'required_with:layout|numeric|min:1|max:100',
'layout.*.font_size' => 'nullable|integer|min:8|max:48',
'layout.*.align' => 'nullable|in:right,center,left',
'layout.*.default' => 'nullable|string|max:1000',
'base_type' => 'nullable|in:blank,letterhead,full_template',
'background_settings' => 'nullable|array',
'background_settings.fit' => 'nullable|in:contain,cover,stretch',
'background_settings.top' => 'nullable|numeric|min:0|max:95',
'background_settings.height' => 'nullable|numeric|min:5|max:100',
'page_width_mm' => 'nullable|integer|min:100|max:500',
'page_height_mm' => 'nullable|integer|min:100|max:500',
'is_default' => 'nullable|boolean',
'is_active' => 'nullable|boolean',
];
}
private function invoicePayload(Invoice $invoice): array
{
$array = $invoice->toArray();
$array['balance_due'] = max(0, (float) $invoice->total - (float) $invoice->paid_amount);
$array['capabilities'] = [
'update' => Gate::allows('update', $invoice),
'approve' => Gate::allows('approve', $invoice),
'issue' => Gate::allows('issue', $invoice),
'void' => Gate::allows('void', $invoice),
];
if ($invoice->template) {
$array['template'] = $this->templatePayload($invoice->template);
}
return $array;
}
private function templatePayload(InvoiceTemplate $template): array
{
return $template->toArray() + [
'background_url' => $template->background_path ? url("/api/invoice-templates/{$template->id}/background") : null,
];
}
}

مشاهده پرونده

@ -3,11 +3,15 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Contact;
use App\Models\ContactPhone;
use App\Models\Lead; use App\Models\Lead;
use App\Models\LeadAssignment;
use App\Models\PipelineStage; use App\Models\PipelineStage;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Services\AutomationDispatcher;
use App\Services\LeadService; use App\Services\LeadService;
use App\Support\AccessControl; use App\Support\AccessControl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -17,7 +21,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
class LeadController extends Controller class LeadController extends Controller
{ {
public function __construct(private LeadService $leadService) {} public function __construct(private LeadService $leadService, private AutomationDispatcher $automations) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
@ -76,7 +80,7 @@ class LeadController extends Controller
public function export(Request $request): StreamedResponse|JsonResponse public function export(Request $request): StreamedResponse|JsonResponse
{ {
$user = auth()->user(); $user = auth()->user();
if (!$user?->hasRole('admin') && !$user?->can('export_leads')) { if (! $user?->hasRole('admin') && ! $user?->can('export_leads')) {
return response()->json(['message' => 'شما مجوز خروجی گرفتن از لیدها را ندارید.'], 403); return response()->json(['message' => 'شما مجوز خروجی گرفتن از لیدها را ندارید.'], 403);
} }
@ -168,11 +172,11 @@ class LeadController extends Controller
if ($user?->hasRole('agent')) { if ($user?->hasRole('agent')) {
$validated['assigned_to'] = $user->id; $validated['assigned_to'] = $user->id;
} elseif ($user?->hasRole('supervisor') && !empty($validated['assigned_to'])) { } elseif ($user?->hasRole('supervisor') && ! empty($validated['assigned_to'])) {
$assignee = User::find($validated['assigned_to']); $assignee = User::find($validated['assigned_to']);
$teamIds = AccessControl::teamIds($user); $teamIds = AccessControl::teamIds($user);
$assigneeTeamIds = $assignee?->teams()->pluck('teams.id')->all() ?? []; $assigneeTeamIds = $assignee?->teams()->pluck('teams.id')->all() ?? [];
if (!array_intersect($teamIds, $assigneeTeamIds)) { if (! array_intersect($teamIds, $assigneeTeamIds)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
} }
@ -183,7 +187,7 @@ class LeadController extends Controller
?? PipelineStage::where('is_default', true)->value('id') ?? PipelineStage::where('is_default', true)->value('id')
?? PipelineStage::orderBy('sort_order')->value('id'); ?? PipelineStage::orderBy('sort_order')->value('id');
$assignedTo = $validated['assigned_to'] ?? null; $assignedTo = $validated['assigned_to'] ?? null;
if (!$assignedTo) { if (! $assignedTo) {
$strategy = Setting::where('key', 'assignment_strategy')->value('value') ?? 'round_robin'; $strategy = Setting::where('key', 'assignment_strategy')->value('value') ?? 'round_robin';
if ($strategy !== 'manual' || auth()->user()?->hasRole('admin')) { if ($strategy !== 'manual' || auth()->user()?->hasRole('admin')) {
$assignedTo = User::role('agent') $assignedTo = User::role('agent')
@ -206,9 +210,9 @@ class LeadController extends Controller
$lead = Lead::create($validated); $lead = Lead::create($validated);
$contact = \App\Models\Contact::create([ $contact = Contact::create([
'lead_id' => $lead->id, 'lead_id' => $lead->id,
'name' => trim(($lead->first_name ?? '') . ' ' . ($lead->last_name ?? '')) ?: ($lead->company ?? 'مخاطب اولیه'), 'name' => trim(($lead->first_name ?? '').' '.($lead->last_name ?? '')) ?: ($lead->company ?? 'مخاطب اولیه'),
'role' => 'رابط', 'role' => 'رابط',
'description' => 'مخاطب اولیه لید', 'description' => 'مخاطب اولیه لید',
'status' => 'active', 'status' => 'active',
@ -218,7 +222,7 @@ class LeadController extends Controller
]); ]);
foreach (array_filter([$validated['phone'] ?? null, $validated['phone_secondary'] ?? null]) as $phone) { foreach (array_filter([$validated['phone'] ?? null, $validated['phone_secondary'] ?? null]) as $phone) {
\App\Models\ContactPhone::create([ ContactPhone::create([
'contact_id' => $contact->id, 'contact_id' => $contact->id,
'phone' => $phone, 'phone' => $phone,
'type' => 'mobile', 'type' => 'mobile',
@ -226,8 +230,8 @@ class LeadController extends Controller
]); ]);
} }
if (!empty($validated['assigned_to'])) { if (! empty($validated['assigned_to'])) {
\App\Models\LeadAssignment::create([ LeadAssignment::create([
'lead_id' => $lead->id, 'lead_id' => $lead->id,
'user_id' => $validated['assigned_to'], 'user_id' => $validated['assigned_to'],
'assigned_by' => auth()->id(), 'assigned_by' => auth()->id(),
@ -236,6 +240,7 @@ class LeadController extends Controller
} }
ActivityLogger::log('lead_created', "Lead {$lead->full_name} created", $lead); ActivityLogger::log('lead_created', "Lead {$lead->full_name} created", $lead);
$this->automations->dispatch('lead_created', $lead, "lead-created:{$lead->id}", ['source' => $lead->source, 'priority' => $lead->priority]);
$payload = $lead->load(['leadStatus', 'pipelineStage', 'campaign'])->toArray(); $payload = $lead->load(['leadStatus', 'pipelineStage', 'campaign'])->toArray();
if ($duplicateLead && in_array($duplicatePolicy, ['warn', 'merge_suggestion'], true)) { if ($duplicateLead && in_array($duplicatePolicy, ['warn', 'merge_suggestion'], true)) {
@ -262,8 +267,8 @@ class LeadController extends Controller
'callLogs.contact:id,name,role', 'callLogs.contact:id,name,role',
'callLogs.phone:id,phone,type,status', 'callLogs.phone:id,phone,type,status',
'callLogs.user:id,name', 'callLogs.user:id,name',
'calls' => fn($q) => $q->with('contact:id,name,role', 'contactPhone:id,phone,type,status')->latest(), 'calls' => fn ($q) => $q->with('contact:id,name,role', 'contactPhone:id,phone,type,status')->latest(),
'followUps' => fn($q) => $q->latest(), 'followUps' => fn ($q) => $q->latest(),
'notes.user:id,name', 'notes.user:id,name',
'attachments.uploader:id,name', 'attachments.uploader:id,name',
]) ])
@ -325,7 +330,7 @@ class LeadController extends Controller
]); ]);
$leads = Lead::whereIn('id', $validated['ids'])->get(); $leads = Lead::whereIn('id', $validated['ids'])->get();
if ($leads->count() !== count(array_unique($validated['ids'])) || $leads->contains(fn(Lead $lead) => Gate::denies('delete', $lead))) { if ($leads->count() !== count(array_unique($validated['ids'])) || $leads->contains(fn (Lead $lead) => Gate::denies('delete', $lead))) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
@ -346,27 +351,27 @@ class LeadController extends Controller
$lead = Lead::where('assigned_to', $user->id) $lead = Lead::where('assigned_to', $user->id)
->where(function ($q) { ->where(function ($q) {
$q->whereNull('last_call_at') $q->whereNull('last_call_at')
->orWhere('next_follow_up_at', '<=', now()); ->orWhere('next_follow_up_at', '<=', now());
}) })
->orderBy('priority', 'desc') ->orderBy('priority', 'desc')
->orderBy('created_at', 'asc') ->orderBy('created_at', 'asc')
->first(); ->first();
if (!$lead) { if (! $lead) {
$lead = Lead::where('assigned_to', $user->id) $lead = Lead::where('assigned_to', $user->id)
->orderBy('priority', 'desc') ->orderBy('priority', 'desc')
->orderBy('last_call_at', 'asc') ->orderBy('last_call_at', 'asc')
->first(); ->first();
} }
if (!$lead) { if (! $lead) {
return response()->json(['message' => 'لیدی برای تماس وجود ندارد'], 404); return response()->json(['message' => 'لیدی برای تماس وجود ندارد'], 404);
} }
ActivityLogger::log('lead_viewed', "Lead {$lead->id} viewed as next lead", $lead); ActivityLogger::log('lead_viewed', "Lead {$lead->id} viewed as next lead", $lead);
return response()->json( return response()->json(
$lead->load(['leadStatus', 'pipelineStage', 'campaign', 'calls' => fn($q) => $q->latest()->limit(5)]) $lead->load(['leadStatus', 'pipelineStage', 'campaign', 'calls' => fn ($q) => $q->latest()->limit(5)])
); );
} }
@ -374,7 +379,7 @@ class LeadController extends Controller
{ {
$user = auth()->user(); $user = auth()->user();
if (!$user) { if (! $user) {
return false; return false;
} }
@ -388,6 +393,7 @@ class LeadController extends Controller
if ($user->hasRole('supervisor')) { if ($user->hasRole('supervisor')) {
$teamIds = $user->teams->pluck('id')->toArray(); $teamIds = $user->teams->pluck('id')->toArray();
return in_array($lead->team_id, $teamIds, true) || $lead->assigned_to === $user->id; return in_array($lead->team_id, $teamIds, true) || $lead->assigned_to === $user->id;
} }

مشاهده پرونده

@ -40,7 +40,7 @@ class LeadStatusController extends Controller
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'sometimes|string|max:255', 'name' => 'sometimes|string|max:255',
'slug' => 'nullable|string|max:80|unique:lead_statuses,slug,' . $leadStatus->id, 'slug' => 'nullable|string|max:80|unique:lead_statuses,slug,'.$leadStatus->id,
'color' => 'nullable|string|max:20', 'color' => 'nullable|string|max:20',
'icon' => 'nullable|string|max:255', 'icon' => 'nullable|string|max:255',
'sort_order' => 'nullable|integer', 'sort_order' => 'nullable|integer',
@ -61,6 +61,7 @@ class LeadStatusController extends Controller
{ {
$leadStatus->delete(); $leadStatus->delete();
ActivityLogger::log('lead_status_deleted', "Status {$leadStatus->name} deleted"); ActivityLogger::log('lead_status_deleted', "Status {$leadStatus->name} deleted");
return response()->json(['message' => 'وضعیت حذف شد']); return response()->json(['message' => 'وضعیت حذف شد']);
} }

مشاهده پرونده

@ -35,7 +35,7 @@ class LostReasonController extends Controller
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'sometimes|string|max:255', 'name' => 'sometimes|string|max:255',
'slug' => 'sometimes|string|max:80|unique:lost_reasons,slug,' . $lostReason->id, 'slug' => 'sometimes|string|max:80|unique:lost_reasons,slug,'.$lostReason->id,
'color' => 'nullable|string|max:20', 'color' => 'nullable|string|max:20',
'sort_order' => 'nullable|integer', 'sort_order' => 'nullable|integer',
'is_active' => 'nullable|boolean', 'is_active' => 'nullable|boolean',

مشاهده پرونده

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class MediaController extends Controller
{
public function avatar(string $filename): BinaryFileResponse
{
abort_unless(
$filename === basename($filename)
&& preg_match('/^[A-Za-z0-9_-]+\.(?:jpe?g|png|webp)$/i', $filename),
404,
);
$path = "avatars/{$filename}";
abort_unless(Storage::disk('public')->exists($path), 404);
return response()->file(Storage::disk('public')->path($path), [
'Cache-Control' => 'public, max-age=86400, immutable',
'X-Content-Type-Options' => 'nosniff',
]);
}
}

مشاهده پرونده

@ -3,48 +3,126 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Company; use App\Http\Requests\StoreCallNoteRequest;
use App\Models\Deal; use App\Http\Requests\UpdateNoteRequest;
use App\Models\Lead; use App\Http\Resources\NoteResource;
use App\Http\Responses\ApiResponse;
use App\Models\Call;
use App\Models\Note; use App\Models\Note;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Support\EntityResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class NoteController extends Controller class NoteController extends Controller
{ {
public function callIndex(Call $call): JsonResponse
{
Gate::authorize('view', $call);
$query = $call->notesHistory()->with('user:id,name');
if (! auth()->user()->hasRole('admin')) {
$query->where(fn ($visibility) => $visibility
->where('visibility', '<>', 'private')
->orWhere('user_id', auth()->id()));
}
$notes = $query->orderByDesc('is_pinned')->latest()->get();
return ApiResponse::success($notes->map(fn (Note $note) => $this->resource($note))->all());
}
public function callStore(StoreCallNoteRequest $request, Call $call): JsonResponse
{
Gate::authorize('create', [Note::class, $call]);
$note = DB::transaction(function () use ($request, $call): Note {
$note = $call->notesHistory()->create([
'user_id' => $request->user()->id,
'content' => $request->validated('content'),
'type' => $request->validated('type', 'general'),
'visibility' => $request->validated('visibility', 'team'),
]);
ActivityLogger::log('call_note_created', "Call note {$note->id} created", $note, null, $note->only(['type', 'visibility', 'is_pinned']));
return $note;
});
return ApiResponse::success($this->resource($note->load('user:id,name')), 201, 'یادداشت تماس ثبت شد.');
}
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'entity_type' => 'required|in:lead,company,deal', 'entity_type' => 'required|in:lead,company,deal',
'entity_id' => 'required|integer', 'entity_id' => 'required|integer',
'content' => 'required|string', 'content' => 'required|string|max:5000',
]); ]);
$model = EntityResolver::authorize($validated['entity_type'], $validated['entity_id'], 'update');
$note = DB::transaction(function () use ($model, $validated): Note {
$note = $model->notes()->create([
'user_id' => auth()->id(),
'content' => $validated['content'],
'type' => 'general',
'visibility' => 'team',
]);
ActivityLogger::log('note_created', 'یادداشت جدید ثبت شد', $note, null, $note->only(['type', 'visibility']));
$model = $this->resolve($validated['entity_type'], $validated['entity_id']); return $note;
$note = $model->notes()->create([ });
'user_id' => auth()->id(),
'content' => $validated['content'],
]);
ActivityLogger::log('note_created', 'یادداشت جدید ثبت شد', $model); return ApiResponse::success($this->resource($note->load('user:id,name')), 201, 'یادداشت ثبت شد.');
return response()->json($note->load('user:id,name'), 201); }
public function update(UpdateNoteRequest $request, Note $note): JsonResponse
{
Gate::authorize('update', $note);
$updated = DB::transaction(function () use ($request, $note): Note {
$before = $note->only(['type', 'visibility', 'is_pinned']);
$note->fill($request->validated());
$note->edited_at = now();
$note->save();
ActivityLogger::log('call_note_edited', "Note {$note->id} edited", $note, $before, $note->only(['type', 'visibility', 'is_pinned']));
return $note;
});
return ApiResponse::success($this->resource($updated->load('user:id,name')), message: 'یادداشت ویرایش شد.');
} }
public function destroy(Note $note): JsonResponse public function destroy(Note $note): JsonResponse
{ {
abort_unless(auth()->id() === $note->user_id || auth()->user()?->hasRole('admin'), 403, 'دسترسی غیرمجاز'); Gate::authorize('delete', $note);
$note->delete(); DB::transaction(function () use ($note): void {
ActivityLogger::log('note_deleted', "Note {$note->id} deleted"); $before = $note->only(['type', 'visibility', 'is_pinned']);
return response()->json(['message' => 'یادداشت حذف شد']); $note->delete();
ActivityLogger::log('call_note_deleted', "Note {$note->id} deleted", $note, $before, ['deleted' => true]);
});
return ApiResponse::success(null, message: 'یادداشت حذف شد.');
} }
private function resolve(string $type, int $id): Lead|Company|Deal public function pin(Note $note): JsonResponse
{ {
return match ($type) { return $this->setPinned($note, true);
'lead' => Lead::findOrFail($id), }
'company' => Company::findOrFail($id),
'deal' => Deal::findOrFail($id), public function unpin(Note $note): JsonResponse
}; {
return $this->setPinned($note, false);
}
private function setPinned(Note $note, bool $pinned): JsonResponse
{
Gate::authorize('pin', $note);
$note->update(['is_pinned' => $pinned]);
ActivityLogger::log($pinned ? 'call_note_pinned' : 'call_note_unpinned', "Note {$note->id} pin changed", $note, ['is_pinned' => ! $pinned], ['is_pinned' => $pinned]);
return ApiResponse::success($this->resource($note->load('user:id,name')), message: $pinned ? 'یادداشت سنجاق شد.' : 'سنجاق یادداشت برداشته شد.');
}
private function resource(Note $note): array
{
return (new NoteResource($note))->resolve(request());
} }
} }

مشاهده پرونده

@ -3,47 +3,83 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\NotificationResource;
use App\Http\Responses\ApiResponse;
use App\Models\Notification; use App\Models\Notification;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class NotificationController extends Controller class NotificationController extends Controller
{ {
public function index(): JsonResponse public function index(Request $request): JsonResponse
{ {
$notifications = Notification::where('user_id', auth()->id()) $query = Notification::where('user_id', auth()->id());
->orderBy('created_at', 'desc') if ($request->boolean('archived')) {
$query->whereNotNull('archived_at');
} else {
$query->whereNull('archived_at');
}
if ($request->filled('type')) {
$query->where('type', $request->string('type'));
}
if ($request->has('unread')) {
$query->where('is_read', $request->boolean('unread') ? false : true);
}
$paginator = $query
->orderByDesc('created_at')
->paginate(20); ->paginate(20);
return response()->json($notifications); return ApiResponse::paginated($paginator, fn (Notification $notification) => $this->resource($notification));
} }
public function markRead(Notification $notification): JsonResponse public function markRead(Notification $notification): JsonResponse
{ {
if ($notification->user_id !== auth()->id()) { Gate::authorize('update', $notification);
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); $notification->update(['is_read' => true, 'read_at' => $notification->read_at ?? now()]);
}
$notification->update(['is_read' => true]); return ApiResponse::success($this->resource($notification), message: 'اعلان خوانده شد.');
return response()->json($notification);
} }
public function markAllRead(): JsonResponse public function markAllRead(): JsonResponse
{ {
Notification::where('user_id', auth()->id()) Notification::where('user_id', auth()->id())
->where('is_read', false) ->where('is_read', false)
->update(['is_read' => true]); ->whereNull('archived_at')
->update(['is_read' => true, 'read_at' => now()]);
return response()->json(['message' => 'همه اعلان‌ها خوانده شد']); return ApiResponse::success(null, message: 'همه اعلان‌ها خوانده شد.');
} }
public function unreadCount(): JsonResponse public function unreadCount(): JsonResponse
{ {
$count = Notification::where('user_id', auth()->id()) $count = Notification::where('user_id', auth()->id())->where('is_read', false)->whereNull('archived_at')->count();
->where('is_read', false)
->count();
return response()->json(['count' => $count]); return ApiResponse::success($count);
}
public function archive(Notification $notification): JsonResponse
{
Gate::authorize('update', $notification);
$notification->update([
'archived_at' => now(),
'is_read' => true,
'read_at' => $notification->read_at ?? now(),
]);
return ApiResponse::success($this->resource($notification), message: 'اعلان بایگانی شد.');
}
public function destroy(Notification $notification): JsonResponse
{
Gate::authorize('delete', $notification);
$notification->delete();
return ApiResponse::success(null, message: 'اعلان حذف شد.');
}
private function resource(Notification $notification): array
{
return (new NotificationResource($notification))->resolve(request());
} }
} }

مشاهده پرونده

@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Models\DealStage;
use App\Models\Pipeline;
use App\Services\DealPipelineService;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PipelineController extends Controller
{
public function __construct(private DealPipelineService $service) {}
public function index(Request $request): JsonResponse
{
abort_unless($request->user()->can('view_pipelines'), 403);
$query = Pipeline::with('stages')->where('is_active', true)->orderBy('sort_order');
if (! $request->user()->hasRole('admin')) {
$teamIds = AccessControl::teamIds($request->user());
$query->where(fn ($scope) => $scope->whereNull('team_id')->orWhereIn('team_id', $teamIds));
}
return response()->json($query->get());
}
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->can('manage_pipelines'), 403);
$data = $request->validate([
'name' => 'required|string|max:120', 'team_id' => 'nullable|exists:teams,id',
'is_default' => 'boolean', 'stages' => 'required|array|min:2|max:20',
'stages.*.name' => 'required|string|max:80', 'stages.*.color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'stages.*.probability' => 'required|integer|min:0|max:100', 'stages.*.is_won' => 'boolean', 'stages.*.is_lost' => 'boolean',
]);
if (! $request->user()->hasRole('admin') && ! empty($data['team_id'])) {
abort_unless(in_array((int) $data['team_id'], AccessControl::teamIds($request->user()), true), 403);
}
$pipeline = DB::transaction(function () use ($data): Pipeline {
if ($data['is_default'] ?? false) {
Pipeline::query()->update(['is_default' => false]);
}
$pipeline = Pipeline::create([
'name' => $data['name'], 'slug' => Str::slug($data['name']).'-'.Str::lower(Str::random(5)),
'team_id' => $data['team_id'] ?? null, 'is_default' => $data['is_default'] ?? false, 'is_active' => true,
]);
foreach ($data['stages'] as $index => $stage) {
$pipeline->stages()->create($stage + ['slug' => Str::slug($stage['name']).'-'.$index, 'sort_order' => $index, 'is_active' => true]);
}
return $pipeline;
});
return response()->json($pipeline->load('stages'), 201);
}
public function board(Request $request, Pipeline $pipeline): JsonResponse
{
abort_unless($request->user()->can('view_pipelines'), 403);
abort_unless($this->visible($pipeline, $request), 403);
return response()->json($this->service->board($pipeline, $request->user(), $request->only(['search', 'owner_id', 'forecast_category', 'status'])));
}
public function move(Request $request, Deal $deal): JsonResponse
{
abort_unless($request->user()->can('move_deals'), 403);
abort_unless(AccessControl::canAccessDeal($request->user(), $deal), 403);
$data = $request->validate(['deal_stage_id' => 'required|exists:deal_stages,id', 'version' => 'required|integer|min:1', 'reason' => 'nullable|string|max:1000', 'final_amount' => 'nullable|numeric|min:0']);
$stage = DealStage::findOrFail($data['deal_stage_id']);
return response()->json($this->service->move($deal, $stage, $request->user(), $data['version'], $data['reason'] ?? null, isset($data['final_amount']) ? (float) $data['final_amount'] : null));
}
private function visible(Pipeline $pipeline, Request $request): bool
{
return $request->user()->hasRole('admin') || $pipeline->team_id === null || in_array($pipeline->team_id, AccessControl::teamIds($request->user()), true);
}
}

مشاهده پرونده

@ -3,16 +3,19 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\FollowUp; use App\Models\Lead;
use App\Models\LeadStatus;
use App\Models\PipelineStage; use App\Models\PipelineStage;
use App\Services\NotificationService;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Services\FollowUpService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
class PipelineStageController extends Controller class PipelineStageController extends Controller
{ {
public function __construct(private FollowUpService $followUps) {}
public function index(): JsonResponse public function index(): JsonResponse
{ {
return response()->json( return response()->json(
@ -48,7 +51,7 @@ class PipelineStageController extends Controller
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'sometimes|string|max:255', 'name' => 'sometimes|string|max:255',
'slug' => 'nullable|string|max:80|unique:pipeline_stages,slug,' . $pipelineStage->id, 'slug' => 'nullable|string|max:80|unique:pipeline_stages,slug,'.$pipelineStage->id,
'color' => 'nullable|string|max:20', 'color' => 'nullable|string|max:20',
'sort_order' => 'nullable|integer', 'sort_order' => 'nullable|integer',
'is_active' => 'nullable|boolean', 'is_active' => 'nullable|boolean',
@ -69,6 +72,7 @@ class PipelineStageController extends Controller
{ {
$pipelineStage->delete(); $pipelineStage->delete();
ActivityLogger::log('pipeline_stage_deleted', "Stage {$pipelineStage->name} deleted"); ActivityLogger::log('pipeline_stage_deleted', "Stage {$pipelineStage->name} deleted");
return response()->json(['message' => 'مرحله حذف شد']); return response()->json(['message' => 'مرحله حذف شد']);
} }
@ -77,6 +81,7 @@ class PipelineStageController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'lead_id' => 'required|exists:leads,id', 'lead_id' => 'required|exists:leads,id',
'next_follow_up_at' => 'nullable|date', 'next_follow_up_at' => 'nullable|date',
'follow_up_notes' => 'nullable|string|max:2000',
'final_result' => 'nullable|in:موفق,ناموفق', 'final_result' => 'nullable|in:موفق,ناموفق',
'lost_reason' => 'nullable|string|max:255', 'lost_reason' => 'nullable|string|max:255',
'deal_value' => 'nullable|numeric|min:0', 'deal_value' => 'nullable|numeric|min:0',
@ -86,7 +91,7 @@ class PipelineStageController extends Controller
'customer_notes' => 'nullable|string', 'customer_notes' => 'nullable|string',
]); ]);
$lead = \App\Models\Lead::findOrFail($validated['lead_id']); $lead = Lead::findOrFail($validated['lead_id']);
Gate::authorize('changeStage', $lead); Gate::authorize('changeStage', $lead);
$update = ['pipeline_stage_id' => $pipelineStage->id]; $update = ['pipeline_stage_id' => $pipelineStage->id];
@ -100,7 +105,7 @@ class PipelineStageController extends Controller
$request->validate(['final_result' => 'required|in:موفق,ناموفق']); $request->validate(['final_result' => 'required|in:موفق,ناموفق']);
$update['final_result'] = $validated['final_result']; $update['final_result'] = $validated['final_result'];
$statusName = $validated['final_result']; $statusName = $validated['final_result'];
$update['lead_status_id'] = \App\Models\LeadStatus::where($statusName === 'موفق' ? 'is_won' : 'is_lost', true)->value('id'); $update['lead_status_id'] = LeadStatus::where($statusName === 'موفق' ? 'is_won' : 'is_lost', true)->value('id');
} }
if (($pipelineStage->is_won || $pipelineStage->is_lost) && ($validated['final_result'] ?? null) === 'ناموفق') { if (($pipelineStage->is_won || $pipelineStage->is_lost) && ($validated['final_result'] ?? null) === 'ناموفق') {
@ -125,17 +130,14 @@ class PipelineStageController extends Controller
$lead->update($update); $lead->update($update);
if ($pipelineStage->requires_follow_up) { if ($pipelineStage->requires_follow_up) {
FollowUp::create([ $this->followUps->schedule(
'lead_id' => $lead->id, $lead,
'user_id' => $lead->assigned_to ?? auth()->id(), $lead->assigned_to ?? auth()->id(),
'scheduled_at' => $validated['next_follow_up_at'], $validated['next_follow_up_at'],
'notes' => 'ثبت شده از قیف فروش', $request->user(),
'status' => 'pending', $validated['follow_up_notes'] ?? null,
]); source: 'pipeline',
);
if ($lead->assigned_to) {
NotificationService::notifyFollowUpReminder($lead->assigned_to, $lead->company ?? $lead->full_name);
}
} }
ActivityLogger::log('lead_stage_changed', "Lead {$lead->id} moved to stage {$pipelineStage->name}", $lead); ActivityLogger::log('lead_stage_changed', "Lead {$lead->id} moved to stage {$pipelineStage->name}", $lead);

مشاهده پرونده

@ -7,51 +7,64 @@ use App\Models\Product;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ProductController extends Controller class ProductController extends Controller
{ {
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
Gate::authorize('viewAny', Product::class);
$query = Product::with('salesScript:id,title')->withCount('deals'); $query = Product::with('salesScript:id,title')->withCount('deals');
if ($request->search) $query->where('name', 'like', "%{$request->search}%"); if ($request->search) {
if ($request->filled('is_active')) $query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN)); $query->where('name', 'like', "%{$request->search}%");
}
if ($request->filled('is_active')) {
$query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN));
}
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100))); return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
} }
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$this->authorizeManage(); Gate::authorize('create', Product::class);
$product = Product::create($this->validated($request) + ['created_by' => auth()->id()]); $product = Product::create($this->validated($request) + ['created_by' => auth()->id()]);
ActivityLogger::log('product_created', "Product {$product->name} created", $product); ActivityLogger::log('product_created', "Product {$product->name} created", $product);
return response()->json($product->load('salesScript:id,title'), 201); return response()->json($product->load('salesScript:id,title'), 201);
} }
public function show(Product $product): JsonResponse public function show(Product $product): JsonResponse
{ {
Gate::authorize('view', $product);
return response()->json($product->load('salesScript.sections', 'deals:id,title,product_id,status')); return response()->json($product->load('salesScript.sections', 'deals:id,title,product_id,status'));
} }
public function update(Request $request, Product $product): JsonResponse public function update(Request $request, Product $product): JsonResponse
{ {
$this->authorizeManage(); Gate::authorize('update', $product);
$product->update($this->validated($request, true)); $product->update($this->validated($request, true));
ActivityLogger::log('product_updated', "Product {$product->name} updated", $product); ActivityLogger::log('product_updated', "Product {$product->name} updated", $product);
return response()->json($product->fresh('salesScript:id,title')); return response()->json($product->fresh('salesScript:id,title'));
} }
public function destroy(Product $product): JsonResponse public function destroy(Product $product): JsonResponse
{ {
$this->authorizeManage(); Gate::authorize('delete', $product);
$product->delete(); $product->delete();
ActivityLogger::log('product_deleted', "Product {$product->id} deleted"); ActivityLogger::log('product_deleted', "Product {$product->id} deleted");
return response()->json(['message' => 'محصول/خدمت حذف شد']); return response()->json(['message' => 'محصول/خدمت حذف شد']);
} }
private function validated(Request $request, bool $partial = false): array private function validated(Request $request, bool $partial = false): array
{ {
$sometimes = $partial ? 'sometimes|' : ''; $sometimes = $partial ? 'sometimes|' : '';
return $request->validate([ return $request->validate([
'name' => $sometimes . 'required|string|max:255', 'name' => $sometimes.'required|string|max:255',
'category' => 'nullable|string|max:120', 'category' => 'nullable|string|max:120',
'base_price' => 'nullable|numeric|min:0', 'base_price' => 'nullable|numeric|min:0',
'description' => 'nullable|string', 'description' => 'nullable|string',
@ -61,9 +74,4 @@ class ProductController extends Controller
'objection_handling' => 'nullable|array', 'objection_handling' => 'nullable|array',
]); ]);
} }
private function authorizeManage(): void
{
abort_unless(auth()->user()?->hasRole('admin') || auth()->user()?->hasRole('supervisor') || auth()->user()?->can('manage_products'), 403, 'شما مجوز مدیریت محصولات را ندارید.');
}
} }

مشاهده پرونده

@ -3,75 +3,187 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Call;
use App\Models\QualityReview; use App\Models\QualityReview;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class QualityReviewController extends Controller class QualityReviewController extends Controller
{ {
private const SCORE_FIELDS = [
'greeting_score',
'product_intro_score',
'needs_discovery_score',
'objection_handling_score',
'closing_score',
'crm_accuracy_score',
'follow_up_quality_score',
];
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$user = auth()->user(); Gate::authorize('viewAny', QualityReview::class);
$query = QualityReview::with('call:id,lead_id,created_at', 'agent:id,name', 'reviewer:id,name');
if ($user->hasRole('agent')) { if ($request->has('current_only')) {
$query->where('agent_id', $user->id); $request->merge([
} elseif ($user->hasRole('supervisor')) { 'current_only' => filter_var(
$teamIds = $user->teams->pluck('id')->toArray(); $request->input('current_only'),
$agentIds = \App\Models\Team::whereIn('id', $teamIds)->with('members')->get()->pluck('members.*.id')->flatten(); FILTER_VALIDATE_BOOLEAN,
$query->whereIn('agent_id', $agentIds); FILTER_NULL_ON_FAILURE,
),
]);
} }
return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15)); $filters = $request->validate([
'agent_id' => 'nullable|integer|exists:users,id',
'score_min' => 'nullable|integer|min:0|max:100',
'score_max' => 'nullable|integer|min:0|max:100|gte:score_min',
'status' => 'nullable|string|max:30',
'shared' => 'nullable|boolean',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date|after_or_equal:date_from',
'result' => 'nullable|string|max:50',
'has_recording' => 'nullable|boolean',
'weak_section' => 'nullable|in:greeting_score,product_intro_score,needs_discovery_score,objection_handling_score,closing_score,crm_accuracy_score,follow_up_quality_score',
'weak_threshold' => 'nullable|integer|min:0|max:100',
'current_only' => 'nullable|boolean',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
]);
$user = $request->user();
$query = QualityReview::with('call:id,lead_id,user_id,result,recording_url,created_at', 'agent:id,name', 'reviewer:id,name');
if ($user->hasRole('agent')) {
$query->where('agent_id', $user->id)->where('is_shared_with_agent', true);
} elseif ($user->hasRole('supervisor')) {
$query->whereHas('call', fn ($callQuery) => AccessControl::scopeCalls($callQuery, $user));
}
if ($request->boolean('current_only', true)) {
$query->where('is_current', true);
}
if (! empty($filters['agent_id'])) {
$query->where('agent_id', $filters['agent_id']);
}
if (isset($filters['score_min'])) {
$query->where('overall_score', '>=', $filters['score_min']);
}
if (isset($filters['score_max'])) {
$query->where('overall_score', '<=', $filters['score_max']);
}
if (! empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (array_key_exists('shared', $filters)) {
$query->where('is_shared_with_agent', (bool) $filters['shared']);
}
if (! empty($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
if (! empty($filters['result'])) {
$query->whereHas('call', fn ($call) => $call->where('result', $filters['result']));
}
if (array_key_exists('has_recording', $filters)) {
$query->whereHas('call', fn ($call) => (bool) $filters['has_recording']
? $call->whereNotNull('recording_url')
: $call->whereNull('recording_url'));
}
if (! empty($filters['weak_section'])) {
$query->where($filters['weak_section'], '<', $filters['weak_threshold'] ?? 60);
}
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
} }
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate(array_merge([
'call_id' => 'required|exists:calls,id', 'call_id' => 'required|exists:calls,id',
'agent_id' => 'required|exists:users,id',
'greeting_score' => 'required|integer|min:0|max:100',
'product_intro_score' => 'required|integer|min:0|max:100',
'needs_discovery_score' => 'required|integer|min:0|max:100',
'objection_handling_score' => 'required|integer|min:0|max:100',
'closing_score' => 'required|integer|min:0|max:100',
'crm_accuracy_score' => 'required|integer|min:0|max:100',
'follow_up_quality_score' => 'required|integer|min:0|max:100',
'feedback' => 'nullable|string', 'feedback' => 'nullable|string',
'tag' => 'nullable|string|max:30', 'tag' => 'nullable|string|max:30',
]); 'is_shared_with_agent' => 'nullable|boolean',
'strengths' => 'nullable|array|max:20',
'improvement_areas' => 'nullable|array|max:20',
], $this->scoreRules()));
$validated['reviewer_id'] = auth()->id(); $call = Call::findOrFail($validated['call_id']);
$validated['overall_score'] = (int) round(collect([ Gate::authorize('create', [QualityReview::class, $call]);
$validated['greeting_score'], $validated['product_intro_score'],
$validated['needs_discovery_score'], $validated['objection_handling_score'],
$validated['closing_score'], $validated['crm_accuracy_score'],
$validated['follow_up_quality_score'],
])->avg());
$review = QualityReview::create($validated); $review = DB::transaction(function () use ($validated, $call, $request): QualityReview {
$latestVersion = QualityReview::where('call_id', $call->id)->lockForUpdate()->max('version') ?? 0;
QualityReview::where('call_id', $call->id)->update(['is_current' => false]);
ActivityLogger::log('quality_review_created', "Quality review for agent {$validated['agent_id']} created", $review); return QualityReview::create($validated + [
'reviewer_id' => $request->user()->id,
'agent_id' => $call->user_id,
'version' => $latestVersion + 1,
'is_current' => true,
'is_shared_with_agent' => $validated['is_shared_with_agent'] ?? false,
'overall_score' => $this->overallScore($validated),
]);
});
return response()->json($review->load('agent:id,name', 'reviewer:id,name'), 201); ActivityLogger::log('quality_review_created', "Quality review for call {$call->id} created", $review);
return response()->json($review->load('call:id,lead_id,user_id,created_at', 'agent:id,name', 'reviewer:id,name'), 201);
} }
public function show(QualityReview $qualityReview): JsonResponse public function show(QualityReview $qualityReview): JsonResponse
{ {
return response()->json($qualityReview->load('call.lead', 'agent', 'reviewer')); Gate::authorize('view', $qualityReview);
return response()->json($qualityReview->load('call.lead', 'agent:id,name', 'reviewer:id,name'));
} }
public function update(Request $request, QualityReview $qualityReview): JsonResponse public function update(Request $request, QualityReview $qualityReview): JsonResponse
{ {
$validated = $request->validate([ Gate::authorize('update', $qualityReview);
$validated = $request->validate(array_merge([
'feedback' => 'nullable|string', 'feedback' => 'nullable|string',
'tag' => 'nullable|string|max:30', 'tag' => 'nullable|string|max:30',
'is_shared_with_agent' => 'nullable|boolean', 'is_shared_with_agent' => 'nullable|boolean',
]); 'strengths' => 'nullable|array|max:20',
'improvement_areas' => 'nullable|array|max:20',
], $this->scoreRules(required: false)));
if (collect(self::SCORE_FIELDS)->contains(fn (string $field) => array_key_exists($field, $validated))) {
$validated['overall_score'] = $this->overallScore(array_merge($qualityReview->only(self::SCORE_FIELDS), $validated));
}
$qualityReview->update($validated); $qualityReview->update($validated);
ActivityLogger::log('quality_review_updated', "Quality review {$qualityReview->id} updated", $qualityReview);
return response()->json($qualityReview); return response()->json($qualityReview->fresh(['call:id,lead_id,user_id,created_at', 'agent:id,name', 'reviewer:id,name']));
}
public function acknowledge(Request $request, QualityReview $qualityReview): JsonResponse
{
abort_unless($request->user()->can('acknowledge_quality_reviews'), 403);
Gate::authorize('view', $qualityReview);
abort_unless($qualityReview->agent_id === $request->user()->id, 403, 'فقط کارشناس ارزیابی‌شده می‌تواند تأیید کند.');
$data = $request->validate(['agent_response' => 'nullable|string|max:2000']);
$qualityReview->update(['status' => 'acknowledged', 'acknowledged_at' => now(), 'agent_response' => $data['agent_response'] ?? null]);
ActivityLogger::log('quality_review_acknowledged', "Quality review {$qualityReview->id} acknowledged", $qualityReview);
return response()->json($qualityReview->fresh());
}
private function scoreRules(bool $required = true): array
{
return collect(self::SCORE_FIELDS)->mapWithKeys(
fn (string $field): array => [$field => ($required ? 'required' : 'sometimes').'|integer|min:0|max:100']
)->all();
}
private function overallScore(array $data): int
{
return (int) round(collect(self::SCORE_FIELDS)->map(fn (string $field) => (int) $data[$field])->avg());
} }
} }

مشاهده پرونده

@ -3,14 +3,21 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Campaign;
use App\Models\Invoice;
use App\Models\Lead;
use App\Models\LeadStatus;
use App\Models\SlaBreach;
use App\Models\Task;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use App\Models\Campaign;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Services\ReportService; use App\Services\ReportService;
use App\Support\AccessControl; use App\Support\AccessControl;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
@ -18,6 +25,75 @@ class ReportController extends Controller
{ {
public function __construct(private ReportService $reportService) {} public function __construct(private ReportService $reportService) {}
public function operations(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$filters = $request->validate(['date_from' => 'nullable|date', 'date_to' => 'nullable|date|after_or_equal:date_from']);
$leads = Lead::query();
AccessControl::scopeLeads($leads, $request->user());
$invoices = Invoice::query()->whereHas('lead', function ($query) use ($request) {
AccessControl::scopeLeads($query, $request->user());
});
$tasks = Task::query();
AccessControl::scopeTasks($tasks, $request->user());
if (! empty($filters['date_from'])) {
$leads->whereDate('leads.created_at', '>=', $filters['date_from']);
$invoices->whereDate('invoices.created_at', '>=', $filters['date_from']);
$tasks->whereDate('tasks.created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$leads->whereDate('leads.created_at', '<=', $filters['date_to']);
$invoices->whereDate('invoices.created_at', '<=', $filters['date_to']);
$tasks->whereDate('tasks.created_at', '<=', $filters['date_to']);
}
$pipeline = LeadStatus::query()->where('is_active', true)->orderBy('sort_order')->get()
->map(fn (LeadStatus $status) => [
'stage' => $status->name,
'count' => (clone $leads)->where('lead_status_id', $status->id)->count(),
]);
$sla = SlaBreach::query();
if ($request->user()->hasRole('agent')) {
$sla->where('assigned_to', $request->user()->id);
} elseif ($request->user()->hasRole('supervisor')) {
$sla->whereIn('assigned_to', AccessControl::teamMemberIds($request->user()));
}
return response()->json([
'summary' => [
'open_leads' => (clone $leads)->whereNull('final_result')->count(),
'issued_invoices' => (clone $invoices)->where('status', 'issued')->count(),
'invoiced_total' => (float) (clone $invoices)->where('status', 'issued')->sum('total'),
'outstanding_total' => max(
0,
(float) (clone $invoices)->where('status', 'issued')->sum('total')
- (float) (clone $invoices)->where('status', 'issued')->sum('paid_amount'),
),
'sla_breaches' => (clone $sla)->where('status', 'breached')->count(),
'overdue_tasks' => (clone $tasks)->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())->count(),
],
'pipeline' => $pipeline,
'sla_by_status' => (clone $sla)->selectRaw('status, COUNT(*) as count')->groupBy('status')->get(),
'tasks_by_status' => (clone $tasks)->selectRaw('status, COUNT(*) as count')->groupBy('status')->get(),
]);
}
public function kpi(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
return response()->json($this->reportService->kpiDashboard(
$validated['date_from'] ?? null,
$validated['date_to'] ?? null,
$validated['agent_id'] ?? null,
$agentIds
));
}
public function agentPerformance(Request $request): JsonResponse public function agentPerformance(Request $request): JsonResponse
{ {
Gate::authorize('view-report-data'); Gate::authorize('view-report-data');
@ -29,14 +105,14 @@ class ReportController extends Controller
]); ]);
$allowedAgentIds = $this->allowedAgentIds(); $allowedAgentIds = $this->allowedAgentIds();
if (!empty($validated['agent_id']) && !in_array((int) $validated['agent_id'], $allowedAgentIds, true)) { if (! empty($validated['agent_id']) && ! in_array((int) $validated['agent_id'], $allowedAgentIds, true)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
if (empty($validated['agent_id'])) { if (empty($validated['agent_id'])) {
$agents = User::role('agent')->whereIn('id', $allowedAgentIds)->orderBy('name')->get(); $agents = User::role('agent')->whereIn('id', $allowedAgentIds)->orderBy('name')->get();
return response()->json($agents->map(fn(User $agent) => $this->reportService->agentPerformance( return response()->json($agents->map(fn (User $agent) => $this->reportService->agentPerformance(
$agent->id, $agent->id,
$validated['date_from'] ?? null, $validated['date_from'] ?? null,
$validated['date_to'] ?? null $validated['date_to'] ?? null
@ -55,6 +131,7 @@ class ReportController extends Controller
public function teamPerformance(Request $request): JsonResponse public function teamPerformance(Request $request): JsonResponse
{ {
Gate::authorize('view-report-data'); Gate::authorize('view-report-data');
abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.');
$validated = $request->validate([ $validated = $request->validate([
'team_id' => 'nullable|exists:teams,id', 'team_id' => 'nullable|exists:teams,id',
@ -63,14 +140,14 @@ class ReportController extends Controller
]); ]);
$allowedTeamIds = $this->allowedTeamIds(); $allowedTeamIds = $this->allowedTeamIds();
if (!empty($validated['team_id']) && !in_array((int) $validated['team_id'], $allowedTeamIds, true)) { if (! empty($validated['team_id']) && ! in_array((int) $validated['team_id'], $allowedTeamIds, true)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
if (empty($validated['team_id'])) { if (empty($validated['team_id'])) {
$teams = Team::whereIn('id', $allowedTeamIds)->orderBy('name')->get(); $teams = Team::whereIn('id', $allowedTeamIds)->orderBy('name')->get();
return response()->json($teams->map(fn(Team $team) => $this->reportService->teamPerformance( return response()->json($teams->map(fn (Team $team) => $this->reportService->teamPerformance(
$team->id, $team->id,
$validated['date_from'] ?? null, $validated['date_from'] ?? null,
$validated['date_to'] ?? null $validated['date_to'] ?? null
@ -89,6 +166,7 @@ class ReportController extends Controller
public function campaignReport(Request $request, int $campaignId): JsonResponse public function campaignReport(Request $request, int $campaignId): JsonResponse
{ {
Gate::authorize('view-report-data'); Gate::authorize('view-report-data');
abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.');
$validated = $request->validate([ $validated = $request->validate([
'date_from' => 'nullable|date', 'date_from' => 'nullable|date',
@ -185,7 +263,9 @@ class ReportController extends Controller
Gate::authorize('view-report-data'); Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request); $validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null); $agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403); if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
return response()->json($this->reportService->lostReasonReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds)); return response()->json($this->reportService->lostReasonReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
} }
@ -195,7 +275,9 @@ class ReportController extends Controller
Gate::authorize('view-report-data'); Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request); $validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null); $agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403); if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
return response()->json($this->reportService->sourcePerformanceReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds)); return response()->json($this->reportService->sourcePerformanceReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
} }
@ -205,7 +287,9 @@ class ReportController extends Controller
Gate::authorize('view-report-data'); Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request); $validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null); $agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403); if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
return response()->json($this->reportService->duplicateLeadsReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds)); return response()->json($this->reportService->duplicateLeadsReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
} }
@ -213,6 +297,7 @@ class ReportController extends Controller
public function importQuality(Request $request): JsonResponse public function importQuality(Request $request): JsonResponse
{ {
Gate::authorize('view-report-data'); Gate::authorize('view-report-data');
abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.');
$validated = $request->validate(['date_from' => 'nullable|date', 'date_to' => 'nullable|date']); $validated = $request->validate(['date_from' => 'nullable|date', 'date_to' => 'nullable|date']);
return response()->json($this->reportService->importQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null)); return response()->json($this->reportService->importQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null));
@ -223,7 +308,9 @@ class ReportController extends Controller
Gate::authorize('view-report-data'); Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request); $validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null); $agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403); if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
return response()->json($this->reportService->callQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds)); return response()->json($this->reportService->callQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
} }
@ -233,7 +320,9 @@ class ReportController extends Controller
Gate::authorize('view-report-data'); Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request); $validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null); $agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403); if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
return response()->json($this->reportService->bestContactTimeReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds)); return response()->json($this->reportService->bestContactTimeReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
} }
@ -243,18 +332,23 @@ class ReportController extends Controller
Gate::authorize('export-report-data'); Gate::authorize('export-report-data');
$type = $request->type ?? 'agent'; $type = $request->type ?? 'agent';
$agentExportTypes = ['kpi', 'agent', 'conversion', 'call', 'follow_up', 'lost_reason', 'source', 'duplicate', 'call_quality', 'best_contact_time'];
if ($request->user()->hasRole('agent') && ! in_array($type, $agentExportTypes, true)) {
return response()->json(['message' => 'کارشناس فقط می‌تواند گزارش عملکرد خود را خروجی بگیرد.'], 403);
}
$agentIds = $this->requestedAgentScope($request->integer('agent_id') ?: null); $agentIds = $this->requestedAgentScope($request->integer('agent_id') ?: null);
if ($agentIds === false) { if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
if ($request->filled('team_id') && !in_array($request->integer('team_id'), $this->allowedTeamIds(), true)) { if ($request->filled('team_id') && ! in_array($request->integer('team_id'), $this->allowedTeamIds(), true)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403); return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
} }
ActivityLogger::log('report_exported', "Report export requested: {$type}"); ActivityLogger::log('report_exported', "Report export requested: {$type}");
$data = match ($type) { $data = match ($type) {
'kpi' => $this->reportService->kpiDashboard($request->date_from, $request->date_to, $request->integer('agent_id') ?: null, $agentIds),
'agent' => $this->reportService->agentPerformance($request->integer('agent_id') ?: auth()->id(), $request->date_from, $request->date_to), 'agent' => $this->reportService->agentPerformance($request->integer('agent_id') ?: auth()->id(), $request->date_from, $request->date_to),
'team' => $this->reportService->teamPerformance($request->integer('team_id') ?: ($this->allowedTeamIds()[0] ?? 0), $request->date_from, $request->date_to), 'team' => $this->reportService->teamPerformance($request->integer('team_id') ?: ($this->allowedTeamIds()[0] ?? 0), $request->date_from, $request->date_to),
'campaign' => $request->integer('campaign_id') ? $this->reportService->campaignReport($request->integer('campaign_id'), $request->date_from, $request->date_to) : [], 'campaign' => $request->integer('campaign_id') ? $this->reportService->campaignReport($request->integer('campaign_id'), $request->date_from, $request->date_to) : [],
@ -271,7 +365,7 @@ class ReportController extends Controller
}; };
$rows = $this->flattenForCsv($data); $rows = $this->flattenForCsv($data);
$filename = 'report-' . $type . '-' . now()->format('Ymd-His') . '.csv'; $filename = 'report-'.$type.'-'.now()->format('Ymd-His').'.csv';
return response()->streamDownload(function () use ($rows) { return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
@ -280,11 +374,12 @@ class ReportController extends Controller
fputcsv($out, ['message'], ','); fputcsv($out, ['message'], ',');
fputcsv($out, ['داده‌ای برای خروجی وجود ندارد'], ','); fputcsv($out, ['داده‌ای برای خروجی وجود ندارد'], ',');
fclose($out); fclose($out);
return; return;
} }
fputcsv($out, array_keys($rows[0]), ','); fputcsv($out, array_keys($rows[0]), ',');
foreach ($rows as $row) { foreach ($rows as $row) {
fputcsv($out, array_map(fn($value) => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_UNICODE), $row), ','); fputcsv($out, array_map(fn ($value) => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_UNICODE), $row), ',');
} }
fclose($out); fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
@ -336,9 +431,9 @@ class ReportController extends Controller
} }
/** /**
* @return list<int>|false * @return list<int>|false|null
*/ */
private function requestedAgentScope(?int $agentId): array|false private function requestedAgentScope(?int $agentId): array|false|null
{ {
$allowedAgentIds = $this->allowedAgentIds(); $allowedAgentIds = $this->allowedAgentIds();
@ -346,28 +441,30 @@ class ReportController extends Controller
return in_array($agentId, $allowedAgentIds, true) ? [$agentId] : false; return in_array($agentId, $allowedAgentIds, true) ? [$agentId] : false;
} }
return auth()->user()->hasRole('admin') ? [] : $allowedAgentIds; return auth()->user()->hasRole('admin') ? null : $allowedAgentIds;
} }
private function flattenForCsv(mixed $data): array private function flattenForCsv(mixed $data): array
{ {
if ($data instanceof \Illuminate\Support\Collection) { if ($data instanceof Collection) {
$data = $data->toArray(); $data = $data->toArray();
} }
if ($data instanceof \Illuminate\Database\Eloquent\Model) { if ($data instanceof Model) {
$data = $data->toArray(); $data = $data->toArray();
} }
if (is_array($data) && array_is_list($data)) { if (is_array($data) && array_is_list($data)) {
return array_map(fn($row) => is_array($row) ? $row : ['value' => $row], $data); return array_map(fn ($row) => is_array($row) ? $row : ['value' => $row], $data);
} }
if (is_array($data)) { if (is_array($data)) {
foreach (['agents', 'by_stage', 'by_result', 'items', 'by_reason', 'sources', 'phone_duplicates', 'batches', 'by_agent', 'by_hour'] as $key) { foreach (['agents', 'by_stage', 'by_result', 'items', 'by_reason', 'sources', 'phone_duplicates', 'batches', 'by_agent', 'by_hour'] as $key) {
if (isset($data[$key]) && is_iterable($data[$key])) { if (isset($data[$key]) && is_iterable($data[$key])) {
return collect($data[$key])->map(fn($row) => $row instanceof \Illuminate\Database\Eloquent\Model ? $row->toArray() : (array) $row)->values()->all(); return collect($data[$key])->map(fn ($row) => $row instanceof Model ? $row->toArray() : (array) $row)->values()->all();
} }
} }
return [$data]; return [$data];
} }
return []; return [];
} }
} }

مشاهده پرونده

@ -6,14 +6,15 @@ use App\Http\Controllers\Controller;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RoleController extends Controller class RoleController extends Controller
{ {
public function index(): JsonResponse public function index(): JsonResponse
{ {
$roles = Role::with('permissions')->orderBy('name')->get(); $roles = Role::with('permissions')->orderBy('name')->get();
return response()->json($roles); return response()->json($roles);
} }
@ -44,7 +45,7 @@ class RoleController extends Controller
public function update(Request $request, Role $role): JsonResponse public function update(Request $request, Role $role): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'sometimes|string|unique:roles,name,' . $role->id, 'name' => 'sometimes|string|unique:roles,name,'.$role->id,
'permissions' => 'sometimes|array', 'permissions' => 'sometimes|array',
'permissions.*' => 'exists:permissions,name', 'permissions.*' => 'exists:permissions,name',
]); ]);

مشاهده پرونده

@ -3,73 +3,111 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Campaign;
use App\Models\Product;
use App\Models\SalesScript; use App\Models\SalesScript;
use App\Models\User;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Services\NotificationService;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class ScriptController extends Controller class ScriptController extends Controller
{ {
public function index(): JsonResponse public function index(Request $request): JsonResponse
{ {
return response()->json( Gate::authorize('viewAny', SalesScript::class);
SalesScript::with('sections', 'campaign:id,name') $query = SalesScript::with('sections', 'campaign:id,name,sales_script_id', 'product:id,name,sales_script_id', 'assignees:id,name');
->orderBy('created_at', 'desc') if (! $request->user()->can('manage_scripts')) {
->paginate(15) $query->where('is_active', true)->whereHas('assignees', fn ($users) => $users->whereKey($request->user()->id));
); }
foreach (['category', 'lead_source'] as $filter) {
if ($request->filled($filter)) {
$query->where($filter, $request->string($filter));
}
}
if ($request->filled('search')) {
$search = $request->string('search');
$query->where(fn ($scope) => $scope->where('title', 'like', "%{$search}%")->orWhere('description', 'like', "%{$search}%"));
}
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
} }
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$validated = $request->validate([ Gate::authorize('create', SalesScript::class);
'title' => 'required|string|max:255', $validated = $request->validate($this->rules());
'description' => 'nullable|string', $this->authorizeParents($validated);
'campaign_id' => 'nullable|exists:campaigns,id', $this->authorizeAssignees($validated['assigned_user_ids'] ?? [], $request->user());
'product_id' => 'nullable|exists:products,id',
'version' => 'nullable|string|max:20',
'checklist' => 'nullable|array',
'objection_handling' => 'nullable|array',
'sections' => 'nullable|array',
'sections.*.title' => 'required|string|max:255',
'sections.*.content' => 'required|string',
'sections.*.sort_order' => 'nullable|integer',
]);
$script = SalesScript::create([ $script = DB::transaction(function () use ($validated, $request): SalesScript {
'title' => $validated['title'], $script = SalesScript::create(collect($validated)->except(['sections', 'campaign_id', 'product_id', 'assigned_user_ids'])->all());
'description' => $validated['description'] ?? null, $this->syncSections($script, $validated['sections'] ?? []);
'campaign_id' => $validated['campaign_id'] ?? null, $this->syncParents($script, $validated);
'product_id' => $validated['product_id'] ?? null, $this->syncAssignees($script, $validated['assigned_user_ids'] ?? [], $request->user());
'version' => $validated['version'] ?? '1.0',
'is_active' => true,
'checklist' => $validated['checklist'] ?? null,
'objection_handling' => $validated['objection_handling'] ?? null,
]);
if ($request->sections) { return $script;
foreach ($validated['sections'] as $i => $section) { });
$script->sections()->create([
'title' => $section['title'],
'content' => $section['content'],
'sort_order' => $section['sort_order'] ?? $i,
]);
}
}
ActivityLogger::log('script_created', "Script {$script->title} created", $script); ActivityLogger::log('script_created', "Script {$script->title} created", $script);
return response()->json($script->load('sections'), 201); return response()->json($script->load('sections', 'campaign:id,name,sales_script_id', 'product:id,name,sales_script_id', 'assignees:id,name'), 201);
} }
public function show(SalesScript $script): JsonResponse public function show(SalesScript $script): JsonResponse
{ {
return response()->json($script->load('sections', 'campaign')); Gate::authorize('view', $script);
return response()->json($script->load('sections', 'campaign:id,name,sales_script_id', 'product:id,name,sales_script_id', 'assignees:id,name'));
} }
public function update(Request $request, SalesScript $script): JsonResponse public function update(Request $request, SalesScript $script): JsonResponse
{ {
$validated = $request->validate([ Gate::authorize('update', $script);
'title' => 'sometimes|string|max:255', $validated = $request->validate($this->rules(partial: true));
$this->authorizeParents($validated);
if ($request->has('assigned_user_ids')) {
$this->authorizeAssignees($validated['assigned_user_ids'] ?? [], $request->user());
}
DB::transaction(function () use ($script, $validated, $request): void {
$script->update(collect($validated)->except(['sections', 'campaign_id', 'product_id', 'assigned_user_ids'])->all());
if ($request->has('sections')) {
$this->syncSections($script, $validated['sections'] ?? []);
}
if ($request->has('campaign_id') || $request->has('product_id')) {
$this->syncParents($script, $validated, true);
}
if ($request->has('assigned_user_ids')) {
$this->syncAssignees($script, $validated['assigned_user_ids'] ?? [], $request->user());
}
});
ActivityLogger::log('script_updated', "Script {$script->title} updated", $script);
return response()->json($script->fresh(['sections', 'campaign:id,name,sales_script_id', 'product:id,name,sales_script_id', 'assignees:id,name']));
}
public function destroy(SalesScript $script): JsonResponse
{
Gate::authorize('delete', $script);
$title = $script->title;
$script->delete();
ActivityLogger::log('script_deleted', "Script {$title} deleted");
return response()->json(['message' => 'اسکریپت حذف شد']);
}
private function rules(bool $partial = false): array
{
$required = $partial ? 'sometimes' : 'required';
return [
'title' => "{$required}|string|max:255",
'description' => 'nullable|string', 'description' => 'nullable|string',
'campaign_id' => 'nullable|exists:campaigns,id', 'campaign_id' => 'nullable|exists:campaigns,id',
'product_id' => 'nullable|exists:products,id', 'product_id' => 'nullable|exists:products,id',
@ -77,52 +115,83 @@ class ScriptController extends Controller
'is_active' => 'nullable|boolean', 'is_active' => 'nullable|boolean',
'checklist' => 'nullable|array', 'checklist' => 'nullable|array',
'objection_handling' => 'nullable|array', 'objection_handling' => 'nullable|array',
'category' => 'nullable|string|max:100',
'lead_source' => 'nullable|string|max:100',
'suggested_questions' => 'nullable|array|max:50',
'required_disclosures' => 'nullable|array|max:50',
'is_template' => 'nullable|boolean',
'assigned_user_ids' => 'nullable|array|max:200',
'assigned_user_ids.*' => 'integer|distinct|exists:users,id',
'sections' => 'nullable|array', 'sections' => 'nullable|array',
'sections.*.id' => 'nullable|exists:script_sections,id', 'sections.*.id' => 'nullable|integer|exists:script_sections,id',
'sections.*.title' => 'required|string|max:255', 'sections.*.title' => 'required|string|max:255',
'sections.*.content' => 'required|string', 'sections.*.content' => 'required|string',
'sections.*.sort_order' => 'nullable|integer', 'sections.*.sort_order' => 'nullable|integer|min:0|distinct',
]); ];
}
$script->update($validated); private function authorizeParents(array $validated): void
{
if (! empty($validated['campaign_id'])) {
Gate::authorize('update', Campaign::findOrFail($validated['campaign_id']));
}
if (! empty($validated['product_id'])) {
Gate::authorize('update', Product::findOrFail($validated['product_id']));
}
}
if ($request->has('sections')) { private function syncParents(SalesScript $script, array $validated, bool $partial = false): void
$existingIds = $script->sections->pluck('id')->toArray(); {
$updatedIds = []; if (! $partial || array_key_exists('campaign_id', $validated)) {
Campaign::where('sales_script_id', $script->id)->update(['sales_script_id' => null]);
foreach ($validated['sections'] as $i => $section) { if (! empty($validated['campaign_id'])) {
if (isset($section['id']) && in_array($section['id'], $existingIds)) { Campaign::whereKey($validated['campaign_id'])->update(['sales_script_id' => $script->id]);
$script->sections()->where('id', $section['id'])->update([ }
'title' => $section['title'], }
'content' => $section['content'], if (! $partial || array_key_exists('product_id', $validated)) {
'sort_order' => $section['sort_order'] ?? $i, Product::where('sales_script_id', $script->id)->update(['sales_script_id' => null]);
]); if (! empty($validated['product_id'])) {
$updatedIds[] = $section['id']; Product::whereKey($validated['product_id'])->update(['sales_script_id' => $script->id]);
} else {
$newSection = $script->sections()->create([
'title' => $section['title'],
'content' => $section['content'],
'sort_order' => $section['sort_order'] ?? $i,
]);
$updatedIds[] = $newSection->id;
}
}
$toDelete = array_diff($existingIds, $updatedIds);
if (!empty($toDelete)) {
$script->sections()->whereIn('id', $toDelete)->delete();
} }
} }
ActivityLogger::log('script_updated', "Script {$script->title} updated", $script);
return response()->json($script->load('sections'));
} }
public function destroy(SalesScript $script): JsonResponse private function syncSections(SalesScript $script, array $sections): void
{ {
$script->delete(); $existingIds = $script->sections()->pluck('id')->all();
ActivityLogger::log('script_deleted', "Script {$script->title} deleted"); $keptIds = [];
return response()->json(['message' => 'اسکریپت حذف شد']); DB::table('script_sections')->where('sales_script_id', $script->id)->update(['sort_order' => DB::raw('-id')]);
foreach (array_values($sections) as $index => $section) {
$values = ['title' => $section['title'], 'content' => $section['content'], 'sort_order' => $section['sort_order'] ?? $index];
if (! empty($section['id']) && in_array($section['id'], $existingIds, true)) {
$script->sections()->whereKey($section['id'])->update($values);
$keptIds[] = $section['id'];
} else {
$keptIds[] = $script->sections()->create($values)->id;
}
}
$script->sections()->whereNotIn('id', $keptIds)->delete();
}
private function authorizeAssignees(array $ids, User $actor): void
{
$users = User::whereKey($ids)->where('is_active', true)->get();
abort_unless($users->count() === count(array_unique($ids)), 422, 'یک یا چند کارشناس انتخاب‌شده معتبر نیستند.');
foreach ($users as $user) {
abort_unless($user->hasRole('agent'), 422, 'اسکریپت فقط به کارشناس فروش قابل تخصیص است.');
abort_unless($actor->hasRole('admin') || AccessControl::canAssignUser($actor, $user->id), 403, 'کارشناس انتخاب‌شده خارج از تیم شما است.');
}
}
private function syncAssignees(SalesScript $script, array $ids, User $actor): void
{
$previous = $script->assignees()->pluck('users.id')->all();
$script->assignees()->syncWithPivotValues($ids, ['assigned_by' => $actor->id]);
foreach (array_diff($ids, $previous) as $userId) {
NotificationService::send((int) $userId, 'اسکریپت فروش جدید', "اسکریپت «{$script->title}» به شما اختصاص داده شد", 'script_assigned', [
'script_id' => $script->id,
'url' => '/sales-scripts',
]);
}
} }
} }

مشاهده پرونده

@ -20,8 +20,11 @@ class SettingController extends Controller
{ {
Gate::authorize('viewAny', Setting::class); Gate::authorize('viewAny', Setting::class);
SettingsCatalog::ensureDefaults(); SettingsCatalog::ensureDefaults();
$runtimeKeys = collect(SettingsCatalog::definitions())
->where('is_runtime_enforced', true)
->pluck('key');
$settings = Setting::all() $settings = Setting::whereIn('key', $runtimeKeys)->get()
->map(function (Setting $setting) { ->map(function (Setting $setting) {
$meta = SettingsCatalog::metaFor($setting->key) ?? []; $meta = SettingsCatalog::metaFor($setting->key) ?? [];
if ($setting->is_secret) { if ($setting->is_secret) {
@ -32,10 +35,11 @@ class SettingController extends Controller
'label' => $meta['label'] ?? $setting->key, 'label' => $meta['label'] ?? $setting->key,
'hint' => $meta['hint'] ?? $setting->description, 'hint' => $meta['hint'] ?? $setting->description,
'used_by' => $meta['used_by'] ?? [], 'used_by' => $meta['used_by'] ?? [],
'coming_soon' => $meta['coming_soon'] ?? !$setting->is_runtime_enforced, 'coming_soon' => $meta['coming_soon'] ?? ! $setting->is_runtime_enforced,
]); ]);
}) })
->groupBy('group'); ->groupBy('group');
return response()->json($settings); return response()->json($settings);
} }
@ -91,9 +95,13 @@ class SettingController extends Controller
public function public(): JsonResponse public function public(): JsonResponse
{ {
SettingsCatalog::ensureDefaults(); SettingsCatalog::ensureDefaults();
$settings = Setting::where('is_public', true)->get() $publicRuntimeKeys = collect(SettingsCatalog::definitions())
->filter(fn(Setting $setting) => !$setting->is_secret) ->where('is_runtime_enforced', true)
->mapWithKeys(fn(Setting $setting) => [$setting->key => $setting->effectiveValue()]); ->where('is_public', true)
->pluck('key');
$settings = Setting::whereIn('key', $publicRuntimeKeys)->get()
->filter(fn (Setting $setting) => ! $setting->is_secret)
->mapWithKeys(fn (Setting $setting) => [$setting->key => $setting->effectiveValue()]);
return response()->json($settings); return response()->json($settings);
} }
@ -101,27 +109,34 @@ class SettingController extends Controller
public function testVoip(): JsonResponse public function testVoip(): JsonResponse
{ {
Gate::authorize('viewAny', Setting::class); Gate::authorize('viewAny', Setting::class);
$provider = Setting::where('key', 'voip_provider')->value('value') ?: 'mock'; $provider = Setting::where('key', 'voip_provider')->value('value') ?: 'none';
$missing = []; $missing = [];
if ($provider === 'ami') { if ($provider === 'ami') {
foreach (['voip_ami_host', 'voip_ami_port', 'voip_ami_username', 'voip_ami_secret', 'voip_ami_channel_technology', 'voip_ami_context'] as $key) { foreach (['voip_ami_host', 'voip_ami_port', 'voip_ami_username', 'voip_ami_secret', 'voip_ami_channel_technology', 'voip_ami_context'] as $key) {
if (!Setting::where('key', $key)->value('value')) $missing[] = $key; if (! Setting::where('key', $key)->value('value')) {
$missing[] = $key;
}
} }
} }
if ($provider === 'api') { if ($provider === 'api') {
foreach (['voip_api_base_url', 'voip_api_token'] as $key) { foreach (['voip_api_base_url', 'voip_api_token'] as $key) {
if (!Setting::where('key', $key)->value('value')) $missing[] = $key; if (! Setting::where('key', $key)->value('value')) {
$missing[] = $key;
}
} }
} }
if ($provider === 'socket') { if ($provider === 'socket') {
foreach (['voip_socket_host', 'voip_socket_port'] as $key) { foreach (['voip_socket_host', 'voip_socket_port'] as $key) {
if (!Setting::where('key', $key)->value('value')) $missing[] = $key; if (! Setting::where('key', $key)->value('value')) {
$missing[] = $key;
}
} }
} }
ActivityLogger::log('voip_connection_tested', "VoIP provider {$provider} tested"); ActivityLogger::log('voip_connection_tested', "VoIP provider {$provider} tested");
if ($missing === []) { if ($missing === []) {
$result = $this->voipManager->testConnection(); $result = $this->voipManager->testConnection();
return response()->json([ return response()->json([
'ok' => (bool) ($result['ok'] ?? false), 'ok' => (bool) ($result['ok'] ?? false),
'provider' => $provider, 'provider' => $provider,
@ -131,13 +146,34 @@ class SettingController extends Controller
} }
return response()->json([ return response()->json([
'ok' => !$missing, 'ok' => ! $missing,
'provider' => $provider, 'provider' => $provider,
'message' => $missing ? 'تنظیمات اتصال کامل نیست.' : 'تنظیمات اتصال معتبر است.', 'message' => $missing ? 'تنظیمات اتصال کامل نیست.' : 'تنظیمات اتصال معتبر است.',
'missing' => $missing, 'missing' => $missing,
]); ]);
} }
public function testVoipCall(Request $request): JsonResponse
{
Gate::authorize('update', Setting::class);
$validated = $request->validate([
'phone' => ['required', 'string', 'max:30', 'regex:/^[0-9+*#]+$/'],
'extension' => ['required', 'string', 'max:20', 'regex:/^[0-9*#]+$/'],
]);
$result = $this->voipManager->initiateCall($validated['phone'], $validated['extension']);
ActivityLogger::log('voip_test_call_requested', 'A real VoIP test call was requested', null, null, [
'success' => (bool) ($result['success'] ?? false),
'status' => $result['status'] ?? null,
]);
return response()->json([
'ok' => (bool) ($result['success'] ?? false),
'message' => $result['message'] ?? 'نتیجه تماس آزمایشی مشخص نیست.',
'provider_call_id' => $result['provider_call_id'] ?? null,
'status' => $result['status'] ?? null,
], ($result['success'] ?? false) ? 200 : 422);
}
private function validateSettingValue(array $payload, ?Setting $existing): void private function validateSettingValue(array $payload, ?Setting $existing): void
{ {
$type = $payload['type'] ?? $existing?->type ?? 'string'; $type = $payload['type'] ?? $existing?->type ?? 'string';
@ -152,7 +188,7 @@ class SettingController extends Controller
} }
if ($existing?->allowed_values) { if ($existing?->allowed_values) {
$rules[] = 'in:' . implode(',', $existing->allowed_values); $rules[] = 'in:'.implode(',', $existing->allowed_values);
} }
Validator::make(['value' => $value], ['value' => $rules])->validate(); Validator::make(['value' => $value], ['value' => $rules])->validate();

مشاهده پرونده

@ -0,0 +1,168 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\TaskStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssignTaskRequest;
use App\Http\Requests\StoreTaskRequest;
use App\Http\Requests\TaskIndexRequest;
use App\Http\Requests\UpdateTaskRequest;
use App\Http\Resources\TaskResource;
use App\Http\Responses\ApiResponse;
use App\Models\Task;
use App\Services\TaskService;
use App\Support\AccessControl;
use App\Support\EntityResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class TaskController extends Controller
{
public function __construct(private TaskService $service) {}
public function index(TaskIndexRequest $request): JsonResponse
{
Gate::authorize('viewAny', Task::class);
$validated = $request->validated();
$query = Task::with(['assignee:id,name,avatar', 'assigner:id,name', 'creator:id,name', 'taskable', 'parent:id,subject']);
AccessControl::scopeTasks($query, $request->user());
foreach (['status', 'priority', 'assigned_to', 'created_by'] as $filter) {
if (array_key_exists($filter, $validated)) {
$query->where($filter, $validated[$filter]);
}
}
if (! empty($validated['due_from'])) {
$query->where('due_at', '>=', $validated['due_from']);
}
if (! empty($validated['due_to'])) {
$query->where('due_at', '<=', $validated['due_to']);
}
if ($request->boolean('overdue')) {
$query->active()->whereNotNull('due_at')->where('due_at', '<', now());
}
if (! empty($validated['taskable_type'])) {
$query->where('taskable_type', EntityResolver::classFor($validated['taskable_type']));
}
if (! empty($validated['taskable_id'])) {
$query->where('taskable_id', $validated['taskable_id']);
}
if (! empty($validated['search'])) {
$search = trim($validated['search']);
$query->where(fn ($searchQuery) => $searchQuery
->where('subject', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%"));
}
[$sortColumn, $sortDirection] = $this->sort($validated['sort'] ?? '-created_at');
$paginator = $query->orderBy($sortColumn, $sortDirection)->paginate((int) ($validated['per_page'] ?? 20));
return ApiResponse::paginated($paginator, fn (Task $task) => $this->resource($task));
}
public function store(StoreTaskRequest $request): JsonResponse
{
Gate::authorize('create', Task::class);
$task = $this->service->create($request->validated(), $request->user());
return ApiResponse::success($this->resource($task), 201, 'کار ایجاد شد.');
}
public function show(Task $task): JsonResponse
{
Gate::authorize('view', $task);
return ApiResponse::success($this->resource($task->load(['assignee:id,name,avatar', 'assigner:id,name', 'creator:id,name', 'taskable', 'parent:id,subject'])));
}
public function update(UpdateTaskRequest $request, Task $task): JsonResponse
{
Gate::authorize('update', $task);
$updated = $this->service->update($task, $request->validated(), $request->user());
return ApiResponse::success($this->resource($updated), message: 'کار ویرایش شد.');
}
public function destroy(Task $task): JsonResponse
{
Gate::authorize('delete', $task);
$this->service->delete($task);
return ApiResponse::success(null, message: 'کار حذف شد.');
}
public function assign(AssignTaskRequest $request, Task $task): JsonResponse
{
Gate::authorize('assign', [$task, (int) $request->validated('assigned_to')]);
$updated = $this->service->assign($task, (int) $request->validated('assigned_to'), (int) $request->validated('version'), $request->user());
return ApiResponse::success($this->resource($updated), message: 'مسئول کار تغییر کرد.');
}
public function start(Request $request, Task $task): JsonResponse
{
$validated = $request->validate(['version' => 'required|integer|min:1']);
$updated = $this->service->transition($task, TaskStatus::InProgress, (int) $validated['version'], $request->user());
return ApiResponse::success($this->resource($updated), message: 'کار شروع شد.');
}
public function complete(Request $request, Task $task): JsonResponse
{
$validated = $request->validate(['version' => 'required|integer|min:1']);
$updated = $this->service->transition($task, TaskStatus::Done, (int) $validated['version'], $request->user());
return ApiResponse::success($this->resource($updated), message: 'کار تکمیل شد.');
}
public function reopen(Request $request, Task $task): JsonResponse
{
$validated = $request->validate(['version' => 'required|integer|min:1']);
$updated = $this->service->transition($task, TaskStatus::Open, (int) $validated['version'], $request->user());
return ApiResponse::success($this->resource($updated), message: 'کار بازگشایی شد.');
}
public function cancel(Request $request, Task $task): JsonResponse
{
$validated = $request->validate(['version' => 'required|integer|min:1']);
$updated = $this->service->transition($task, TaskStatus::Cancelled, (int) $validated['version'], $request->user());
return ApiResponse::success($this->resource($updated), message: 'کار لغو شد.');
}
public function bulkAssign(Request $request): JsonResponse
{
$validated = $request->validate([
'task_ids' => 'required|array|min:1|max:100',
'task_ids.*' => 'required|integer|distinct',
'assigned_to' => 'required|integer|exists:users,id',
]);
$tasks = $this->service->bulkAssign($validated['task_ids'], (int) $validated['assigned_to'], $request->user());
return ApiResponse::success(array_map(fn (Task $task) => $this->resource($task), $tasks), message: 'کارها تخصیص داده شدند.');
}
public function bulkComplete(Request $request): JsonResponse
{
$validated = $request->validate([
'task_ids' => 'required|array|min:1|max:100',
'task_ids.*' => 'required|integer|distinct',
]);
$tasks = $this->service->bulkComplete($validated['task_ids'], $request->user());
return ApiResponse::success(array_map(fn (Task $task) => $this->resource($task), $tasks), message: 'کارها تکمیل شدند.');
}
private function resource(Task $task): array
{
return (new TaskResource($task))->resolve(request());
}
private function sort(string $sort): array
{
return str_starts_with($sort, '-') ? [substr($sort, 1), 'desc'] : [$sort, 'asc'];
}
}

مشاهده پرونده

@ -4,13 +4,13 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Call;
use App\Models\CallLog; use App\Models\CallLog;
use App\Models\Company; use App\Models\Company;
use App\Models\Deal; use App\Models\Deal;
use App\Models\FollowUp; use App\Models\FollowUp;
use App\Models\Lead; use App\Models\Lead;
use App\Models\Note; use App\Models\Note;
use App\Support\EntityResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -23,21 +23,23 @@ class TimelineController extends Controller
'entity_id' => 'required|integer', 'entity_id' => 'required|integer',
]); ]);
EntityResolver::authorize($validated['entity_type'], $validated['entity_id']);
[$class, $id] = [$this->classFor($validated['entity_type']), $validated['entity_id']]; [$class, $id] = [$this->classFor($validated['entity_type']), $validated['entity_id']];
$items = collect(); $items = collect();
ActivityLog::with('user:id,name')->where('subject_type', $class)->where('subject_id', $id)->latest()->limit(100)->get() ActivityLog::with('user:id,name')->where('subject_type', $class)->where('subject_id', $id)->latest()->limit(100)->get()
->each(fn($log) => $items->push($this->item($log->created_at, $this->label($log->action), $log->description, $log->user?->name, $log->action))); ->each(fn ($log) => $items->push($this->item($log->created_at, $this->label($log->action), $log->description, $log->user?->name, $log->action)));
Note::with('user:id,name')->where('notable_type', $class)->where('notable_id', $id)->latest()->limit(100)->get() Note::with('user:id,name')->where('notable_type', $class)->where('notable_id', $id)->latest()->limit(100)->get()
->each(fn($note) => $items->push($this->item($note->created_at, 'یادداشت ثبت شد', $note->content, $note->user?->name, 'note_created'))); ->each(fn ($note) => $items->push($this->item($note->created_at, 'یادداشت ثبت شد', $note->content, $note->user?->name, 'note_created')));
if ($class === Lead::class) { if ($class === Lead::class) {
CallLog::with('user:id,name')->where('lead_id', $id)->latest('called_at')->limit(100)->get() CallLog::with('user:id,name')->where('lead_id', $id)->latest('called_at')->limit(100)->get()
->each(fn($call) => $items->push($this->item($call->called_at, 'تماس ثبت شد', trim(($call->result ? "نتیجه: {$call->result}. " : '') . ($call->notes ?? '')), $call->user?->name, 'call'))); ->each(fn ($call) => $items->push($this->item($call->called_at, 'تماس ثبت شد', trim(($call->result ? "نتیجه: {$call->result}. " : '').($call->notes ?? '')), $call->user?->name, 'call')));
FollowUp::with('user:id,name')->where('lead_id', $id)->latest('scheduled_at')->limit(100)->get() FollowUp::with('user:id,name')->where('lead_id', $id)->latest('scheduled_at')->limit(100)->get()
->each(fn($followUp) => $items->push($this->item($followUp->created_at, 'پیگیری ایجاد شد', 'زمان پیگیری: ' . optional($followUp->scheduled_at)->toDateTimeString(), $followUp->user?->name, 'follow_up_created'))); ->each(fn ($followUp) => $items->push($this->item($followUp->created_at, 'پیگیری ایجاد شد', 'زمان پیگیری: '.optional($followUp->scheduled_at)->toDateTimeString(), $followUp->user?->name, 'follow_up_created')));
} }
return response()->json([ return response()->json([

مشاهده پرونده

@ -3,17 +3,80 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Responses\ApiResponse;
use App\Models\User; use App\Models\User;
use App\Models\Team;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Support\AccessControl;
use App\Support\EntityResolver;
use App\Support\PasswordPolicy; use App\Support\PasswordPolicy;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
class UserController extends Controller class UserController extends Controller
{ {
public function referralTargets(Request $request): JsonResponse
{
$actor = $request->user();
$query = User::query()->where('is_active', true)->whereKeyNot($actor->id)
->whereHas('roles', fn ($roles) => $roles->whereIn('name', ['agent', 'supervisor']))
->with('roles:id,name', 'teams:id,name');
if (! $actor->hasRole('admin')) {
$teamIds = AccessControl::teamIds($actor);
$query->whereHas('teams', fn ($teams) => $teams->whereIn('teams.id', $teamIds));
}
return response()->json($query->orderBy('name')->get()->map(fn (User $user) => [
'id' => $user->id,
'name' => $user->name,
'role' => $user->roles->first()?->name,
'team' => $user->teams->first()?->name,
])->values());
}
public function assignable(Request $request): JsonResponse
{
$validated = $request->validate([
'context' => 'required|in:task',
'entity_type' => 'nullable|required_with:entity_id|in:lead,contact,company,deal,call,campaign',
'entity_id' => 'nullable|required_with:entity_type|integer|min:1',
'search' => 'nullable|string|max:120',
]);
$actor = $request->user();
abort_unless($actor->can('create_tasks') || $actor->can('assign_tasks'), 403);
if (! empty($validated['entity_type'])) {
EntityResolver::authorize($validated['entity_type'], (int) $validated['entity_id']);
}
$query = User::query()->where('is_active', true)->with(['roles:id,name', 'teams:id,name'])->withCount([
'assignedTasks as open_tasks_count' => fn ($tasks) => $tasks->active(),
]);
if ($actor->hasRole('supervisor')) {
$query->whereIn('id', array_values(array_unique(array_merge([$actor->id], AccessControl::teamMemberIds($actor)))));
} elseif (! $actor->hasRole('admin')) {
$query->whereKey($actor->id);
}
if (! empty($validated['search'])) {
$query->where('name', 'like', '%'.trim($validated['search']).'%');
}
$users = $query->orderBy('name')->limit(20)->get()->map(fn (User $user) => [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->avatar_url,
'role_label' => $user->roles->first()?->name,
'team_label' => $user->teams->first()?->name,
'is_active' => $user->is_active,
'open_tasks_count' => $user->open_tasks_count,
])->values()->all();
return ApiResponse::success($users);
}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
Gate::authorize('viewAny', User::class); Gate::authorize('viewAny', User::class);
@ -23,9 +86,9 @@ class UserController extends Controller
if ($request->search) { if ($request->search) {
$query->where(function ($q) use ($request) { $query->where(function ($q) use ($request) {
$q->where('name', 'like', "%{$request->search}%") $q->where('name', 'like', "%{$request->search}%")
->orWhere('email', 'like', "%{$request->search}%") ->orWhere('email', 'like', "%{$request->search}%")
->orWhere('phone', 'like', "%{$request->search}%") ->orWhere('phone', 'like', "%{$request->search}%")
->orWhere('voip_extension', 'like', "%{$request->search}%"); ->orWhere('voip_extension', 'like', "%{$request->search}%");
}); });
} }
@ -34,7 +97,7 @@ class UserController extends Controller
} }
if ($request->team_id) { if ($request->team_id) {
$query->whereHas('teams', fn($q) => $q->where('teams.id', $request->team_id)); $query->whereHas('teams', fn ($q) => $q->where('teams.id', $request->team_id));
} }
if ($request->has('is_active')) { if ($request->has('is_active')) {
@ -54,6 +117,17 @@ class UserController extends Controller
return response()->json([$user->load('roles', 'teams')]); return response()->json([$user->load('roles', 'teams')]);
} }
if ($user?->hasRole('supervisor')) {
return response()->json(
User::role('agent')
->whereIn('id', AccessControl::teamMemberIds($user))
->where('is_active', true)
->with('roles', 'teams')
->orderBy('name')
->get()
);
}
return response()->json( return response()->json(
User::role('agent') User::role('agent')
->where('is_active', true) ->where('is_active', true)
@ -104,10 +178,10 @@ class UserController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'name' => 'sometimes|string|max:255', 'name' => 'sometimes|string|max:255',
'email' => 'sometimes|email|unique:users,email,' . $user->id, 'email' => 'sometimes|email|unique:users,email,'.$user->id,
'password' => ['sometimes', ...PasswordPolicy::rules()], 'password' => ['sometimes', ...PasswordPolicy::rules()],
'phone' => 'nullable|string|max:20', 'phone' => 'nullable|string|max:20',
'voip_extension' => ['nullable', 'string', 'max:20', 'regex:/^[0-9*#]+$/', 'unique:users,voip_extension,' . $user->id], 'voip_extension' => ['nullable', 'string', 'max:20', 'regex:/^[0-9*#]+$/', 'unique:users,voip_extension,'.$user->id],
'is_active' => 'sometimes|boolean', 'is_active' => 'sometimes|boolean',
]); ]);
@ -141,7 +215,7 @@ class UserController extends Controller
{ {
Gate::authorize('update', $user); Gate::authorize('update', $user);
$user->update(['is_active' => !$user->is_active]); $user->update(['is_active' => ! $user->is_active]);
ActivityLogger::log('user_toggled', "User {$user->email} active: {$user->is_active}"); ActivityLogger::log('user_toggled', "User {$user->email} active: {$user->is_active}");

مشاهده پرونده

@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Call;
use App\Models\CallLog;
use App\Models\Setting;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VoipWebhookController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$secret = (string) Setting::where('key', 'voip_webhook_secret')->value('value');
abort_if($secret === '', 503, 'Webhook تلفن پیکربندی نشده است.');
$signature = (string) $request->header('X-VoIP-Signature');
$expected = hash_hmac('sha256', $request->getContent(), $secret);
abort_unless($signature !== '' && hash_equals($expected, $signature), 401, 'امضای Webhook معتبر نیست.');
$validated = $request->validate([
'provider_call_id' => 'required|string|max:255',
'status' => 'required|in:initiated,ringing,answered,completed,failed,busy,no_answer,cancelled',
'duration_seconds' => 'nullable|integer|min:0|max:86400',
'recording_url' => 'nullable|url|max:2048',
'result' => 'nullable|string|max:50',
'started_at' => 'nullable|date',
'ended_at' => 'nullable|date',
]);
$call = Call::where('provider_call_id', $validated['provider_call_id'])->firstOrFail();
$terminal = in_array($validated['status'], ['completed', 'failed', 'busy', 'no_answer', 'cancelled'], true);
$call->update([
'provider_status' => $validated['status'],
'duration' => $validated['duration_seconds'] ?? $call->duration,
'recording_url' => $validated['recording_url'] ?? $call->recording_url,
'result' => $validated['result'] ?? $call->result,
'started_at' => $validated['started_at'] ?? $call->started_at,
'ended_at' => $validated['ended_at'] ?? ($terminal ? now() : $call->ended_at),
'provider_payload' => $request->all(),
]);
CallLog::where('call_id', $call->id)->update([
'recording_url' => $call->recording_url,
'result' => $call->result,
]);
ActivityLogger::log('voip_webhook_received', "VoIP status {$validated['status']} received for call {$call->id}", $call, null, ['provider_call_id' => $call->provider_call_id]);
return response()->json(['ok' => true, 'call_id' => $call->id]);
}
}

مشاهده پرونده

@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Call;
use App\Models\Company;
use App\Models\Contact;
use App\Models\DashboardPreference;
use App\Models\Lead;
use App\Models\NotificationPreference;
use App\Models\SavedView;
use App\Support\AccessControl;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WorkspaceController extends Controller
{
public function search(Request $request): JsonResponse
{
abort_unless($request->user()->can('use_global_search'), 403);
$data = $request->validate(['q' => 'required|string|min:2|max:100']);
$q = $data['q'];
$user = $request->user();
$leads = Lead::query();
AccessControl::scopeLeads($leads, $user);
$companies = Company::query();
AccessControl::scopeCompanies($companies, $user);
$contacts = Contact::query();
AccessControl::scopeContacts($contacts, $user);
$calls = Call::with('lead:id,first_name,last_name,company');
AccessControl::scopeCalls($calls, $user);
return response()->json([
'leads' => $leads->where(fn (Builder $x) => $x->where('first_name', 'like', "%{$q}%")->orWhere('last_name', 'like', "%{$q}%")->orWhere('company', 'like', "%{$q}%")->orWhere('phone', 'like', "%{$q}%"))->limit(5)->get(['id', 'first_name', 'last_name', 'company', 'lead_score', 'score_level']),
'companies' => $companies->where('name', 'like', "%{$q}%")->limit(5)->get(['id', 'name', 'industry']),
'contacts' => $contacts->where('name', 'like', "%{$q}%")->limit(5)->get(['id', 'name', 'email']),
'calls' => $calls->where('notes', 'like', "%{$q}%")->limit(5)->latest()->get(['id', 'lead_id', 'result', 'created_at']),
]);
}
public function savedViews(Request $request): JsonResponse
{
abort_unless($request->user()->can('manage_saved_views'), 403);
$entity = $request->validate(['entity_type' => 'nullable|string|in:lead,deal,company,contact,call,task'])['entity_type'] ?? null;
$teamIds = AccessControl::teamIds($request->user());
$query = SavedView::where(fn (Builder $q) => $q->where('user_id', $request->user()->id)->orWhere('visibility', 'public')->orWhere(fn (Builder $team) => $team->where('visibility', 'team')->whereIn('team_id', $teamIds)));
if ($entity) {
$query->where('entity_type', $entity);
}
return response()->json($query->orderByDesc('is_default')->latest()->get());
}
public function storeSavedView(Request $request): JsonResponse
{
abort_unless($request->user()->can('manage_saved_views'), 403);
$data = $request->validate([
'entity_type' => 'required|in:lead,deal,company,contact,call,task', 'name' => 'required|string|max:100',
'visibility' => 'required|in:private,team,public', 'team_id' => 'nullable|exists:teams,id',
'filters' => 'required|array|max:30', 'columns' => 'nullable|array|max:30', 'sort' => 'nullable|array|max:5', 'is_default' => 'boolean',
]);
if ($data['visibility'] !== 'private') {
abort_unless($request->user()->can('share_team_views'), 403);
}
if (($data['visibility'] ?? '') === 'team') {
abort_unless(in_array((int) ($data['team_id'] ?? 0), AccessControl::teamIds($request->user()), true), 403);
}
if ($data['is_default'] ?? false) {
SavedView::where('user_id', $request->user()->id)->where('entity_type', $data['entity_type'])->update(['is_default' => false]);
}
return response()->json(SavedView::create($data + ['user_id' => $request->user()->id]), 201);
}
public function deleteSavedView(Request $request, SavedView $savedView): JsonResponse
{
abort_unless($savedView->user_id === $request->user()->id || $request->user()->hasRole('admin'), 403);
$savedView->delete();
return response()->json(['message' => 'نمای ذخیره‌شده حذف شد.']);
}
public function preferences(Request $request): JsonResponse
{
return response()->json([
'dashboard' => DashboardPreference::firstOrCreate(['user_id' => $request->user()->id]),
'notifications' => NotificationPreference::where('user_id', $request->user()->id)->get(),
]);
}
public function updatePreferences(Request $request): JsonResponse
{
$data = $request->validate([
'widget_order' => 'nullable|array|max:30', 'hidden_widgets' => 'nullable|array|max:30', 'default_filters' => 'nullable|array|max:20',
'notifications' => 'nullable|array|max:30', 'notifications.*.notification_type' => 'required|string|max:60',
'notifications.*.in_app_enabled' => 'boolean', 'notifications.*.is_muted' => 'boolean',
]);
$dashboard = DashboardPreference::updateOrCreate(['user_id' => $request->user()->id], collect($data)->only(['widget_order', 'hidden_widgets', 'default_filters'])->all());
foreach ($data['notifications'] ?? [] as $preference) {
NotificationPreference::updateOrCreate(['user_id' => $request->user()->id, 'notification_type' => $preference['notification_type']], $preference);
}
return response()->json(['dashboard' => $dashboard, 'notifications' => NotificationPreference::where('user_id', $request->user()->id)->get()]);
}
}

مشاهده پرونده

@ -2,10 +2,10 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Services\ActivityLogger;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use App\Services\ActivityLogger;
class LogActivity class LogActivity
{ {
@ -14,7 +14,7 @@ class LogActivity
$response = $next($request); $response = $next($request);
if ($request->method() !== 'GET' && auth()->check()) { if ($request->method() !== 'GET' && auth()->check()) {
$action = $request->method() . ' ' . $request->path(); $action = $request->method().' '.$request->path();
$description = null; $description = null;
if (str_contains($request->path(), 'login')) { if (str_contains($request->path(), 'login')) {

مشاهده پرونده

@ -14,14 +14,14 @@ class MaskPhoneNumber
{ {
$response = $next($request); $response = $next($request);
if (!$response instanceof JsonResponse) { if (! $response instanceof JsonResponse) {
return $response; return $response;
} }
$user = auth()->user(); $user = auth()->user();
$maskPhones = Setting::where('key', 'phone_mask_enabled')->value('value') !== 'false' $maskPhones = Setting::where('key', 'phone_mask_enabled')->value('value') !== 'false'
&& (!$user || !$user->can('view_full_phone')); && (! $user || ! $user->can('view_full_phone'));
$maskRecordings = !$user || (!$user->hasRole('admin') && !$user->can('listen_recordings')); $maskRecordings = ! $user || (! $user->hasRole('admin') && ! $user->can('listen_recordings'));
if ($maskPhones || $maskRecordings) { if ($maskPhones || $maskRecordings) {
$data = $response->getData(true); $data = $response->getData(true);
@ -52,7 +52,10 @@ class MaskPhoneNumber
if (Setting::where('key', 'phone_mask_level')->value('value') === 'full') { if (Setting::where('key', 'phone_mask_level')->value('value') === 'full') {
return '********'; return '********';
} }
if (strlen($phone) < 7) return $phone; if (strlen($phone) < 7) {
return substr($phone, 0, 4) . ' *** **' . substr($phone, -2); return $phone;
}
return substr($phone, 0, 4).' *** **'.substr($phone, -2);
} }
} }

مشاهده پرونده

@ -23,12 +23,12 @@ class SecurityHeaders
]; ];
foreach ($headers as $name => $value) { foreach ($headers as $name => $value) {
if (!$response->headers->has($name)) { if (! $response->headers->has($name)) {
$response->headers->set($name, $value); $response->headers->set($name, $value);
} }
} }
if ($request->is('api/*') || $request->is('sanctum/*')) { if (($request->is('api/*') || $request->is('sanctum/*')) && ! $request->is('api/media/avatars/*')) {
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, private'); $response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
$response->headers->set('Pragma', 'no-cache'); $response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0'); $response->headers->set('Expires', '0');

مشاهده پرونده

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AssignTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'assigned_to' => 'required|integer|exists:users,id',
'version' => 'required|integer|min:1',
];
}
}

مشاهده پرونده

@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreCallNoteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'content' => 'required|string|max:5000',
'type' => 'nullable|string|in:general,call_summary,objection,commitment,internal',
'visibility' => 'nullable|string|in:private,team,organization',
];
}
}

مشاهده پرونده

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use App\Enums\TaskPriority;
use App\Enums\TaskVisibility;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'subject' => 'required|string|max:255',
'description' => 'nullable|string|max:5000',
'taskable_type' => 'nullable|required_with:taskable_id|in:lead,contact,company,deal,call,campaign',
'taskable_id' => 'nullable|required_with:taskable_type|integer|min:1',
'assigned_to' => 'nullable|integer|exists:users,id',
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
'due_at' => 'nullable|date',
'reminder_at' => 'nullable|date',
'parent_task_id' => 'nullable|integer|exists:tasks,id',
'estimated_minutes' => 'nullable|integer|min:1|max:100000',
'visibility' => ['nullable', Rule::enum(TaskVisibility::class)],
];
}
public function after(): array
{
return [function (Validator $validator): void {
if ($this->filled('reminder_at') && $this->filled('due_at') && $this->date('reminder_at')->gt($this->date('due_at'))) {
$validator->errors()->add('reminder_at', 'زمان یادآوری نمی‌تواند بعد از موعد کار باشد.');
}
}];
}
}

مشاهده پرونده

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use App\Enums\TaskPriority;
use App\Enums\TaskStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class TaskIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => ['nullable', Rule::enum(TaskStatus::class)],
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
'assigned_to' => 'nullable|integer|exists:users,id',
'created_by' => 'nullable|integer|exists:users,id',
'due_from' => 'nullable|date',
'due_to' => 'nullable|date|after_or_equal:due_from',
'overdue' => 'nullable|boolean',
'taskable_type' => 'nullable|string|in:lead,contact,company,deal,call,campaign',
'taskable_id' => 'nullable|integer|min:1',
'search' => 'nullable|string|max:120',
'sort' => 'nullable|string|in:created_at,-created_at,due_at,-due_at,priority,-priority',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
];
}
}

مشاهده پرونده

@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNoteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'content' => 'sometimes|required|string|max:5000',
'type' => 'sometimes|string|in:general,call_summary,objection,commitment,internal',
'visibility' => 'sometimes|string|in:private,team,organization',
];
}
}

مشاهده پرونده

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use App\Enums\TaskPriority;
use App\Enums\TaskVisibility;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'subject' => 'sometimes|required|string|max:255',
'description' => 'sometimes|nullable|string|max:5000',
'priority' => ['sometimes', Rule::enum(TaskPriority::class)],
'due_at' => 'sometimes|nullable|date',
'reminder_at' => 'sometimes|nullable|date',
'estimated_minutes' => 'sometimes|nullable|integer|min:1|max:100000',
'visibility' => ['sometimes', Rule::enum(TaskVisibility::class)],
'version' => 'required|integer|min:1',
];
}
public function after(): array
{
return [function (Validator $validator): void {
$dueAt = $this->input('due_at', $this->route('task')?->due_at);
$reminderAt = $this->input('reminder_at', $this->route('task')?->reminder_at);
if ($dueAt && $reminderAt && Carbon::parse($reminderAt)->gt(Carbon::parse($dueAt))) {
$validator->errors()->add('reminder_at', 'زمان یادآوری نمی‌تواند بعد از موعد کار باشد.');
}
}];
}
}

مشاهده پرونده

@ -0,0 +1,40 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CallResource extends JsonResource
{
public static $wrap = null;
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'lead_id' => $this->lead_id,
'lead' => $this->whenLoaded('lead', fn () => $this->lead?->only(['id', 'first_name', 'last_name', 'company', 'phone'])),
'contact_id' => $this->contact_id,
'contact' => $this->whenLoaded('contact', fn () => $this->contact?->only(['id', 'name', 'role'])),
'contact_phone_id' => $this->contact_phone_id,
'contact_phone' => $this->whenLoaded('contactPhone', fn () => $this->contactPhone?->only(['id', 'phone', 'type', 'status'])),
'user_id' => $this->user_id,
'agent' => $this->whenLoaded('user', fn () => $this->user?->only(['id', 'name'])),
'direction' => $this->direction,
'status' => $this->provider_status === 'completed' || $this->result ? 'completed' : 'pending',
'provider_status' => $this->provider_status,
'phone' => $this->phone,
'duration_seconds' => (int) $this->duration,
'result' => $this->result,
'notes' => $this->notes,
'provider_call_id' => $this->provider_call_id,
'recording_url' => $this->recording_url,
'is_manual' => (bool) $this->is_manual,
'started_at' => optional($this->started_at ?? $this->created_at)->toISOString(),
'ended_at' => optional($this->ended_at)->toISOString(),
'created_at' => optional($this->created_at)->toISOString(),
'updated_at' => optional($this->updated_at)->toISOString(),
];
}
}

مشاهده پرونده

@ -0,0 +1,45 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CampaignResource extends JsonResource
{
public static $wrap = null;
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'product_service' => $this->product_service,
'product_id' => $this->product_id,
'product' => $this->whenLoaded('product', fn () => $this->product?->only(['id', 'name', 'base_price'])),
'channel' => $this->channel,
'start_date' => optional($this->start_date)->toDateString(),
'end_date' => optional($this->end_date)->toDateString(),
'target' => $this->target,
'budget' => $this->budget !== null ? (float) $this->budget : null,
'actual_cost' => $this->actual_cost !== null ? (float) $this->actual_cost : null,
'status' => $this->status,
'sales_script_id' => $this->sales_script_id,
'sales_script' => $this->whenLoaded('salesScript', fn () => $this->salesScript?->only(['id', 'title', 'version', 'is_active'])),
'assigned_agents' => $this->whenLoaded('assignedAgents', fn () => $this->assignedAgents->map->only(['id', 'name'])->values()),
'assigned_supervisors' => $this->whenLoaded('assignedSupervisors', fn () => $this->assignedSupervisors->map->only(['id', 'name'])->values()),
'leads' => $this->whenLoaded('leads'),
'leads_count' => $this->whenCounted('leads'),
'contacted_leads_count' => $this->when(isset($this->contacted_leads_count), (int) $this->contacted_leads_count),
'won_leads_count' => $this->when(isset($this->won_leads_count), (int) $this->won_leads_count),
'won_value' => $this->when(isset($this->won_value_sum), (float) ($this->won_value_sum ?? 0)),
'conversion_rate' => $this->when(isset($this->won_leads_count), $this->leads_count > 0 ? round(($this->won_leads_count / $this->leads_count) * 100, 1) : 0),
'target_progress' => $this->when(isset($this->won_leads_count), $this->target > 0 ? min(100, round(($this->won_leads_count / $this->target) * 100, 1)) : 0),
'cost_per_lead' => $this->when(isset($this->leads_count), $this->leads_count > 0 ? round(((float) ($this->actual_cost ?? 0)) / $this->leads_count, 2) : 0),
'roi' => $this->when(isset($this->won_value_sum), (float) ($this->actual_cost ?? 0) > 0 ? round((((float) ($this->won_value_sum ?? 0) - (float) $this->actual_cost) / (float) $this->actual_cost) * 100, 1) : null),
'created_at' => optional($this->created_at)->toISOString(),
'updated_at' => optional($this->updated_at)->toISOString(),
];
}
}

مشاهده پرونده

@ -0,0 +1,41 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Gate;
class FollowUpResource extends JsonResource
{
public static $wrap = null;
public function toArray(Request $request): array
{
$gate = Gate::forUser($request->user());
return [
'id' => $this->id,
'lead_id' => $this->lead_id,
'lead' => $this->whenLoaded('lead', fn () => $this->lead?->only(['id', 'first_name', 'last_name', 'company', 'phone'])),
'user_id' => $this->user_id,
'assignee' => $this->whenLoaded('user', fn () => $this->user?->only(['id', 'name'])),
'created_by' => $this->created_by,
'creator' => $this->whenLoaded('creator', fn () => $this->creator?->only(['id', 'name'])),
'call_id' => $this->call_id,
'source' => $this->source,
'scheduled_at' => optional($this->scheduled_at)->toISOString(),
'completed_at' => optional($this->completed_at)->toISOString(),
'notes' => $this->notes,
'status' => $this->status,
'is_overdue' => (bool) $this->is_overdue || ($this->status === 'pending' && $this->scheduled_at?->isPast()),
'capabilities' => [
'update' => $gate->allows('update', $this->resource),
'complete' => $gate->allows('complete', $this->resource),
'delete' => $gate->allows('delete', $this->resource),
],
'created_at' => optional($this->created_at)->toISOString(),
'updated_at' => optional($this->updated_at)->toISOString(),
];
}
}

مشاهده پرونده

@ -0,0 +1,34 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Gate;
class NoteResource extends JsonResource
{
public static $wrap = null;
public function toArray(Request $request): array
{
$gate = Gate::forUser($request->user());
return [
'id' => $this->id,
'content' => $this->content,
'type' => $this->type,
'visibility' => $this->visibility,
'is_pinned' => (bool) $this->is_pinned,
'author' => $this->whenLoaded('user', fn () => $this->user?->only(['id', 'name'])),
'edited_at' => $this->edited_at?->toISOString(),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
'capabilities' => [
'edit' => $gate->allows('update', $this->resource),
'delete' => $gate->allows('delete', $this->resource),
'pin' => $gate->allows('pin', $this->resource),
],
];
}
}

مشاهده پرونده

@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class NotificationResource extends JsonResource
{
public static $wrap = null;
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'message' => $this->message,
'type' => $this->type,
'data' => $this->data ?? [],
'is_read' => (bool) $this->is_read,
'read_at' => $this->read_at?->toISOString(),
'archived_at' => $this->archived_at?->toISOString(),
'created_at' => $this->created_at?->toISOString(),
];
}
}

مشاهده پرونده

@ -0,0 +1,72 @@
<?php
namespace App\Http\Resources;
use App\Support\EntityResolver;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue;
use Illuminate\Support\Facades\Gate;
class TaskResource extends JsonResource
{
public static $wrap = null;
public function toArray(Request $request): array
{
$entity = $this->whenLoaded('taskable');
$taskable = $entity && ! $entity instanceof MissingValue
? [
'type' => EntityResolver::typeOf($entity),
'id' => $entity->getKey(),
'label' => $this->entityLabel($entity),
]
: null;
$gate = Gate::forUser($request->user());
return [
'id' => $this->id,
'subject' => $this->subject,
'description' => $this->description,
'taskable_type' => $taskable['type'] ?? null,
'taskable_id' => $taskable['id'] ?? $this->taskable_id,
'taskable' => $taskable,
'assigned_to' => $this->assigned_to,
'assignee' => $this->whenLoaded('assignee', fn () => $this->assignee?->only(['id', 'name', 'avatar', 'avatar_url'])),
'assigned_by' => $this->assigned_by,
'assigner' => $this->whenLoaded('assigner', fn () => $this->assigner?->only(['id', 'name'])),
'created_by' => $this->created_by,
'creator' => $this->whenLoaded('creator', fn () => $this->creator?->only(['id', 'name'])),
'priority' => $this->priority->value,
'status' => $this->status->value,
'due_at' => $this->due_at?->toISOString(),
'started_at' => $this->started_at?->toISOString(),
'completed_at' => $this->completed_at?->toISOString(),
'reminder_at' => $this->reminder_at?->toISOString(),
'parent_task_id' => $this->parent_task_id,
'parent' => $this->whenLoaded('parent', fn () => $this->parent?->only(['id', 'subject'])),
'estimated_minutes' => $this->estimated_minutes,
'visibility' => $this->visibility->value,
'version' => $this->version,
'is_overdue' => $this->is_overdue,
'capabilities' => [
'update' => $gate->allows('update', $this->resource),
'assign' => $gate->allows('assign', [$this->resource, $this->assigned_to]),
'transition' => $gate->allows('transition', $this->resource),
'delete' => $gate->allows('delete', $this->resource),
],
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
}
private function entityLabel(object $entity): string
{
return (string) ($entity->name
?? $entity->title
?? $entity->company
?? trim(($entity->first_name ?? '').' '.($entity->last_name ?? ''))
?: class_basename($entity).' #'.$entity->getKey());
}
}

مشاهده پرونده

@ -0,0 +1,53 @@
<?php
namespace App\Http\Responses;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
final class ApiResponse
{
public static function success(mixed $data, int $status = 200, ?string $message = null, array $meta = [], array $links = []): JsonResponse
{
return response()->json([
'data' => $data,
'meta' => (object) $meta,
'links' => (object) $links,
'message' => $message,
], $status);
}
public static function paginated(LengthAwarePaginator $paginator, callable $transformer): JsonResponse
{
return self::success(
collect($paginator->items())->map($transformer)->values()->all(),
meta: [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
],
links: [
'first' => $paginator->url(1),
'last' => $paginator->url($paginator->lastPage()),
'prev' => $paginator->previousPageUrl(),
'next' => $paginator->nextPageUrl(),
],
);
}
public static function error(string $message, string $code, int $status, array $errors = [], ?string $traceId = null): JsonResponse
{
$traceId ??= (string) Str::uuid();
return response()->json([
'message' => $message,
'code' => $code,
'errors' => (object) $errors,
'trace_id' => $traceId,
], $status)->header('X-Trace-ID', $traceId);
}
}

مشاهده پرونده

@ -0,0 +1,17 @@
<?php
namespace App\Jobs;
use App\Services\TaskReminderService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendTaskReminders implements ShouldQueue
{
use Queueable;
public function handle(TaskReminderService $service): void
{
$service->sendDueNotifications();
}
}

مشاهده پرونده

@ -10,8 +10,14 @@ class ActivityLog extends Model
protected $fillable = [ protected $fillable = [
'user_id', 'action', 'description', 'user_id', 'action', 'description',
'subject_type', 'subject_id', 'ip_address', 'user_agent', 'subject_type', 'subject_id', 'ip_address', 'user_agent',
'before_data', 'after_data', 'request_id',
]; ];
protected function casts(): array
{
return ['before_data' => 'array', 'after_data' => 'array'];
}
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);

مشاهده پرونده

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class AutomationRule extends Model
{
use SoftDeletes;
protected $fillable = ['name', 'trigger', 'conditions', 'actions', 'team_id', 'is_active', 'max_runs_per_record', 'created_by'];
protected function casts(): array
{
return ['conditions' => 'array', 'actions' => 'array', 'is_active' => 'boolean', 'max_runs_per_record' => 'integer'];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function runs(): HasMany
{
return $this->hasMany(AutomationRun::class);
}
}

مشاهده پرونده

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class AutomationRun extends Model
{
protected $fillable = ['automation_rule_id', 'subject_type', 'subject_id', 'status', 'attempt', 'event_key', 'input', 'output', 'error', 'started_at', 'completed_at'];
protected function casts(): array
{
return ['input' => 'array', 'output' => 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime'];
}
public function rule(): BelongsTo
{
return $this->belongsTo(AutomationRule::class, 'automation_rule_id');
}
public function subject(): MorphTo
{
return $this->morphTo();
}
}

مشاهده پرونده

@ -5,17 +5,25 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Call extends Model class Call extends Model
{ {
protected $fillable = [ protected $fillable = [
'lead_id', 'contact_id', 'contact_phone_id', 'user_id', 'direction', 'phone', 'duration', 'lead_id', 'contact_id', 'contact_phone_id', 'user_id', 'direction', 'phone', 'duration',
'result', 'notes', 'provider_call_id', 'recording_url', 'is_manual', 'result', 'notes', 'provider_call_id', 'recording_url', 'is_manual',
'provider_status', 'started_at', 'ended_at', 'provider_payload',
]; ];
protected function casts(): array protected function casts(): array
{ {
return ['is_manual' => 'boolean', 'duration' => 'integer']; return [
'is_manual' => 'boolean',
'duration' => 'integer',
'started_at' => 'datetime',
'ended_at' => 'datetime',
'provider_payload' => 'array',
];
} }
public function lead(): BelongsTo public function lead(): BelongsTo
@ -35,11 +43,21 @@ class Call extends Model
public function contactPhone(): BelongsTo public function contactPhone(): BelongsTo
{ {
return $this->belongsTo(ContactPhone::class); return $this->belongsTo(ContactPhone::class)->withTrashed();
} }
public function qualityReview(): HasOne public function qualityReview(): HasOne
{ {
return $this->hasOne(QualityReview::class); return $this->hasOne(QualityReview::class);
} }
public function notesHistory(): MorphMany
{
return $this->morphMany(Note::class, 'notable');
}
public function tasks(): MorphMany
{
return $this->morphMany(Task::class, 'taskable');
}
} }

مشاهده پرونده

@ -3,8 +3,10 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Campaign extends Model class Campaign extends Model
{ {
@ -12,9 +14,13 @@ class Campaign extends Model
'name', 'name',
'description', 'description',
'product_service', 'product_service',
'product_id',
'channel',
'start_date', 'start_date',
'end_date', 'end_date',
'target', 'target',
'budget',
'actual_cost',
'status', 'status',
'sales_script_id', 'sales_script_id',
]; ];
@ -25,6 +31,8 @@ class Campaign extends Model
'start_date' => 'date', 'start_date' => 'date',
'end_date' => 'date', 'end_date' => 'date',
'target' => 'integer', 'target' => 'integer',
'budget' => 'decimal:2',
'actual_cost' => 'decimal:2',
]; ];
} }
@ -32,6 +40,7 @@ class Campaign extends Model
{ {
return $this->belongsToMany(User::class) return $this->belongsToMany(User::class)
->wherePivot('role', 'agent') ->wherePivot('role', 'agent')
->withPivotValue('role', 'agent')
->withPivot('role'); ->withPivot('role');
} }
@ -39,6 +48,7 @@ class Campaign extends Model
{ {
return $this->belongsToMany(User::class) return $this->belongsToMany(User::class)
->wherePivot('role', 'supervisor') ->wherePivot('role', 'supervisor')
->withPivotValue('role', 'supervisor')
->withPivot('role'); ->withPivot('role');
} }
@ -46,4 +56,19 @@ class Campaign extends Model
{ {
return $this->hasMany(Lead::class); return $this->hasMany(Lead::class);
} }
public function salesScript(): BelongsTo
{
return $this->belongsTo(SalesScript::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function tasks(): MorphMany
{
return $this->morphMany(Task::class, 'taskable');
}
} }

مشاهده پرونده

@ -46,4 +46,14 @@ class Company extends Model
{ {
return $this->morphMany(Attachment::class, 'attachable'); return $this->morphMany(Attachment::class, 'attachable');
} }
public function tasks(): MorphMany
{
return $this->morphMany(Task::class, 'taskable');
}
public function customFieldValues(): MorphMany
{
return $this->morphMany(CustomFieldValue::class, 'fieldable');
}
} }

مشاهده پرونده

@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Contact extends Model class Contact extends Model
{ {
@ -43,4 +44,14 @@ class Contact extends Model
{ {
return $this->hasMany(CallLog::class); return $this->hasMany(CallLog::class);
} }
public function tasks(): MorphMany
{
return $this->morphMany(Task::class, 'taskable');
}
public function customFieldValues(): MorphMany
{
return $this->morphMany(CustomFieldValue::class, 'fieldable');
}
} }

مشاهده پرونده

@ -5,9 +5,12 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ContactPhone extends Model class ContactPhone extends Model
{ {
use SoftDeletes;
protected $fillable = [ protected $fillable = [
'contact_id', 'phone', 'type', 'status', 'call_count', 'contact_id', 'phone', 'type', 'status', 'call_count',
'successful_call_count', 'failed_call_count', 'last_called_at', 'successful_call_count', 'failed_call_count', 'last_called_at',

مشاهده پرونده

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class CustomFieldDefinition extends Model
{
use SoftDeletes;
protected $fillable = ['entity_type', 'key', 'label', 'type', 'options', 'validation', 'visible_to_roles', 'default_value', 'sort_order', 'is_required', 'is_active', 'is_filterable', 'is_searchable', 'created_by'];
protected function casts(): array
{
return ['options' => 'array', 'validation' => 'array', 'visible_to_roles' => 'array', 'is_required' => 'boolean', 'is_active' => 'boolean', 'is_filterable' => 'boolean', 'is_searchable' => 'boolean'];
}
public function values(): HasMany
{
return $this->hasMany(CustomFieldValue::class);
}
}

مشاهده پرونده

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class CustomFieldValue extends Model
{
protected $fillable = ['custom_field_definition_id', 'fieldable_type', 'fieldable_id', 'value_string', 'value_text', 'value_number', 'value_date', 'value_datetime', 'value_boolean', 'value_json', 'updated_by'];
protected function casts(): array
{
return ['value_number' => 'decimal:4', 'value_date' => 'date', 'value_datetime' => 'datetime', 'value_boolean' => 'boolean', 'value_json' => 'array'];
}
public function definition(): BelongsTo
{
return $this->belongsTo(CustomFieldDefinition::class, 'custom_field_definition_id');
}
public function fieldable(): MorphTo
{
return $this->morphTo();
}
}

مشاهده پرونده

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DashboardPreference extends Model
{
protected $fillable = ['user_id', 'widget_order', 'hidden_widgets', 'default_filters'];
protected function casts(): array
{
return ['widget_order' => 'array', 'hidden_widgets' => 'array', 'default_filters' => 'array'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

مشاهده پرونده

@ -12,17 +12,22 @@ class Deal extends Model
use SoftDeletes; use SoftDeletes;
protected $fillable = [ protected $fillable = [
'title', 'company_id', 'lead_id', 'contact_id', 'product_id', 'title', 'company_id', 'lead_id', 'contact_id', 'product_id', 'pipeline_id', 'deal_stage_id',
'estimated_value', 'win_probability', 'sales_stage', 'expected_close_date', 'estimated_value', 'final_amount', 'win_probability', 'sales_stage', 'expected_close_date',
'owner_id', 'status', 'won_lost_reason', 'notes', 'created_by', 'last_activity_at', 'closed_at', 'owner_id', 'status', 'won_lost_reason', 'competitor',
'forecast_category', 'version', 'notes', 'created_by',
]; ];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'estimated_value' => 'decimal:2', 'estimated_value' => 'decimal:2',
'final_amount' => 'decimal:2',
'win_probability' => 'integer', 'win_probability' => 'integer',
'expected_close_date' => 'date', 'expected_close_date' => 'date',
'last_activity_at' => 'datetime',
'closed_at' => 'datetime',
'version' => 'integer',
]; ];
} }
@ -51,6 +56,26 @@ class Deal extends Model
return $this->belongsTo(User::class, 'owner_id'); return $this->belongsTo(User::class, 'owner_id');
} }
public function pipeline(): BelongsTo
{
return $this->belongsTo(Pipeline::class);
}
public function stage(): BelongsTo
{
return $this->belongsTo(DealStage::class, 'deal_stage_id');
}
public function stageHistory()
{
return $this->hasMany(DealStageHistory::class)->latest();
}
public function customFieldValues(): MorphMany
{
return $this->morphMany(CustomFieldValue::class, 'fieldable');
}
public function notes(): MorphMany public function notes(): MorphMany
{ {
return $this->morphMany(Note::class, 'notable'); return $this->morphMany(Note::class, 'notable');
@ -60,4 +85,9 @@ class Deal extends Model
{ {
return $this->morphMany(Attachment::class, 'attachable'); return $this->morphMany(Attachment::class, 'attachable');
} }
public function tasks(): MorphMany
{
return $this->morphMany(Task::class, 'taskable');
}
} }

مشاهده پرونده

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class DealStage extends Model
{
protected $fillable = ['pipeline_id', 'name', 'slug', 'color', 'probability', 'sort_order', 'is_won', 'is_lost', 'is_active'];
protected function casts(): array
{
return ['probability' => 'integer', 'sort_order' => 'integer', 'is_won' => 'boolean', 'is_lost' => 'boolean', 'is_active' => 'boolean'];
}
public function pipeline(): BelongsTo
{
return $this->belongsTo(Pipeline::class);
}
public function deals(): HasMany
{
return $this->hasMany(Deal::class);
}
}

مشاهده پرونده

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DealStageHistory extends Model
{
protected $fillable = ['deal_id', 'from_stage_id', 'to_stage_id', 'changed_by', 'note'];
public function deal(): BelongsTo
{
return $this->belongsTo(Deal::class);
}
public function fromStage(): BelongsTo
{
return $this->belongsTo(DealStage::class, 'from_stage_id');
}
public function toStage(): BelongsTo
{
return $this->belongsTo(DealStage::class, 'to_stage_id');
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'changed_by');
}
}

مشاهده پرونده

@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FollowUp extends Model class FollowUp extends Model
{ {
protected $fillable = [ protected $fillable = [
'lead_id', 'user_id', 'call_id', 'scheduled_at', 'lead_id', 'user_id', 'created_by', 'call_id', 'source', 'scheduled_at',
'completed_at', 'notes', 'status', 'is_overdue', 'completed_at', 'notes', 'status', 'is_overdue',
]; ];
@ -31,6 +31,11 @@ class FollowUp extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function call(): BelongsTo public function call(): BelongsTo
{ {
return $this->belongsTo(Call::class); return $this->belongsTo(Call::class);

61
backend/app/Models/Invoice.php normal فایل
مشاهده پرونده

@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Invoice extends Model
{
protected $fillable = [
'number', 'lead_id', 'invoice_template_id', 'created_by', 'approved_by',
'status', 'currency', 'customer_snapshot', 'seller_snapshot', 'lead_snapshot', 'items',
'resolved_fields', 'page_width_mm', 'page_height_mm', 'subtotal', 'discount', 'tax', 'total',
'paid_amount', 'payment_status', 'notes', 'payment_terms', 'due_date', 'issued_at', 'approved_at', 'rejected_at',
'rejection_reason', 'voided_at', 'version',
];
protected function casts(): array
{
return [
'customer_snapshot' => 'array',
'seller_snapshot' => 'array',
'lead_snapshot' => 'array',
'items' => 'array',
'resolved_fields' => 'array',
'page_width_mm' => 'integer',
'page_height_mm' => 'integer',
'subtotal' => 'decimal:2',
'discount' => 'decimal:2',
'tax' => 'decimal:2',
'total' => 'decimal:2',
'paid_amount' => 'decimal:2',
'due_date' => 'date',
'issued_at' => 'datetime',
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
'voided_at' => 'datetime',
'version' => 'integer',
];
}
public function lead(): BelongsTo
{
return $this->belongsTo(Lead::class);
}
public function template(): BelongsTo
{
return $this->belongsTo(InvoiceTemplate::class, 'invoice_template_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by');
}
}

مشاهده پرونده

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class InvoiceTemplate extends Model
{
protected $fillable = [
'name', 'background_path', 'background_name', 'background_mime', 'layout',
'source_path', 'source_name', 'source_mime', 'base_type', 'background_settings',
'page_width_mm', 'page_height_mm', 'is_default', 'is_active', 'created_by',
];
protected function casts(): array
{
return [
'layout' => 'array',
'background_settings' => 'array',
'is_default' => 'boolean',
'is_active' => 'boolean',
'page_width_mm' => 'integer',
'page_height_mm' => 'integer',
];
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
}

مشاهده پرونده

@ -15,7 +15,7 @@ class Lead extends Model
protected $fillable = [ protected $fillable = [
'company_id', 'first_name', 'last_name', 'company', 'phone', 'phone_secondary', 'company_id', 'first_name', 'last_name', 'company', 'phone', 'phone_secondary',
'email', 'city', 'province', 'source', 'product_interest', 'email', 'city', 'province', 'source', 'product_interest',
'priority', 'lead_score', 'notes', 'tags', 'priority', 'lead_score', 'score_level', 'score_breakdown', 'scored_at', 'notes', 'tags',
'interest_level', 'call_attempts', 'lost_reason', 'final_result', 'interest_level', 'call_attempts', 'lost_reason', 'final_result',
'deal_value', 'sold_product', 'contract_date', 'payment_status', 'customer_notes', 'deal_value', 'sold_product', 'contract_date', 'payment_status', 'customer_notes',
'lead_status_id', 'pipeline_stage_id', 'campaign_id', 'lead_status_id', 'pipeline_stage_id', 'campaign_id',
@ -28,6 +28,8 @@ class Lead extends Model
return [ return [
'priority' => 'integer', 'priority' => 'integer',
'lead_score' => 'integer', 'lead_score' => 'integer',
'score_breakdown' => 'array',
'scored_at' => 'datetime',
'call_attempts' => 'integer', 'call_attempts' => 'integer',
'deal_value' => 'decimal:2', 'deal_value' => 'decimal:2',
'is_unassigned' => 'boolean', 'is_unassigned' => 'boolean',
@ -39,14 +41,17 @@ class Lead extends Model
public function getFullNameAttribute(): string public function getFullNameAttribute(): string
{ {
return $this->first_name . ' ' . $this->last_name; return $this->first_name.' '.$this->last_name;
} }
public function getMaskedPhoneAttribute(): string public function getMaskedPhoneAttribute(): string
{ {
$phone = $this->phone; $phone = $this->phone;
if (strlen($phone) < 7) return $phone; if (strlen($phone) < 7) {
return substr($phone, 0, 4) . ' *** **' . substr($phone, -2); return $phone;
}
return substr($phone, 0, 4).' *** **'.substr($phone, -2);
} }
public function leadStatus(): BelongsTo public function leadStatus(): BelongsTo
@ -124,16 +129,31 @@ class Lead extends Model
return $this->morphMany(Attachment::class, 'attachable'); return $this->morphMany(Attachment::class, 'attachable');
} }
public function tasks(): MorphMany
{
return $this->morphMany(Task::class, 'taskable');
}
public function deals(): HasMany public function deals(): HasMany
{ {
return $this->hasMany(Deal::class); return $this->hasMany(Deal::class);
} }
public function customFieldValues(): MorphMany
{
return $this->morphMany(CustomFieldValue::class, 'fieldable');
}
public function leadAssignments(): HasMany public function leadAssignments(): HasMany
{ {
return $this->hasMany(LeadAssignment::class); return $this->hasMany(LeadAssignment::class);
} }
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
public function scopeAssignedTo($query, $userId) public function scopeAssignedTo($query, $userId)
{ {
return $query->where('assigned_to', $userId); return $query->where('assigned_to', $userId);

مشاهده پرونده

@ -5,10 +5,21 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Note extends Model class Note extends Model
{ {
protected $fillable = ['content', 'user_id']; use SoftDeletes;
protected $fillable = ['notable_type', 'notable_id', 'content', 'user_id', 'type', 'visibility', 'is_pinned', 'edited_at', 'source_key'];
protected function casts(): array
{
return [
'is_pinned' => 'boolean',
'edited_at' => 'datetime',
];
}
public function notable(): MorphTo public function notable(): MorphTo
{ {

مشاهده پرونده

@ -4,10 +4,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Notification extends Model class Notification extends Model
{ {
protected $fillable = ['user_id', 'title', 'message', 'type', 'data', 'is_read']; use SoftDeletes;
protected $fillable = ['user_id', 'title', 'message', 'type', 'data', 'is_read', 'read_at', 'archived_at', 'idempotency_key'];
protected $table = 'internal_notifications'; protected $table = 'internal_notifications';
@ -16,6 +19,8 @@ class Notification extends Model
return [ return [
'data' => 'array', 'data' => 'array',
'is_read' => 'boolean', 'is_read' => 'boolean',
'read_at' => 'datetime',
'archived_at' => 'datetime',
]; ];
} }

مشاهده پرونده

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NotificationPreference extends Model
{
protected $fillable = ['user_id', 'notification_type', 'in_app_enabled', 'is_muted'];
protected function casts(): array
{
return ['in_app_enabled' => 'boolean', 'is_muted' => 'boolean'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

35
backend/app/Models/Pipeline.php normal فایل
مشاهده پرونده

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Pipeline extends Model
{
use SoftDeletes;
protected $fillable = ['name', 'slug', 'team_id', 'is_default', 'is_active', 'sort_order'];
protected function casts(): array
{
return ['is_default' => 'boolean', 'is_active' => 'boolean', 'sort_order' => 'integer'];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function stages(): HasMany
{
return $this->hasMany(DealStage::class)->orderBy('sort_order');
}
public function deals(): HasMany
{
return $this->hasMany(Deal::class);
}
}

مشاهده پرونده

@ -35,4 +35,9 @@ class Product extends Model
{ {
return $this->hasMany(Deal::class); return $this->hasMany(Deal::class);
} }
public function campaigns(): HasMany
{
return $this->hasMany(Campaign::class);
}
} }

مشاهده پرونده

@ -8,16 +8,24 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QualityReview extends Model class QualityReview extends Model
{ {
protected $fillable = [ protected $fillable = [
'call_id', 'reviewer_id', 'agent_id', 'call_id', 'reviewer_id', 'agent_id', 'version', 'is_current',
'greeting_score', 'product_intro_score', 'needs_discovery_score', 'greeting_score', 'product_intro_score', 'needs_discovery_score',
'objection_handling_score', 'closing_score', 'crm_accuracy_score', 'objection_handling_score', 'closing_score', 'crm_accuracy_score',
'follow_up_quality_score', 'overall_score', 'follow_up_quality_score', 'overall_score',
'feedback', 'tag', 'is_shared_with_agent', 'feedback', 'strengths', 'improvement_areas', 'tag', 'status', 'is_shared_with_agent',
'acknowledged_at', 'agent_response',
]; ];
protected function casts(): array protected function casts(): array
{ {
return ['is_shared_with_agent' => 'boolean']; return [
'is_shared_with_agent' => 'boolean',
'is_current' => 'boolean',
'version' => 'integer',
'strengths' => 'array',
'improvement_areas' => 'array',
'acknowledged_at' => 'datetime',
];
} }
public function call(): BelongsTo public function call(): BelongsTo

مشاهده پرونده

@ -3,12 +3,13 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class SalesScript extends Model class SalesScript extends Model
{ {
protected $fillable = ['title', 'description', 'campaign_id', 'product_id', 'version', 'is_active', 'checklist', 'objection_handling']; protected $fillable = ['title', 'description', 'version', 'is_active', 'category', 'lead_source', 'checklist', 'objection_handling', 'suggested_questions', 'required_disclosures', 'is_template'];
protected function casts(): array protected function casts(): array
{ {
@ -16,21 +17,31 @@ class SalesScript extends Model
'is_active' => 'boolean', 'is_active' => 'boolean',
'checklist' => 'array', 'checklist' => 'array',
'objection_handling' => 'array', 'objection_handling' => 'array',
'suggested_questions' => 'array',
'required_disclosures' => 'array',
'is_template' => 'boolean',
]; ];
} }
public function campaign(): BelongsTo public function campaign(): HasOne
{ {
return $this->belongsTo(Campaign::class); return $this->hasOne(Campaign::class);
} }
public function product(): BelongsTo public function product(): HasOne
{ {
return $this->belongsTo(Product::class); return $this->hasOne(Product::class);
} }
public function sections(): HasMany public function sections(): HasMany
{ {
return $this->hasMany(ScriptSection::class); return $this->hasMany(ScriptSection::class);
} }
public function assignees(): BelongsToMany
{
return $this->belongsToMany(User::class, 'sales_script_user')
->withPivot('assigned_by')
->withTimestamps();
}
} }

مشاهده پرونده

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class SavedView extends Model
{
use SoftDeletes;
protected $fillable = ['user_id', 'team_id', 'entity_type', 'name', 'visibility', 'filters', 'columns', 'sort', 'is_default'];
protected function casts(): array
{
return ['filters' => 'array', 'columns' => 'array', 'sort' => 'array', 'is_default' => 'boolean'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
}

مشاهده پرونده

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class SlaBreach extends Model
{
protected $fillable = ['sla_rule_id', 'breachable_type', 'breachable_id', 'assigned_to', 'status', 'due_at', 'warned_at', 'breached_at', 'resolved_at', 'event_key', 'details'];
protected function casts(): array
{
return ['due_at' => 'datetime', 'warned_at' => 'datetime', 'breached_at' => 'datetime', 'resolved_at' => 'datetime', 'details' => 'array'];
}
public function rule(): BelongsTo
{
return $this->belongsTo(SlaRule::class, 'sla_rule_id');
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function breachable(): MorphTo
{
return $this->morphTo();
}
}

27
backend/app/Models/SlaRule.php normal فایل
مشاهده پرونده

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SlaRule extends Model
{
protected $fillable = ['name', 'event', 'warning_minutes', 'breach_minutes', 'scope', 'is_active', 'created_by'];
protected function casts(): array
{
return ['warning_minutes' => 'integer', 'breach_minutes' => 'integer', 'scope' => 'array', 'is_active' => 'boolean'];
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function breaches(): HasMany
{
return $this->hasMany(SlaBreach::class);
}
}

79
backend/app/Models/Task.php normal فایل
مشاهده پرونده

@ -0,0 +1,79 @@
<?php
namespace App\Models;
use App\Enums\TaskPriority;
use App\Enums\TaskStatus;
use App\Enums\TaskVisibility;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Task extends Model
{
use SoftDeletes;
protected $fillable = [
'subject', 'description', 'taskable_type', 'taskable_id', 'assigned_to', 'assigned_by',
'created_by', 'priority', 'status', 'due_at', 'started_at', 'completed_at', 'reminder_at',
'parent_task_id', 'estimated_minutes', 'visibility', 'version',
];
protected function casts(): array
{
return [
'priority' => TaskPriority::class,
'status' => TaskStatus::class,
'visibility' => TaskVisibility::class,
'due_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'reminder_at' => 'datetime',
'version' => 'integer',
'estimated_minutes' => 'integer',
];
}
public function taskable(): MorphTo
{
return $this->morphTo();
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function assigner(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_by');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_task_id');
}
public function subtasks(): HasMany
{
return $this->hasMany(self::class, 'parent_task_id');
}
public function scopeActive(Builder $query): Builder
{
return $query->whereIn('status', [TaskStatus::Open->value, TaskStatus::InProgress->value]);
}
public function getIsOverdueAttribute(): bool
{
return $this->status->isActive() && $this->due_at?->isPast() === true;
}
}

مشاهده پرونده

@ -10,14 +10,13 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasApiTokens, HasRoles, SoftDeletes; use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes;
protected $fillable = [ protected $fillable = [
'name', 'email', 'password', 'phone', 'voip_extension', 'avatar', 'is_active', 'name', 'email', 'password', 'phone', 'voip_extension', 'avatar', 'is_active',
@ -66,30 +65,52 @@ class User extends Authenticatable
return $this->hasMany(FollowUp::class); return $this->hasMany(FollowUp::class);
} }
public function assignedTasks(): HasMany
{
return $this->hasMany(Task::class, 'assigned_to');
}
public function savedViews(): HasMany
{
return $this->hasMany(SavedView::class);
}
public function dashboardPreference()
{
return $this->hasOne(DashboardPreference::class);
}
public function notificationPreferences(): HasMany
{
return $this->hasMany(NotificationPreference::class);
}
public function leadAssignments(): HasMany public function leadAssignments(): HasMany
{ {
return $this->hasMany(LeadAssignment::class, 'assigned_by'); return $this->hasMany(LeadAssignment::class, 'assigned_by');
} }
public function createdInvoices(): HasMany
{
return $this->hasMany(Invoice::class, 'created_by');
}
public function approvedInvoices(): HasMany
{
return $this->hasMany(Invoice::class, 'approved_by');
}
protected function avatarUrl(): Attribute protected function avatarUrl(): Attribute
{ {
return Attribute::get(function (): ?string { return Attribute::get(function (): ?string {
if (!$this->avatar) { if (! $this->avatar) {
return null; return null;
} }
$path = parse_url($this->avatar, PHP_URL_PATH) ?: $this->avatar; $path = parse_url($this->avatar, PHP_URL_PATH) ?: $this->avatar;
$path = ltrim($path, '/'); $filename = basename(str_replace('\\', '/', $path));
if (str_starts_with($path, 'storage/')) { return '/api/media/avatars/'.rawurlencode($filename);
$path = substr($path, strlen('storage/'));
}
if (str_starts_with($path, 'public/')) {
$path = substr($path, strlen('public/'));
}
return url(Storage::url($path));
}); });
} }
} }

مشاهده پرونده

@ -0,0 +1,33 @@
<?php
namespace App\Notifications;
use App\Models\Notification;
use App\Models\Task;
use App\Models\User;
final class TaskAssignedNotification
{
public static function send(Task $task, User $actor, bool $reassigned = false): void
{
if (! $task->assigned_to || $task->assigned_to === $actor->id) {
return;
}
Notification::firstOrCreate(
['idempotency_key' => "task:{$task->id}:assignment:{$task->version}:{$task->assigned_to}"],
[
'user_id' => $task->assigned_to,
'title' => $reassigned ? 'کار جدید به شما واگذار شد' : 'کار جدید برای شما ثبت شد',
'message' => $task->subject,
'type' => $reassigned ? 'task_reassigned' : 'task_assigned',
'data' => [
'task_id' => $task->id,
'url' => "/tasks?task={$task->id}",
'actor' => ['id' => $actor->id, 'name' => $actor->name],
],
'is_read' => false,
]
);
}
}

مشاهده پرونده

@ -0,0 +1,37 @@
<?php
namespace App\Notifications;
use App\Models\Notification;
use App\Models\Task;
final class TaskDueNotification
{
public static function send(Task $task, string $kind): void
{
if (! $task->assigned_to) {
return;
}
$moment = $kind === 'overdue' ? $task->due_at : $task->reminder_at;
if (! $moment) {
return;
}
Notification::firstOrCreate(
['idempotency_key' => "task:{$task->id}:{$kind}:{$moment->getTimestamp()}"],
[
'user_id' => $task->assigned_to,
'title' => $kind === 'overdue' ? 'کار عقب‌افتاده' : 'یادآوری موعد کار',
'message' => $task->subject,
'type' => $kind === 'overdue' ? 'task_overdue' : 'task_due',
'data' => [
'task_id' => $task->id,
'url' => "/tasks?task={$task->id}",
'due_at' => $task->due_at?->toISOString(),
],
'is_read' => false,
]
);
}
}

مشاهده پرونده

@ -0,0 +1,21 @@
<?php
namespace App\Policies;
use App\Models\Attachment;
use App\Models\User;
use App\Support\AccessControl;
class AttachmentPolicy
{
public function view(User $user, Attachment $attachment): bool
{
return AccessControl::canAccessEntity($user, $attachment->attachable);
}
public function delete(User $user, Attachment $attachment): bool
{
return $this->view($user, $attachment)
&& ($user->hasRole('admin') || $attachment->uploaded_by === $user->id);
}
}

مشاهده پرونده

@ -9,6 +9,11 @@ use App\Support\AccessControl;
class CallPolicy class CallPolicy
{ {
public function before(User $user): ?bool
{
return $user->hasRole('admin') ? true : null;
}
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return $user->hasRole('admin') || $user->can('view_all_calls') || $user->can('view_team_calls') || $user->can('view_own_calls'); return $user->hasRole('admin') || $user->can('view_all_calls') || $user->can('view_team_calls') || $user->can('view_own_calls');

مشاهده پرونده

@ -8,9 +8,14 @@ use App\Support\AccessControl;
class CampaignPolicy class CampaignPolicy
{ {
public function before(User $user): ?bool
{
return $user->hasRole('admin') ? true : null;
}
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return $user->hasAnyRole(['admin', 'supervisor', 'agent']); return $user->can('view_campaigns');
} }
public function view(User $user, Campaign $campaign): bool public function view(User $user, Campaign $campaign): bool
@ -20,12 +25,12 @@ class CampaignPolicy
public function create(User $user): bool public function create(User $user): bool
{ {
return $user->hasRole('admin') || $user->can('manage_campaigns'); return $user->can('manage_campaigns');
} }
public function update(User $user, Campaign $campaign): bool public function update(User $user, Campaign $campaign): bool
{ {
return AccessControl::canAccessCampaign($user, $campaign) && ($user->hasRole('admin') || $user->can('manage_campaigns')); return AccessControl::canAccessCampaign($user, $campaign) && $user->can('manage_campaigns');
} }
public function delete(User $user, Campaign $campaign): bool public function delete(User $user, Campaign $campaign): bool

مشاهده پرونده

@ -0,0 +1,40 @@
<?php
namespace App\Policies;
use App\Models\Company;
use App\Models\User;
use App\Support\AccessControl;
class CompanyPolicy
{
public function before(User $user): ?bool
{
return $user->hasRole('admin') ? true : null;
}
public function viewAny(User $user): bool
{
return $user->can('view_leads');
}
public function view(User $user, Company $company): bool
{
return AccessControl::canAccessCompany($user, $company);
}
public function create(User $user): bool
{
return $user->can('create_leads');
}
public function update(User $user, Company $company): bool
{
return $user->can('edit_leads') && $this->view($user, $company);
}
public function delete(User $user, Company $company): bool
{
return $user->can('delete_leads') && $this->view($user, $company);
}
}

مشاهده پرونده

@ -0,0 +1,40 @@
<?php
namespace App\Policies;
use App\Models\Contact;
use App\Models\User;
use App\Support\AccessControl;
class ContactPolicy
{
public function before(User $user): ?bool
{
return $user->hasRole('admin') ? true : null;
}
public function viewAny(User $user): bool
{
return $user->can('view_leads');
}
public function view(User $user, Contact $contact): bool
{
return AccessControl::canAccessContact($user, $contact);
}
public function create(User $user): bool
{
return $user->can('create_leads');
}
public function update(User $user, Contact $contact): bool
{
return $user->can('edit_leads') && $this->view($user, $contact);
}
public function delete(User $user, Contact $contact): bool
{
return $user->can('delete_leads') && $this->view($user, $contact);
}
}

مشاهده پرونده

@ -0,0 +1,23 @@
<?php
namespace App\Policies;
use App\Models\User;
class DashboardPolicy
{
public function viewAdmin(User $user): bool
{
return $user->hasRole('admin') && $user->can('view_admin_dashboard');
}
public function viewSupervisor(User $user): bool
{
return $user->hasRole('supervisor') && $user->can('view_supervisor_dashboard');
}
public function viewAgent(User $user): bool
{
return $user->hasRole('agent') && $user->can('view_agent_dashboard');
}
}

مشاهده پرونده

@ -0,0 +1,40 @@
<?php
namespace App\Policies;
use App\Models\Deal;
use App\Models\User;
use App\Support\AccessControl;
class DealPolicy
{
public function before(User $user): ?bool
{
return $user->hasRole('admin') ? true : null;
}
public function viewAny(User $user): bool
{
return $user->can('view_leads');
}
public function view(User $user, Deal $deal): bool
{
return AccessControl::canAccessDeal($user, $deal);
}
public function create(User $user): bool
{
return $user->can('create_leads');
}
public function update(User $user, Deal $deal): bool
{
return $user->can('edit_leads') && $this->view($user, $deal);
}
public function delete(User $user, Deal $deal): bool
{
return $user->can('delete_leads') && $this->view($user, $deal);
}
}

مشاهده پرونده

@ -11,7 +11,7 @@ class FollowUpPolicy
{ {
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return $user->hasAnyRole(['admin', 'supervisor', 'agent']); return $user->can('view_leads');
} }
public function view(User $user, FollowUp $followUp): bool public function view(User $user, FollowUp $followUp): bool
@ -21,11 +21,25 @@ class FollowUpPolicy
public function create(User $user, Lead $lead): bool public function create(User $user, Lead $lead): bool
{ {
return AccessControl::canAccessLead($user, $lead); return $user->can('edit_leads') && AccessControl::canAccessLead($user, $lead);
} }
public function update(User $user, FollowUp $followUp): bool public function update(User $user, FollowUp $followUp): bool
{ {
return AccessControl::canAccessFollowUp($user, $followUp); return $followUp->created_by === $user->id
&& $user->can('edit_leads')
&& AccessControl::canAccessFollowUp($user, $followUp);
}
public function complete(User $user, FollowUp $followUp): bool
{
return $followUp->user_id === $user->id && AccessControl::canAccessFollowUp($user, $followUp);
}
public function delete(User $user, FollowUp $followUp): bool
{
return $followUp->created_by === $user->id
&& $user->can('edit_leads')
&& AccessControl::canAccessFollowUp($user, $followUp);
} }
} }

برخی از فایل ها نشان داده نشدند زیرا تعداد زیادی فایل در این تفاوت تغییر کرده اند نمایش بیشتر