diff --git a/app/Domains/Administrator/Controllers/AdminAppAdministratorController.php b/app/Domains/Administrator/Controllers/AdminAppAdministratorController.php new file mode 100644 index 0000000..5329c12 --- /dev/null +++ b/app/Domains/Administrator/Controllers/AdminAppAdministratorController.php @@ -0,0 +1,49 @@ +administratorService->list( + $request->user()->tenant()->firstOrFail(), + $request->string('search')->trim()->toString() ?: null, + )); + } + + public function store(StoreAdministratorRequest $request): AdministratorResource + { + return AdministratorResource::make($this->administratorService->create( + $request->user()->tenant()->firstOrFail(), + $request->validated(), + )); + } + + public function update(UpdateAdministratorRequest $request, int $administrator): AdministratorResource + { + return AdministratorResource::make($this->administratorService->update( + $request->user()->tenant()->firstOrFail(), + $administrator, + $request->validated(), + )); + } + + public function destroy(Request $request, int $administrator): Response + { + $this->administratorService->delete($request->user()->tenant()->firstOrFail(), $administrator, $request->user()); + + return response()->noContent(); + } +} diff --git a/app/Domains/Administrator/Requests/StoreAdministratorRequest.php b/app/Domains/Administrator/Requests/StoreAdministratorRequest.php new file mode 100644 index 0000000..529a181 --- /dev/null +++ b/app/Domains/Administrator/Requests/StoreAdministratorRequest.php @@ -0,0 +1,38 @@ +user()?->rol_codigo === RoleCode::AdminApp->value; + } + + protected function prepareForValidation(): void + { + if (is_string($this->input('email'))) { + $this->merge(['email' => mb_strtolower(trim($this->input('email')))]); + } + } + + /** @return array */ + public function rules(): array + { + + return [ + 'nombre_apellido' => ['required', 'string', 'max:255'], + 'dni' => ['required', 'string', 'max:50'], + 'email' => [ + 'required', + 'email', + 'max:255', + Rule::unique('users', 'email')->whereNull('deleted_at'), + ], + ]; + } +} diff --git a/app/Domains/Administrator/Requests/UpdateAdministratorRequest.php b/app/Domains/Administrator/Requests/UpdateAdministratorRequest.php new file mode 100644 index 0000000..6750789 --- /dev/null +++ b/app/Domains/Administrator/Requests/UpdateAdministratorRequest.php @@ -0,0 +1,41 @@ +user()?->rol_codigo === RoleCode::AdminApp->value; + } + + protected function prepareForValidation(): void + { + if (is_string($this->input('email'))) { + $this->merge(['email' => mb_strtolower(trim($this->input('email')))]); + } + } + + /** @return array */ + public function rules(): array + { + $administratorId = (int) $this->route('administrator'); + + return [ + 'nombre_apellido' => ['required', 'string', 'max:255'], + 'dni' => ['required', 'string', 'max:50'], + 'email' => [ + 'required', + 'email', + 'max:255', + Rule::unique('users', 'email') + ->whereNull('deleted_at') + ->ignore($administratorId), + ], + ]; + } +} diff --git a/app/Domains/Administrator/Resources/AdministratorResource.php b/app/Domains/Administrator/Resources/AdministratorResource.php new file mode 100644 index 0000000..d8879d0 --- /dev/null +++ b/app/Domains/Administrator/Resources/AdministratorResource.php @@ -0,0 +1,27 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'nombre_apellido' => $this->nombre_apellido, + 'dni' => $this->dni, + 'email' => $this->email, + 'rol_codigo' => $this->rol_codigo, + 'role' => $this->whenLoaded('role', fn () => [ + 'codigo' => $this->role?->codigo, + 'nombre' => $this->role?->nombre, + ]), + ]; + } +} diff --git a/app/Domains/Administrator/Services/AdministratorService.php b/app/Domains/Administrator/Services/AdministratorService.php new file mode 100644 index 0000000..932b438 --- /dev/null +++ b/app/Domains/Administrator/Services/AdministratorService.php @@ -0,0 +1,99 @@ + */ + public function list(Tenant $tenant, ?string $search = null): Collection + { + return $this->query($tenant)->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}%") + ->orWhere('email', 'like', "%{$search}%"); + })) + ->orderBy('nombre_apellido')->get(); + } + + /** @param array $data */ + public function create(Tenant $tenant, array $data): User + { + return DB::transaction(function () use ($tenant, $data): User { + $administrator = User::query()->create([ + ...$this->attributes($data), + 'password' => Str::random(64), + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + $this->resetPasswordAttemptService->createForAdminAppEmail( + $administrator->email, + ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED, + ); + + return $administrator->load('role'); + }); + } + + /** @param array $data */ + public function update(Tenant $tenant, int $administratorId, array $data): User + { + return DB::transaction(function () use ($tenant, $administratorId, $data): User { + $administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId); + $administrator->update($this->attributes($data)); + + return $administrator->load('role'); + }); + } + + public function delete(Tenant $tenant, int $administratorId, User $actor): void + { + DB::transaction(function () use ($tenant, $administratorId, $actor): void { + // 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); + if ($administrator->is($actor)) { + throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']); + } + $activeAdministrators = $this->query($tenant)->lockForUpdate()->get(); + if ($activeAdministrators->count() <= 1) { + throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']); + } + abort_unless($activeAdministrators->contains('id', $actor->id), 403); + $administrator->tokens()->delete(); + $administrator->delete(); + }); + } + + private function query(Tenant $tenant): Builder + { + return User::query()->where('tenant_codigo', $tenant->codigo) + ->where('rol_codigo', RoleCode::AdminApp->value); + } + + /** @param array $data + * @return array + */ + private function attributes(array $data): array + { + return [ + ...Arr::only($data, ['nombre_apellido', 'dni']), + 'email' => mb_strtolower(trim((string) $data['email'])), + ]; + } +} diff --git a/app/Domains/Administrator/documentacion/README.md b/app/Domains/Administrator/documentacion/README.md new file mode 100644 index 0000000..ba50635 --- /dev/null +++ b/app/Domains/Administrator/documentacion/README.md @@ -0,0 +1,63 @@ +# Administradores de AdminApp + +CRUD de usuarios con rol `adminapp`, limitado al tenant del usuario autenticado. +Todos los administradores del tenant pueden gestionar esta sección. + +## Endpoints + +Base: `/api/v1/adminapp/tenant/administrators`. +Requieren `auth:sanctum` y `adminapp.tenant`. + +- `GET /`: listado ordenado por nombre; acepta `search` por nombre, DNI o email. +- `POST /`: alta; responde `201` con `data`. +- `PUT /{administrator}`: actualización de los tres campos; responde `200` con `data`. +- `DELETE /{administrator}`: baja lógica; responde `204`. + +Alta y actualización reciben: + +```json +{ + "nombre_apellido": "Ada Lovelace", + "dni": "12345678", + "email": "ada@example.test" +} +``` + +Nombre (hasta 255 caracteres), DNI (hasta 50) y email (hasta 255) son obligatorios. +El email se normaliza a minúsculas antes de validar y debe ser único entre +usuarios activos, sin importar su tenant o rol. Se permite reutilizar el email +de un usuario eliminado. Rol y tenant no son editables desde esta API. + +Las respuestas incluyen `id`, `nombre_apellido`, `dni`, `email`, `rol_codigo` +y `role` (`codigo`, `nombre`). Nunca incluyen contraseña ni datos de escaneo. + +## Alta y acceso + +Se genera una contraseña aleatoria y un intento de establecimiento de contraseña +con motivo `administrator_created`, reutilizando `createForAdminAppEmail`. +El evento usa el canal `adminapp`; el listener existente envía el email después +del commit mediante la cola `emails`. Requiere la configuración de correo, +dominio AdminApp y worker existentes. No se envían contraseñas en texto plano. + +## Eliminación y aislamiento + +Las consultas de usuarios se limitan por tenant y rol `adminapp`. IDs ajenos, +usuarios eliminados y usuarios de otros roles devuelven `404`. +La validación de campos devuelve `422`; falta de autenticación, `401`, y rol +no autorizado, `403`. + +No se permite eliminar al propio usuario ni dejar al tenant sin administradores +(`422`, error `administrator`). La eliminación bloquea la fila del tenant dentro +de una transacción para serializar bajas concurrentes. También verifica que el +actor siga activo, revoca tokens y aplica el borrado lógico existente en `users`. + +No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el frontend. + +## Verificación + +`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php` + +Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de +contraseña, restricciones de rol y tenant, baja lógica, tokens y protecciones de +eliminación. El caso de petición autenticada antes de la baja del actor se simula; +no es una prueba con conexiones concurrentes reales. diff --git a/app/Domains/Administrator/routes/api.php b/app/Domains/Administrator/routes/api.php new file mode 100644 index 0000000..428be5d --- /dev/null +++ b/app/Domains/Administrator/routes/api.php @@ -0,0 +1,10 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::apiResource('administrators', AdminAppAdministratorController::class)->except('show'); + }); diff --git a/app/Domains/Auth/Models/ResetPasswordAttempt.php b/app/Domains/Auth/Models/ResetPasswordAttempt.php index d298def..98ba329 100644 --- a/app/Domains/Auth/Models/ResetPasswordAttempt.php +++ b/app/Domains/Auth/Models/ResetPasswordAttempt.php @@ -17,6 +17,8 @@ class ResetPasswordAttempt extends Model public const REASON_STAFF_CREATED = 'staff_created'; + public const REASON_ADMINISTRATOR_CREATED = 'administrator_created'; + public const STATUS_PENDING = 'pending'; public const STATUS_VALIDATED = 'validated'; diff --git a/routes/api.php b/routes/api.php index 61a93b5..57ebc2b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -16,5 +16,6 @@ require __DIR__.'/../app/Domains/Ticket/routes/api.php'; require __DIR__.'/../app/Domains/Event/routes/api.php'; require __DIR__.'/../app/Domains/Forms/routes/api.php'; require __DIR__.'/../app/Domains/Staff/routes/api.php'; +require __DIR__.'/../app/Domains/Administrator/routes/api.php'; require __DIR__.'/../app/Domains/FiestaFutbolInfantil/routes/api.php'; require __DIR__.'/../app/Domains/Desfile/routes/api.php'; diff --git a/tests/Feature/Administrator/AdministratorControllerTest.php b/tests/Feature/Administrator/AdministratorControllerTest.php new file mode 100644 index 0000000..ddd160e --- /dev/null +++ b/tests/Feature/Administrator/AdministratorControllerTest.php @@ -0,0 +1,148 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + $headerLogo = $this->createAttachment('header.png'); + $footerLogo = $this->createAttachment('footer.png'); + $this->tenant = Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + 'website_type_code' => 'onticket', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#cc0000', + 'success_color' => '#008800', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + $this->admin = User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + } + + private const URL = '/api/v1/adminapp/tenant/administrators'; + + private function payload(): array + { + return ['nombre_apellido' => 'Ada Lovelace', 'dni' => '12345678', 'email' => 'ada@example.test']; + } + + public function test_crud_and_password_setup_and_token_revocation(): void + { + Sanctum::actingAs($this->admin); + $response = $this->postJson(self::URL, [...$this->payload(), 'email' => ' ADA@example.test ', 'rol_codigo' => 'admin', 'tenant_codigo' => 'other']) + ->assertCreated()->assertJsonPath('data.email', 'ada@example.test') + ->assertJsonPath('data.rol_codigo', 'adminapp')->assertJsonMissingPath('data.password'); + $id = $response->json('data.id'); + $this->assertDatabaseHas('users', ['id' => $id, 'tenant_codigo' => $this->tenant->codigo, 'rol_codigo' => 'adminapp']); + $this->assertDatabaseHas('reset_password_attempts', ['user_id' => $id, 'reason' => ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED, 'status' => ResetPasswordAttempt::STATUS_PENDING]); + Event::assertDispatched(PasswordResetRequested::class, fn ($event) => $event->channel === PasswordResetRequested::CHANNEL_ADMINAPP && $event->tenantCode === $this->tenant->codigo); + $this->getJson(self::URL.'?search=Ada')->assertOk()->assertJsonCount(1, 'data'); + $this->putJson(self::URL."/{$id}", [...$this->payload(), 'nombre_apellido' => 'Ada Byron', 'rol_codigo' => 'scanner']) + ->assertOk()->assertJsonPath('data.nombre_apellido', 'Ada Byron')->assertJsonPath('data.rol_codigo', 'adminapp'); + $token = User::findOrFail($id)->createToken('adminapp')->accessToken; + $this->deleteJson(self::URL."/{$id}")->assertNoContent(); + $this->assertSoftDeleted('users', ['id' => $id]); + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $token->id]); + $this->getJson(self::URL.'?search=Ada')->assertOk()->assertJsonCount(0, 'data'); + $this->postJson(self::URL, $this->payload())->assertCreated(); + } + + public function test_validation_and_case_insensitive_active_email_uniqueness(): void + { + Sanctum::actingAs($this->admin); + $this->postJson(self::URL, [])->assertUnprocessable()->assertJsonValidationErrors(['nombre_apellido', 'dni', 'email']); + $this->postJson(self::URL, [...$this->payload(), 'email' => 'invalid'])->assertUnprocessable()->assertJsonValidationErrors('email'); + $this->postJson(self::URL, [...$this->payload(), 'email' => strtoupper($this->admin->email)])->assertUnprocessable()->assertJsonValidationErrors('email'); + $target = User::factory()->create(['rol_codigo' => 'adminapp', 'tenant_codigo' => $this->tenant->codigo]); + $this->putJson(self::URL."/{$target->id}", [...$this->payload(), 'email' => strtoupper($this->admin->email)])->assertUnprocessable()->assertJsonValidationErrors('email'); + } + + public function test_other_tenants_and_roles_are_excluded(): void + { + Sanctum::actingAs($this->admin); + $otherTenant = $this->tenant->replicate(); + $otherTenant->codigo = 'other'; + $otherTenant->dominio = 'other.test'; + $otherTenant->save(); + $targets = [ + User::factory()->create(['rol_codigo' => 'adminapp', 'tenant_codigo' => $otherTenant->codigo]), + User::factory()->create(['rol_codigo' => 'scanner', 'tenant_codigo' => $this->tenant->codigo]), + User::factory()->create(['rol_codigo' => 'admin', 'tenant_codigo' => $this->tenant->codigo]), + ]; + $this->getJson(self::URL)->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $this->admin->id); + foreach ($targets as $target) { + $this->putJson(self::URL."/{$target->id}", $this->payload())->assertNotFound(); + $this->deleteJson(self::URL."/{$target->id}")->assertNotFound(); + $this->assertNotSoftDeleted($target); + } + } + + public function test_cannot_delete_self_even_with_another_administrator(): void + { + Sanctum::actingAs($this->admin); + $this->deleteJson(self::URL."/{$this->admin->id}")->assertUnprocessable()->assertJsonValidationErrors('administrator'); + User::factory()->create(['rol_codigo' => 'adminapp', 'tenant_codigo' => $this->tenant->codigo]); + $this->deleteJson(self::URL."/{$this->admin->id}")->assertUnprocessable(); + $this->assertNotSoftDeleted($this->admin); + } + + public function test_in_flight_request_from_deleted_actor_cannot_remove_last_administrator(): void + { + $remaining = User::factory()->create(['rol_codigo' => 'adminapp', 'tenant_codigo' => $this->tenant->codigo]); + $this->admin->delete(); + Sanctum::actingAs($this->admin); + $this->deleteJson(self::URL."/{$remaining->id}")->assertUnprocessable()->assertJsonValidationErrors('administrator'); + $this->assertNotSoftDeleted($remaining); + } + + public function test_authentication_and_role_are_required_for_all_operations(): void + { + $this->getJson(self::URL)->assertUnauthorized(); + foreach (['user', 'scanner', 'admin'] as $role) { + Sanctum::actingAs(User::factory()->create(['rol_codigo' => $role, 'tenant_codigo' => $this->tenant->codigo])); + $this->getJson(self::URL)->assertForbidden(); + $this->postJson(self::URL, $this->payload())->assertForbidden(); + $this->putJson(self::URL."/{$this->admin->id}", $this->payload())->assertForbidden(); + $this->deleteJson(self::URL."/{$this->admin->id}")->assertForbidden(); + } + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create(['path' => "test/{$filename}", 'filename' => $filename, 'type' => AttachmentType::Image, 'mime_type' => 'image/png']); + } +}