From c7afb701966b370e8fcfd9cb8865e165cf1161f2 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 1 Oct 2026 09:25:25 -0300 Subject: [PATCH] feat(staff): implement event scope for staff management; update controllers, services, and resources to handle event_id; add tests for event-based access --- .../AdminAppAdministratorController.php | 3 + .../Services/AdministratorService.php | 24 +-- .../Administrator/documentacion/README.md | 2 + .../Controllers/AdminAppStaffController.php | 6 +- .../Core/Staff/Resources/StaffResource.php | 1 + .../Core/Staff/Services/StaffService.php | 28 ++-- .../Core/Staff/documentacion/README.md | 2 + tests/Feature/Staff/StaffEventScopeTest.php | 158 ++++++++++++++++++ 8 files changed, 199 insertions(+), 25 deletions(-) create mode 100644 tests/Feature/Staff/StaffEventScopeTest.php diff --git a/app/Domains/Core/Administrator/Controllers/AdminAppAdministratorController.php b/app/Domains/Core/Administrator/Controllers/AdminAppAdministratorController.php index 4c3b6e80..b98e866e 100644 --- a/app/Domains/Core/Administrator/Controllers/AdminAppAdministratorController.php +++ b/app/Domains/Core/Administrator/Controllers/AdminAppAdministratorController.php @@ -20,6 +20,7 @@ class AdminAppAdministratorController extends Controller return AdministratorResource::collection($this->administratorService->list( $request->user()->tenant()->firstOrFail(), $request->string('search')->trim()->toString() ?: null, + $request->user()->event_id, )); } @@ -28,6 +29,7 @@ class AdminAppAdministratorController extends Controller return AdministratorResource::make($this->administratorService->create( $request->user()->tenant()->firstOrFail(), $request->validated(), + $request->user()->event_id, )); } @@ -37,6 +39,7 @@ class AdminAppAdministratorController extends Controller $request->user()->tenant()->firstOrFail(), $administrator, $request->validated(), + $request->user()->event_id, )); } diff --git a/app/Domains/Core/Administrator/Services/AdministratorService.php b/app/Domains/Core/Administrator/Services/AdministratorService.php index e0a786bf..043a2674 100644 --- a/app/Domains/Core/Administrator/Services/AdministratorService.php +++ b/app/Domains/Core/Administrator/Services/AdministratorService.php @@ -19,9 +19,9 @@ class AdministratorService public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {} /** @return Collection */ - public function list(Tenant $tenant, ?string $search = null): Collection + public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection { - return $this->query($tenant)->with('role') + return $this->query($tenant, $eventId)->with('role') ->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void { $query->where('nombre_apellido', 'like', "%{$search}%") ->orWhere('dni', 'like', "%{$search}%") @@ -31,14 +31,15 @@ class AdministratorService } /** @param array $data */ - public function create(Tenant $tenant, array $data): User + public function create(Tenant $tenant, array $data, ?int $eventId = null): User { - return DB::transaction(function () use ($tenant, $data): User { + return DB::transaction(function () use ($tenant, $data, $eventId): User { $administrator = User::query()->create([ ...$this->attributes($data), 'password' => Str::random(64), 'rol_codigo' => RoleCode::AdminApp->value, 'tenant_codigo' => $tenant->codigo, + 'event_id' => $eventId, ]); $this->resetPasswordAttemptService->createForAdminAppEmail( $administrator->email, @@ -50,10 +51,10 @@ class AdministratorService } /** @param array $data */ - public function update(Tenant $tenant, int $administratorId, array $data): User + public function update(Tenant $tenant, int $administratorId, array $data, ?int $eventId = null): User { - return DB::transaction(function () use ($tenant, $administratorId, $data): User { - $administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId); + return DB::transaction(function () use ($tenant, $administratorId, $data, $eventId): User { + $administrator = $this->query($tenant, $eventId)->lockForUpdate()->findOrFail($administratorId); $administrator->update($this->attributes($data)); return $administrator->load('role'); @@ -66,11 +67,11 @@ class AdministratorService // Serialize deletions for this tenant, including requests already authenticated // when another administrator removes their account. Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail(); - $administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId); + $administrator = $this->query($tenant, $actor->event_id)->lockForUpdate()->findOrFail($administratorId); if ($administrator->is($actor)) { throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']); } - $activeAdministrators = $this->query($tenant)->lockForUpdate()->get(); + $activeAdministrators = $this->query($tenant, $actor->event_id)->lockForUpdate()->get(); if ($activeAdministrators->count() <= 1) { throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']); } @@ -80,10 +81,11 @@ class AdministratorService }); } - private function query(Tenant $tenant): Builder + private function query(Tenant $tenant, ?int $eventId = null): Builder { return User::query()->where('tenant_codigo', $tenant->codigo) - ->where('rol_codigo', RoleCode::AdminApp->value); + ->where('rol_codigo', RoleCode::AdminApp->value) + ->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId)); } /** @param array $data diff --git a/app/Domains/Core/Administrator/documentacion/README.md b/app/Domains/Core/Administrator/documentacion/README.md index ba506358..8c3ec561 100644 --- a/app/Domains/Core/Administrator/documentacion/README.md +++ b/app/Domains/Core/Administrator/documentacion/README.md @@ -55,6 +55,8 @@ No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el fronten ## Verificación +Cuando el actor tiene `event_id`, los nuevos administradores heredan su evento y el listado, la búsqueda, la edición y la baja se limitan a ese evento dentro del tenant. La comprobación de administradores activos también usa ese alcance. El evento se toma del usuario autenticado, no del cuerpo de la solicitud; sin `event_id` se conserva el comportamiento por tenant. + `php artisan test tests/Feature/Administrator/AdministratorControllerTest.php` Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de diff --git a/app/Domains/Core/Staff/Controllers/AdminAppStaffController.php b/app/Domains/Core/Staff/Controllers/AdminAppStaffController.php index 1f1d69cb..d4ef1a45 100644 --- a/app/Domains/Core/Staff/Controllers/AdminAppStaffController.php +++ b/app/Domains/Core/Staff/Controllers/AdminAppStaffController.php @@ -26,6 +26,7 @@ class AdminAppStaffController extends Controller return StaffResource::collection($this->staffService->list( $request->user()->tenant()->firstOrFail(), $request->string('search')->trim()->toString() ?: null, + $request->user()->event_id, )); } @@ -34,6 +35,7 @@ class AdminAppStaffController extends Controller return StaffResource::make($this->staffService->create( $request->user()->tenant()->firstOrFail(), $request->validated(), + $request->user()->event_id, )); } @@ -43,12 +45,13 @@ class AdminAppStaffController extends Controller $request->user()->tenant()->firstOrFail(), $staff, $request->validated(), + $request->user()->event_id, )); } public function destroy(Request $request, int $staff): Response { - $this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff); + $this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff, $request->user()->event_id); return response()->noContent(); } @@ -60,6 +63,7 @@ class AdminAppStaffController extends Controller $scanner = $this->staffService->find( $request->user()->tenant()->firstOrFail(), $staff, + $request->user()->event_id, ); return ScanAttemptResource::collection( diff --git a/app/Domains/Core/Staff/Resources/StaffResource.php b/app/Domains/Core/Staff/Resources/StaffResource.php index 5892a514..0e758799 100644 --- a/app/Domains/Core/Staff/Resources/StaffResource.php +++ b/app/Domains/Core/Staff/Resources/StaffResource.php @@ -18,6 +18,7 @@ class StaffResource extends JsonResource 'dni' => $this->dni, 'email' => $this->email, 'rol_codigo' => $this->rol_codigo, + 'event_id' => $this->event_id, 'role' => $this->whenLoaded('role', fn () => [ 'codigo' => $this->role?->codigo, 'nombre' => $this->role?->nombre, diff --git a/app/Domains/Core/Staff/Services/StaffService.php b/app/Domains/Core/Staff/Services/StaffService.php index 89ca950b..7a7d624b 100644 --- a/app/Domains/Core/Staff/Services/StaffService.php +++ b/app/Domains/Core/Staff/Services/StaffService.php @@ -2,11 +2,11 @@ namespace App\Domains\Core\Staff\Services; +use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Core\Auth\Models\ResetPasswordAttempt; use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Services\ResetPasswordAttemptService; use App\Domains\Core\Authorization\Enums\RoleCode; -use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Core\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -22,9 +22,9 @@ class StaffService ) {} /** @return Collection */ - public function list(Tenant $tenant, ?string $search = null): Collection + public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection { - return $this->staffQuery($tenant) + return $this->staffQuery($tenant, $eventId) ->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')]) ->when($search, function (Builder $query, string $search): void { $query->where(function (Builder $query) use ($search): void { @@ -52,18 +52,19 @@ class StaffService } /** @param array $data */ - public function create(Tenant $tenant, array $data): User + public function create(Tenant $tenant, array $data, ?int $eventId = null): User { $categoryIds = $this->categoryIdsFor($tenant, $data); $this->assertCategoriesBelongToTenant($tenant, $categoryIds); - return DB::transaction(function () use ($tenant, $data, $categoryIds): User { + return DB::transaction(function () use ($tenant, $data, $categoryIds, $eventId): User { $staff = User::query()->create([ ...Arr::only($data, ['nombre_apellido', 'dni', 'email']), 'email' => mb_strtolower(trim((string) $data['email'])), 'password' => Str::random(64), 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $tenant->codigo, + 'event_id' => $eventId, ]); $staff->scanCategories()->sync($categoryIds); $this->resetPasswordAttemptService->createForScannerEmail( @@ -76,9 +77,9 @@ class StaffService } /** @param array $data */ - public function update(Tenant $tenant, int $staffId, array $data): User + public function update(Tenant $tenant, int $staffId, array $data, ?int $eventId = null): User { - $staff = $this->find($tenant, $staffId); + $staff = $this->find($tenant, $staffId, $eventId); $categoryIds = $this->categoryIdsFor($tenant, $data); $this->assertCategoriesBelongToTenant($tenant, $categoryIds); @@ -92,9 +93,9 @@ class StaffService }); } - public function delete(Tenant $tenant, int $staffId): void + public function delete(Tenant $tenant, int $staffId, ?int $eventId = null): void { - $staff = $this->find($tenant, $staffId); + $staff = $this->find($tenant, $staffId, $eventId); DB::transaction(function () use ($staff): void { $staff->tokens()->delete(); @@ -102,16 +103,17 @@ class StaffService }); } - public function find(Tenant $tenant, int $staffId): User + public function find(Tenant $tenant, int $staffId, ?int $eventId = null): User { - return $this->staffQuery($tenant)->findOrFail($staffId); + return $this->staffQuery($tenant, $eventId)->findOrFail($staffId); } - private function staffQuery(Tenant $tenant): Builder + private function staffQuery(Tenant $tenant, ?int $eventId = null): Builder { return User::query() ->where('tenant_codigo', $tenant->codigo) - ->where('rol_codigo', RoleCode::Scanner->value); + ->where('rol_codigo', RoleCode::Scanner->value) + ->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId)); } /** diff --git a/app/Domains/Core/Staff/documentacion/README.md b/app/Domains/Core/Staff/documentacion/README.md index f72d4a63..49539c01 100644 --- a/app/Domains/Core/Staff/documentacion/README.md +++ b/app/Domains/Core/Staff/documentacion/README.md @@ -18,3 +18,5 @@ Recurso REST `/v1/adminapp/tenant/staff`, excepto detalle individual, protegido ## Dependencias y reglas Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado. + +Si el administrador autenticado tiene `event_id`, el alta de scanners hereda ese valor y las búsquedas, ediciones, bajas y consultas de intentos de escaneo se limitan a personal del mismo evento. El cliente no puede elegir ni cambiar el evento. Sin `event_id`, se mantiene el alcance por tenant. diff --git a/tests/Feature/Staff/StaffEventScopeTest.php b/tests/Feature/Staff/StaffEventScopeTest.php new file mode 100644 index 00000000..2359144c --- /dev/null +++ b/tests/Feature/Staff/StaffEventScopeTest.php @@ -0,0 +1,158 @@ +id(); + $table->string('codigo'); + $table->boolean('scanner_category_validation_enabled')->default(false); + }); + Schema::create('roles', function (Blueprint $table): void { + $table->id(); + $table->string('codigo'); + $table->string('nombre'); + }); + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->string('rol_codigo'); + $table->string('tenant_codigo'); + $table->unsignedBigInteger('event_id')->nullable(); + $table->string('nombre_apellido'); + $table->string('dni'); + $table->string('email'); + $table->string('active_email')->nullable(); + $table->string('password')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Schema::create('categorias', function (Blueprint $table): void { + $table->id(); + $table->string('nombre'); + $table->string('tenant_code')->nullable(); + $table->unsignedBigInteger('categoria_id')->nullable(); + }); + Schema::create('catalog_items', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('category_id'); + $table->string('tenant_code'); + $table->softDeletes(); + }); + Schema::create('category_scanners', function (Blueprint $table): void { + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('categoria_id'); + $table->timestamps(); + }); + Schema::create('personal_access_tokens', function (Blueprint $table): void { + $table->id(); + $table->string('tokenable_type'); + $table->unsignedBigInteger('tokenable_id'); + }); + + DB::table('tenants')->insert(['codigo' => 'onticket']); + foreach (['adminapp', 'scanner'] as $role) { + DB::table('roles')->insert(['codigo' => $role, 'nombre' => $role]); + foreach ([10, 20, null] as $eventId) { + $this->insertUser($role, 'onticket', $eventId); + } + $this->insertUser($role, 'other', 10); + } + Sanctum::actingAs(User::query()->findOrFail(1)); + } + + public function test_lists_and_searches_are_restricted_to_the_authenticated_event(): void + { + foreach (['administrators' => 1, 'staff' => 5] as $path => $id) { + foreach (['', '?search=Persona&event_id=20'] as $query) { + $this->getJson("/api/v1/adminapp/tenant/{$path}{$query}") + ->assertOk()->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $id)->assertJsonPath('data.0.event_id', 10); + } + } + } + + public function test_creation_inherits_the_actor_event_even_if_the_client_supplies_another(): void + { + $this->mock(ResetPasswordAttemptService::class, function (MockInterface $mock): void { + $mock->shouldReceive('createForAdminAppEmail')->once(); + $mock->shouldReceive('createForScannerEmail')->once(); + }); + foreach (['administrators' => 'adminapp', 'staff' => 'scanner'] as $path => $role) { + $this->postJson("/api/v1/adminapp/tenant/{$path}", [ + ...$this->payload("new-{$role}@example.com"), 'event_id' => 20, + ])->assertSuccessful()->assertJsonPath('data.event_id', 10); + $this->assertDatabaseHas('users', ['email' => "new-{$role}@example.com", 'event_id' => 10, 'rol_codigo' => $role]); + } + } + + public function test_foreign_and_unassigned_staff_cannot_be_edited_deleted_or_inspected(): void + { + foreach (['administrators' => [2, 3, 4], 'staff' => [6, 7, 8]] as $path => $ids) { + foreach ($ids as $id) { + $this->putJson("/api/v1/adminapp/tenant/{$path}/{$id}", $this->payload("update-{$id}@example.com"))->assertNotFound(); + $this->deleteJson("/api/v1/adminapp/tenant/{$path}/{$id}")->assertNotFound(); + $this->assertDatabaseHas('users', ['id' => $id, 'deleted_at' => null, 'email' => "person-{$id}@example.com"]); + if ($path === 'staff') { + $this->getJson("/api/v1/adminapp/tenant/staff/{$id}/scan-attempts")->assertNotFound(); + } + } + } + } + + public function test_own_staff_can_be_edited_and_deleted_without_changing_its_event(): void + { + $adminId = $this->insertUser('adminapp', 'onticket', 10); + foreach (['administrators' => $adminId, 'staff' => 5] as $path => $id) { + $this->putJson("/api/v1/adminapp/tenant/{$path}/{$id}", [ + ...$this->payload("updated-{$id}@example.com"), 'event_id' => 20, + ])->assertOk()->assertJsonPath('data.event_id', 10); + $this->deleteJson("/api/v1/adminapp/tenant/{$path}/{$id}")->assertNoContent(); + $this->assertSoftDeleted('users', ['id' => $id]); + } + } + + public function test_unscoped_actor_keeps_tenant_lists_and_creates_without_an_event(): void + { + Sanctum::actingAs(User::query()->findOrFail(3)); + $this->mock(ResetPasswordAttemptService::class, function (MockInterface $mock): void { + $mock->shouldReceive('createForAdminAppEmail')->once(); + $mock->shouldReceive('createForScannerEmail')->once(); + }); + foreach (['administrators', 'staff'] as $path) { + $this->getJson("/api/v1/adminapp/tenant/{$path}")->assertOk()->assertJsonCount(3, 'data'); + $this->postJson("/api/v1/adminapp/tenant/{$path}", $this->payload("legacy-{$path}@example.com")) + ->assertSuccessful()->assertJsonPath('data.event_id', null); + } + } + + private function insertUser(string $role, string $tenant, ?int $eventId): int + { + $id = DB::table('users')->count() + 1; + + return DB::table('users')->insertGetId([ + 'id' => $id, 'rol_codigo' => $role, 'tenant_codigo' => $tenant, + 'event_id' => $eventId, 'nombre_apellido' => 'Persona', 'dni' => '12345678', + 'email' => "person-{$id}@example.com", 'active_email' => "person-{$id}@example.com", + ]); + } + + private function payload(string $email): array + { + return ['nombre_apellido' => 'Persona editada', 'dni' => '87654321', 'email' => $email]; + } +}