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/
# Build output
artifacts/
backend/public/build/
backend/public/hot
frontend/dist/
frontend/dist-ssr/
frontend/test-results/
frontend/playwright-report/
# Laravel runtime data
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\Services\ActivityLogger;
use App\Services\CallService;
use Illuminate\Support\Facades\DB;
use App\Services\FollowUpService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
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
{
@ -29,7 +30,7 @@ class AgentMobileController extends Controller
'call_results' => CallResult::where('is_active', true)
->orderBy('sort_order')
->get(['name', 'slug', 'requires_follow_up', 'is_final'])
->map(fn(CallResult $result) => [
->map(fn (CallResult $result) => [
'name' => $result->name,
'slug' => $result->slug,
'requires_follow_up' => $result->requires_follow_up,
@ -71,7 +72,7 @@ class AgentMobileController extends Controller
$this->applyMobileFilter($query, $request->query('filter'));
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) {
$q->where('interest_level', 'hot')
->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') {
$query->whereHas('pipelineStage', fn($stage) => $stage->where('name', 'like', '%پیشنهاد%'));
$query->whereHas('pipelineStage', fn ($stage) => $stage->where('name', 'like', '%پیشنهاد%'));
}
if ($request->query('filter') === 'closed') {
@ -103,7 +104,7 @@ class AgentMobileController extends Controller
->orderBy('next_follow_up_at')
->limit(40)
->get()
->map(fn(Lead $lead) => $this->leadCard($lead))
->map(fn (Lead $lead) => $this->leadCard($lead))
->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',
'contactRelations.fromContact:id,name,role',
'contactRelations.toContact:id,name,role',
'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),
'followUps' => fn($q) => $q->latest('scheduled_at')->limit(8),
'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),
'followUps' => fn ($q) => $q->latest('scheduled_at')->limit(8),
]);
$primary = $this->primaryContact($lead);
@ -147,20 +148,20 @@ class AgentMobileController extends Controller
'tags' => $this->tags($lead),
'final_result' => $lead->final_result,
],
'calls' => $lead->calls->map(fn(Call $call) => [
'calls' => $lead->calls->map(fn (Call $call) => [
'id' => $call->id,
'contact_name' => $call->contact?->name,
'result' => $call->result,
'notes' => $call->notes,
'created_at' => optional($call->created_at)->toIso8601String(),
])->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([
$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,
])),
'files' => [],
'contact_routes' => $lead->contactRelations->map(fn($relation) => [
'contact_routes' => $lead->contactRelations->map(fn ($relation) => [
'id' => $relation->id,
'from' => $relation->fromContact?->name,
'to' => $relation->toContact?->name,
@ -174,17 +175,17 @@ class AgentMobileController extends Controller
public function followUps(Request $request): JsonResponse
{
$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.leadStatus: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: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.pipelineStage:id,name,color',
'lead.contacts.phones:id,contact_id,type,status,call_count,last_call_result,last_called_at',
])
->where('user_id', $request->user()->id)
->where('scheduled_at', '<=', now()->addWeek())
->orderBy('scheduled_at')
->limit(80)
->get()
->map(fn(FollowUp $followUp) => $this->followUpCard($followUp));
->map(fn (FollowUp $followUp) => $this->followUpCard($followUp));
return response()->json([
'data' => [
@ -224,16 +225,16 @@ class AgentMobileController extends Controller
]);
$lead = Lead::findOrFail($validated['lead_id']);
if (!$this->canAccessLead($request, $lead)) {
if (! $this->canAccessLead($request, $lead)) {
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'])
->whereHas('contact', fn($query) => $query->where('lead_id', $lead->id))
->whereHas('contact', fn ($query) => $query->where('lead_id', $lead->id))
->exists();
if (!$belongsToLead) {
if (! $belongsToLead) {
return response()->json(['message' => 'شماره انتخاب‌شده برای این لید معتبر نیست'], 422);
}
}
@ -271,13 +272,13 @@ class AgentMobileController extends Controller
]);
$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);
}
$followUpAt = $validated['next_follow_up_at'] ?? $validated['referral']['next_follow_up_at'] ?? null;
$callResult = CallResult::where('name', $validated['result'])->first();
if ($callResult?->requires_follow_up && !$followUpAt) {
if ($callResult?->requires_follow_up && ! $followUpAt) {
return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422);
}
@ -300,7 +301,7 @@ class AgentMobileController extends Controller
'call_id' => $registeredCall->id,
'lead_id' => $registeredCall->lead_id,
'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']);
if (!$this->canAccessLead($request, $lead)) {
if (! $this->canAccessLead($request, $lead)) {
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);
}
$followUp = FollowUp::create([
'lead_id' => $lead->id,
'user_id' => $request->user()->id,
'call_id' => $validated['call_id'] ?? null,
'scheduled_at' => $validated['scheduled_at'],
'status' => 'pending',
'notes' => $validated['notes'] ?? null,
]);
$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);
$followUp = $this->followUps->schedule(
$lead,
$request->user()->id,
$validated['scheduled_at'],
$request->user(),
$validated['notes'] ?? null,
$validated['call_id'] ?? null,
'agent_mobile',
);
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']);
if (!$this->canAccessLead($request, $lead)) {
if (! $this->canAccessLead($request, $lead)) {
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);
}
@ -440,7 +439,7 @@ class AgentMobileController extends Controller
->where(function ($query) {
$query->whereNull('last_call_at')
->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')
->orderByRaw('next_follow_up_at IS NULL')
@ -533,6 +532,7 @@ class AgentMobileController extends Controller
private function primaryContact(Lead $lead)
{
$contacts = $lead->relationLoaded('contacts') ? $lead->contacts : $lead->contacts()->with('phones')->get();
return $contacts->firstWhere('is_primary', true) ?? $contacts->first();
}
@ -543,13 +543,13 @@ class AgentMobileController extends Controller
private function tags(Lead $lead): array
{
if (!$lead->tags) {
if (! $lead->tags) {
return [];
}
$decoded = json_decode($lead->tags, true);
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))));
@ -557,34 +557,64 @@ class AgentMobileController extends Controller
private function priorityLabel(int $priority): string
{
if ($priority >= 8) return 'خیلی بالا';
if ($priority >= 5) return 'بالا';
if ($priority >= 2) return 'متوسط';
if ($priority >= 8) {
return 'خیلی بالا';
}
if ($priority >= 5) {
return 'بالا';
}
if ($priority >= 2) {
return 'متوسط';
}
return 'عادی';
}
private function nextAction(Lead $lead): string
{
if (!$lead->last_call_at) 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 'پیگیری امروز';
if (! $lead->last_call_at) {
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 'ادامه پیگیری';
}
private function followUpStatus(Lead $lead): string
{
if (!$lead->next_follow_up_at) return 'بدون زمان پیگیری';
if ($lead->next_follow_up_at->isPast()) return 'عقب‌افتاده';
if ($lead->next_follow_up_at->isToday()) return 'امروز';
if (! $lead->next_follow_up_at) {
return 'بدون زمان پیگیری';
}
if ($lead->next_follow_up_at->isPast()) {
return 'عقب‌افتاده';
}
if ($lead->next_follow_up_at->isToday()) {
return 'امروز';
}
return 'زمان‌بندی‌شده';
}
private function followUpGroup(FollowUp $followUp): string
{
if ($followUp->status === 'completed') return 'done';
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';
if ($followUp->status === 'completed') {
return 'done';
}
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';
}

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

@ -24,7 +24,7 @@ class AssignmentController extends Controller
]);
$lead = Lead::findOrFail($validated['lead_id']);
$agent = User::findOrFail($validated['agent_id']);
if (!$this->canAssignToAgent($lead, $agent)) {
if (! $this->canAssignToAgent($lead, $agent)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
@ -47,7 +47,7 @@ class AssignmentController extends Controller
]);
$agent = User::findOrFail($validated['agent_id']);
$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);
}
@ -56,7 +56,7 @@ class AssignmentController extends Controller
$validated['agent_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)]);
}
@ -74,8 +74,8 @@ class AssignmentController extends Controller
if (
$agents->count() !== count(array_unique($validated['agent_ids']))
|| $leads->count() !== count(array_unique($validated['lead_ids']))
|| $leads->contains(fn(Lead $lead) => Gate::denies('assign', $lead))
|| $agents->contains(fn(User $agent) => !$this->agentIsAssignable($agent))
|| $leads->contains(fn (Lead $lead) => Gate::denies('assign', $lead))
|| $agents->contains(fn (User $agent) => ! $this->agentIsAssignable($agent))
) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
@ -85,7 +85,7 @@ class AssignmentController extends Controller
$validated['agent_ids'],
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)]);
}
@ -98,7 +98,7 @@ class AssignmentController extends Controller
]);
$lead = Lead::findOrFail($validated['lead_id']);
$agent = User::findOrFail($validated['agent_id']);
if (!$this->canAssignToAgent($lead, $agent)) {
if (! $this->canAssignToAgent($lead, $agent)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
@ -112,12 +112,36 @@ class AssignmentController extends Controller
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
{
$leadModel = Lead::findOrFail($leadId);
Gate::authorize('assign', $leadModel);
$lead = $this->assignmentService->returnToPool($leadId);
return response()->json($lead);
}
@ -130,7 +154,7 @@ class AssignmentController extends Controller
{
$user = auth()->user();
if (!$agent->hasRole('agent') || !$agent->is_active) {
if (! $agent->hasRole('agent') || ! $agent->is_active) {
return false;
}

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

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

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

@ -26,13 +26,13 @@ class AuthController extends Controller
? User::whereRaw('lower(email) = ?', [$login])->first()
: 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([
'phone' => ['شماره موبایل یا رمز عبور اشتباه است'],
]);
}
if (!$user->is_active) {
if (! $user->is_active) {
return response()->json(['message' => 'حساب کاربری شما غیرفعال است'], 403);
}
@ -66,7 +66,7 @@ class AuthController extends Controller
])->save();
}
ActivityLogger::log('logout', "User logged out");
ActivityLogger::log('logout', 'User logged out');
Auth::guard('web')->logout();
if ($request->hasSession()) {
@ -79,10 +79,20 @@ class AuthController extends Controller
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');
return response()->json([
'authenticated' => true,
'data' => $user,
]);
}
@ -93,8 +103,8 @@ class AuthController extends Controller
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $user->id,
'phone' => 'nullable|string|max:20|unique:users,phone,' . $user->id,
'email' => 'required|email|unique:users,email,'.$user->id,
'phone' => 'nullable|string|max:20|unique:users,phone,'.$user->id,
'password' => ['nullable', ...PasswordPolicy::rules()],
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048',
]);
@ -104,7 +114,7 @@ class AuthController extends Controller
$validated['avatar'] = $path;
}
if (!empty($validated['password'])) {
if (! empty($validated['password'])) {
$validated['password'] = Hash::make($validated['password']);
} else {
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;
use App\Http\Controllers\Controller;
use App\Http\Resources\CallResource;
use App\Http\Responses\ApiResponse;
use App\Models\Call;
use App\Models\CallResult;
use App\Models\Lead;
@ -26,13 +28,19 @@ class CallController extends Controller
$user = auth()->user();
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);
if ($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) {
$query->where('result', $request->result);
}
@ -42,16 +50,30 @@ class CallController extends Controller
if ($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
{
return response()->json(
return ApiResponse::success(
CallResult::where('is_active', true)
->orderBy('sort_order')
->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);
return response()->json($result, 201);
return ApiResponse::success($result, 201, 'تماس آغاز شد.');
}
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);
}
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
@ -113,6 +135,54 @@ class CallController extends Controller
$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;
use App\Http\Controllers\Controller;
use App\Http\Resources\CampaignResource;
use App\Http\Responses\ApiResponse;
use App\Models\Campaign;
use App\Models\User;
use App\Services\ActivityLogger;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class CampaignController extends Controller
{
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);
AccessControl::scopeCampaigns($query, auth()->user());
@ -26,7 +38,9 @@ class CampaignController extends Controller
$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
@ -37,40 +51,44 @@ class CampaignController extends Controller
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'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',
'end_date' => 'nullable|date|after_or_equal:start_date',
'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',
'sales_script_id' => 'nullable|exists:sales_scripts,id',
'agent_ids' => 'nullable|array',
'agent_ids.*' => 'exists:users,id',
'supervisor_ids' => 'nullable|array',
'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) {
$campaign->assignedAgents()->sync($request->agent_ids);
}
if ($request->supervisor_ids) {
$campaign->assignedSupervisors()->sync($request->supervisor_ids);
}
return $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
{
Gate::authorize('view', $campaign);
return response()->json(
return ApiResponse::success($this->resource(
$campaign->load([
'assignedAgents', 'assignedSupervisors', 'salesScript',
'leads' => fn($q) => $q->with('leadStatus'),
'assignedAgents', 'assignedSupervisors', 'salesScript', 'product',
'leads' => fn ($q) => $q->with('leadStatus'),
])
);
));
}
public function update(Request $request, Campaign $campaign): JsonResponse
@ -81,28 +99,34 @@ class CampaignController extends Controller
'name' => 'sometimes|string|max:255',
'description' => 'nullable|string',
'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',
'end_date' => 'nullable|date|after_or_equal:start_date',
'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',
'sales_script_id' => 'nullable|exists:sales_scripts,id',
'agent_ids' => 'nullable|array',
'agent_ids.*' => 'exists:users,id',
'supervisor_ids' => 'nullable|array',
'supervisor_ids.*' => 'exists:users,id',
]);
$campaign->update($validated);
if ($request->has('agent_ids')) {
$campaign->assignedAgents()->sync($request->agent_ids);
}
if ($request->has('supervisor_ids')) {
$campaign->assignedSupervisors()->sync($request->supervisor_ids);
}
$this->authorizeAssignments($validated);
DB::transaction(function () use ($campaign, $validated, $request): void {
$campaign->update(collect($validated)->except(['agent_ids', 'supervisor_ids'])->all());
if ($request->has('agent_ids') || $request->has('supervisor_ids')) {
$agentIds = $request->has('agent_ids') ? ($validated['agent_ids'] ?? []) : $campaign->assignedAgents()->pluck('users.id')->all();
$supervisorIds = $request->has('supervisor_ids') ? ($validated['supervisor_ids'] ?? []) : $campaign->assignedSupervisors()->pluck('users.id')->all();
$this->syncAssignments($campaign, $agentIds, $supervisorIds);
}
});
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
@ -111,6 +135,38 @@ class CampaignController extends Controller
$campaign->delete();
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 Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class CompanyController extends Controller
{
@ -16,23 +17,31 @@ class CompanyController extends Controller
public function index(Request $request): JsonResponse
{
Gate::authorize('viewAny', Company::class);
$query = Company::with('owner:id,name')->withCount(['contacts', 'leads', 'deals']);
$this->scope($query);
AccessControl::scopeCompanies($query, auth()->user());
if ($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)));
}
public function store(Request $request): JsonResponse
{
Gate::authorize('create', Company::class);
$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) {
return response()->json(['message' => 'شرکت مشابهی در سیستم وجود دارد.', 'duplicates' => $suggestions], 422);
}
@ -41,13 +50,17 @@ class CompanyController extends Controller
ActivityLogger::log('company_created', "Company {$company->name} created", $company);
$payload = $company->load('owner:id,name')->toArray();
if ($suggestions) $payload['duplicate_suggestions'] = $suggestions;
if ($suggestions) {
$payload['duplicate_suggestions'] = $suggestions;
}
return response()->json($payload, 201);
}
public function show(Company $company): JsonResponse
{
$this->authorizeAccess($company);
Gate::authorize('view', $company);
return response()->json($company->load([
'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
{
$this->authorizeAccess($company);
Gate::authorize('update', $company);
$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));
ActivityLogger::log('company_updated', "Company {$company->name} updated", $company);
return response()->json($company->fresh('owner:id,name'));
}
public function destroy(Company $company): JsonResponse
{
$this->authorizeAccess($company, true);
Gate::authorize('delete', $company);
$company->delete();
ActivityLogger::log('company_deleted', "Company {$company->id} deleted");
return response()->json(['message' => 'شرکت حذف شد']);
}
public function merge(Request $request, Company $company): JsonResponse
{
abort_unless(auth()->user()?->hasRole('admin') || auth()->user()?->can('merge_duplicates'), 403, 'شما مجوز ادغام رکوردهای تکراری را ندارید.');
$validated = $request->validate(['target_id' => 'required|exists:companies,id|different:' . $company->id]);
Gate::authorize('update', $company);
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']);
Gate::authorize('update', $target);
return response()->json($this->duplicates->mergeCompanies($company, $target, auth()->id()));
}
private function validated(Request $request, bool $partial = false): array
{
$sometimes = $partial ? 'sometimes|' : '';
return $request->validate([
'name' => $sometimes . 'required|string|max:255',
'name' => $sometimes.'required|string|max:255',
'company_type' => 'nullable|string|max:80',
'industry' => 'nullable|string|max:120',
'city' => 'nullable|string|max:120',
@ -96,26 +118,13 @@ class CompanyController extends Controller
private function prepare(array $data): array
{
if (array_key_exists('name', $data)) $data['normalized_name'] = DuplicateService::normalizeName($data['name']);
if (array_key_exists('website', $data)) $data['normalized_website'] = DuplicateService::normalizeWebsite($data['website']);
if (array_key_exists('name', $data)) {
$data['normalized_name'] = DuplicateService::normalizeName($data['name']);
}
if (array_key_exists('website', $data)) {
$data['normalized_website'] = DuplicateService::normalizeWebsite($data['website']);
}
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;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\Contact;
use App\Models\ContactPhone;
use App\Models\Deal;
use App\Models\Lead;
use App\Services\ActivityLogger;
use App\Support\AccessControl;
@ -12,20 +14,31 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
class ContactController extends Controller
{
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');
AccessControl::scopeContacts($query, auth()->user());
if ($request->lead_id) $query->where('lead_id', $request->lead_id);
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->lead_id) {
$query->where('lead_id', $request->lead_id);
}
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) {
$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)));
@ -33,9 +46,12 @@ class ContactController extends Controller
public function storeStandalone(Request $request): JsonResponse
{
Gate::authorize('create', Contact::class);
$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);
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['lead_id'] = $lead->id;
$this->authorizeParents($validated, 'update');
$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]);
}
@ -67,16 +84,14 @@ class ContactController extends Controller
public function setPrimary(Request $request, Contact $contact): JsonResponse
{
if ($contact->lead) {
$this->authorizeLeadAccess($contact->lead);
}
Gate::authorize('update', $contact);
$validated = $request->validate([
'reason' => 'nullable|string|max:255',
]);
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([
'is_primary' => true,
'primary_reason' => $validated['reason'] ?? 'انتخاب دستی توسط کاربر',
@ -90,27 +105,54 @@ class ContactController extends Controller
public function update(Request $request, Contact $contact): JsonResponse
{
Gate::authorize('update', $contact);
$validated = $this->validateContact($request, true);
$contact->update($validated);
if ($request->has('phones')) {
$contact->phones()->delete();
foreach ($validated['phones'] ?? [] as $phone) {
ContactPhone::create([
'contact_id' => $contact->id,
'phone' => $phone['phone'],
'type' => $phone['type'] ?? 'mobile',
'status' => $phone['status'] ?? 'active',
]);
$this->authorizeParents($validated, 'update', $contact);
DB::transaction(function () use ($contact, $validated, $request): void {
$beforePhoneIds = $contact->phones()->withTrashed()->pluck('id')->all();
$contact->update(collect($validated)->except('phones')->all());
if ($contact->is_primary) {
$this->primaryScope($contact)->whereKeyNot($contact->id)->update(['is_primary' => false]);
}
}
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);
return response()->json($contact->fresh('phones.lastCaller'));
}
public function destroy(Contact $contact): JsonResponse
{
Gate::authorize('delete', $contact);
$contact->delete();
ActivityLogger::log('contact_deleted', "Contact {$contact->id} deleted");
return response()->json(['message' => 'مخاطب حذف شد']);
}
@ -119,14 +161,43 @@ class ContactController extends Controller
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
{
$sometimes = $partial ? 'sometimes|' : '';
return $request->validate([
'lead_id' => 'nullable|exists:leads,id',
'company_id' => 'nullable|exists:companies,id',
'deal_id' => 'nullable|exists:deals,id',
'name' => $sometimes . 'required|string|max:255',
'lead_id' => $sometimes.'nullable|exists:leads,id',
'company_id' => $sometimes.'nullable|exists:companies,id',
'deal_id' => $sometimes.'nullable|exists:deals,id',
'name' => $sometimes.'required|string|max:255',
'first_name' => 'nullable|string|max:120',
'last_name' => 'nullable|string|max:120',
'role' => 'nullable|string|max:80',
@ -137,8 +208,9 @@ class ContactController extends Controller
'status' => 'nullable|string|max:30',
'is_primary' => 'nullable|boolean',
'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.*.id' => 'nullable|integer|exists:contact_phones,id',
'phones.*.type' => 'nullable|string|in:mobile,landline,extension',
'phones.*.status' => 'nullable|string|max:30',
]);
@ -160,10 +232,14 @@ class ContactController extends Controller
'description' => $validated['description'] ?? null,
'status' => $validated['status'] ?? 'active',
'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(),
]);
if ($contact->is_primary) {
$this->primaryScope($contact)->whereKeyNot($contact->id)->update(['is_primary' => false]);
}
foreach ($validated['phones'] as $phone) {
ContactPhone::create([
'contact_id' => $contact->id,
@ -175,4 +251,20 @@ class ContactController extends Controller
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;
use App\Http\Controllers\Controller;
use App\Models\Team;
use App\Models\User;
use App\Services\DashboardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class DashboardController extends Controller
{
@ -13,13 +15,14 @@ class DashboardController extends Controller
public function admin(): JsonResponse
{
Gate::authorize('view-admin-dashboard');
$stats = $this->dashboardService->admin();
// Agent ranking
$agents = \App\Models\User::role('agent')
$agents = User::role('agent')
->withCount(['assignedLeads', 'calls'])
->get()
->map(fn($agent) => [
->map(fn ($agent) => [
'id' => $agent->id,
'name' => $agent->name,
'leads_count' => $agent->assigned_leads_count,
@ -35,12 +38,13 @@ class DashboardController extends Controller
public function supervisor(): JsonResponse
{
Gate::authorize('view-supervisor-dashboard');
$teamId = auth()->user()->teams->first()?->id;
$stats = $this->dashboardService->supervisor($teamId);
// Team members
$team = \App\Models\Team::with('members')->find($teamId);
$stats['team_members'] = $team?->members->map(fn($member) => [
$team = Team::with('members')->find($teamId);
$stats['team_members'] = $team?->members->map(fn ($member) => [
'id' => $member->id,
'name' => $member->name,
'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
{
Gate::authorize('view-agent-dashboard');
$stats = $this->dashboardService->agent();
return response()->json($stats);
}
}

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

@ -3,85 +3,114 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\Contact;
use App\Models\Deal;
use App\Models\Lead;
use App\Models\Product;
use App\Services\ActivityLogger;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class DealController extends Controller
{
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');
$this->scope($query);
foreach (['status', 'sales_stage', 'company_id', 'lead_id', 'owner_id', 'product_id'] as $filter) {
if ($request->filled($filter)) $query->where($filter, $request->get($filter));
Gate::authorize('viewAny', Deal::class);
$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');
AccessControl::scopeDeals($query, auth()->user());
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)));
}
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);
return response()->json($deal->load('company', 'contact', 'product', 'owner'), 201);
}
public function show(Deal $deal): JsonResponse
{
$this->authorizeAccess($deal);
return response()->json($deal->load('company', 'lead', 'contact.phones', 'product.salesScript.sections', 'owner:id,name', 'timelineNotes.user:id,name', 'attachments.uploader:id,name'));
Gate::authorize('view', $deal);
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
{
$this->authorizeAccess($deal);
$deal->update($this->validated($request, true));
Gate::authorize('update', $deal);
$validated = $this->validated($request, true);
$this->authorizeRelations($validated);
$deal->update($validated);
ActivityLogger::log('deal_updated', "Deal {$deal->title} updated", $deal);
return response()->json($deal->fresh('company', 'contact', 'product', 'owner'));
}
public function destroy(Deal $deal): JsonResponse
{
$this->authorizeAccess($deal, true);
Gate::authorize('delete', $deal);
$deal->delete();
ActivityLogger::log('deal_deleted', "Deal {$deal->id} deleted");
return response()->json(['message' => 'فرصت فروش حذف شد']);
}
private function validated(Request $request, bool $partial = false): array
{
$sometimes = $partial ? 'sometimes|' : '';
return $request->validate([
'title' => $sometimes . 'required|string|max:255',
'title' => $sometimes.'required|string|max:255',
'company_id' => 'nullable|exists:companies,id',
'lead_id' => 'nullable|exists:leads,id',
'contact_id' => 'nullable|exists:contacts,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',
'final_amount' => 'nullable|numeric|min:0',
'win_probability' => 'nullable|integer|min:0|max:100',
'sales_stage' => 'nullable|string|max:80',
'expected_close_date' => 'nullable|date',
'owner_id' => 'nullable|exists:users,id',
'status' => 'nullable|string|max:40',
'won_lost_reason' => 'nullable|string|max:255',
'competitor' => 'nullable|string|max:255',
'forecast_category' => 'nullable|in:pipeline,best_case,commit,closed',
'notes' => 'nullable|string',
]);
}
private function scope($query): void
private function authorizeRelations(array $validated): void
{
$user = auth()->user();
if ($user?->hasRole('admin')) return;
if ($user?->hasRole('agent')) $query->where('owner_id', $user->id);
if ($user?->hasRole('supervisor')) $query->whereIn('owner_id', array_merge([$user->id], AccessControl::teamMemberIds($user)));
}
private function authorizeAccess(Deal $deal, bool $manage = false): void
{
$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 (array_key_exists('owner_id', $validated)) {
abort_unless(AccessControl::canAssignUser($user, $validated['owner_id']), 403, 'مالک انتخاب‌شده خارج از محدوده مجاز است.');
}
foreach (['company_id' => Company::class, 'lead_id' => Lead::class, 'contact_id' => Contact::class] as $key => $model) {
if (! empty($validated[$key])) {
Gate::authorize('view', $model::findOrFail($validated[$key]));
}
}
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'
? $this->duplicates->companySuggestions($validated, $validated['exclude_id'] ?? null)
: $this->duplicates->leadSuggestions($validated, $validated['exclude_id'] ?? null);
? $this->duplicates->companySuggestions($validated, $validated['exclude_id'] ?? null, auth()->user())
: $this->duplicates->leadSuggestions($validated, $validated['exclude_id'] ?? null, auth()->user());
return response()->json(['data' => $items]);
}

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

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

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

@ -5,12 +5,13 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ImportBatch;
use App\Models\Setting;
use App\Services\ImportService;
use App\Services\ActivityLogger;
use App\Services\ImportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
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';
$maxMb = max(1, (int) (Setting::where('key', 'import_max_file_size_mb')->value('value') ?: 10));
$request->validate([
'file' => 'required|file|mimes:' . $allowedTypes . '|max:' . ($maxMb * 1024),
'file' => 'required|file|mimes:'.$allowedTypes.'|max:'.($maxMb * 1024),
], [
'file.mimes' => 'نوع فایل import مجاز نیست.',
'file.max' => "حجم فایل import نباید بیشتر از {$maxMb} مگابایت باشد.",
@ -46,7 +47,7 @@ class ImportController extends Controller
$preview = $this->importService->preview($fullPath);
// Store rows for later processing
$rows = \Maatwebsite\Excel\Facades\Excel::toArray([], $fullPath)[0] ?? [];
$rows = Excel::toArray([], $fullPath)[0] ?? [];
$dataRows = array_slice($rows, 1);
foreach ($dataRows as $index => $row) {
@ -68,6 +69,7 @@ class ImportController extends Controller
]);
} catch (\Exception $e) {
$batch->update(['status' => 'failed', 'errors' => $e->getMessage()]);
return response()->json(['message' => $e->getMessage()], 422);
}
}
@ -127,6 +129,7 @@ class ImportController extends Controller
$this->importService->rollback($batchId);
ActivityLogger::log('lead_import_rolled_back', "Import batch {$batchId} rolled back", $batch);
return response()->json(['message' => 'بازگشت import انجام شد']);
}
@ -145,7 +148,7 @@ class ImportController extends Controller
{
Gate::authorize('import', ImportBatch::class);
$spreadsheet = new Spreadsheet();
$spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet();
$sheet->fromArray([
['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;
use App\Http\Controllers\Controller;
use App\Models\Contact;
use App\Models\ContactPhone;
use App\Models\Lead;
use App\Models\LeadAssignment;
use App\Models\PipelineStage;
use App\Models\Setting;
use App\Models\User;
use App\Services\ActivityLogger;
use App\Services\AutomationDispatcher;
use App\Services\LeadService;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
@ -17,7 +21,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
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
{
@ -76,7 +80,7 @@ class LeadController extends Controller
public function export(Request $request): StreamedResponse|JsonResponse
{
$user = auth()->user();
if (!$user?->hasRole('admin') && !$user?->can('export_leads')) {
if (! $user?->hasRole('admin') && ! $user?->can('export_leads')) {
return response()->json(['message' => 'شما مجوز خروجی گرفتن از لیدها را ندارید.'], 403);
}
@ -168,11 +172,11 @@ class LeadController extends Controller
if ($user?->hasRole('agent')) {
$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']);
$teamIds = AccessControl::teamIds($user);
$assigneeTeamIds = $assignee?->teams()->pluck('teams.id')->all() ?? [];
if (!array_intersect($teamIds, $assigneeTeamIds)) {
if (! array_intersect($teamIds, $assigneeTeamIds)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
}
@ -183,7 +187,7 @@ class LeadController extends Controller
?? PipelineStage::where('is_default', true)->value('id')
?? PipelineStage::orderBy('sort_order')->value('id');
$assignedTo = $validated['assigned_to'] ?? null;
if (!$assignedTo) {
if (! $assignedTo) {
$strategy = Setting::where('key', 'assignment_strategy')->value('value') ?? 'round_robin';
if ($strategy !== 'manual' || auth()->user()?->hasRole('admin')) {
$assignedTo = User::role('agent')
@ -206,9 +210,9 @@ class LeadController extends Controller
$lead = Lead::create($validated);
$contact = \App\Models\Contact::create([
$contact = Contact::create([
'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' => 'رابط',
'description' => 'مخاطب اولیه لید',
'status' => 'active',
@ -218,7 +222,7 @@ class LeadController extends Controller
]);
foreach (array_filter([$validated['phone'] ?? null, $validated['phone_secondary'] ?? null]) as $phone) {
\App\Models\ContactPhone::create([
ContactPhone::create([
'contact_id' => $contact->id,
'phone' => $phone,
'type' => 'mobile',
@ -226,8 +230,8 @@ class LeadController extends Controller
]);
}
if (!empty($validated['assigned_to'])) {
\App\Models\LeadAssignment::create([
if (! empty($validated['assigned_to'])) {
LeadAssignment::create([
'lead_id' => $lead->id,
'user_id' => $validated['assigned_to'],
'assigned_by' => auth()->id(),
@ -236,6 +240,7 @@ class LeadController extends Controller
}
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();
if ($duplicateLead && in_array($duplicatePolicy, ['warn', 'merge_suggestion'], true)) {
@ -262,8 +267,8 @@ class LeadController extends Controller
'callLogs.contact:id,name,role',
'callLogs.phone:id,phone,type,status',
'callLogs.user:id,name',
'calls' => fn($q) => $q->with('contact:id,name,role', 'contactPhone:id,phone,type,status')->latest(),
'followUps' => fn($q) => $q->latest(),
'calls' => fn ($q) => $q->with('contact:id,name,role', 'contactPhone:id,phone,type,status')->latest(),
'followUps' => fn ($q) => $q->latest(),
'notes.user:id,name',
'attachments.uploader:id,name',
])
@ -325,7 +330,7 @@ class LeadController extends Controller
]);
$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);
}
@ -346,27 +351,27 @@ class LeadController extends Controller
$lead = Lead::where('assigned_to', $user->id)
->where(function ($q) {
$q->whereNull('last_call_at')
->orWhere('next_follow_up_at', '<=', now());
->orWhere('next_follow_up_at', '<=', now());
})
->orderBy('priority', 'desc')
->orderBy('created_at', 'asc')
->first();
if (!$lead) {
if (! $lead) {
$lead = Lead::where('assigned_to', $user->id)
->orderBy('priority', 'desc')
->orderBy('last_call_at', 'asc')
->first();
}
if (!$lead) {
if (! $lead) {
return response()->json(['message' => 'لیدی برای تماس وجود ندارد'], 404);
}
ActivityLogger::log('lead_viewed', "Lead {$lead->id} viewed as next lead", $lead);
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();
if (!$user) {
if (! $user) {
return false;
}
@ -388,6 +393,7 @@ class LeadController extends Controller
if ($user->hasRole('supervisor')) {
$teamIds = $user->teams->pluck('id')->toArray();
return in_array($lead->team_id, $teamIds, true) || $lead->assigned_to === $user->id;
}

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

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

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

@ -35,7 +35,7 @@ class LostReasonController extends Controller
{
$validated = $request->validate([
'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',
'sort_order' => 'nullable|integer',
'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;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\Deal;
use App\Models\Lead;
use App\Http\Requests\StoreCallNoteRequest;
use App\Http\Requests\UpdateNoteRequest;
use App\Http\Resources\NoteResource;
use App\Http\Responses\ApiResponse;
use App\Models\Call;
use App\Models\Note;
use App\Services\ActivityLogger;
use App\Support\EntityResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
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
{
$validated = $request->validate([
'entity_type' => 'required|in:lead,company,deal',
'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']);
$note = $model->notes()->create([
'user_id' => auth()->id(),
'content' => $validated['content'],
]);
return $note;
});
ActivityLogger::log('note_created', 'یادداشت جدید ثبت شد', $model);
return response()->json($note->load('user:id,name'), 201);
return ApiResponse::success($this->resource($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
{
abort_unless(auth()->id() === $note->user_id || auth()->user()?->hasRole('admin'), 403, 'دسترسی غیرمجاز');
$note->delete();
ActivityLogger::log('note_deleted', "Note {$note->id} deleted");
return response()->json(['message' => 'یادداشت حذف شد']);
Gate::authorize('delete', $note);
DB::transaction(function () use ($note): void {
$before = $note->only(['type', 'visibility', 'is_pinned']);
$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) {
'lead' => Lead::findOrFail($id),
'company' => Company::findOrFail($id),
'deal' => Deal::findOrFail($id),
};
return $this->setPinned($note, true);
}
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;
use App\Http\Controllers\Controller;
use App\Http\Resources\NotificationResource;
use App\Http\Responses\ApiResponse;
use App\Models\Notification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class NotificationController extends Controller
{
public function index(): JsonResponse
public function index(Request $request): JsonResponse
{
$notifications = Notification::where('user_id', auth()->id())
->orderBy('created_at', 'desc')
$query = Notification::where('user_id', auth()->id());
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);
return response()->json($notifications);
return ApiResponse::paginated($paginator, fn (Notification $notification) => $this->resource($notification));
}
public function markRead(Notification $notification): JsonResponse
{
if ($notification->user_id !== auth()->id()) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
Gate::authorize('update', $notification);
$notification->update(['is_read' => true, 'read_at' => $notification->read_at ?? now()]);
$notification->update(['is_read' => true]);
return response()->json($notification);
return ApiResponse::success($this->resource($notification), message: 'اعلان خوانده شد.');
}
public function markAllRead(): JsonResponse
{
Notification::where('user_id', auth()->id())
->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
{
$count = Notification::where('user_id', auth()->id())
->where('is_read', false)
->count();
$count = Notification::where('user_id', auth()->id())->where('is_read', false)->whereNull('archived_at')->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;
use App\Http\Controllers\Controller;
use App\Models\FollowUp;
use App\Models\Lead;
use App\Models\LeadStatus;
use App\Models\PipelineStage;
use App\Services\NotificationService;
use App\Services\ActivityLogger;
use App\Services\FollowUpService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class PipelineStageController extends Controller
{
public function __construct(private FollowUpService $followUps) {}
public function index(): JsonResponse
{
return response()->json(
@ -48,7 +51,7 @@ class PipelineStageController extends Controller
{
$validated = $request->validate([
'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',
'sort_order' => 'nullable|integer',
'is_active' => 'nullable|boolean',
@ -69,6 +72,7 @@ class PipelineStageController extends Controller
{
$pipelineStage->delete();
ActivityLogger::log('pipeline_stage_deleted', "Stage {$pipelineStage->name} deleted");
return response()->json(['message' => 'مرحله حذف شد']);
}
@ -77,6 +81,7 @@ class PipelineStageController extends Controller
$validated = $request->validate([
'lead_id' => 'required|exists:leads,id',
'next_follow_up_at' => 'nullable|date',
'follow_up_notes' => 'nullable|string|max:2000',
'final_result' => 'nullable|in:موفق,ناموفق',
'lost_reason' => 'nullable|string|max:255',
'deal_value' => 'nullable|numeric|min:0',
@ -86,7 +91,7 @@ class PipelineStageController extends Controller
'customer_notes' => 'nullable|string',
]);
$lead = \App\Models\Lead::findOrFail($validated['lead_id']);
$lead = Lead::findOrFail($validated['lead_id']);
Gate::authorize('changeStage', $lead);
$update = ['pipeline_stage_id' => $pipelineStage->id];
@ -100,7 +105,7 @@ class PipelineStageController extends Controller
$request->validate(['final_result' => 'required|in:موفق,ناموفق']);
$update['final_result'] = $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) === 'ناموفق') {
@ -125,17 +130,14 @@ class PipelineStageController extends Controller
$lead->update($update);
if ($pipelineStage->requires_follow_up) {
FollowUp::create([
'lead_id' => $lead->id,
'user_id' => $lead->assigned_to ?? auth()->id(),
'scheduled_at' => $validated['next_follow_up_at'],
'notes' => 'ثبت شده از قیف فروش',
'status' => 'pending',
]);
if ($lead->assigned_to) {
NotificationService::notifyFollowUpReminder($lead->assigned_to, $lead->company ?? $lead->full_name);
}
$this->followUps->schedule(
$lead,
$lead->assigned_to ?? auth()->id(),
$validated['next_follow_up_at'],
$request->user(),
$validated['follow_up_notes'] ?? null,
source: 'pipeline',
);
}
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 Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ProductController extends Controller
{
public function index(Request $request): JsonResponse
{
Gate::authorize('viewAny', Product::class);
$query = Product::with('salesScript:id,title')->withCount('deals');
if ($request->search) $query->where('name', 'like', "%{$request->search}%");
if ($request->filled('is_active')) $query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN));
if ($request->search) {
$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)));
}
public function store(Request $request): JsonResponse
{
$this->authorizeManage();
Gate::authorize('create', Product::class);
$product = Product::create($this->validated($request) + ['created_by' => auth()->id()]);
ActivityLogger::log('product_created', "Product {$product->name} created", $product);
return response()->json($product->load('salesScript:id,title'), 201);
}
public function show(Product $product): JsonResponse
{
Gate::authorize('view', $product);
return response()->json($product->load('salesScript.sections', 'deals:id,title,product_id,status'));
}
public function update(Request $request, Product $product): JsonResponse
{
$this->authorizeManage();
Gate::authorize('update', $product);
$product->update($this->validated($request, true));
ActivityLogger::log('product_updated', "Product {$product->name} updated", $product);
return response()->json($product->fresh('salesScript:id,title'));
}
public function destroy(Product $product): JsonResponse
{
$this->authorizeManage();
Gate::authorize('delete', $product);
$product->delete();
ActivityLogger::log('product_deleted', "Product {$product->id} deleted");
return response()->json(['message' => 'محصول/خدمت حذف شد']);
}
private function validated(Request $request, bool $partial = false): array
{
$sometimes = $partial ? 'sometimes|' : '';
return $request->validate([
'name' => $sometimes . 'required|string|max:255',
'name' => $sometimes.'required|string|max:255',
'category' => 'nullable|string|max:120',
'base_price' => 'nullable|numeric|min:0',
'description' => 'nullable|string',
@ -61,9 +74,4 @@ class ProductController extends Controller
'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;
use App\Http\Controllers\Controller;
use App\Models\Call;
use App\Models\QualityReview;
use App\Services\ActivityLogger;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
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
{
$user = auth()->user();
$query = QualityReview::with('call:id,lead_id,created_at', 'agent:id,name', 'reviewer:id,name');
Gate::authorize('viewAny', QualityReview::class);
if ($user->hasRole('agent')) {
$query->where('agent_id', $user->id);
} elseif ($user->hasRole('supervisor')) {
$teamIds = $user->teams->pluck('id')->toArray();
$agentIds = \App\Models\Team::whereIn('id', $teamIds)->with('members')->get()->pluck('members.*.id')->flatten();
$query->whereIn('agent_id', $agentIds);
if ($request->has('current_only')) {
$request->merge([
'current_only' => filter_var(
$request->input('current_only'),
FILTER_VALIDATE_BOOLEAN,
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
{
$validated = $request->validate([
$validated = $request->validate(array_merge([
'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',
'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();
$validated['overall_score'] = (int) round(collect([
$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());
$call = Call::findOrFail($validated['call_id']);
Gate::authorize('create', [QualityReview::class, $call]);
$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
{
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
{
$validated = $request->validate([
Gate::authorize('update', $qualityReview);
$validated = $request->validate(array_merge([
'feedback' => 'nullable|string',
'tag' => 'nullable|string|max:30',
'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);
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;
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\User;
use App\Models\Campaign;
use App\Services\ActivityLogger;
use App\Services\ReportService;
use App\Support\AccessControl;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -18,6 +25,75 @@ class ReportController extends Controller
{
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
{
Gate::authorize('view-report-data');
@ -29,14 +105,14 @@ class ReportController extends Controller
]);
$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);
}
if (empty($validated['agent_id'])) {
$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,
$validated['date_from'] ?? null,
$validated['date_to'] ?? null
@ -55,6 +131,7 @@ class ReportController extends Controller
public function teamPerformance(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.');
$validated = $request->validate([
'team_id' => 'nullable|exists:teams,id',
@ -63,14 +140,14 @@ class ReportController extends Controller
]);
$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);
}
if (empty($validated['team_id'])) {
$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,
$validated['date_from'] ?? null,
$validated['date_to'] ?? null
@ -89,6 +166,7 @@ class ReportController extends Controller
public function campaignReport(Request $request, int $campaignId): JsonResponse
{
Gate::authorize('view-report-data');
abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.');
$validated = $request->validate([
'date_from' => 'nullable|date',
@ -185,7 +263,9 @@ class ReportController extends Controller
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$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));
}
@ -195,7 +275,9 @@ class ReportController extends Controller
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$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));
}
@ -205,7 +287,9 @@ class ReportController extends Controller
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$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));
}
@ -213,6 +297,7 @@ class ReportController extends Controller
public function importQuality(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
abort_if($request->user()->hasRole('agent'), 403, 'کارشناس فقط به گزارش عملکرد خود دسترسی دارد.');
$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));
@ -223,7 +308,9 @@ class ReportController extends Controller
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$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));
}
@ -233,7 +320,9 @@ class ReportController extends Controller
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$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));
}
@ -243,18 +332,23 @@ class ReportController extends Controller
Gate::authorize('export-report-data');
$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);
if ($agentIds === false) {
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);
}
ActivityLogger::log('report_exported', "Report export requested: {$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),
'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) : [],
@ -271,7 +365,7 @@ class ReportController extends Controller
};
$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) {
$out = fopen('php://output', 'w');
@ -280,11 +374,12 @@ class ReportController extends Controller
fputcsv($out, ['message'], ',');
fputcsv($out, ['داده‌ای برای خروجی وجود ندارد'], ',');
fclose($out);
return;
}
fputcsv($out, array_keys($rows[0]), ',');
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);
}, $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();
@ -346,28 +441,30 @@ class ReportController extends Controller
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
{
if ($data instanceof \Illuminate\Support\Collection) {
if ($data instanceof Collection) {
$data = $data->toArray();
}
if ($data instanceof \Illuminate\Database\Eloquent\Model) {
if ($data instanceof Model) {
$data = $data->toArray();
}
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)) {
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])) {
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 [];
}
}

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

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

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

@ -3,73 +3,111 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Campaign;
use App\Models\Product;
use App\Models\SalesScript;
use App\Models\User;
use App\Services\ActivityLogger;
use App\Services\NotificationService;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class ScriptController extends Controller
{
public function index(): JsonResponse
public function index(Request $request): JsonResponse
{
return response()->json(
SalesScript::with('sections', 'campaign:id,name')
->orderBy('created_at', 'desc')
->paginate(15)
);
Gate::authorize('viewAny', SalesScript::class);
$query = SalesScript::with('sections', 'campaign:id,name,sales_script_id', 'product:id,name,sales_script_id', 'assignees:id,name');
if (! $request->user()->can('manage_scripts')) {
$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
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'campaign_id' => 'nullable|exists:campaigns,id',
'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',
]);
Gate::authorize('create', SalesScript::class);
$validated = $request->validate($this->rules());
$this->authorizeParents($validated);
$this->authorizeAssignees($validated['assigned_user_ids'] ?? [], $request->user());
$script = SalesScript::create([
'title' => $validated['title'],
'description' => $validated['description'] ?? null,
'campaign_id' => $validated['campaign_id'] ?? null,
'product_id' => $validated['product_id'] ?? null,
'version' => $validated['version'] ?? '1.0',
'is_active' => true,
'checklist' => $validated['checklist'] ?? null,
'objection_handling' => $validated['objection_handling'] ?? null,
]);
$script = DB::transaction(function () use ($validated, $request): SalesScript {
$script = SalesScript::create(collect($validated)->except(['sections', 'campaign_id', 'product_id', 'assigned_user_ids'])->all());
$this->syncSections($script, $validated['sections'] ?? []);
$this->syncParents($script, $validated);
$this->syncAssignees($script, $validated['assigned_user_ids'] ?? [], $request->user());
if ($request->sections) {
foreach ($validated['sections'] as $i => $section) {
$script->sections()->create([
'title' => $section['title'],
'content' => $section['content'],
'sort_order' => $section['sort_order'] ?? $i,
]);
}
}
return $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
{
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
{
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
Gate::authorize('update', $script);
$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',
'campaign_id' => 'nullable|exists:campaigns,id',
'product_id' => 'nullable|exists:products,id',
@ -77,52 +115,83 @@ class ScriptController extends Controller
'is_active' => 'nullable|boolean',
'checklist' => '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.*.id' => 'nullable|exists:script_sections,id',
'sections.*.id' => 'nullable|integer|exists:script_sections,id',
'sections.*.title' => 'required|string|max:255',
'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')) {
$existingIds = $script->sections->pluck('id')->toArray();
$updatedIds = [];
foreach ($validated['sections'] as $i => $section) {
if (isset($section['id']) && in_array($section['id'], $existingIds)) {
$script->sections()->where('id', $section['id'])->update([
'title' => $section['title'],
'content' => $section['content'],
'sort_order' => $section['sort_order'] ?? $i,
]);
$updatedIds[] = $section['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();
private function syncParents(SalesScript $script, array $validated, bool $partial = false): void
{
if (! $partial || array_key_exists('campaign_id', $validated)) {
Campaign::where('sales_script_id', $script->id)->update(['sales_script_id' => null]);
if (! empty($validated['campaign_id'])) {
Campaign::whereKey($validated['campaign_id'])->update(['sales_script_id' => $script->id]);
}
}
if (! $partial || array_key_exists('product_id', $validated)) {
Product::where('sales_script_id', $script->id)->update(['sales_script_id' => null]);
if (! empty($validated['product_id'])) {
Product::whereKey($validated['product_id'])->update(['sales_script_id' => $script->id]);
}
}
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();
ActivityLogger::log('script_deleted', "Script {$script->title} deleted");
return response()->json(['message' => 'اسکریپت حذف شد']);
$existingIds = $script->sections()->pluck('id')->all();
$keptIds = [];
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);
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) {
$meta = SettingsCatalog::metaFor($setting->key) ?? [];
if ($setting->is_secret) {
@ -32,10 +35,11 @@ class SettingController extends Controller
'label' => $meta['label'] ?? $setting->key,
'hint' => $meta['hint'] ?? $setting->description,
'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');
return response()->json($settings);
}
@ -91,9 +95,13 @@ class SettingController extends Controller
public function public(): JsonResponse
{
SettingsCatalog::ensureDefaults();
$settings = Setting::where('is_public', true)->get()
->filter(fn(Setting $setting) => !$setting->is_secret)
->mapWithKeys(fn(Setting $setting) => [$setting->key => $setting->effectiveValue()]);
$publicRuntimeKeys = collect(SettingsCatalog::definitions())
->where('is_runtime_enforced', true)
->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);
}
@ -101,27 +109,34 @@ class SettingController extends Controller
public function testVoip(): JsonResponse
{
Gate::authorize('viewAny', Setting::class);
$provider = Setting::where('key', 'voip_provider')->value('value') ?: 'mock';
$provider = Setting::where('key', 'voip_provider')->value('value') ?: 'none';
$missing = [];
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) {
if (!Setting::where('key', $key)->value('value')) $missing[] = $key;
if (! Setting::where('key', $key)->value('value')) {
$missing[] = $key;
}
}
}
if ($provider === 'api') {
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') {
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");
if ($missing === []) {
$result = $this->voipManager->testConnection();
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'provider' => $provider,
@ -131,13 +146,34 @@ class SettingController extends Controller
}
return response()->json([
'ok' => !$missing,
'ok' => ! $missing,
'provider' => $provider,
'message' => $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
{
$type = $payload['type'] ?? $existing?->type ?? 'string';
@ -152,7 +188,7 @@ class SettingController extends Controller
}
if ($existing?->allowed_values) {
$rules[] = 'in:' . implode(',', $existing->allowed_values);
$rules[] = 'in:'.implode(',', $existing->allowed_values);
}
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\Models\ActivityLog;
use App\Models\Call;
use App\Models\CallLog;
use App\Models\Company;
use App\Models\Deal;
use App\Models\FollowUp;
use App\Models\Lead;
use App\Models\Note;
use App\Support\EntityResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -23,21 +23,23 @@ class TimelineController extends Controller
'entity_id' => 'required|integer',
]);
EntityResolver::authorize($validated['entity_type'], $validated['entity_id']);
[$class, $id] = [$this->classFor($validated['entity_type']), $validated['entity_id']];
$items = collect();
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()
->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) {
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()
->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([

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

@ -3,17 +3,80 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Responses\ApiResponse;
use App\Models\User;
use App\Models\Team;
use App\Services\ActivityLogger;
use App\Support\AccessControl;
use App\Support\EntityResolver;
use App\Support\PasswordPolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
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
{
Gate::authorize('viewAny', User::class);
@ -23,9 +86,9 @@ class UserController extends Controller
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('name', 'like', "%{$request->search}%")
->orWhere('email', 'like', "%{$request->search}%")
->orWhere('phone', 'like', "%{$request->search}%")
->orWhere('voip_extension', 'like', "%{$request->search}%");
->orWhere('email', 'like', "%{$request->search}%")
->orWhere('phone', 'like', "%{$request->search}%")
->orWhere('voip_extension', 'like', "%{$request->search}%");
});
}
@ -34,7 +97,7 @@ class UserController extends Controller
}
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')) {
@ -54,6 +117,17 @@ class UserController extends Controller
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(
User::role('agent')
->where('is_active', true)
@ -104,10 +178,10 @@ class UserController extends Controller
$validated = $request->validate([
'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()],
'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',
]);
@ -141,7 +215,7 @@ class UserController extends Controller
{
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}");

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

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

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

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

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

@ -23,12 +23,12 @@ class SecurityHeaders
];
foreach ($headers as $name => $value) {
if (!$response->headers->has($name)) {
if (! $response->headers->has($name)) {
$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('Pragma', 'no-cache');
$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 = [
'user_id', 'action', 'description',
'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
{
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\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Call extends Model
{
protected $fillable = [
'lead_id', 'contact_id', 'contact_phone_id', 'user_id', 'direction', 'phone', 'duration',
'result', 'notes', 'provider_call_id', 'recording_url', 'is_manual',
'provider_status', 'started_at', 'ended_at', 'provider_payload',
];
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
@ -35,11 +43,21 @@ class Call extends Model
public function contactPhone(): BelongsTo
{
return $this->belongsTo(ContactPhone::class);
return $this->belongsTo(ContactPhone::class)->withTrashed();
}
public function qualityReview(): HasOne
{
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;
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\MorphMany;
class Campaign extends Model
{
@ -12,9 +14,13 @@ class Campaign extends Model
'name',
'description',
'product_service',
'product_id',
'channel',
'start_date',
'end_date',
'target',
'budget',
'actual_cost',
'status',
'sales_script_id',
];
@ -25,6 +31,8 @@ class Campaign extends Model
'start_date' => 'date',
'end_date' => 'date',
'target' => 'integer',
'budget' => 'decimal:2',
'actual_cost' => 'decimal:2',
];
}
@ -32,6 +40,7 @@ class Campaign extends Model
{
return $this->belongsToMany(User::class)
->wherePivot('role', 'agent')
->withPivotValue('role', 'agent')
->withPivot('role');
}
@ -39,6 +48,7 @@ class Campaign extends Model
{
return $this->belongsToMany(User::class)
->wherePivot('role', 'supervisor')
->withPivotValue('role', 'supervisor')
->withPivot('role');
}
@ -46,4 +56,19 @@ class Campaign extends Model
{
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');
}
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\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Contact extends Model
{
@ -43,4 +44,14 @@ class Contact extends Model
{
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\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ContactPhone extends Model
{
use SoftDeletes;
protected $fillable = [
'contact_id', 'phone', 'type', 'status', 'call_count',
'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;
protected $fillable = [
'title', 'company_id', 'lead_id', 'contact_id', 'product_id',
'estimated_value', 'win_probability', 'sales_stage', 'expected_close_date',
'owner_id', 'status', 'won_lost_reason', 'notes', 'created_by',
'title', 'company_id', 'lead_id', 'contact_id', 'product_id', 'pipeline_id', 'deal_stage_id',
'estimated_value', 'final_amount', 'win_probability', 'sales_stage', 'expected_close_date',
'last_activity_at', 'closed_at', 'owner_id', 'status', 'won_lost_reason', 'competitor',
'forecast_category', 'version', 'notes', 'created_by',
];
protected function casts(): array
{
return [
'estimated_value' => 'decimal:2',
'final_amount' => 'decimal:2',
'win_probability' => 'integer',
'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');
}
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
{
return $this->morphMany(Note::class, 'notable');
@ -60,4 +85,9 @@ class Deal extends Model
{
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
{
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',
];
@ -31,6 +31,11 @@ class FollowUp extends Model
return $this->belongsTo(User::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function call(): BelongsTo
{
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 = [
'company_id', 'first_name', 'last_name', 'company', 'phone', 'phone_secondary',
'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',
'deal_value', 'sold_product', 'contract_date', 'payment_status', 'customer_notes',
'lead_status_id', 'pipeline_stage_id', 'campaign_id',
@ -28,6 +28,8 @@ class Lead extends Model
return [
'priority' => 'integer',
'lead_score' => 'integer',
'score_breakdown' => 'array',
'scored_at' => 'datetime',
'call_attempts' => 'integer',
'deal_value' => 'decimal:2',
'is_unassigned' => 'boolean',
@ -39,14 +41,17 @@ class Lead extends Model
public function getFullNameAttribute(): string
{
return $this->first_name . ' ' . $this->last_name;
return $this->first_name.' '.$this->last_name;
}
public function getMaskedPhoneAttribute(): string
{
$phone = $this->phone;
if (strlen($phone) < 7) return $phone;
return substr($phone, 0, 4) . ' *** **' . substr($phone, -2);
if (strlen($phone) < 7) {
return $phone;
}
return substr($phone, 0, 4).' *** **'.substr($phone, -2);
}
public function leadStatus(): BelongsTo
@ -124,16 +129,31 @@ class Lead extends Model
return $this->morphMany(Attachment::class, 'attachable');
}
public function tasks(): MorphMany
{
return $this->morphMany(Task::class, 'taskable');
}
public function deals(): HasMany
{
return $this->hasMany(Deal::class);
}
public function customFieldValues(): MorphMany
{
return $this->morphMany(CustomFieldValue::class, 'fieldable');
}
public function leadAssignments(): HasMany
{
return $this->hasMany(LeadAssignment::class);
}
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
public function scopeAssignedTo($query, $userId)
{
return $query->where('assigned_to', $userId);

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

@ -5,10 +5,21 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
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
{

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

@ -4,10 +4,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
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';
@ -16,6 +19,8 @@ class Notification extends Model
return [
'data' => 'array',
'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);
}
public function campaigns(): HasMany
{
return $this->hasMany(Campaign::class);
}
}

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

@ -8,16 +8,24 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QualityReview extends Model
{
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',
'objection_handling_score', 'closing_score', 'crm_accuracy_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
{
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

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

@ -3,12 +3,13 @@
namespace App\Models;
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\HasOne;
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
{
@ -16,21 +17,31 @@ class SalesScript extends Model
'is_active' => 'boolean',
'checklist' => '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
{
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\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasApiTokens, HasRoles, SoftDeletes;
use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes;
protected $fillable = [
'name', 'email', 'password', 'phone', 'voip_extension', 'avatar', 'is_active',
@ -66,30 +65,52 @@ class User extends Authenticatable
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
{
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
{
return Attribute::get(function (): ?string {
if (!$this->avatar) {
if (! $this->avatar) {
return null;
}
$path = parse_url($this->avatar, PHP_URL_PATH) ?: $this->avatar;
$path = ltrim($path, '/');
$filename = basename(str_replace('\\', '/', $path));
if (str_starts_with($path, 'storage/')) {
$path = substr($path, strlen('storage/'));
}
if (str_starts_with($path, 'public/')) {
$path = substr($path, strlen('public/'));
}
return url(Storage::url($path));
return '/api/media/avatars/'.rawurlencode($filename);
});
}
}

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

@ -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
{
public function before(User $user): ?bool
{
return $user->hasRole('admin') ? true : null;
}
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');

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

@ -8,9 +8,14 @@ use App\Support\AccessControl;
class CampaignPolicy
{
public function before(User $user): ?bool
{
return $user->hasRole('admin') ? true : null;
}
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
@ -20,12 +25,12 @@ class CampaignPolicy
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
{
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

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

@ -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
{
return $user->hasAnyRole(['admin', 'supervisor', 'agent']);
return $user->can('view_leads');
}
public function view(User $user, FollowUp $followUp): bool
@ -21,11 +21,25 @@ class FollowUpPolicy
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
{
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);
}
}

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