From 2a15dc92be87b6f3eea293142e1899fff94e0d6a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 4 Aug 2026 16:20:30 -0300 Subject: [PATCH] feat: Implement staff management features; add controller, service, resource, routes, and tests for staff and category assignments --- app/Domains/Auth/Models/User.php | 13 ++ app/Domains/Authorization/Enums/RoleCode.php | 1 + app/Domains/Catalog/Models/Category.php | 13 ++ .../AdminApp/StaffFormController.php | 22 +++ .../Forms/Resources/StaffFormResource.php | 20 +++ .../Forms/Services/StaffFormService.php | 27 ++++ app/Domains/Forms/routes/adminapp.php | 2 + .../Controllers/AdminAppStaffController.php | 49 +++++++ .../Staff/Requests/StoreStaffRequest.php | 26 ++++ .../Staff/Requests/UpdateStaffRequest.php | 33 +++++ app/Domains/Staff/Resources/StaffResource.php | 32 ++++ app/Domains/Staff/Services/StaffService.php | 113 ++++++++++++++ app/Domains/Staff/routes/api.php | 10 ++ ...0300_create_staff_category_assignments.php | 25 ++++ database/seeders/AuthorizationSeeder.php | 9 ++ routes/api.php | 1 + .../Forms/AdminAppStaffFormControllerTest.php | 84 +++++++++++ tests/Feature/Staff/StaffControllerTest.php | 138 ++++++++++++++++++ 18 files changed, 618 insertions(+) create mode 100644 app/Domains/Forms/Controllers/AdminApp/StaffFormController.php create mode 100644 app/Domains/Forms/Resources/StaffFormResource.php create mode 100644 app/Domains/Forms/Services/StaffFormService.php create mode 100644 app/Domains/Staff/Controllers/AdminAppStaffController.php create mode 100644 app/Domains/Staff/Requests/StoreStaffRequest.php create mode 100644 app/Domains/Staff/Requests/UpdateStaffRequest.php create mode 100644 app/Domains/Staff/Resources/StaffResource.php create mode 100644 app/Domains/Staff/Services/StaffService.php create mode 100644 app/Domains/Staff/routes/api.php create mode 100644 database/migrations/2026_08_04_000300_create_staff_category_assignments.php create mode 100644 tests/Feature/Forms/AdminAppStaffFormControllerTest.php create mode 100644 tests/Feature/Staff/StaffControllerTest.php diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index b360049..d502e04 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -4,12 +4,14 @@ namespace App\Domains\Auth\Models; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Models\Role; +use App\Domains\Catalog\Models\Category; use App\Domains\Tenant\Models\Tenant; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -59,6 +61,17 @@ class User extends Authenticatable return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); } + /** @return BelongsToMany */ + public function scanCategories(): BelongsToMany + { + return $this->belongsToMany( + Category::class, + 'category_scanners', + 'user_id', + 'categoria_id', + )->withTimestamps(); + } + /** * @return array */ diff --git a/app/Domains/Authorization/Enums/RoleCode.php b/app/Domains/Authorization/Enums/RoleCode.php index 373b27b..305b297 100644 --- a/app/Domains/Authorization/Enums/RoleCode.php +++ b/app/Domains/Authorization/Enums/RoleCode.php @@ -6,5 +6,6 @@ enum RoleCode: string { case Admin = 'admin'; case AdminApp = 'adminapp'; + case Scanner = 'scanner'; case User = 'user'; } diff --git a/app/Domains/Catalog/Models/Category.php b/app/Domains/Catalog/Models/Category.php index f38fc95..ee683ba 100644 --- a/app/Domains/Catalog/Models/Category.php +++ b/app/Domains/Catalog/Models/Category.php @@ -2,11 +2,13 @@ namespace App\Domains\Catalog\Models; +use App\Domains\Auth\Models\User; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ @@ -64,4 +66,15 @@ class Category extends Model { return $this->hasMany(CatalogItem::class); } + + /** @return BelongsToMany */ + public function scanners(): BelongsToMany + { + return $this->belongsToMany( + User::class, + 'category_scanners', + 'categoria_id', + 'user_id', + )->withTimestamps(); + } } diff --git a/app/Domains/Forms/Controllers/AdminApp/StaffFormController.php b/app/Domains/Forms/Controllers/AdminApp/StaffFormController.php new file mode 100644 index 0000000..e46fec6 --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/StaffFormController.php @@ -0,0 +1,22 @@ +staffFormService->get( + $request->user('sanctum')->tenant()->firstOrFail() + ) + ); + } +} diff --git a/app/Domains/Forms/Resources/StaffFormResource.php b/app/Domains/Forms/Resources/StaffFormResource.php new file mode 100644 index 0000000..28df1d4 --- /dev/null +++ b/app/Domains/Forms/Resources/StaffFormResource.php @@ -0,0 +1,20 @@ + */ + public function toArray(Request $request): array + { + return [ + 'categories' => $this->resource['categories']->map(fn ($category) => [ + 'id' => $category->id, + 'nombre' => $category->nombre, + ])->values(), + ]; + } +} diff --git a/app/Domains/Forms/Services/StaffFormService.php b/app/Domains/Forms/Services/StaffFormService.php new file mode 100644 index 0000000..f2fa7b0 --- /dev/null +++ b/app/Domains/Forms/Services/StaffFormService.php @@ -0,0 +1,27 @@ +} */ + public function get(Tenant $tenant): array + { + return [ + 'categories' => Category::query() + ->whereNull('categoria_id') + ->where(function (Builder $query) use ($tenant): void { + $query->where('tenant_code', $tenant->codigo) + ->orWhereHas('catalogItems', fn (Builder $items) => $items + ->where('tenant_code', $tenant->codigo)); + }) + ->orderBy('nombre') + ->get(), + ]; + } +} diff --git a/app/Domains/Forms/routes/adminapp.php b/app/Domains/Forms/routes/adminapp.php index a25247d..14e62ed 100644 --- a/app/Domains/Forms/routes/adminapp.php +++ b/app/Domains/Forms/routes/adminapp.php @@ -2,6 +2,7 @@ use App\Domains\Forms\Controllers\AdminApp\EventFormController; use App\Domains\Forms\Controllers\AdminApp\SaleFormController; +use App\Domains\Forms\Controllers\AdminApp\StaffFormController; use Illuminate\Support\Facades\Route; Route::prefix('v1/adminapp/forms') @@ -9,4 +10,5 @@ Route::prefix('v1/adminapp/forms') ->group(function (): void { Route::get('event', EventFormController::class); Route::get('sale', SaleFormController::class); + Route::get('staff', StaffFormController::class); }); diff --git a/app/Domains/Staff/Controllers/AdminAppStaffController.php b/app/Domains/Staff/Controllers/AdminAppStaffController.php new file mode 100644 index 0000000..5d82f36 --- /dev/null +++ b/app/Domains/Staff/Controllers/AdminAppStaffController.php @@ -0,0 +1,49 @@ +staffService->list( + $request->user()->tenant()->firstOrFail(), + $request->string('search')->trim()->toString() ?: null, + )); + } + + public function store(StoreStaffRequest $request): StaffResource + { + return StaffResource::make($this->staffService->create( + $request->user()->tenant()->firstOrFail(), + $request->validated(), + )); + } + + public function update(UpdateStaffRequest $request, int $staff): StaffResource + { + return StaffResource::make($this->staffService->update( + $request->user()->tenant()->firstOrFail(), + $staff, + $request->validated(), + )); + } + + public function destroy(Request $request, int $staff): Response + { + $this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff); + + return response()->noContent(); + } +} diff --git a/app/Domains/Staff/Requests/StoreStaffRequest.php b/app/Domains/Staff/Requests/StoreStaffRequest.php new file mode 100644 index 0000000..5291b6e --- /dev/null +++ b/app/Domains/Staff/Requests/StoreStaffRequest.php @@ -0,0 +1,26 @@ + */ + public function rules(): array + { + return [ + 'nombre_apellido' => ['required', 'string', 'max:255'], + 'dni' => ['required', 'string', 'max:50'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'category_ids' => ['required', 'array', 'min:1'], + 'category_ids.*' => ['required', 'integer', 'distinct', Rule::exists('categorias', 'id')], + ]; + } +} diff --git a/app/Domains/Staff/Requests/UpdateStaffRequest.php b/app/Domains/Staff/Requests/UpdateStaffRequest.php new file mode 100644 index 0000000..0c8f652 --- /dev/null +++ b/app/Domains/Staff/Requests/UpdateStaffRequest.php @@ -0,0 +1,33 @@ + */ + public function rules(): array + { + $staffId = (int) $this->route('staff'); + + return [ + 'nombre_apellido' => ['required', 'string', 'max:255'], + 'dni' => ['required', 'string', 'max:50'], + 'email' => [ + 'required', + 'email', + 'max:255', + Rule::unique('users', 'email')->ignore($staffId), + ], + 'category_ids' => ['required', 'array', 'min:1'], + 'category_ids.*' => ['required', 'integer', 'distinct', Rule::exists('categorias', 'id')], + ]; + } +} diff --git a/app/Domains/Staff/Resources/StaffResource.php b/app/Domains/Staff/Resources/StaffResource.php new file mode 100644 index 0000000..71f7e9d --- /dev/null +++ b/app/Domains/Staff/Resources/StaffResource.php @@ -0,0 +1,32 @@ + */ + 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, + ]), + 'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories + ->map(fn ($category) => [ + 'id' => $category->id, + 'nombre' => $category->nombre, + ])->values()), + ]; + } +} diff --git a/app/Domains/Staff/Services/StaffService.php b/app/Domains/Staff/Services/StaffService.php new file mode 100644 index 0000000..a73a48e --- /dev/null +++ b/app/Domains/Staff/Services/StaffService.php @@ -0,0 +1,113 @@ + */ + public function list(Tenant $tenant, ?string $search = null): Collection + { + return $this->staffQuery($tenant) + ->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')]) + ->when($search, function (Builder $query, string $search): void { + $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(); + } + + /** @return Collection */ + private function assignableCategories(Tenant $tenant): Collection + { + return Category::query() + ->whereNull('categoria_id') + ->where(function (Builder $query) use ($tenant): void { + $query->where('tenant_code', $tenant->codigo) + ->orWhereHas('catalogItems', fn (Builder $items) => $items + ->where('tenant_code', $tenant->codigo)); + }) + ->orderBy('nombre') + ->get(); + } + + /** @param array $data */ + public function create(Tenant $tenant, array $data): User + { + $this->assertCategoriesBelongToTenant($tenant, $data['category_ids']); + + return DB::transaction(function () use ($tenant, $data): 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, + ]); + $staff->scanCategories()->sync($data['category_ids']); + + return $staff->load('role', 'scanCategories'); + }); + } + + /** @param array $data */ + public function update(Tenant $tenant, int $staffId, array $data): User + { + $staff = $this->find($tenant, $staffId); + $this->assertCategoriesBelongToTenant($tenant, $data['category_ids']); + + return DB::transaction(function () use ($staff, $data): User { + $attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']); + $attributes['email'] = mb_strtolower(trim((string) $data['email'])); + $staff->update($attributes); + $staff->scanCategories()->sync($data['category_ids']); + + return $staff->load('role', 'scanCategories'); + }); + } + + public function delete(Tenant $tenant, int $staffId): void + { + $this->find($tenant, $staffId)->delete(); + } + + public function find(Tenant $tenant, int $staffId): User + { + return $this->staffQuery($tenant)->findOrFail($staffId); + } + + private function staffQuery(Tenant $tenant): Builder + { + return User::query() + ->where('tenant_codigo', $tenant->codigo) + ->where('rol_codigo', RoleCode::Scanner->value); + } + + /** @param array $categoryIds */ + private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds): void + { + $validIds = $this->assignableCategories($tenant) + ->whereIn('id', $categoryIds) + ->pluck('id'); + + if ($validIds->count() !== count($categoryIds)) { + throw ValidationException::withMessages([ + 'category_ids' => 'Una o más categorías no pertenecen al tenant.', + ]); + } + } +} diff --git a/app/Domains/Staff/routes/api.php b/app/Domains/Staff/routes/api.php new file mode 100644 index 0000000..ad3481a --- /dev/null +++ b/app/Domains/Staff/routes/api.php @@ -0,0 +1,10 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::apiResource('staff', AdminAppStaffController::class)->except('show'); + }); diff --git a/database/migrations/2026_08_04_000300_create_staff_category_assignments.php b/database/migrations/2026_08_04_000300_create_staff_category_assignments.php new file mode 100644 index 0000000..35bc2ed --- /dev/null +++ b/database/migrations/2026_08_04_000300_create_staff_category_assignments.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnUpdate()->cascadeOnDelete(); + $table->foreignId('categoria_id')->constrained('categorias')->cascadeOnUpdate()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['user_id', 'categoria_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('category_scanners'); + } +}; diff --git a/database/seeders/AuthorizationSeeder.php b/database/seeders/AuthorizationSeeder.php index 9fb6203..212778c 100644 --- a/database/seeders/AuthorizationSeeder.php +++ b/database/seeders/AuthorizationSeeder.php @@ -81,6 +81,10 @@ class AuthorizationSeeder extends Seeder 'nombre' => 'Gestionar tickets', 'descripcion' => 'Permite emitir, invalidar o regenerar tickets.', ], + 'tickets.escanear' => [ + 'nombre' => 'Escanear tickets', + 'descripcion' => 'Permite validar y consumir tickets de las categorías asignadas al usuario.', + ], 'contenido.gestionar' => [ 'nombre' => 'Gestionar contenido', 'descripcion' => 'Permite administrar menús, carruseles, destacados y redes sociales.', @@ -117,6 +121,11 @@ class AuthorizationSeeder extends Seeder 'descripcion' => 'Accede a los menús administrativos de la aplicación.', 'permisos' => [], ], + RoleCode::Scanner->value => [ + 'nombre' => 'Scanner', + 'descripcion' => 'Valida y consume tickets de las categorías que tiene asignadas.', + 'permisos' => ['tickets.escanear'], + ], RoleCode::User->value => [ 'nombre' => 'Usuario', 'descripcion' => 'Cliente final limitado a sus propios datos y operaciones.', diff --git a/routes/api.php b/routes/api.php index eda54d3..9339e26 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,3 +14,4 @@ require __DIR__.'/../app/Domains/Ticket/routes/api.php'; require __DIR__.'/../app/Domains/Event/routes/api.php'; require __DIR__.'/../app/Domains/Bootstrap/routes/api.php'; require __DIR__.'/../app/Domains/Forms/routes/api.php'; +require __DIR__.'/../app/Domains/Staff/routes/api.php'; diff --git a/tests/Feature/Forms/AdminAppStaffFormControllerTest.php b/tests/Feature/Forms/AdminAppStaffFormControllerTest.php new file mode 100644 index 0000000..4b2c033 --- /dev/null +++ b/tests/Feature/Forms/AdminAppStaffFormControllerTest.php @@ -0,0 +1,84 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/staff')->assertUnauthorized(); + } + + public function test_adminapp_user_gets_only_its_tenant_categories(): void + { + $tenant = $this->createTenant('acme'); + $otherTenant = $this->createTenant('other'); + $category = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Bebidas', + ]); + Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'categoria_id' => $category->id, + 'nombre' => 'Gaseosas', + ]); + Category::query()->create([ + 'tenant_code' => $otherTenant->codigo, + 'nombre' => 'Privada', + ]); + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $this->getJson('/api/v1/adminapp/forms/staff') + ->assertOk() + ->assertJsonCount(1, 'data.categories') + ->assertJsonPath('data.categories.0.id', $category->id) + ->assertJsonPath('data.categories.0.nombre', 'Bebidas') + ->assertJsonMissingPath('data.categories.0.categoria_id') + ->assertJsonMissingPath('data.roles'); + } + + public function test_customer_cannot_get_staff_form(): void + { + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + ])); + + $this->getJson('/api/v1/adminapp/forms/staff')->assertForbidden(); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + ]); + } +} diff --git a/tests/Feature/Staff/StaffControllerTest.php b/tests/Feature/Staff/StaffControllerTest.php new file mode 100644 index 0000000..2b678a9 --- /dev/null +++ b/tests/Feature/Staff/StaffControllerTest.php @@ -0,0 +1,138 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + $this->tenant = Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + 'website_type_code' => 'onticket', + ]); + $this->admin = User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + } + + public function test_adminapp_can_create_update_list_and_delete_staff_with_categories(): void + { + Sanctum::actingAs($this->admin); + $firstCategory = $this->createCategory('Bebidas'); + $secondCategory = $this->createCategory('Comidas'); + + $response = $this->postJson('/api/v1/adminapp/tenant/staff', [ + 'nombre_apellido' => 'Ada Lovelace', + 'dni' => '12345678', + 'email' => 'ADA@example.test', + 'category_ids' => [$firstCategory->id], + ])->assertSuccessful() + ->assertJsonPath('data.email', 'ada@example.test') + ->assertJsonPath('data.role.codigo', RoleCode::Scanner->value) + ->assertJsonPath('data.categories.0.id', $firstCategory->id); + + $staffId = $response->json('data.id'); + $this->assertDatabaseHas('category_scanners', [ + 'user_id' => $staffId, + 'categoria_id' => $firstCategory->id, + ]); + + $this->getJson('/api/v1/adminapp/tenant/staff?search=ada') + ->assertOk() + ->assertJsonCount(1, 'data'); + + $this->putJson("/api/v1/adminapp/tenant/staff/{$staffId}", [ + 'nombre_apellido' => 'Ada Byron', + 'dni' => '12345678', + 'email' => 'ada@example.test', + 'category_ids' => [$secondCategory->id], + ])->assertOk() + ->assertJsonPath('data.nombre_apellido', 'Ada Byron') + ->assertJsonPath('data.categories.0.id', $secondCategory->id); + + $this->assertDatabaseMissing('category_scanners', [ + 'user_id' => $staffId, + 'categoria_id' => $firstCategory->id, + ]); + + $this->deleteJson("/api/v1/adminapp/tenant/staff/{$staffId}")->assertNoContent(); + $this->assertDatabaseMissing('users', ['id' => $staffId]); + } + + public function test_admin_cannot_assign_another_tenants_category(): void + { + Sanctum::actingAs($this->admin); + $otherTenant = Tenant::query()->create([ + 'codigo' => 'other', + 'nombre' => 'Other', + 'dominio' => 'other.test', + 'website_type_code' => 'onticket', + ]); + $foreignCategory = Category::query()->create([ + 'tenant_code' => $otherTenant->codigo, + 'nombre' => 'Privada', + ]); + $payload = [ + 'nombre_apellido' => 'Grace Hopper', + 'dni' => '87654321', + 'email' => 'grace@example.test', + 'category_ids' => [$foreignCategory->id], + ]; + + $this->postJson('/api/v1/adminapp/tenant/staff', $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors('category_ids'); + + $parent = $this->createCategory('Local'); + $child = Category::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'categoria_id' => $parent->id, + 'nombre' => 'Subcategoría', + ]); + $payload['category_ids'] = [$child->id]; + + $this->postJson('/api/v1/adminapp/tenant/staff', $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors('category_ids'); + } + + public function test_customer_cannot_manage_staff(): void + { + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + ])); + + $this->getJson('/api/v1/adminapp/tenant/staff')->assertForbidden(); + } + + private function createCategory(string $name): Category + { + return Category::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'nombre' => $name, + ]); + } +}