From 254faedf2c3dac4b8ed514183f57b825cc911488 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 13:55:53 -0300 Subject: [PATCH] feat(scanner): implement ticket scanning functionality with controller, service, and routes --- .../Controllers/Scanner/TicketController.php | 46 ++++ .../Scanner/ScannedTicketResource.php | 22 ++ .../Ticket/Services/ScannerTicketService.php | 117 ++++++++ app/Domains/Ticket/routes/api.php | 2 + app/Domains/Ticket/routes/scanner.php | 14 + app/Http/Middleware/EnsureScannerTenant.php | 30 ++ bootstrap/app.php | 2 + lang/en/api.php | 4 + lang/es/api.php | 4 + .../Ticket/ScannerTicketControllerTest.php | 260 ++++++++++++++++++ 10 files changed, 501 insertions(+) create mode 100644 app/Domains/Ticket/Controllers/Scanner/TicketController.php create mode 100644 app/Domains/Ticket/Resources/Scanner/ScannedTicketResource.php create mode 100644 app/Domains/Ticket/Services/ScannerTicketService.php create mode 100644 app/Domains/Ticket/routes/scanner.php create mode 100644 app/Http/Middleware/EnsureScannerTenant.php create mode 100644 tests/Feature/Ticket/ScannerTicketControllerTest.php diff --git a/app/Domains/Ticket/Controllers/Scanner/TicketController.php b/app/Domains/Ticket/Controllers/Scanner/TicketController.php new file mode 100644 index 0000000..3258ac2 --- /dev/null +++ b/app/Domains/Ticket/Controllers/Scanner/TicketController.php @@ -0,0 +1,46 @@ +user(); + + return ScannedTicketResource::collection( + $this->ticketService->scannedBy($scanner) + ); + } + + public function show(Request $request, string $ticketUuid): TicketResource + { + /** @var User $scanner */ + $scanner = $request->user(); + + return TicketResource::make( + $this->ticketService->detail($scanner, $ticketUuid) + ); + } + + public function scan(Request $request, string $ticketUuid): TicketResource + { + /** @var User $scanner */ + $scanner = $request->user(); + + return TicketResource::make( + $this->ticketService->scan($scanner, $ticketUuid) + ); + } +} diff --git a/app/Domains/Ticket/Resources/Scanner/ScannedTicketResource.php b/app/Domains/Ticket/Resources/Scanner/ScannedTicketResource.php new file mode 100644 index 0000000..ce0d803 --- /dev/null +++ b/app/Domains/Ticket/Resources/Scanner/ScannedTicketResource.php @@ -0,0 +1,22 @@ + */ + public function toArray(Request $request): array + { + return [ + 'product' => $this->name, + 'id' => $this->id, + 'expires_at' => $this->getEffectiveExpiresAt(), + 'status' => $this->status, + ]; + } +} diff --git a/app/Domains/Ticket/Services/ScannerTicketService.php b/app/Domains/Ticket/Services/ScannerTicketService.php new file mode 100644 index 0000000..4378fa7 --- /dev/null +++ b/app/Domains/Ticket/Services/ScannerTicketService.php @@ -0,0 +1,117 @@ + */ + public function scannedBy(User $scanner): Collection + { + return $this->baseQuery() + ->where('tenant_code', $scanner->tenant_codigo) + ->where('scanner_user_id', $scanner->getKey()) + ->orderByDesc('used_at') + ->orderByDesc('id') + ->get(); + } + + public function detail(User $scanner, string $ticketUuid): Ticket + { + $categoryIds = $this->scannerCategoryIds($scanner); + + return $this->baseQuery() + ->where('tenant_code', $scanner->tenant_codigo) + ->where('ticket', $ticketUuid) + ->where(function (Builder $query) use ($scanner, $categoryIds): void { + $query + ->where('scanner_user_id', $scanner->getKey()) + ->orWhereHas( + 'sourceCatalogItem', + fn (Builder $catalogItemQuery): Builder => $catalogItemQuery + ->whereIn('category_id', $categoryIds) + ); + }) + ->firstOrFail(); + } + + public function scan(User $scanner, string $ticketUuid): Ticket + { + return DB::transaction(function () use ($scanner, $ticketUuid): Ticket { + $ticket = $this->baseQuery() + ->where('tenant_code', $scanner->tenant_codigo) + ->where('ticket', $ticketUuid) + ->lockForUpdate() + ->firstOrFail(); + + if (! $this->scannerCanScan($scanner, $ticket)) { + throw ValidationException::withMessages([ + 'ticket' => __('api.ticket.scanner_category_forbidden'), + ]); + } + + if ($ticket->is_used) { + throw ValidationException::withMessages([ + 'ticket' => __('api.ticket.already_scanned'), + ]); + } + + if (! $ticket->is_valid) { + throw ValidationException::withMessages([ + 'ticket' => $ticket->is_expired + ? __('api.ticket.expired_for_scan') + : __('api.ticket.not_valid_for_scan'), + ]); + } + + $ticket->forceFill([ + 'used_at' => now(), + 'scanner_user_id' => $scanner->getKey(), + ])->save(); + + return $ticket->refresh()->load($this->relations()); + }); + } + + /** @return Builder */ + private function baseQuery(): Builder + { + return Ticket::query()->with($this->relations()); + } + + /** @return array */ + private function relations(): array + { + return [ + 'validityGroups.validityTimes', + 'sourceCatalogItem', + 'sourceVariant.eventDate', + 'sourceVariant.catalogItem', + ]; + } + + /** @return array */ + private function scannerCategoryIds(User $scanner): array + { + return $scanner->scanCategories() + ->pluck('categorias.id') + ->map(fn (mixed $id): int => (int) $id) + ->all(); + } + + private function scannerCanScan(User $scanner, Ticket $ticket): bool + { + $categoryId = $ticket->sourceCatalogItem?->category_id; + + return $categoryId !== null + && $scanner->scanCategories() + ->where('categorias.id', $categoryId) + ->exists(); + } +} diff --git a/app/Domains/Ticket/routes/api.php b/app/Domains/Ticket/routes/api.php index 9f9f343..da5305b 100644 --- a/app/Domains/Ticket/routes/api.php +++ b/app/Domains/Ticket/routes/api.php @@ -9,3 +9,5 @@ Route::prefix('tenants/{tenant:codigo}') Route::get('tickets', [TicketController::class, 'index']); Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']); }); + +require __DIR__.'/scanner.php'; diff --git a/app/Domains/Ticket/routes/scanner.php b/app/Domains/Ticket/routes/scanner.php new file mode 100644 index 0000000..383bf6b --- /dev/null +++ b/app/Domains/Ticket/routes/scanner.php @@ -0,0 +1,14 @@ +middleware(['auth:sanctum', 'scanner.tenant']) + ->group(function (): void { + Route::get('/', [TicketController::class, 'index']); + Route::get('{ticketUuid}', [TicketController::class, 'show']) + ->whereUuid('ticketUuid'); + Route::post('{ticketUuid}/scan', [TicketController::class, 'scan']) + ->whereUuid('ticketUuid'); + }); diff --git a/app/Http/Middleware/EnsureScannerTenant.php b/app/Http/Middleware/EnsureScannerTenant.php new file mode 100644 index 0000000..0e281e2 --- /dev/null +++ b/app/Http/Middleware/EnsureScannerTenant.php @@ -0,0 +1,30 @@ +user(); + + if ( + ! $user + || $user->rol_codigo !== RoleCode::Scanner->value + || ! $user->tenant_codigo + ) { + throw new AuthorizationException; + } + + return $next($request); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 1760f1d..3e6b9b1 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Ticket\Exceptions\TicketNotAvailableException; use App\Http\Middleware\EnsureAdminAppTenant; +use App\Http\Middleware\EnsureScannerTenant; use App\Http\Middleware\EnsureTenantHasMenu; use App\Http\Middleware\SetApiLocale; use Illuminate\Auth\Access\AuthorizationException; @@ -25,6 +26,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'adminapp.tenant' => EnsureAdminAppTenant::class, + 'scanner.tenant' => EnsureScannerTenant::class, 'tenant.menu' => EnsureTenantHasMenu::class, ]); $middleware->encryptCookies(except: [ diff --git a/lang/en/api.php b/lang/en/api.php index 91cc55b..645f77f 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -62,6 +62,10 @@ return [ 'invalid_variant' => 'Variant :variant does not belong to product :product.', 'purchase_without_user' => 'Purchase :purchase does not have an associated user.', 'product_not_found' => 'The product for purchase item :purchase_item was not found.', + 'already_scanned' => 'The ticket has already been scanned.', + 'expired_for_scan' => 'The ticket has expired.', + 'not_valid_for_scan' => 'The ticket is not currently valid.', + 'scanner_category_forbidden' => 'The scanner is not assigned to the ticket category.', ], 'integration' => [ 'not_configured' => 'The integration is not configured for this tenant.', diff --git a/lang/es/api.php b/lang/es/api.php index 129e49d..cb3031c 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -62,6 +62,10 @@ return [ 'invalid_variant' => 'La variante :variant no pertenece al producto :product.', 'purchase_without_user' => 'La compra :purchase no tiene un usuario asociado.', 'product_not_found' => 'No se encontró el producto de la línea de compra :purchase_item.', + 'already_scanned' => 'El ticket ya fue escaneado.', + 'expired_for_scan' => 'El ticket está vencido.', + 'not_valid_for_scan' => 'El ticket no es válido en este momento.', + 'scanner_category_forbidden' => 'El scanner no está asignado a la categoría del ticket.', ], 'integration' => [ 'not_configured' => 'La integración no está configurada para este tenant.', diff --git a/tests/Feature/Ticket/ScannerTicketControllerTest.php b/tests/Feature/Ticket/ScannerTicketControllerTest.php new file mode 100644 index 0000000..f842c49 --- /dev/null +++ b/tests/Feature/Ticket/ScannerTicketControllerTest.php @@ -0,0 +1,260 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + $this->tenant = $this->createTenant('acme'); + $this->scanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $this->ticketOwner = User::factory()->create(); + $this->category = Category::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'nombre' => 'Entradas', + ]); + $this->scanner->scanCategories()->attach($this->category); + } + + public function test_scanner_routes_require_authentication_and_scanner_role(): void + { + $this->getJson('/api/v1/scanner/tickets')->assertUnauthorized(); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $this->tenant->codigo, + ])); + + $this->getJson('/api/v1/scanner/tickets')->assertForbidden(); + } + + public function test_scanner_can_list_only_its_scanned_tickets_using_adminapp_format(): void + { + $older = $this->createTicket('11111111-1111-4111-8111-111111111111', [ + 'used_at' => now()->subMinutes(2), + 'scanner_user_id' => $this->scanner->id, + ]); + $newer = $this->createTicket('22222222-2222-4222-8222-222222222222', [ + 'used_at' => now()->subMinute(), + 'scanner_user_id' => $this->scanner->id, + ]); + $otherScanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $this->createTicket('33333333-3333-4333-8333-333333333333', [ + 'used_at' => now(), + 'scanner_user_id' => $otherScanner->id, + ]); + + Sanctum::actingAs($this->scanner); + + $this->getJson('/api/v1/scanner/tickets') + ->assertOk() + ->assertExactJson([ + 'data' => [ + [ + 'product' => $newer->name, + 'id' => $newer->id, + 'expires_at' => null, + 'status' => Ticket::STATUS_USED, + ], + [ + 'product' => $older->name, + 'id' => $older->id, + 'expires_at' => null, + 'status' => Ticket::STATUS_USED, + ], + ], + ]); + } + + public function test_scanner_can_read_an_authorized_ticket_detail_by_uuid(): void + { + $ticket = $this->createTicket('44444444-4444-4444-8444-444444444444'); + Sanctum::actingAs($this->scanner); + + $this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}") + ->assertOk() + ->assertJsonPath('data.id', $ticket->id) + ->assertJsonPath('data.ticket', $ticket->ticket) + ->assertJsonPath('data.name', $ticket->name) + ->assertJsonPath('data.is_valid', true) + ->assertJsonPath('data.is_used', false); + } + + public function test_scanner_cannot_read_a_ticket_from_an_unassigned_category_or_tenant(): void + { + $otherCategory = Category::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'nombre' => 'Comidas', + ]); + $unassignedTicket = $this->createTicket( + '55555555-5555-4555-8555-555555555555', + [], + $otherCategory, + ); + $otherTenant = $this->createTenant('other'); + $foreignCategory = Category::query()->create([ + 'tenant_code' => $otherTenant->codigo, + 'nombre' => 'Externas', + ]); + $foreignTicket = $this->createTicket( + '66666666-6666-4666-8666-666666666666', + [], + $foreignCategory, + $otherTenant, + ); + Sanctum::actingAs($this->scanner); + + $this->getJson("/api/v1/scanner/tickets/{$unassignedTicket->ticket}") + ->assertNotFound(); + $this->getJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}") + ->assertNotFound(); + } + + public function test_scanner_can_scan_an_authorized_ticket_by_uuid(): void + { + $ticket = $this->createTicket('77777777-7777-4777-8777-777777777777'); + Sanctum::actingAs($this->scanner); + + $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") + ->assertOk() + ->assertJsonPath('data.ticket', $ticket->ticket) + ->assertJsonPath('data.scanner_user_id', $this->scanner->id) + ->assertJsonPath('data.is_valid', false) + ->assertJsonPath('data.is_used', true); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'scanner_user_id' => $this->scanner->id, + ]); + $this->assertNotNull($ticket->fresh()->used_at); + } + + public function test_ticket_cannot_be_scanned_twice(): void + { + $ticket = $this->createTicket('88888888-8888-4888-8888-888888888888'); + Sanctum::actingAs($this->scanner); + $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")->assertOk(); + + $otherScanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $otherScanner->scanCategories()->attach($this->category); + Sanctum::actingAs($otherScanner); + + $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") + ->assertUnprocessable() + ->assertJsonValidationErrors('ticket'); + $this->assertSame($this->scanner->id, $ticket->fresh()->scanner_user_id); + } + + public function test_scanner_cannot_scan_a_ticket_from_an_unassigned_category(): void + { + $otherCategory = Category::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'nombre' => 'Comidas', + ]); + $ticket = $this->createTicket( + '99999999-9999-4999-8999-999999999999', + [], + $otherCategory, + ); + Sanctum::actingAs($this->scanner); + + $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") + ->assertUnprocessable() + ->assertJsonValidationErrors('ticket'); + $this->assertNull($ticket->fresh()->used_at); + } + + /** @param array $attributes */ + private function createTicket( + string $uuid, + array $attributes = [], + ?Category $category = null, + ?Tenant $tenant = null, + ): Ticket { + $tenant ??= $this->tenant; + $category ??= $this->category; + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'category_id' => $category->id, + 'slug' => "ticket-{$uuid}", + 'nombre' => 'Entrada general', + 'descripcion' => 'Acceso general', + 'precio' => 100, + 'has_tickets' => true, + ]); + + return Ticket::query()->create(array_merge([ + 'tenant_code' => $tenant->codigo, + 'ticket' => $uuid, + 'name' => 'Entrada general', + 'description' => 'Acceso general', + 'source_catalog_item_id' => $catalogItem->id, + 'user_id' => $this->ticketOwner->id, + ], $attributes)); + } + + private function createTenant(string $code): Tenant + { + $logo = Attachment::query()->create([ + 'key' => (string) Str::uuid(), + 'path' => "tests/{$code}-logo.png", + 'filename' => "{$code}-logo.png", + 'type' => 'image', + 'mime_type' => 'image/png', + 'extension' => 'png', + 'size' => 1, + ]); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#ff7006', + 'secondary_color' => '#777777', + 'danger_color' => '#e04a4a', + 'success_color' => '#81bc73', + 'header_bg_color' => '#313131', + 'footer_bg_color' => '#313131', + 'header_logo_id' => $logo->id, + 'footer_logo_id' => $logo->id, + 'website_type_code' => 'onticket', + ]); + } +}