feat(event): add per-user date change notices

This commit is contained in:
2026-09-15 17:06:12 -03:00
parent 69f2dbe056
commit d02ad5fce2
12 changed files with 370 additions and 3 deletions

View File

@@ -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<EventDateChangeView, $this> */
public function eventDateChangeViews(): HasMany
{
return $this->hasMany(EventDateChangeView::class);
}
/**
* @return BelongsTo<Role, $this>
*/

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Resources\EventDateNoticeResource;
use App\Domains\Event\Services\EventDateNoticeService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class EventDateNoticeController extends Controller
{
public function __construct(private readonly EventDateNoticeService $noticeService) {}
public function claim(Request $request, Tenant $tenant): AnonymousResourceCollection
{
return EventDateNoticeResource::collection(
$this->noticeService->claimFor($request->user(), $tenant)
);
}
}

View File

@@ -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<EventDateChangeView, $this> */
public function views(): HasMany
{
return $this->hasMany(EventDateChangeView::class);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Auth\Models\User;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'user_id',
'event_date_change_id',
'display_count',
'last_displayed_at',
])]
class EventDateChangeView extends Model
{
protected $table = 'user_event_date_change_views';
protected function casts(): array
{
return [
'user_id' => 'integer',
'event_date_change_id' => 'integer',
'display_count' => 'integer',
'last_displayed_at' => 'datetime',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<EventDateChange, $this> */
public function eventDateChange(): BelongsTo
{
return $this->belongsTo(EventDateChange::class);
}
}

View File

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

View File

@@ -14,6 +14,7 @@ class EventDateNoticeFormatter
* @param Collection<int, EventDateChange> $changes
* @return list<array{
* type: string,
* change_ids: list<int>,
* title: string,
* message: list<array{text: string, bold: bool}>
* }>
@@ -32,7 +33,7 @@ class EventDateNoticeFormatter
/**
* @param Collection<int, EventDateChange> $changes
* @return array{type: string, title: string, message: list<array{text: string, bold: bool}>}|null
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|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<int, EventDateChange> $changes
* @return array{type: string, title: string, message: list<array{text: string, bold: bool}>}|null
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|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<int, EventDateChange> $changes
* @return list<int>
*/
private function changeIds(Collection $changes): array
{
return $changes
->pluck('id')
->filter(fn ($id): bool => $id !== null)
->map(fn ($id): int => (int) $id)
->values()
->all();
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Event\Models\EventDateChangeView;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
class EventDateNoticeService
{
public const MAX_DISPLAYS = 3;
public function __construct(private readonly EventDateNoticeFormatter $formatter) {}
/**
* Claims one display of every pending change and returns them grouped by type.
*
* @return list<array{
* type: string,
* change_ids: list<int>,
* title: string,
* message: list<array{text: string, bold: bool}>
* }>
*/
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;
});
}
}

View File

@@ -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.

View File

@@ -1,3 +1,11 @@
<?php
use App\Domains\Event\Controllers\EventDateNoticeController;
use Illuminate\Support\Facades\Route;
require __DIR__.'/adminapp.php';
Route::middleware('auth:sanctum')->post(
'tenants/{tenant:codigo}/event-date-notices/claim',
[EventDateNoticeController::class, 'claim'],
);

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_event_date_change_views', function (Blueprint $table): void {
$table->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');
}
};

View File

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

View File

@@ -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],