From f4638bc9846e3c96d82d2a0a4c2ecf7feabf471d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 3 Aug 2026 13:32:31 -0300 Subject: [PATCH] feat: Implement event management functionality with API endpoints and validation --- .../Controllers/AdminApp/EventController.php | 31 ++ .../Event/Requests/UpdateEventRequest.php | 60 ++++ app/Domains/Event/Resources/EventResource.php | 39 +++ app/Domains/Event/Services/EventService.php | 120 ++++++++ app/Domains/Event/routes/adminapp.php | 11 + app/Domains/Event/routes/api.php | 3 + .../AdminApp/EventFormController.php | 19 ++ .../Forms/Resources/EventFormResource.php | 19 ++ .../Resources/SocialMediaOptionResource.php | 21 ++ .../Forms/Services/EventFormService.php | 17 ++ app/Domains/Forms/routes/adminapp.php | 10 + app/Domains/Forms/routes/api.php | 3 + routes/api.php | 2 + .../Event/AdminAppEventControllerTest.php | 270 ++++++++++++++++++ .../Forms/AdminAppEventFormControllerTest.php | 66 +++++ 15 files changed, 691 insertions(+) create mode 100644 app/Domains/Event/Controllers/AdminApp/EventController.php create mode 100644 app/Domains/Event/Requests/UpdateEventRequest.php create mode 100644 app/Domains/Event/Resources/EventResource.php create mode 100644 app/Domains/Event/Services/EventService.php create mode 100644 app/Domains/Event/routes/adminapp.php create mode 100644 app/Domains/Event/routes/api.php create mode 100644 app/Domains/Forms/Controllers/AdminApp/EventFormController.php create mode 100644 app/Domains/Forms/Resources/EventFormResource.php create mode 100644 app/Domains/Forms/Resources/SocialMediaOptionResource.php create mode 100644 app/Domains/Forms/Services/EventFormService.php create mode 100644 app/Domains/Forms/routes/adminapp.php create mode 100644 app/Domains/Forms/routes/api.php create mode 100644 tests/Feature/Event/AdminAppEventControllerTest.php create mode 100644 tests/Feature/Forms/AdminAppEventFormControllerTest.php diff --git a/app/Domains/Event/Controllers/AdminApp/EventController.php b/app/Domains/Event/Controllers/AdminApp/EventController.php new file mode 100644 index 0000000..a31c2c9 --- /dev/null +++ b/app/Domains/Event/Controllers/AdminApp/EventController.php @@ -0,0 +1,31 @@ +eventService->activeForTenant($request->user()->tenant()->firstOrFail()) + ); + } + + public function update(UpdateEventRequest $request): EventResource + { + return EventResource::make( + $this->eventService->updateActiveForTenant( + $request->user()->tenant()->firstOrFail(), + $request->validated() + ) + ); + } +} diff --git a/app/Domains/Event/Requests/UpdateEventRequest.php b/app/Domains/Event/Requests/UpdateEventRequest.php new file mode 100644 index 0000000..987dccb --- /dev/null +++ b/app/Domains/Event/Requests/UpdateEventRequest.php @@ -0,0 +1,60 @@ + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'location' => ['required', 'string', 'max:255'], + 'dates' => ['required', 'array', 'min:1'], + 'dates.*' => ['required', 'array:date,start_time,end_time'], + 'dates.*.date' => ['required', 'date_format:Y-m-d', 'distinct'], + 'dates.*.start_time' => ['required', 'date_format:H:i'], + 'dates.*.end_time' => ['required', 'date_format:H:i'], + 'social_media' => ['sometimes', 'array'], + 'social_media.*' => ['required', 'array:code,url,orden'], + 'social_media.*.code' => [ + 'required', + 'string', + 'distinct', + Rule::exists('social_media', 'code'), + ], + 'social_media.*.url' => ['required', 'url', 'max:2048'], + 'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'], + 'contact' => ['sometimes', 'array:whatsapp_url,instagram_url,facebook_url'], + 'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'], + 'contact.instagram_url' => ['nullable', 'url', 'max:2048'], + 'contact.facebook_url' => ['nullable', 'url', 'max:2048'], + ]; + } + + /** @return array */ + public function after(): array + { + return [ + function (Validator $validator): void { + $input = $this->all(); + + if (! array_key_exists('social_media', $input) && ! array_key_exists('contact', $input)) { + $validator->errors()->add( + 'social_media', + 'The social media field is required.' + ); + } + }, + ]; + } +} diff --git a/app/Domains/Event/Resources/EventResource.php b/app/Domains/Event/Resources/EventResource.php new file mode 100644 index 0000000..ef566a3 --- /dev/null +++ b/app/Domains/Event/Resources/EventResource.php @@ -0,0 +1,39 @@ + */ + public function toArray(Request $request): array + { + $socialMedia = $this->tenant->socialMedia->keyBy('code'); + + return [ + 'id' => $this->id, + 'title' => $this->name, + 'location' => $this->address, + 'dates' => $this->dates->map(fn ($eventDate): array => [ + 'id' => $eventDate->id, + 'date' => $eventDate->date->format('Y-m-d'), + 'start_time' => substr($eventDate->time_start, 0, 5), + 'end_time' => substr($eventDate->time_end, 0, 5), + ])->values(), + 'social_media' => $this->tenant->socialMedia->map(fn ($item): array => [ + 'code' => $item->code, + 'url' => $item->pivot->url, + 'orden' => $item->pivot->orden, + ])->values(), + 'contact' => [ + 'whatsapp_url' => $socialMedia->get('whatsapp')?->pivot->url, + 'instagram_url' => $socialMedia->get('instagram')?->pivot->url, + 'facebook_url' => $socialMedia->get('facebook')?->pivot->url, + ], + ]; + } +} diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php new file mode 100644 index 0000000..e8d610f --- /dev/null +++ b/app/Domains/Event/Services/EventService.php @@ -0,0 +1,120 @@ + 'whatsapp', + 'instagram_url' => 'instagram', + 'facebook_url' => 'facebook', + ]; + + public function activeForTenant(Tenant $tenant): Event + { + return $tenant->events() + ->whereKey($tenant->active_event_id) + ->with(['dates', 'tenant.socialMedia']) + ->firstOrFail(); + } + + /** @param array $data */ + public function updateActiveForTenant(Tenant $tenant, array $data): Event + { + return DB::transaction(function () use ($tenant, $data): Event { + $tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail(); + $event = $tenant->active_event_id === null + ? $tenant->events()->create([ + 'name' => $data['title'], + 'address' => $data['location'], + ]) + : $tenant->events() + ->whereKey($tenant->active_event_id) + ->lockForUpdate() + ->firstOrFail(); + + $event->update([ + 'name' => $data['title'], + 'address' => $data['location'], + ]); + + if ($tenant->active_event_id === null) { + $tenant->update(['active_event_id' => $event->id]); + } + + $this->syncDates($event, $data['dates']); + if (array_key_exists('social_media', $data)) { + $this->syncSocialMedia($tenant, $data['social_media']); + } else { + $this->syncLegacyContact($tenant, $data['contact']); + } + + return $event->load(['dates', 'tenant.socialMedia']); + }); + } + + /** @param array $dates */ + private function syncDates(Event $event, array $dates): void + { + $existingDates = $event->dates()->get()->values(); + + foreach (array_values($dates) as $index => $date) { + $attributes = [ + 'date' => $date['date'], + 'time_start' => $date['start_time'], + 'time_end' => $date['end_time'], + ]; + + $existingDate = $existingDates->get($index); + + if ($existingDate) { + $existingDate->update($attributes); + } else { + $event->dates()->create($attributes); + } + } + + $existingDates->slice(count($dates))->each->delete(); + $event->unsetRelation('dates'); + } + + /** @param array $contact */ + private function syncLegacyContact(Tenant $tenant, array $contact): void + { + foreach (self::CONTACT_CODES as $field => $code) { + $url = $contact[$field] ?? null; + + if ($url === null || $url === '') { + $tenant->socialMedia()->detach($code); + + continue; + } + + $tenant->socialMedia()->syncWithoutDetaching([ + $code => ['url' => $url], + ]); + } + + $tenant->unsetRelation('socialMedia'); + } + + /** @param array $socialMedia */ + private function syncSocialMedia(Tenant $tenant, array $socialMedia): void + { + $associations = []; + + foreach (array_values($socialMedia) as $index => $item) { + $associations[$item['code']] = [ + 'url' => $item['url'], + 'orden' => $item['orden'] ?? $index, + ]; + } + + $tenant->socialMedia()->sync($associations); + $tenant->unsetRelation('socialMedia'); + } +} diff --git a/app/Domains/Event/routes/adminapp.php b/app/Domains/Event/routes/adminapp.php new file mode 100644 index 0000000..75ae9ea --- /dev/null +++ b/app/Domains/Event/routes/adminapp.php @@ -0,0 +1,11 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::get('event', [EventController::class, 'show']); + Route::put('event', [EventController::class, 'update']); + }); diff --git a/app/Domains/Event/routes/api.php b/app/Domains/Event/routes/api.php new file mode 100644 index 0000000..062e0fe --- /dev/null +++ b/app/Domains/Event/routes/api.php @@ -0,0 +1,3 @@ +eventFormService->get() + ); + } +} diff --git a/app/Domains/Forms/Resources/EventFormResource.php b/app/Domains/Forms/Resources/EventFormResource.php new file mode 100644 index 0000000..0cfd51d --- /dev/null +++ b/app/Domains/Forms/Resources/EventFormResource.php @@ -0,0 +1,19 @@ + */ + public function toArray(Request $request): array + { + return [ + 'social_media' => SocialMediaOptionResource::collection( + $this->resource['social_media'] + ), + ]; + } +} diff --git a/app/Domains/Forms/Resources/SocialMediaOptionResource.php b/app/Domains/Forms/Resources/SocialMediaOptionResource.php new file mode 100644 index 0000000..7dcf582 --- /dev/null +++ b/app/Domains/Forms/Resources/SocialMediaOptionResource.php @@ -0,0 +1,21 @@ + */ + public function toArray(Request $request): array + { + return [ + 'code' => $this->code, + 'name' => $this->name, + 'icon' => $this->icon, + ]; + } +} diff --git a/app/Domains/Forms/Services/EventFormService.php b/app/Domains/Forms/Services/EventFormService.php new file mode 100644 index 0000000..fe7ee65 --- /dev/null +++ b/app/Domains/Forms/Services/EventFormService.php @@ -0,0 +1,17 @@ +} */ + public function get(): array + { + return [ + 'social_media' => SocialMedia::query()->orderBy('id')->get(), + ]; + } +} diff --git a/app/Domains/Forms/routes/adminapp.php b/app/Domains/Forms/routes/adminapp.php new file mode 100644 index 0000000..da1e7e9 --- /dev/null +++ b/app/Domains/Forms/routes/adminapp.php @@ -0,0 +1,10 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::get('event', EventFormController::class); + }); diff --git a/app/Domains/Forms/routes/api.php b/app/Domains/Forms/routes/api.php new file mode 100644 index 0000000..062e0fe --- /dev/null +++ b/app/Domains/Forms/routes/api.php @@ -0,0 +1,3 @@ +seed([AuthorizationSeeder::class, SocialMediaSeeder::class]); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/tenant/event')->assertUnauthorized(); + $this->putJson('/api/v1/adminapp/tenant/event', $this->eventPayload())->assertUnauthorized(); + } + + public function test_an_adminapp_user_can_create_the_active_event_and_contact_information(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $response = $this->putJson('/api/v1/adminapp/tenant/event', $this->eventPayload()) + ->assertOk() + ->assertJsonPath('data.title', 'Festival Acme') + ->assertJsonPath('data.location', 'Predio Ferial, Rosario') + ->assertJsonPath('data.dates.0.date', '2026-10-09') + ->assertJsonPath('data.dates.0.start_time', '09:00') + ->assertJsonPath('data.dates.0.end_time', '18:30') + ->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101') + ->assertJsonPath('data.contact.instagram_url', 'https://instagram.com/acme') + ->assertJsonPath('data.contact.facebook_url', null); + + $eventId = $response->json('data.id'); + + $this->assertDatabaseHas('events', [ + 'id' => $eventId, + 'tenant_code' => $tenant->codigo, + 'name' => 'Festival Acme', + 'address' => 'Predio Ferial, Rosario', + ]); + $this->assertSame($eventId, $tenant->fresh()->active_event_id); + $this->assertDatabaseHas('event_dates', [ + 'event_id' => $eventId, + 'date' => '2026-10-09', + 'time_start' => '09:00:00', + 'time_end' => '18:30:00', + ]); + $this->assertDatabaseHas('tenant_social_media', [ + 'tenant_code' => $tenant->codigo, + 'social_media_code' => 'whatsapp', + 'url' => 'https://wa.me/5493415550101', + ]); + } + + public function test_an_adminapp_user_can_read_only_its_tenant_active_event(): void + { + $tenant = $this->createTenant('acme'); + $otherTenant = $this->createTenant('other'); + $this->createActiveEvent($tenant, 'Acme Event'); + $this->createActiveEvent($otherTenant, 'Other Event'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/event') + ->assertOk() + ->assertJsonPath('data.title', 'Acme Event') + ->assertJsonMissing(['title' => 'Other Event']); + } + + public function test_reading_a_tenant_without_an_active_event_returns_not_found(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/event')->assertNotFound(); + } + + public function test_updating_reuses_the_active_event_and_synchronizes_dates_and_contact(): void + { + $tenant = $this->createTenant('acme'); + $event = $this->createActiveEvent($tenant, 'Old Event'); + $firstDate = $event->dates()->create([ + 'date' => '2026-10-01', + 'time_start' => '08:00', + 'time_end' => '12:00', + ]); + $removedDate = $event->dates()->create([ + 'date' => '2026-10-02', + 'time_start' => '08:00', + 'time_end' => '12:00', + ]); + $tenant->socialMedia()->attach('facebook', [ + 'url' => 'https://facebook.com/old', + 'orden' => 2, + ]); + $tenant->socialMedia()->attach('linkedin', [ + 'url' => 'https://linkedin.com/company/acme', + 'orden' => 3, + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $payload = $this->eventPayload(); + $payload['dates'] = [[ + 'date' => '2026-11-15', + 'start_time' => '10:00', + 'end_time' => '20:00', + ]]; + + $this->putJson('/api/v1/adminapp/tenant/event', $payload) + ->assertOk() + ->assertJsonPath('data.id', $event->id) + ->assertJsonPath('data.dates.0.id', $firstDate->id) + ->assertJsonPath('data.contact.facebook_url', null); + + $this->assertDatabaseHas('event_dates', [ + 'id' => $firstDate->id, + 'date' => '2026-11-15', + ]); + $this->assertDatabaseMissing('event_dates', ['id' => $removedDate->id]); + $this->assertDatabaseMissing('tenant_social_media', [ + 'tenant_code' => $tenant->codigo, + 'social_media_code' => 'facebook', + ]); + $this->assertDatabaseHas('tenant_social_media', [ + 'tenant_code' => $tenant->codigo, + 'social_media_code' => 'linkedin', + 'url' => 'https://linkedin.com/company/acme', + ]); + } + + public function test_update_validates_event_dates_and_contact_urls(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->putJson('/api/v1/adminapp/tenant/event', [ + 'title' => '', + 'location' => '', + 'dates' => [ + ['date' => '09/10/2026', 'start_time' => '9am', 'end_time' => '18:00'], + ['date' => '09/10/2026', 'start_time' => '09:00', 'end_time' => '18:00'], + ], + 'contact' => [ + 'whatsapp_url' => 'not-a-url', + 'instagram_url' => null, + 'facebook_url' => null, + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'title', + 'location', + 'dates.0.date', + 'dates.0.start_time', + 'dates.1.date', + 'contact.whatsapp_url', + ]); + + $this->assertDatabaseCount('events', 0); + } + + public function test_social_media_accepts_any_registered_code_and_rejects_unknown_codes(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + $payload = $this->eventPayload(); + unset($payload['contact']); + $payload['social_media'] = [[ + 'code' => 'linkedin', + 'url' => 'https://linkedin.com/company/acme', + 'orden' => 5, + ]]; + + $this->putJson('/api/v1/adminapp/tenant/event', $payload) + ->assertOk() + ->assertJsonPath('data.social_media.0.code', 'linkedin') + ->assertJsonPath('data.social_media.0.url', 'https://linkedin.com/company/acme') + ->assertJsonPath('data.social_media.0.orden', 5); + + $this->assertDatabaseHas('tenant_social_media', [ + 'tenant_code' => $tenant->codigo, + 'social_media_code' => 'linkedin', + 'url' => 'https://linkedin.com/company/acme', + 'orden' => 5, + ]); + + $payload['social_media'][0]['code'] = 'unknown'; + + $this->putJson('/api/v1/adminapp/tenant/event', $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors(['social_media.0.code']); + } + + public function test_a_customer_cannot_manage_an_event(): void + { + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => null, + ])); + + $this->getJson('/api/v1/adminapp/tenant/event')->assertForbidden(); + } + + /** @return array */ + private function eventPayload(): array + { + return [ + 'title' => 'Festival Acme', + 'location' => 'Predio Ferial, Rosario', + 'dates' => [[ + 'date' => '2026-10-09', + 'start_time' => '09:00', + 'end_time' => '18:30', + ]], + 'contact' => [ + 'whatsapp_url' => 'https://wa.me/5493415550101', + 'instagram_url' => 'https://instagram.com/acme', + 'facebook_url' => null, + ], + ]; + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function createActiveEvent(Tenant $tenant, string $name): Event + { + $event = $tenant->events()->create([ + 'name' => $name, + 'address' => 'Rosario', + ]); + $tenant->update(['active_event_id' => $event->id]); + + return $event; + } +} diff --git a/tests/Feature/Forms/AdminAppEventFormControllerTest.php b/tests/Feature/Forms/AdminAppEventFormControllerTest.php new file mode 100644 index 0000000..a7d469e --- /dev/null +++ b/tests/Feature/Forms/AdminAppEventFormControllerTest.php @@ -0,0 +1,66 @@ +seed([AuthorizationSeeder::class, SocialMediaSeeder::class]); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/event')->assertUnauthorized(); + } + + public function test_an_adminapp_user_can_get_the_event_form(): void + { + $tenant = Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + 'website_type_code' => 'onticket', + ]); + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $this->getJson('/api/v1/adminapp/forms/event') + ->assertOk() + ->assertJsonCount(4, 'data.social_media') + ->assertJsonPath('data.social_media.0.code', 'facebook') + ->assertJsonPath('data.social_media.0.name', 'Facebook') + ->assertJsonPath('data.social_media.0.icon', 'fa-brands fa-facebook') + ->assertJsonPath('data.social_media.3.code', 'linkedin'); + } + + public function test_a_customer_cannot_get_the_event_form(): void + { + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => null, + ])); + + $this->getJson('/api/v1/adminapp/forms/event')->assertForbidden(); + } +}