From d02ad5fce25813c9a0d9a834b2f71d293b493218 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 15 Sep 2026 17:06:12 -0300 Subject: [PATCH] feat(event): add per-user date change notices --- app/Domains/Auth/Models/User.php | 7 + .../Controllers/EventDateNoticeController.php | 22 +++ app/Domains/Event/Models/EventDateChange.php | 7 + .../Event/Models/EventDateChangeView.php | 41 +++++ .../Resources/EventDateNoticeResource.php | 20 +++ .../Services/EventDateNoticeFormatter.php | 21 ++- .../Event/Services/EventDateNoticeService.php | 60 ++++++++ app/Domains/Event/documentacion/README.md | 11 +- app/Domains/Event/routes/api.php | 8 + ...ate_user_event_date_change_views_table.php | 27 ++++ .../Event/EventDateNoticeControllerTest.php | 145 ++++++++++++++++++ .../Event/EventDateNoticeFormatterTest.php | 4 + 12 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 app/Domains/Event/Controllers/EventDateNoticeController.php create mode 100644 app/Domains/Event/Models/EventDateChangeView.php create mode 100644 app/Domains/Event/Resources/EventDateNoticeResource.php create mode 100644 app/Domains/Event/Services/EventDateNoticeService.php create mode 100644 database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php create mode 100644 tests/Feature/Event/EventDateNoticeControllerTest.php diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index de30c29..34f3d49 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -5,6 +5,7 @@ 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\Event\Models\EventDateChangeView; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\ScanAttempt; use Database\Factories\UserFactory; @@ -53,6 +54,12 @@ class User extends Authenticatable return $this->hasMany(ScanAttempt::class, 'scanner_user_id'); } + /** @return HasMany */ + public function eventDateChangeViews(): HasMany + { + return $this->hasMany(EventDateChangeView::class); + } + /** * @return BelongsTo */ diff --git a/app/Domains/Event/Controllers/EventDateNoticeController.php b/app/Domains/Event/Controllers/EventDateNoticeController.php new file mode 100644 index 0000000..e427663 --- /dev/null +++ b/app/Domains/Event/Controllers/EventDateNoticeController.php @@ -0,0 +1,22 @@ +noticeService->claimFor($request->user(), $tenant) + ); + } +} diff --git a/app/Domains/Event/Models/EventDateChange.php b/app/Domains/Event/Models/EventDateChange.php index 635c8d6..5fcbfab 100644 --- a/app/Domains/Event/Models/EventDateChange.php +++ b/app/Domains/Event/Models/EventDateChange.php @@ -8,6 +8,7 @@ use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'tenant_code', @@ -58,4 +59,10 @@ class EventDateChange extends Model { return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed(); } + + /** @return HasMany */ + public function views(): HasMany + { + return $this->hasMany(EventDateChangeView::class); + } } diff --git a/app/Domains/Event/Models/EventDateChangeView.php b/app/Domains/Event/Models/EventDateChangeView.php new file mode 100644 index 0000000..199418f --- /dev/null +++ b/app/Domains/Event/Models/EventDateChangeView.php @@ -0,0 +1,41 @@ + 'integer', + 'event_date_change_id' => 'integer', + 'display_count' => 'integer', + 'last_displayed_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return BelongsTo */ + public function eventDateChange(): BelongsTo + { + return $this->belongsTo(EventDateChange::class); + } +} diff --git a/app/Domains/Event/Resources/EventDateNoticeResource.php b/app/Domains/Event/Resources/EventDateNoticeResource.php new file mode 100644 index 0000000..dd661b8 --- /dev/null +++ b/app/Domains/Event/Resources/EventDateNoticeResource.php @@ -0,0 +1,20 @@ + */ + public function toArray(Request $request): array + { + return [ + 'type' => $this->resource['type'], + 'change_ids' => $this->resource['change_ids'], + 'title' => $this->resource['title'], + 'message' => $this->resource['message'], + ]; + } +} diff --git a/app/Domains/Event/Services/EventDateNoticeFormatter.php b/app/Domains/Event/Services/EventDateNoticeFormatter.php index ee38744..0a4c69d 100644 --- a/app/Domains/Event/Services/EventDateNoticeFormatter.php +++ b/app/Domains/Event/Services/EventDateNoticeFormatter.php @@ -14,6 +14,7 @@ class EventDateNoticeFormatter * @param Collection $changes * @return list, * title: string, * message: list * }> @@ -32,7 +33,7 @@ class EventDateNoticeFormatter /** * @param Collection $changes - * @return array{type: string, title: string, message: list}|null + * @return array{type: string, change_ids: list, title: string, message: list}|null */ private function suspensionNotice(Collection $changes): ?array { @@ -46,6 +47,7 @@ class EventDateNoticeFormatter return [ 'type' => EventDateChangeType::Suspended->value, + 'change_ids' => $this->changeIds($changes), 'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!', 'message' => [ ['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false], @@ -57,7 +59,7 @@ class EventDateNoticeFormatter /** * @param Collection $changes - * @return array{type: string, title: string, message: list}|null + * @return array{type: string, change_ids: list, title: string, message: list}|null */ private function rescheduleNotice(Collection $changes): ?array { @@ -89,6 +91,7 @@ class EventDateNoticeFormatter return [ 'type' => EventDateChangeType::Rescheduled->value, + 'change_ids' => $this->changeIds($changes), 'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!', 'message' => $message, ]; @@ -106,4 +109,18 @@ class EventDateNoticeFormatter ->map(fn ($date): string => $date->format('Y-m-d')) ); } + + /** + * @param Collection $changes + * @return list + */ + private function changeIds(Collection $changes): array + { + return $changes + ->pluck('id') + ->filter(fn ($id): bool => $id !== null) + ->map(fn ($id): int => (int) $id) + ->values() + ->all(); + } } diff --git a/app/Domains/Event/Services/EventDateNoticeService.php b/app/Domains/Event/Services/EventDateNoticeService.php new file mode 100644 index 0000000..239e3fa --- /dev/null +++ b/app/Domains/Event/Services/EventDateNoticeService.php @@ -0,0 +1,60 @@ +, + * title: string, + * message: list + * }> + */ + public function claimFor(User $user, Tenant $tenant): array + { + return DB::transaction(function () use ($user, $tenant): array { + $lockedUser = User::query()->whereKey($user->getKey())->lockForUpdate()->firstOrFail(); + + $changes = EventDateChange::query() + ->where('tenant_code', $tenant->codigo) + ->whereDoesntHave('views', fn ($query) => $query + ->where('user_id', $lockedUser->getKey()) + ->where('display_count', '>=', self::MAX_DISPLAYS)) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + $notices = $this->formatter->format($changes); + $claimedChangeIds = collect($notices)->pluck('change_ids')->flatten()->unique(); + + foreach ($claimedChangeIds as $changeId) { + $view = EventDateChangeView::query()->firstOrNew([ + 'user_id' => $lockedUser->getKey(), + 'event_date_change_id' => $changeId, + ]); + $view->display_count = min( + self::MAX_DISPLAYS, + ((int) $view->display_count) + 1, + ); + $view->last_displayed_at = now(); + $view->save(); + } + + return $notices; + }); + } +} diff --git a/app/Domains/Event/documentacion/README.md b/app/Domains/Event/documentacion/README.md index 6959a0b..dd46873 100644 --- a/app/Domains/Event/documentacion/README.md +++ b/app/Domains/Event/documentacion/README.md @@ -8,6 +8,7 @@ Administra la configuración temporal de un tenant orientado a eventos y sus fec - `Models/EventDate.php`: fecha del evento con inicio, fin, tenant y variantes asociadas. - `Services/EventService.php`: obtiene y actualiza la configuración de evento del tenant. +- `Services/EventDateNoticeService.php`: reclama y agrupa los cambios pendientes de cada usuario. - `Controllers/AdminApp/EventController.php`: consulta y modificación desde AdminApp. - `UpdateEventRequest`: valida datos y reglas cruzadas de fechas. - `EventResource`: serializa la configuración de salida. @@ -19,10 +20,18 @@ Bajo `/v1/adminapp/tenant/event`, protegidos por `auth:sanctum` y `adminapp.tena - `GET`: obtiene la configuración. - `PUT`: actualiza la configuración. +Para el storefront autenticado: + +- `POST /tenants/{tenant}/event-date-notices/claim`: devuelve hasta un aviso de suspensiones y otro de + reprogramaciones. Cada cambio se muestra como máximo tres veces por usuario. + ## Dependencias Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets. ## Consideraciones -El archivo `routes/api.php` no publica operaciones adicionales. Al modificar fechas debe mantenerse la validación de orden y coherencia temporal de `UpdateEventRequest`. +Los avisos se construyen dinámicamente después de excluir los cambios que el usuario ya vio +tres veces. Al reclamar los avisos se incrementa una vez cada cambio incluido, aunque varios +cambios aparezcan agrupados en el mismo mensaje. El reclamo bloquea al usuario durante la +transacción para impedir que pestañas concurrentes superen el máximo. diff --git a/app/Domains/Event/routes/api.php b/app/Domains/Event/routes/api.php index 062e0fe..19a1261 100644 --- a/app/Domains/Event/routes/api.php +++ b/app/Domains/Event/routes/api.php @@ -1,3 +1,11 @@ post( + 'tenants/{tenant:codigo}/event-date-notices/claim', + [EventDateNoticeController::class, 'claim'], +); diff --git a/database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php b/database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php new file mode 100644 index 0000000..a1695a4 --- /dev/null +++ b/database/migrations/2026_09_15_000000_create_user_event_date_change_views_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('event_date_change_id')->constrained('event_date_changes')->cascadeOnDelete(); + $table->unsignedTinyInteger('display_count')->default(0); + $table->timestamp('last_displayed_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'event_date_change_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_event_date_change_views'); + } +}; diff --git a/tests/Feature/Event/EventDateNoticeControllerTest.php b/tests/Feature/Event/EventDateNoticeControllerTest.php new file mode 100644 index 0000000..77e3f38 --- /dev/null +++ b/tests/Feature/Event/EventDateNoticeControllerTest.php @@ -0,0 +1,145 @@ +seed(AuthorizationSeeder::class); + } + + public function test_authentication_is_required_to_claim_notices(): void + { + $tenant = $this->createTenant('acme'); + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertUnauthorized(); + } + + public function test_changes_are_grouped_dynamically_for_each_users_pending_history(): void + { + $tenant = $this->createTenant('acme'); + $userA = $this->createUser($tenant); + + $rescheduled = collect([ + $this->createChange($tenant, EventDateChangeType::Rescheduled, '2027-10-01', '2027-10-11'), + $this->createChange($tenant, EventDateChangeType::Rescheduled, '2027-10-02', '2027-10-12'), + ]); + $suspended = collect([ + $this->createChange($tenant, EventDateChangeType::Suspended, '2027-10-03'), + $this->createChange($tenant, EventDateChangeType::Suspended, '2027-10-04'), + ]); + + Sanctum::actingAs($userA); + + for ($display = 1; $display <= 3; $display++) { + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.type', 'suspended') + ->assertJsonPath('data.0.change_ids', $suspended->modelKeys()) + ->assertJsonPath('data.1.type', 'rescheduled') + ->assertJsonPath('data.1.change_ids', $rescheduled->modelKeys()); + } + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(0, 'data'); + + $latestReschedule = $this->createChange( + $tenant, + EventDateChangeType::Rescheduled, + '2027-10-05', + '2027-10-15', + ); + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.type', 'rescheduled') + ->assertJsonPath('data.0.change_ids', [$latestReschedule->id]) + ->assertJsonPath('data.0.title', 'FECHA REPROGRAMADA!'); + + $userB = $this->createUser($tenant); + Sanctum::actingAs($userB); + + $this->postJson("/api/tenants/{$tenant->codigo}/event-date-notices/claim") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.type', 'suspended') + ->assertJsonPath('data.0.change_ids', $suspended->modelKeys()) + ->assertJsonPath('data.1.type', 'rescheduled') + ->assertJsonPath( + 'data.1.change_ids', + [...$rescheduled->modelKeys(), $latestReschedule->id], + ) + ->assertJsonPath('data.1.title', 'FECHAS REPROGRAMADAS!'); + + foreach ([...$rescheduled, ...$suspended] as $change) { + $this->assertDatabaseHas('user_event_date_change_views', [ + 'user_id' => $userA->id, + 'event_date_change_id' => $change->id, + 'display_count' => 3, + ]); + } + + $this->assertDatabaseHas('user_event_date_change_views', [ + 'user_id' => $userA->id, + 'event_date_change_id' => $latestReschedule->id, + 'display_count' => 1, + ]); + $this->assertDatabaseCount('user_event_date_change_views', 9); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + ]); + } + + private function createUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function createChange( + Tenant $tenant, + EventDateChangeType $type, + string $previousDate, + ?string $newDate = null, + ): EventDateChange { + return EventDateChange::query()->create([ + 'tenant_code' => $tenant->codigo, + 'change_type' => $type, + 'previous_date' => $previousDate, + 'new_date' => $newDate, + ]); + } +} diff --git a/tests/Unit/Event/EventDateNoticeFormatterTest.php b/tests/Unit/Event/EventDateNoticeFormatterTest.php index af53628..8ff0c73 100644 --- a/tests/Unit/Event/EventDateNoticeFormatterTest.php +++ b/tests/Unit/Event/EventDateNoticeFormatterTest.php @@ -22,6 +22,7 @@ class EventDateNoticeFormatterTest extends TestCase $this->assertSame([ [ 'type' => 'suspended', + 'change_ids' => [], 'title' => 'FECHA CANCELADA!', 'message' => [ ['text' => 'La fecha del ', 'bold' => false], @@ -31,6 +32,7 @@ class EventDateNoticeFormatterTest extends TestCase ], [ 'type' => 'rescheduled', + 'change_ids' => [], 'title' => 'FECHA REPROGRAMADA!', 'message' => [ ['text' => 'La fecha del ', 'bold' => false], @@ -57,6 +59,7 @@ class EventDateNoticeFormatterTest extends TestCase $this->assertSame([ [ 'type' => 'suspended', + 'change_ids' => [], 'title' => 'FECHAS CANCELADAS!', 'message' => [ ['text' => 'Las fechas del ', 'bold' => false], @@ -66,6 +69,7 @@ class EventDateNoticeFormatterTest extends TestCase ], [ 'type' => 'rescheduled', + 'change_ids' => [], 'title' => 'FECHAS REPROGRAMADAS!', 'message' => [ ['text' => 'Las fechas del ', 'bold' => false],