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:
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
158
tests/Feature/Staff/StaffEventScopeTest.php
Normal file
158
tests/Feature/Staff/StaffEventScopeTest.php
Normal 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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user