Commit 97db5304 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Enforce role-based data scoping across all list components

- Add branch_id accessor to User model (resolves from role_user pivot)
- Create AppliesRoleScope trait for one-liner scope enforcement
- Enhance PermissionService: include head_trainer_id in group lookup,
  add training_programs and invoices table support for own_groups scope
- Apply AppliesRoleScope to 16 list components:
  ParticipantList, GroupList, EnrollmentList, AttendanceList,
  InvoiceList, ProgramList, EvaluationList, CashSessionList,
  WalletList, AssignmentList, PersonList, FacilityList,
  ProductList, PurchaseOrderList, KitList, StockCountList

Now trainers only see their assigned groups/participants,
branch managers only see their branch data, parents only
see their children, and receptionists are branch-scoped.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ecf6909c
......@@ -121,12 +121,20 @@ private function getAssignedGroupIds(User $user): array
return $this->cachedGroupIds[$user->id];
}
$ids = Assignment::active()
// Groups via Assignment records
$assignmentIds = Assignment::active()
->forUser($user->id)
->where('assignable_type', TrainingGroup::class)
->pluck('assignable_id')
->toArray();
// Groups where user is head_trainer_id
$headTrainerIds = TrainingGroup::where('head_trainer_id', $user->id)
->pluck('id')
->toArray();
$ids = array_unique(array_merge($assignmentIds, $headTrainerIds));
$this->cachedGroupIds[$user->id] = $ids;
return $ids;
}
......@@ -179,6 +187,11 @@ private function applyOwnGroupsScope(Builder $query, User $user, string $table):
'training_sessions' => $query->whereIn("{$table}.training_group_id", $groupIds),
'enrollments' => $query->whereIn("{$table}.training_group_id", $groupIds),
'evaluations' => $query->whereIn("{$table}.training_group_id", $groupIds),
'training_programs' => $query->whereIn("{$table}.id", function ($sub) use ($groupIds) {
$sub->select('training_program_id')
->from('training_groups')
->whereIn('id', $groupIds);
}),
'participants' => $query->whereIn("{$table}.id", function ($sub) use ($groupIds) {
$sub->select('participant_id')
->from('enrollments')
......@@ -190,6 +203,12 @@ private function applyOwnGroupsScope(Builder $query, User $user, string $table):
->from('training_sessions')
->whereIn('training_group_id', $groupIds);
}),
'invoices' => $query->whereIn("{$table}.billable_id", function ($sub) use ($groupIds) {
$sub->select('participant_id')
->from('enrollments')
->whereIn('training_group_id', $groupIds)
->whereIn('status', ['active', 'pending']);
})->where("{$table}.billable_type", Participant::class),
default => $query->whereIn("{$table}.training_group_id", $groupIds),
};
}
......
......@@ -10,6 +10,7 @@
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use App\Livewire\Concerns\AppliesRoleScope;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -21,7 +22,9 @@
#[Title('التكليفات')]
class AssignmentList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'assignments.list';
#[Url]
public string $search = '';
......@@ -130,6 +133,8 @@ public function render()
->when($this->userId, fn ($q) => $q->where('user_id', $this->userId))
->orderByDesc('created_at');
$this->applyRoleScope($query);
return view('livewire.assignments.assignment-list', [
'assignments' => $query->paginate(20),
'statusOptions' => collect(AssignmentStatus::cases())->mapWithKeys(fn ($s) => [$s->value => $s->label()]),
......
......@@ -4,6 +4,7 @@
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\TrainingSession;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -14,7 +15,9 @@
#[Title('سجل الحضور')]
class AttendanceList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'attendance.list';
#[Url]
public string $search = '';
......@@ -78,6 +81,8 @@ public function render()
->orderByDesc('session_date')
->orderByDesc('start_time');
$this->applyRoleScope($query);
return view('livewire.attendance.attendance-list', [
'sessions' => $query->paginate(20),
]);
......
......@@ -4,6 +4,7 @@
use App\Domain\Financial\Models\CashSession;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -14,7 +15,9 @@
#[Title('الورديات النقدية')]
class CashSessionList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'cash_sessions.list';
#[Url]
public string $status = '';
......@@ -34,6 +37,8 @@ public function render()
->when($this->status, fn ($q) => $q->where('status', $this->status))
->orderByDesc('opened_at');
$this->applyRoleScope($query);
return view('livewire.cash-sessions.cash-session-list', [
'sessions' => $query->paginate(20),
'statusOptions' => [
......
<?php
namespace App\Livewire\Concerns;
use App\Domain\Identity\Services\PermissionService;
use Illuminate\Database\Eloquent\Builder;
/**
* Apply PermissionService::applyScope() to Livewire list queries.
*
* Usage in any list component:
* use AppliesRoleScope;
* protected string $scopePermission = 'participants.list';
*
* Then in render():
* $query = Participant::query();
* $this->applyRoleScope($query);
*/
trait AppliesRoleScope
{
protected function applyRoleScope(Builder $query, ?string $permission = null): Builder
{
$user = auth()->user();
if (!$user) {
return $query->whereRaw('1 = 0');
}
if ($user->is_super_admin) {
return $query;
}
$perm = $permission ?? $this->scopePermission ?? null;
if (!$perm) {
return $query;
}
return app(PermissionService::class)->applyScope($query, $user, $perm);
}
}
......@@ -7,6 +7,7 @@
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Services\EnrollmentService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -17,7 +18,9 @@
#[Title('التسجيلات')]
class EnrollmentList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'enrollments.list';
#[Url]
public string $search = '';
......@@ -85,6 +88,8 @@ public function render()
->when($this->paymentFilter, fn ($q) => $q->where('payment_status', $this->paymentFilter))
->orderByDesc('enrollment_date');
$this->applyRoleScope($query);
return view('livewire.enrollments.enrollment-list', [
'enrollments' => $query->paginate(20),
'groups' => TrainingGroup::whereIn('status', ['forming', 'active', 'full'])->orderBy('name_ar')->get(['id', 'name_ar']),
......
......@@ -4,6 +4,7 @@
use App\Domain\Training\Enums\EvaluationStatus;
use App\Domain\Training\Models\Evaluation;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -14,7 +15,9 @@
#[Title('التقييمات')]
class EvaluationList extends Component
{
use WithPagination;
use WithPagination, AppliesRoleScope;
protected string $scopePermission = 'evaluations.list';
#[Url]
public string $search = '';
......@@ -54,6 +57,8 @@ public function render()
->when($this->groupId, fn ($q) => $q->where('training_group_id', $this->groupId))
->orderByDesc('evaluation_date');
$this->applyRoleScope($query);
$groups = \App\Domain\Training\Models\TrainingGroup::orderBy('name_ar')->get(['id', 'name_ar']);
return view('livewire.evaluations.evaluation-list', [
......
......@@ -9,6 +9,7 @@
use App\Domain\Identity\Models\Branch;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -19,7 +20,9 @@
#[Title('المنشآت')]
class FacilityList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'facilities.list';
#[Url]
public string $search = '';
......@@ -84,6 +87,8 @@ public function render()
->orderBy('sort_order')
->orderBy('name_ar');
$this->applyRoleScope($query);
return view('livewire.facilities.facility-list', [
'facilities' => $query->paginate(20),
'branches' => Branch::orderBy('name_ar')->get(['id', 'name_ar']),
......
......@@ -8,6 +8,7 @@
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Services\TrainingGroupService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Livewire\Concerns\AppliesRoleScope;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -19,7 +20,9 @@
#[Title('المجموعات التدريبية')]
class GroupList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'groups.list';
#[Url]
public string $search = '';
......@@ -110,6 +113,8 @@ public function render()
->when($this->programFilter, fn ($q) => $q->where('training_program_id', $this->programFilter))
->orderBy($this->sortBy, $this->sortDir);
$this->applyRoleScope($query);
return view('livewire.groups.group-list', [
'groups' => $query->paginate(15),
'programs' => TrainingProgram::orderBy('name_ar')->get(['id', 'name_ar']),
......
......@@ -6,6 +6,7 @@
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\KitService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -16,7 +17,9 @@
#[Title('الأطقم')]
class KitList extends Component
{
use WithPagination;
use WithPagination, AppliesRoleScope;
protected string $scopePermission = 'inventory.manage';
#[Url]
public string $search = '';
......@@ -138,6 +141,8 @@ public function render()
})
->orderByDesc('created_at');
$this->applyRoleScope($query);
return view('livewire.inventory.kit-list', [
'kits' => $query->paginate(20),
'warehouses' => Warehouse::orderBy('name_ar')->get(['id', 'name_ar']),
......
......@@ -5,6 +5,7 @@
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\ProductCategory;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -15,7 +16,9 @@
#[Title('المنتجات')]
class ProductList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'inventory.list';
#[Url]
public string $search = '';
......@@ -89,6 +92,8 @@ public function render()
})
->orderByDesc('created_at');
$this->applyRoleScope($query);
return view('livewire.inventory.product-list', [
'products' => $query->paginate(20),
'categories' => ProductCategory::active()->orderBy('name_ar')->get(['id', 'name_ar']),
......
......@@ -8,6 +8,7 @@
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -18,7 +19,9 @@
#[Title('أوامر الشراء')]
class PurchaseOrderList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'inventory.manage';
#[Url]
public string $search = '';
......@@ -98,6 +101,8 @@ public function render()
->when($this->status, fn ($q) => $q->where('status', $this->status))
->orderByDesc('created_at');
$this->applyRoleScope($query);
return view('livewire.inventory.purchase-order-list', [
'purchaseOrders' => $query->paginate(20),
'statuses' => PurchaseOrderStatus::cases(),
......
......@@ -7,6 +7,7 @@
use App\Domain\Inventory\Services\StockCountService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -17,7 +18,9 @@
#[Title('الجرد المخزني')]
class StockCountList extends Component
{
use WithPagination;
use WithPagination, AppliesRoleScope;
protected string $scopePermission = 'inventory.manage';
#[Url]
public string $search = '';
......@@ -85,6 +88,8 @@ public function render()
->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter))
->orderByDesc('created_at');
$this->applyRoleScope($query);
return view('livewire.inventory.stock-count-list', [
'stockCounts' => $query->paginate(20),
'statuses' => StockCountStatus::cases(),
......
......@@ -6,6 +6,7 @@
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -16,7 +17,9 @@
#[Title('الفواتير')]
class InvoiceList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'invoices.list';
#[Url]
public string $search = '';
......@@ -50,6 +53,8 @@ public function render()
->when($this->status, fn ($q) => $q->where('status', $this->status))
->orderByDesc('issue_date');
$this->applyRoleScope($query);
return view('livewire.invoices.invoice-list', [
'invoices' => $query->paginate(20),
'statusOptions' => collect(InvoiceStatus::cases())->mapWithKeys(
......
......@@ -5,6 +5,7 @@
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Activity;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -15,7 +16,9 @@
#[Title('المشتركين')]
class ParticipantList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'participants.list';
#[Url]
public string $search = '';
......@@ -69,6 +72,8 @@ public function render()
->when($this->skillLevel, fn ($q) => $q->where('skill_level', $this->skillLevel))
->orderByDesc('created_at');
$this->applyRoleScope($query);
return view('livewire.participants.participant-list', [
'participants' => $query->paginate(20),
'activities' => Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']),
......
......@@ -3,6 +3,7 @@
namespace App\Livewire\People;
use App\Domain\Identity\Models\Person;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -13,7 +14,9 @@
#[Title('السجل العام')]
class PersonList extends Component
{
use WithPagination;
use WithPagination, AppliesRoleScope;
protected string $scopePermission = 'people.list';
#[Url]
public string $search = '';
......@@ -55,6 +58,8 @@ public function render()
->when($this->classification, fn ($q) => $q->where('classification', $this->classification))
->orderBy('name_ar');
$this->applyRoleScope($query);
return view('livewire.people.person-list', [
'people' => $query->paginate(20),
]);
......
......@@ -8,6 +8,7 @@
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Services\TrainingProgramService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -18,7 +19,9 @@
#[Title('البرامج التدريبية')]
class ProgramList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'programs.list';
#[Url]
public string $search = '';
......@@ -100,6 +103,8 @@ public function render()
->when($this->activityFilter, fn ($q) => $q->where('activity_id', $this->activityFilter))
->orderBy($this->sortBy, $this->sortDir);
$this->applyRoleScope($query);
return view('livewire.programs.program-list', [
'programs' => $query->paginate(15),
'activities' => Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']),
......
......@@ -5,6 +5,7 @@
use App\Domain\Financial\Models\Wallet;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\AppliesRoleScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -15,7 +16,9 @@
#[Title('المحافظ')]
class WalletList extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'wallets.list';
#[Url]
public string $search = '';
......@@ -53,6 +56,8 @@ public function render()
})
->orderByDesc('balance');
$this->applyRoleScope($query);
return view('livewire.wallets.wallet-list', [
'wallets' => $query->paginate(20),
]);
......
......@@ -101,4 +101,23 @@ public function hasPermission(string $permission): bool
->flatMap(fn (Role $role) => $role->permissions)
->contains('name', $permission);
}
/**
* Resolve branch_id from role_user pivot (primary role's branch assignment).
* Falls back to session active_branch_id if no pivot branch set.
*/
public function getBranchIdAttribute(): ?int
{
// First: check role_user pivot for explicit branch assignment
$pivotBranch = $this->roles()
->wherePivotNotNull('branch_id')
->first()?->pivot?->branch_id;
if ($pivotBranch) {
return (int) $pivotBranch;
}
// Fallback: session-stored active branch (for branch switcher)
return session('active_branch_id') ? (int) session('active_branch_id') : null;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment