feat(event): add per-user date change notices
This commit is contained in:
@@ -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>
|
||||
*/
|
||||
|
||||
22
app/Domains/Event/Controllers/EventDateNoticeController.php
Normal file
22
app/Domains/Event/Controllers/EventDateNoticeController.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
41
app/Domains/Event/Models/EventDateChangeView.php
Normal file
41
app/Domains/Event/Models/EventDateChangeView.php
Normal 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);
|
||||
}
|
||||
}
|
||||
20
app/Domains/Event/Resources/EventDateNoticeResource.php
Normal file
20
app/Domains/Event/Resources/EventDateNoticeResource.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
60
app/Domains/Event/Services/EventDateNoticeService.php
Normal file
60
app/Domains/Event/Services/EventDateNoticeService.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
145
tests/Feature/Event/EventDateNoticeControllerTest.php
Normal file
145
tests/Feature/Event/EventDateNoticeControllerTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user