feat(staff): implement event scope for staff management; update controllers, services, and resources to handle event_id; add tests for event-based access

This commit is contained in:
2026-10-01 09:25:25 -03:00
parent 31955f862d
commit c7afb70196
8 changed files with 199 additions and 25 deletions

View File

@@ -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,
));
}

View File

@@ -19,9 +19,9 @@ class AdministratorService
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
/** @return Collection<int, User> */
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<string, mixed> $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<string, mixed> $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<string, mixed> $data

View File

@@ -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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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<int, User> */
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<string, mixed> $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<string, mixed> $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));
}
/**

View File

@@ -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.

View File

@@ -0,0 +1,158 @@
<?php
namespace Tests\Feature\Staff;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\ResetPasswordAttemptService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Laravel\Sanctum\Sanctum;
use Mockery\MockInterface;
use Tests\TestCase;
class StaffEventScopeTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Exercise HTTP authorization on SQLite without the incompatible legacy migrations.
Schema::create('tenants', function (Blueprint $table): void {
$table->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];
}
}