diff --git a/app/Domains/Ticketing/Event/Models/Event.php b/app/Domains/Ticketing/Event/Models/Event.php index 5ca611e..d27905f 100644 --- a/app/Domains/Ticketing/Event/Models/Event.php +++ b/app/Domains/Ticketing/Event/Models/Event.php @@ -5,6 +5,7 @@ namespace App\Domains\Ticketing\Event\Models; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Shared\Attachable\Models\Attachment; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -53,6 +54,12 @@ class Event extends Model return $this->hasMany(CatalogItem::class)->whereAvailable()->orderBy('group_order')->orderBy('id'); } + /** @return HasMany */ + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class); + } + /** @return BelongsToMany */ public function socialMedia(): BelongsToMany { diff --git a/app/Domains/Ticketing/Ticket/Controllers/TicketController.php b/app/Domains/Ticketing/Ticket/Controllers/TicketController.php index 6ccc548..007589c 100644 --- a/app/Domains/Ticketing/Ticket/Controllers/TicketController.php +++ b/app/Domains/Ticketing/Ticket/Controllers/TicketController.php @@ -6,31 +6,47 @@ use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Exceptions\TicketNotAvailableException; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Requests\DownloadTicketsPdfRequest; +use App\Domains\Ticketing\Ticket\Requests\ListTicketsRequest; +use App\Domains\Ticketing\Ticket\Resources\TicketEventResource; use App\Domains\Ticketing\Ticket\Resources\TicketResource; use App\Domains\Ticketing\Ticket\Services\TicketPdfService; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; +use App\Domains\Ticketing\Ticket\Services\UserTicketQueryService; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Response; class TicketController extends Controller { - public function __construct(private readonly TicketPdfService $ticketPdfService) {} + public function __construct( + private readonly TicketPdfService $ticketPdfService, + private readonly UserTicketQueryService $userTickets, + ) {} - public function index(Request $request, Tenant $tenant): JsonResponse + public function index(ListTicketsRequest $request, Tenant $tenant): JsonResponse { - $tickets = Ticket::query() - ->where('tenant_code', $tenant->codigo) - ->where('user_id', $request->user()->getKey()) - ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS]) - ->orderByDesc('id') - ->get(); + $eventId = $request->validated('event_id'); + $tickets = $this->userTickets->tickets( + $tenant, + $request->user(), + $eventId === null ? null : (int) $eventId, + ); return TicketResource::collection($tickets)->response(); } + public function events(Request $request, Tenant $tenant): AnonymousResourceCollection + { + abort_unless($tenant->storefront_website_type_code === 'onticket_multi_event', 404); + + return TicketEventResource::collection( + $this->userTickets->events($tenant, $request->user()) + ); + } + public function downloadPdf(DownloadTicketsPdfRequest $request, Tenant $tenant): Response { $ticketIds = $request->validated('ticket_ids'); diff --git a/app/Domains/Ticketing/Ticket/Models/Ticket.php b/app/Domains/Ticketing/Ticket/Models/Ticket.php index f4c0311..0236eb7 100644 --- a/app/Domains/Ticketing/Ticket/Models/Ticket.php +++ b/app/Domains/Ticketing/Ticket/Models/Ticket.php @@ -2,16 +2,17 @@ namespace App\Domains\Ticketing\Ticket\Models; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Variant; -use App\Shared\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Commerce\Purchase\Models\PurchaseItem; +use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticketing\Ticket\Services\ResolvedValidityGroup; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; +use App\Shared\Logging\Models\Concerns\LogsValueChanges; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -24,6 +25,7 @@ use Illuminate\Validation\ValidationException; #[Fillable([ 'tenant_code', + 'event_id', 'ticket', 'source_purchase_item_id', 'source_catalog_item_id', @@ -75,6 +77,7 @@ class Ticket extends Model protected function casts(): array { return [ + 'event_id' => 'integer', 'source_catalog_item_id' => 'integer', 'source_variant_id' => 'integer', 'source_purchase_item_id' => 'integer', @@ -136,6 +139,12 @@ class Ticket extends Model return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); } + /** @return BelongsTo */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + public function allow_refund(): bool { return $this->tenant?->allow_refund() ?? false; diff --git a/app/Domains/Ticketing/Ticket/Requests/ListTicketsRequest.php b/app/Domains/Ticketing/Ticket/Requests/ListTicketsRequest.php new file mode 100644 index 0000000..1d2b379 --- /dev/null +++ b/app/Domains/Ticketing/Ticket/Requests/ListTicketsRequest.php @@ -0,0 +1,30 @@ + */ + public function rules(): array + { + /** @var Tenant $tenant */ + $tenant = $this->route('tenant'); + + return [ + 'event_id' => [ + 'sometimes', + 'integer', + Rule::exists('events', 'id')->where('tenant_code', $tenant->codigo), + ], + ]; + } +} diff --git a/app/Domains/Ticketing/Ticket/Resources/TicketEventResource.php b/app/Domains/Ticketing/Ticket/Resources/TicketEventResource.php new file mode 100644 index 0000000..aa910dd --- /dev/null +++ b/app/Domains/Ticketing/Ticket/Resources/TicketEventResource.php @@ -0,0 +1,21 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'image' => $this->attachment?->getTemporaryUrl(1440), + ]; + } +} diff --git a/app/Domains/Ticketing/Ticket/Resources/TicketResource.php b/app/Domains/Ticketing/Ticket/Resources/TicketResource.php index 836c19a..4b94aee 100644 --- a/app/Domains/Ticketing/Ticket/Resources/TicketResource.php +++ b/app/Domains/Ticketing/Ticket/Resources/TicketResource.php @@ -15,6 +15,7 @@ class TicketResource extends JsonResource return [ 'id' => $this->id, 'tenant_code' => $this->tenant_code, + 'event_id' => $this->event_id, 'ticket' => $this->ticket, 'status' => $this->status, 'status_label' => $this->status_label, diff --git a/app/Domains/Ticketing/Ticket/Services/TicketGeneratorService.php b/app/Domains/Ticketing/Ticket/Services/TicketGeneratorService.php index 4b3b09c..1521f20 100644 --- a/app/Domains/Ticketing/Ticket/Services/TicketGeneratorService.php +++ b/app/Domains/Ticketing/Ticket/Services/TicketGeneratorService.php @@ -2,9 +2,9 @@ namespace App\Domains\Ticketing\Ticket\Services; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Variant; +use App\Domains\Core\Auth\Models\User; use App\Domains\Ticketing\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticketing\Ticket\Models\Ticket; use Illuminate\Support\Collection; @@ -44,6 +44,7 @@ class TicketGeneratorService $variant = $target['variant']; $ticket = Ticket::query()->create([ 'tenant_code' => $item->tenant_code, + 'event_id' => $item->event_id, 'ticket' => (string) Str::uuid(), 'source_purchase_item_id' => $sourcePurchaseItemId, 'source_catalog_item_id' => $item->getKey(), diff --git a/app/Domains/Ticketing/Ticket/Services/UserTicketQueryService.php b/app/Domains/Ticketing/Ticket/Services/UserTicketQueryService.php new file mode 100644 index 0000000..be43822 --- /dev/null +++ b/app/Domains/Ticketing/Ticket/Services/UserTicketQueryService.php @@ -0,0 +1,45 @@ + */ + public function tickets(Tenant $tenant, User $user, ?int $eventId = null): Collection + { + return $this->ticketQuery($tenant, $user) + ->when($eventId !== null, fn (Builder $query): Builder => $query + ->where('event_id', $eventId)) + ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS]) + ->orderByDesc('id') + ->get(); + } + + /** @return Collection */ + public function events(Tenant $tenant, User $user): Collection + { + return Event::query() + ->where('tenant_code', $tenant->codigo) + ->whereHas('tickets', fn (Builder $query): Builder => $query + ->where('tenant_code', $tenant->codigo) + ->where('user_id', $user->getKey())) + ->with('attachment') + ->orderByDesc('id') + ->get(); + } + + /** @return Builder */ + private function ticketQuery(Tenant $tenant, User $user): Builder + { + return Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->where('user_id', $user->getKey()); + } +} diff --git a/app/Domains/Ticketing/Ticket/routes/api.php b/app/Domains/Ticketing/Ticket/routes/api.php index 8c6666a..caf8f89 100644 --- a/app/Domains/Ticketing/Ticket/routes/api.php +++ b/app/Domains/Ticketing/Ticket/routes/api.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route; Route::prefix('tenants/{tenant:codigo}') ->middleware('auth:sanctum') ->group(function (): void { + Route::get('tickets/events', [TicketController::class, 'events']); Route::get('tickets', [TicketController::class, 'index']); Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']); }); diff --git a/database/migrations/2026_09_22_020000_add_event_id_to_tickets.php b/database/migrations/2026_09_22_020000_add_event_id_to_tickets.php new file mode 100644 index 0000000..351f002 --- /dev/null +++ b/database/migrations/2026_09_22_020000_add_event_id_to_tickets.php @@ -0,0 +1,52 @@ +foreignId('event_id') + ->nullable() + ->after('tenant_code') + ->constrained('events') + ->cascadeOnUpdate() + ->restrictOnDelete(); + $table->index(['tenant_code', 'user_id', 'event_id']); + }); + + DB::table('tickets') + ->join('catalog_items', 'catalog_items.id', '=', 'tickets.source_catalog_item_id') + ->whereNotNull('catalog_items.event_id') + ->select(['tickets.id', 'catalog_items.event_id as source_event_id']) + ->orderBy('tickets.id') + ->chunk(500, function ($tickets): void { + foreach ($tickets as $ticket) { + DB::table('tickets') + ->where('id', $ticket->id) + ->update(['event_id' => $ticket->source_event_id]); + } + }); + } + + public function down(): void + { + if (DB::getDriverName() === 'sqlite') { + Schema::table('tickets', function (Blueprint $table): void { + $table->dropIndex(['tenant_code', 'user_id', 'event_id']); + $table->dropColumn('event_id'); + }); + + return; + } + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropIndex(['tenant_code', 'user_id', 'event_id']); + $table->dropConstrainedForeignId('event_id'); + }); + } +}; diff --git a/tests/Feature/Ticket/TicketControllerTest.php b/tests/Feature/Ticket/TicketControllerTest.php index fb6fc5e..39a9dfc 100644 --- a/tests/Feature/Ticket/TicketControllerTest.php +++ b/tests/Feature/Ticket/TicketControllerTest.php @@ -2,18 +2,20 @@ namespace Tests\Feature\Ticket; -use App\Shared\Attachable\Enums\AttachmentType; -use App\Shared\Attachable\Models\Attachment; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Catalog\Models\Attribute; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Inventory; +use App\Domains\Core\Auth\Models\User; +use App\Domains\Core\Tenant\Models\StorefrontWebsiteType; +use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Event\Events\EventDateRescheduled; use App\Domains\Ticketing\Event\Events\EventDateSuspended; +use App\Domains\Ticketing\Event\Models\Event as TicketEvent; use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Services\EventService; -use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Shared\Attachable\Enums\AttachmentType; +use App\Shared\Attachable\Models\Attachment; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Str; @@ -164,6 +166,59 @@ class TicketControllerTest extends TestCase ->assertJsonPath('data.0.id', $visibleTicket->id); } + public function test_a_multi_event_user_can_list_only_events_with_their_tickets(): void + { + StorefrontWebsiteType::query()->create([ + 'codigo' => 'onticket_multi_event', + 'nombre' => 'OnTicket multi event', + 'header_type' => 'standard', + ]); + $tenant = $this->createTenant('multi'); + $tenant->update(['storefront_website_type_code' => 'onticket_multi_event']); + $otherTenant = $this->createTenant('other'); + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $firstEvent = $this->createEvent($tenant, 'First event'); + $secondEvent = $this->createEvent($tenant, 'Second event'); + $eventWithoutTickets = $this->createEvent($tenant, 'No tickets'); + $otherTenantEvent = $this->createEvent($otherTenant, 'Other tenant event'); + + $this->createTicket($tenant, $user, 'First ticket')->update(['event_id' => $firstEvent->id]); + $this->createTicket($tenant, $user, 'Second ticket')->update(['event_id' => $secondEvent->id]); + $this->createTicket($tenant, $otherUser, 'Other user')->update(['event_id' => $eventWithoutTickets->id]); + $this->createTicket($otherTenant, $user, 'Other tenant')->update(['event_id' => $otherTenantEvent->id]); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/{$tenant->codigo}/tickets/events") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $secondEvent->id) + ->assertJsonPath('data.0.title', 'Second event') + ->assertJsonPath('data.1.id', $firstEvent->id) + ->assertJsonMissing(['title' => 'No tickets']) + ->assertJsonMissing(['title' => 'Other tenant event']); + } + + public function test_a_user_can_filter_their_tickets_by_event(): void + { + $tenant = $this->createTenant('multi'); + $user = User::factory()->create(); + $firstEvent = $this->createEvent($tenant, 'First event'); + $secondEvent = $this->createEvent($tenant, 'Second event'); + $visibleTicket = $this->createTicket($tenant, $user, 'Visible ticket'); + $visibleTicket->update(['event_id' => $firstEvent->id]); + $hiddenTicket = $this->createTicket($tenant, $user, 'Hidden ticket'); + $hiddenTicket->update(['event_id' => $secondEvent->id]); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/{$tenant->codigo}/tickets?event_id={$firstEvent->id}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $visibleTicket->id) + ->assertJsonPath('data.0.event_id', $firstEvent->id) + ->assertJsonMissing(['id' => $hiddenTicket->id]); + } + public function test_authentication_is_required_to_list_tickets(): void { $tenant = $this->createTenant('current'); @@ -248,6 +303,16 @@ class TicketControllerTest extends TestCase ]); } + private function createEvent(Tenant $tenant, string $title): TicketEvent + { + return TicketEvent::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => Str::slug($title).'-'.Str::lower(Str::random(8)), + 'title' => $title, + 'published_at' => now(), + ]); + } + private function createAttachment(string $name): Attachment { return Attachment::query()->create([ diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index a74876f..487b5cb 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -2,22 +2,23 @@ namespace Tests\Feature\Ticket; -use App\Shared\Attachable\Enums\AttachmentType; -use App\Shared\Attachable\Models\Attachment; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Catalog\Enums\CatalogItemType; use App\Domains\Commerce\Catalog\Models\Attribute; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Services\CatalogService; -use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Commerce\Purchase\Models\Purchase; -use App\Shared\Enums\FieldType; +use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event as TicketEvent; +use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Ticket\Enums\ValidityTimeType; use App\Domains\Ticketing\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticketing\Ticket\Models\ValidityTime; use App\Domains\Ticketing\Ticket\Services\TicketGeneratorService; +use App\Shared\Attachable\Enums\AttachmentType; +use App\Shared\Attachable\Models\Attachment; +use App\Shared\Enums\FieldType; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -79,6 +80,26 @@ class TicketGeneratorServiceTest extends TestCase ]); } + public function test_it_preserves_the_catalog_items_event_on_the_ticket(): void + { + $event = TicketEvent::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'original-event', + 'title' => 'Original event', + 'published_at' => now(), + ]); + $item = $this->createTicketableItem('event-ticket'); + $item->update(['event_id' => $event->id]); + + $ticket = $this->service->generate($item->fresh(), $this->user)->sole(); + + $this->assertSame($event->id, $ticket->event_id); + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'event_id' => $event->id, + ]); + } + public function test_it_rejects_an_item_without_tickets_enabled(): void { $item = $this->createTicketableItem('disabled');