feat: Implement event management functionality with API endpoints and validation

This commit is contained in:
2026-08-03 13:32:31 -03:00
parent c3713c62a6
commit f4638bc984
15 changed files with 691 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Event\Controllers\AdminApp;
use App\Domains\Event\Requests\UpdateEventRequest;
use App\Domains\Event\Resources\EventResource;
use App\Domains\Event\Services\EventService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class EventController extends Controller
{
public function __construct(protected EventService $eventService) {}
public function show(Request $request): EventResource
{
return EventResource::make(
$this->eventService->activeForTenant($request->user()->tenant()->firstOrFail())
);
}
public function update(UpdateEventRequest $request): EventResource
{
return EventResource::make(
$this->eventService->updateActiveForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated()
)
);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateEventRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
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<int, callable> */
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.'
);
}
},
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\Event;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Event */
class EventResource extends JsonResource
{
/** @return array<string, mixed> */
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,
],
];
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
class EventService
{
private const CONTACT_CODES = [
'whatsapp_url' => '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<string, mixed> $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<int, array{date: string, start_time: string, end_time: string}> $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<string, string|null> $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<int, array{code: string, url: string, orden?: int}> $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');
}
}

View File

@@ -0,0 +1,11 @@
<?php
use App\Domains\Event\Controllers\AdminApp\EventController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('event', [EventController::class, 'show']);
Route::put('event', [EventController::class, 'update']);
});

View File

@@ -0,0 +1,3 @@
<?php
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\EventFormResource;
use App\Domains\Forms\Services\EventFormService;
use App\Http\Controllers\Controller;
class EventFormController extends Controller
{
public function __construct(protected EventFormService $eventFormService) {}
public function __invoke(): EventFormResource
{
return EventFormResource::make(
$this->eventFormService->get()
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'social_media' => SocialMediaOptionResource::collection(
$this->resource['social_media']
),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Tenant\Models\SocialMedia;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin SocialMedia */
class SocialMediaOptionResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'code' => $this->code,
'name' => $this->name,
'icon' => $this->icon,
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Tenant\Models\SocialMedia;
use Illuminate\Database\Eloquent\Collection;
class EventFormService
{
/** @return array{social_media: Collection<int, SocialMedia>} */
public function get(): array
{
return [
'social_media' => SocialMedia::query()->orderBy('id')->get(),
];
}
}

View File

@@ -0,0 +1,10 @@
<?php
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/forms')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('event', EventFormController::class);
});

View File

@@ -0,0 +1,3 @@
<?php
require __DIR__.'/adminapp.php';

View File

@@ -10,3 +10,5 @@ require __DIR__.'/../app/Domains/Tenant/routes/api.php';
require __DIR__.'/../app/Domains/Integration/routes/api.php';
require __DIR__.'/../app/Domains/Menu/routes/api.php';
require __DIR__.'/../app/Domains/Ticket/routes/api.php';
require __DIR__.'/../app/Domains/Event/routes/api.php';
require __DIR__.'/../app/Domains/Forms/routes/api.php';

View File

@@ -0,0 +1,270 @@
<?php
namespace Tests\Feature\Event;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Database\Seeders\SocialMediaSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppEventControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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<string, mixed> */
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;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature\Forms;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Database\Seeders\SocialMediaSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppEventFormControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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();
}
}