refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared

This commit is contained in:
2026-09-18 10:14:02 -03:00
parent 4659c1049d
commit 1241e1f7e8
425 changed files with 0 additions and 0 deletions

View File

@@ -1,70 +0,0 @@
<?php
namespace App\Domains\Event\Controllers\AdminApp;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Requests\RescheduleEventDateRequest;
use App\Domains\Event\Requests\StoreEventDateRequest;
use App\Domains\Event\Requests\UpdateEventRequest;
use App\Domains\Event\Resources\EventDateResource;
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->forTenant($request->user()->tenant()->firstOrFail())
);
}
public function update(UpdateEventRequest $request): EventResource
{
return EventResource::make(
$this->eventService->updateForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated()
)
);
}
public function storeDate(StoreEventDateRequest $request): EventDateResource
{
return EventDateResource::make(
$this->eventService->createDateForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated(),
)
);
}
public function rescheduleDate(
RescheduleEventDateRequest $request,
EventDate $eventDate,
): EventDateResource {
return EventDateResource::make(
$this->eventService->rescheduleDateForTenant(
$request->user()->tenant()->firstOrFail(),
$eventDate,
$request->validated(),
$request->user(),
)
);
}
public function suspendDate(Request $request, EventDate $eventDate): EventDateResource
{
return EventDateResource::make(
$this->eventService->suspendDateForTenant(
$request->user()->tenant()->firstOrFail(),
$eventDate,
$request->user(),
)
);
}
}

View File

@@ -1,22 +0,0 @@
<?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

@@ -1,9 +0,0 @@
<?php
namespace App\Domains\Event\Enums;
enum EventDateChangeType: string
{
case Rescheduled = 'rescheduled';
case Suspended = 'suspended';
}

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Domains\Event\Enums;
enum EventDateStatus: string
{
case Rescheduled = 'rescheduled';
case Suspended = 'suspended';
case Scheduled = 'scheduled';
case InProgress = 'in_progress';
case Completed = 'completed';
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Domains\Event\Events;
use Illuminate\Foundation\Events\Dispatchable;
class EventDateRescheduled
{
use Dispatchable;
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function __construct(
public readonly string $tenantCode,
public readonly int $sourceEventDateId,
public readonly int $destinationEventDateId,
public readonly string $previousDate,
public readonly string $newDate,
public readonly array $purchaseTickets,
) {}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Domains\Event\Events;
use Illuminate\Foundation\Events\Dispatchable;
class EventDateSuspended
{
use Dispatchable;
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function __construct(
public readonly string $tenantCode,
public readonly int $eventDateId,
public readonly string $date,
public readonly array $purchaseTickets,
) {}
}

View File

@@ -1,201 +0,0 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Services\EffectiveEventDateResolver;
use App\Domains\Event\Services\EventDateTextFormatter;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
#[Fillable([
'tenant_code',
'date',
'time_start',
'time_end',
'rescheduled_to_event_date_id',
'suspended_at',
])]
class EventDate extends Model
{
use HasFactory;
public $timestamps = false;
protected $appends = ['status'];
protected static function booted(): void
{
static::creating(fn (self $eventDate) => $eventDate->syncValidityTime());
static::created(fn (self $eventDate) => $eventDate->syncTenantDateText());
static::updated(function (self $eventDate): void {
if ($eventDate->wasChanged(['date', 'time_start', 'time_end'])) {
$eventDate->syncValidityTime();
}
$eventDate->syncTenantDateText();
});
static::deleted(function (self $eventDate): void {
$eventDate->syncTenantDateText();
ValidityTime::query()
->whereKey($eventDate->validity_time_id)
->delete();
});
}
protected function casts(): array
{
return [
'date' => 'date:Y-m-d',
'validity_time_id' => 'integer',
'rescheduled_to_event_date_id' => 'integer',
'suspended_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return BelongsTo<EventDate, $this> */
public function rescheduledTo(): BelongsTo
{
return $this->belongsTo(self::class, 'rescheduled_to_event_date_id');
}
public function effectiveDate(): ?self
{
return app(EffectiveEventDateResolver::class)->resolve($this);
}
/** @return HasMany<EventDate, $this> */
public function rescheduledFrom(): HasMany
{
return $this->hasMany(self::class, 'rescheduled_to_event_date_id');
}
/** @return HasMany<EventDateChange, $this> */
public function changeHistory(): HasMany
{
return $this->hasMany(EventDateChange::class, 'source_event_date_id');
}
/** @return HasMany<EventDateChange, $this> */
public function destinationChangeHistory(): HasMany
{
return $this->hasMany(EventDateChange::class, 'destination_event_date_id');
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasMany(Variant::class);
}
/** @return BelongsToMany<Variant, $this> */
public function selectedByVariants(): BelongsToMany
{
return $this->belongsToMany(
Variant::class,
'variant_event_dates',
'event_date_id',
'variant_id',
);
}
public function startsAt(): CarbonInterface
{
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start);
}
public function endsAt(): CarbonInterface
{
$endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
return $endsAt->lessThanOrEqualTo($this->startsAt())
? $endsAt->addDay()
: $endsAt;
}
public function getStatusAttribute(): EventDateStatus
{
if ($this->rescheduled_to_event_date_id !== null) {
return EventDateStatus::Rescheduled;
}
if ($this->suspended_at !== null) {
return EventDateStatus::Suspended;
}
if (now()->lt($this->startsAt())) {
return EventDateStatus::Scheduled;
}
if (now()->lt($this->endsAt())) {
return EventDateStatus::InProgress;
}
return EventDateStatus::Completed;
}
private function syncTenantDateText(): void
{
$tenant = $this->tenant()->first();
if (! $tenant) {
return;
}
$tenant->update([
'event_date_text' => app(EventDateTextFormatter::class)->format(
$tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->pluck('date')
),
]);
}
private function syncValidityTime(): void
{
$startsAt = $this->startsAt();
$expiresAt = $this->endsAt();
$attributes = [
'type' => ValidityTimeType::FixedWindow,
'start_time' => null,
'end_time' => null,
'fixed_starts_at' => $startsAt,
'fixed_expires_at' => $expiresAt,
];
if ($this->validity_time_id === null) {
$validityTime = ValidityTime::query()->create($attributes);
$this->validity_time_id = $validityTime->getKey();
$this->setRelation('validityTime', $validityTime);
return;
}
$this->validityTime()->update($attributes);
$this->unsetRelation('validityTime');
}
}

View File

@@ -1,68 +0,0 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Event\Enums\EventDateChangeType;
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',
'change_type',
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
])]
class EventDateChange extends Model
{
public $timestamps = false;
protected function casts(): array
{
return [
'change_type' => EventDateChangeType::class,
'source_event_date_id' => 'integer',
'destination_event_date_id' => 'integer',
'created_by_user_id' => 'integer',
'previous_date' => 'date:Y-m-d',
'new_date' => 'date:Y-m-d',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<EventDate, $this> */
public function sourceEventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class, 'source_event_date_id');
}
/** @return BelongsTo<EventDate, $this> */
public function destinationEventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class, 'destination_event_date_id');
}
/** @return BelongsTo<User, $this> */
public function createdBy(): BelongsTo
{
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

@@ -1,41 +0,0 @@
<?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

@@ -1,21 +0,0 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RescheduleEventDateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'date' => ['required', 'date_format:Y-m-d'],
];
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEventDateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'date' => ['required', 'date_format:Y-m-d'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['required', 'date_format:H:i'],
];
}
}

View File

@@ -1,100 +0,0 @@
<?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'],
'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'],
'allow_ticket_refund' => ['sometimes', 'boolean'],
'allow_ticket_total_refund' => ['sometimes', 'boolean'],
'allow_ticket_partial_refund' => ['sometimes', 'boolean'],
'ticket_partial_refund_percentage' => [
'sometimes',
'numeric',
'decimal:0,2',
'min:0',
'max:99.99',
],
];
}
/** @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.'
);
}
if (! array_key_exists('allow_ticket_refund', $input)) {
return;
}
foreach ([
'allow_ticket_total_refund',
'allow_ticket_partial_refund',
'ticket_partial_refund_percentage',
] as $field) {
if (! array_key_exists($field, $input)) {
$validator->errors()->add($field, 'El campo es obligatorio.');
}
}
$totalEnabled = $this->boolean('allow_ticket_total_refund');
$partialEnabled = $this->boolean('allow_ticket_partial_refund');
$refundEnabled = $this->boolean('allow_ticket_refund');
if ($refundEnabled && ! $totalEnabled && ! $partialEnabled) {
$validator->errors()->add(
'allow_ticket_refund',
'Seleccioná al menos un tipo de reembolso.'
);
}
if ($refundEnabled
&& $partialEnabled
&& (float) ($input['ticket_partial_refund_percentage'] ?? 0) <= 0) {
$validator->errors()->add(
'ticket_partial_refund_percentage',
'Ingresá un porcentaje mayor que cero para el reembolso parcial.'
);
}
},
];
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin EventDateChange */
class EventDateChangeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'type' => $this->change_type->value,
'source_event_date_id' => $this->source_event_date_id,
'destination_event_date_id' => $this->destination_event_date_id,
'previous_date' => $this->previous_date->format('Y-m-d'),
'new_date' => $this->new_date?->format('Y-m-d'),
'occurred_at' => $this->created_at->toISOString(),
];
}
}

View File

@@ -1,20 +0,0 @@
<?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

@@ -1,31 +0,0 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin EventDate */
class EventDateResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'validity_time_id' => $this->validity_time_id,
'validity_time' => ValidityTimeResource::make($this->whenLoaded('validityTime')),
'date' => $this->date->format('Y-m-d'),
'start_time' => substr($this->time_start, 0, 5),
'end_time' => substr($this->time_end, 0, 5),
'status' => $this->status->value,
'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id,
'suspended_at' => $this->suspended_at?->toISOString(),
'rescheduled_dates' => EventDateResource::collection(
$this->whenLoaded('adminRescheduledDates')
),
];
}
}

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Services\EventDateGroupingService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Tenant */
class EventResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$socialMedia = $this->socialMedia->keyBy('code');
return [
'id' => $this->id,
'title' => $this->event_title,
'location' => $this->event_location,
'allow_ticket_refund' => $this->allow_ticket_refund,
'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
'dates' => EventDateResource::collection(
app(EventDateGroupingService::class)->group($this->eventDates)
),
'social_media' => $this->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

@@ -1,57 +0,0 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class AffectedEventDatePurchaseResolver
{
/**
* Finds active tickets belonging to paid purchases before an event-date mutation.
*
* @param Collection<int, int>|list<int> $eventDateIds
* @return list<array{purchase_id: int, ticket_ids: list<int>}>
*/
public function resolve(Tenant $tenant, Collection|array $eventDateIds): array
{
$eventDateIds = collect($eventDateIds)->map(fn (mixed $id): int => (int) $id)->unique()->values();
if ($eventDateIds->isEmpty()) {
return [];
}
/** @var Collection<int, Ticket> $tickets */
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->whereHas('sourcePurchaseItem.purchase', fn (Builder $query) => $query
->where('status', Purchase::STATUS_PAID))
->whereHas('sourceVariant', function (Builder $query) use ($eventDateIds): void {
$query->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn (Builder $eventDates) => $eventDates
->whereIn('event_dates.id', $eventDateIds));
})
->with([
...TicketValidityResolver::RELATIONS,
'sourcePurchaseItem.purchase',
])
->get()
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->values();
return $tickets
->groupBy(fn (Ticket $ticket): int => (int) $ticket->sourcePurchaseItem->purchase->getKey())
->map(function (Collection $purchaseTickets): array {
return [
'purchase_id' => (int) $purchaseTickets->first()->sourcePurchaseItem->purchase->getKey(),
'ticket_ids' => $purchaseTickets->modelKeys(),
];
})
->values()
->all();
}
}

View File

@@ -1,45 +0,0 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\EventDate;
class EffectiveEventDateResolver
{
public function resolve(EventDate $eventDate): ?EventDate
{
$date = $this->resolveLatest($eventDate);
return $date !== null && $date->suspended_at === null ? $date : null;
}
/** Sigue las reprogramaciones para presentación, incluso si el destino está suspendido. */
public function resolveLatest(EventDate $eventDate): ?EventDate
{
$current = $eventDate;
$visited = [];
while (true) {
$identity = $current->getKey() === null
? 'object:'.spl_object_id($current)
: 'key:'.$current->getKey();
if (isset($visited[$identity])) {
return null;
}
$visited[$identity] = true;
if ($current->rescheduled_to_event_date_id === null) {
return $current;
}
$current->loadMissing('rescheduledTo');
$current = $current->rescheduledTo;
if ($current === null) {
return null;
}
}
}
}

View File

@@ -1,101 +0,0 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\EventDate;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
class EventDateGroupingService
{
/**
* Groups every historical date under its final active destination.
*
* @param Collection<int, EventDate> $dates
* @return Collection<int, EventDate>
*/
public function group(Collection $dates): Collection
{
$byId = $dates->keyBy(fn (EventDate $date): int => (int) $date->getKey());
$groups = collect();
foreach ($dates as $date) {
$destination = $this->finalDestination($date, $byId);
$key = (int) $destination->getKey();
if (! $groups->has($key)) {
$groups->put($key, [
'destination' => $destination,
'rescheduled' => collect(),
]);
}
if (! $date->is($destination)) {
$group = $groups->get($key);
$historicalDate = clone $date;
$historicalDate->setAttribute(
'rescheduled_to_event_date_id',
$destination->getKey(),
);
$group['rescheduled']->push($historicalDate);
$groups->put($key, $group);
}
}
return $groups
->map(function (array $group): EventDate {
/** @var EventDate $destination */
$destination = clone $group['destination'];
/** @var Collection<int, EventDate> $rescheduled */
$rescheduled = $group['rescheduled'];
$destination->setRelation(
'adminRescheduledDates',
new EloquentCollection($rescheduled->sort($this->dateSorter())->values()->all()),
);
return $destination;
})
->sort($this->dateSorter())
->values();
}
/** @param Collection<int, EventDate> $byId */
private function finalDestination(EventDate $date, Collection $byId): EventDate
{
$current = $date;
$visited = collect();
while ($current->rescheduled_to_event_date_id !== null) {
$currentId = (int) $current->getKey();
if ($visited->contains($currentId)) {
break;
}
$visited->push($currentId);
$destination = $byId->get((int) $current->rescheduled_to_event_date_id);
if (! $destination instanceof EventDate) {
break;
}
$current = $destination;
}
return $current;
}
/** @return callable(EventDate, EventDate): int */
private function dateSorter(): callable
{
return fn (EventDate $left, EventDate $right): int => [
$left->date->format('Y-m-d'),
$left->time_start,
$left->getKey(),
] <=> [
$right->date->format('Y-m-d'),
$right->time_start,
$right->getKey(),
];
}
}

View File

@@ -1,76 +0,0 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Support\Collection;
class EventDateInfoFormatter
{
/** @param Collection<int, EventDateChange> $changes */
public function format(EventDate $eventDate, Collection $changes): ?string
{
$messages = collect();
if ($eventDate->suspended_at !== null) {
$messages->push('Esta fecha fue cancelada.');
}
$sourceDates = $this->reschedulesEndingAt($eventDate, $changes)
->pluck('previous_date')
->filter()
->map(fn ($date): string => $date->format('d/m/Y'))
->unique()
->values();
if ($sourceDates->isNotEmpty()) {
$verb = $sourceDates->count() === 1 ? 'se reprogramó' : 'se reprogramaron';
$messages->push("{$sourceDates->join(', ', ' y ')} {$verb} para este día.");
}
return $messages->isEmpty() ? null : $messages->join(' ');
}
/**
* Includes direct and intermediate reschedules that ultimately end at the
* displayed event date, while preserving the original change order.
*
* @param Collection<int, EventDateChange> $changes
* @return Collection<int, EventDateChange>
*/
private function reschedulesEndingAt(EventDate $eventDate, Collection $changes): Collection
{
$eventDateId = $eventDate->getKey();
$reschedules = $changes->where('change_type', EventDateChangeType::Rescheduled);
if ($eventDateId === null) {
return $reschedules->where('destination_event_date_id', null);
}
$destinationIds = [(int) $eventDateId => true];
do {
$foundAncestor = false;
foreach ($reschedules as $change) {
$destinationId = $change->destination_event_date_id;
$sourceId = $change->source_event_date_id;
if ($destinationId === null || $sourceId === null) {
continue;
}
if (isset($destinationIds[(int) $destinationId]) && ! isset($destinationIds[(int) $sourceId])) {
$destinationIds[(int) $sourceId] = true;
$foundAncestor = true;
}
}
} while ($foundAncestor);
return $reschedules
->filter(fn (EventDateChange $change): bool => $change->destination_event_date_id !== null
&& isset($destinationIds[(int) $change->destination_event_date_id]));
}
}

View File

@@ -1,126 +0,0 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Support\Collection;
class EventDateNoticeFormatter
{
public function __construct(private readonly EventDateTextFormatter $dateTextFormatter) {}
/**
* @param Collection<int, EventDateChange> $changes
* @return list<array{
* type: string,
* change_ids: list<int>,
* title: string,
* message: list<array{text: string, bold: bool}>
* }>
*/
public function format(Collection $changes): array
{
return collect([
$this->suspensionNotice(
$changes->where('change_type', EventDateChangeType::Suspended)
),
$this->rescheduleNotice(
$changes->where('change_type', EventDateChangeType::Rescheduled)
),
])->filter()->values()->all();
}
/**
* @param Collection<int, EventDateChange> $changes
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
*/
private function suspensionNotice(Collection $changes): ?array
{
$dates = $this->formatDates($changes, 'previous_date');
if ($dates === null) {
return null;
}
$plural = $changes->count() > 1;
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],
['text' => $dates, 'bold' => true],
['text' => $plural ? ' han sido canceladas.' : ' ha sido cancelada.', 'bold' => false],
],
];
}
/**
* @param Collection<int, EventDateChange> $changes
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
*/
private function rescheduleNotice(Collection $changes): ?array
{
$changes = $changes->whereNotNull('new_date');
$sourceDates = $this->formatDates($changes, 'previous_date');
$destinationDates = $this->formatDates($changes, 'new_date');
if ($sourceDates === null || $destinationDates === null) {
return null;
}
$plural = $changes->count() > 1;
$message = [
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
['text' => $sourceDates, 'bold' => true],
[
'text' => $plural ? ' han sido reprogramadas para el ' : ' ha sido reprogramada para el ',
'bold' => false,
],
['text' => $destinationDates, 'bold' => true],
];
if ($plural) {
$message[] = ['text' => ', ', 'bold' => false];
$message[] = ['text' => 'respectivamente', 'bold' => true];
}
$message[] = ['text' => '.', 'bold' => false];
return [
'type' => EventDateChangeType::Rescheduled->value,
'change_ids' => $this->changeIds($changes),
'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!',
'message' => $message,
];
}
/**
* @param Collection<int, EventDateChange> $changes
*/
private function formatDates(Collection $changes, string $attribute): ?string
{
return $this->dateTextFormatter->formatForSentence(
$changes
->pluck($attribute)
->filter()
->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

@@ -1,60 +0,0 @@
<?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

@@ -1,90 +0,0 @@
<?php
namespace App\Domains\Event\Services;
use DateTimeImmutable;
class EventDateTextFormatter
{
/** @var array<int, string> */
private const MONTHS = [
1 => 'Enero',
2 => 'Febrero',
3 => 'Marzo',
4 => 'Abril',
5 => 'Mayo',
6 => 'Junio',
7 => 'Julio',
8 => 'Agosto',
9 => 'Septiembre',
10 => 'Octubre',
11 => 'Noviembre',
12 => 'Diciembre',
];
/** @param iterable<string> $dates */
public function format(iterable $dates): ?string
{
return $this->formatWithOptions($dates, false, false);
}
/** @param iterable<string> $dates */
public function formatForSentence(iterable $dates): ?string
{
return $this->formatWithOptions($dates, true, true);
}
/** @param iterable<string> $dates */
private function formatWithOptions(
iterable $dates,
bool $padDays,
bool $includeYearPreposition,
): ?string {
$normalizedDates = collect($dates)
->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date))
->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
->sortBy(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
->values();
if ($normalizedDates->isEmpty()) {
return null;
}
$years = $normalizedDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y'))
->map(function ($yearDates, string $year) use ($padDays, $includeYearPreposition): string {
$months = $yearDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('n'))
->map(function ($monthDates, string $month) use ($padDays): string {
$days = $monthDates
->map(fn (DateTimeImmutable $date): string => $padDays
? $date->format('d')
: (string) ((int) $date->format('j')))
->values()
->all();
return $this->join($days).' de '.self::MONTHS[(int) $month];
})
->values()
->all();
return $this->join($months).($includeYearPreposition ? ' de ' : ' ').$year;
})
->values()
->all();
return $this->join($years);
}
/** @param array<int, string> $parts */
private function join(array $parts): string
{
if (count($parts) <= 1) {
return $parts[0] ?? '';
}
$last = array_pop($parts);
return implode(', ', $parts).' y '.$last;
}
}

View File

@@ -1,350 +0,0 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Catalog\Services\VariantReplacementService;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EventService
{
private const CONTACT_CODES = [
'whatsapp_url' => 'whatsapp',
'instagram_url' => 'instagram',
'facebook_url' => 'facebook',
];
public function __construct(
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
private readonly VariantReplacementService $variantReplacementService,
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
) {}
public function forTenant(Tenant $tenant): Tenant
{
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
}
/** @param array<string, mixed> $data */
public function updateForTenant(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Tenant {
$tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
$tenant->update([
'event_title' => $data['title'],
'event_location' => $data['location'],
...array_intersect_key($data, array_flip([
'allow_ticket_refund',
'allow_ticket_total_refund',
'allow_ticket_partial_refund',
'ticket_partial_refund_percentage',
])),
]);
if (array_key_exists('social_media', $data)) {
$this->syncSocialMedia($tenant, $data['social_media']);
} else {
$this->syncLegacyContact($tenant, $data['contact']);
}
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
});
}
/** @param array{date: string, start_time: string, end_time: string} $data */
public function createDateForTenant(Tenant $tenant, array $data): EventDate
{
return DB::transaction(function () use ($tenant, $data): EventDate {
$attributes = $this->dateAttributes($data);
if ($tenant->eventDates()->where($attributes)->exists()) {
throw ValidationException::withMessages([
'date' => ['La fecha y el horario ya existen.'],
]);
}
return $tenant->eventDates()->create($attributes)->load('validityTime');
});
}
/** @param array{date: string} $data */
public function rescheduleDateForTenant(
Tenant $tenant,
EventDate $eventDate,
array $data,
?User $createdBy = null,
): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
$source = $this->lockedDateForTenant($tenant, $eventDate);
if ($source->suspended_at !== null) {
throw ValidationException::withMessages([
'event_date' => ['No se puede reprogramar una fecha suspendida.'],
]);
}
if ($source->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([
'event_date' => ['La fecha ya fue reprogramada.'],
]);
}
$destination = $tenant->eventDates()
->whereDate('date', $data['date'])
->lockForUpdate()
->first();
if ($destination === null) {
$destination = $tenant->eventDates()->create([
'date' => $data['date'],
'time_start' => $source->time_start,
'time_end' => $source->time_end,
]);
}
if ($destination->is($source) || $this->chainContains($destination, $source)) {
throw ValidationException::withMessages([
'date' => ['La reprogramación generaría una referencia circular.'],
]);
}
$effectiveDestination = $this->effectiveEventDateResolver->resolve($destination);
if ($effectiveDestination === null) {
throw ValidationException::withMessages([
'date' => ['La fecha de destino no es utilizable.'],
]);
}
$affectedDateIds = $this->affectedDateIds($tenant, $source);
$this->invalidateEventDateCarts->invalidate($tenant, $affectedDateIds);
$purchaseTickets = $this->affectedPurchaseResolver->resolve(
$tenant,
$affectedDateIds,
);
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
$this->variantReplacementService->replaceEventDate($source, $effectiveDestination);
EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => EventDateChangeType::Rescheduled,
'source_event_date_id' => $source->getKey(),
'destination_event_date_id' => $destination->getKey(),
'created_by_user_id' => $createdBy?->getKey(),
'previous_date' => $source->date->format('Y-m-d'),
'new_date' => $destination->date->format('Y-m-d'),
]);
EventDateRescheduled::dispatch(
$tenant->codigo,
$source->getKey(),
$destination->getKey(),
$source->date->format('d/m/Y'),
$destination->date->format('d/m/Y'),
$purchaseTickets,
);
return $source->fresh(['validityTime', 'rescheduledTo.validityTime']);
});
}
public function suspendDateForTenant(
Tenant $tenant,
EventDate $eventDate,
?User $createdBy = null,
): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
$date = $this->lockedDateForTenant($tenant, $eventDate);
if ($date->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([
'event_date' => ['No se puede suspender una fecha que ya fue reprogramada.'],
]);
}
if ($date->suspended_at !== null) {
return $date->load('validityTime');
}
$affectedDateIds = $this->affectedDateIds($tenant, $date);
$this->invalidateEventDateCarts->invalidate(
$tenant,
$affectedDateIds,
StockReservationService::REASON_EVENT_DATE_SUSPENDED,
);
$purchaseTickets = $this->affectedPurchaseResolver->resolve($tenant, $affectedDateIds);
$date->update(['suspended_at' => now()]);
$this->variantReplacementService->disableForSuspension($date);
$this->disableTicketsWithoutUsableDates($tenant, $date);
EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => EventDateChangeType::Suspended,
'source_event_date_id' => $date->getKey(),
'destination_event_date_id' => null,
'created_by_user_id' => $createdBy?->getKey(),
'previous_date' => $date->date->format('Y-m-d'),
'new_date' => null,
]);
EventDateSuspended::dispatch(
$tenant->codigo,
$date->getKey(),
$date->date->format('d/m/Y'),
$purchaseTickets,
);
return $date->fresh('validityTime');
});
}
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate
{
return $tenant->eventDates()
->whereKey($eventDate->getKey())
->lockForUpdate()
->firstOrFail();
}
private function chainContains(EventDate $start, EventDate $expected): bool
{
$current = $start;
$visited = [];
while ($current->rescheduled_to_event_date_id !== null) {
if ($current->is($expected)) {
return true;
}
if (isset($visited[$current->getKey()])) {
return true;
}
$visited[$current->getKey()] = true;
$current = $current->rescheduledTo()->lockForUpdate()->first();
if ($current === null) {
return false;
}
}
return $current->is($expected);
}
/** @return Collection<int, int> */
private function affectedDateIds(Tenant $tenant, EventDate $eventDate): Collection
{
$affectedDateIds = collect([$eventDate->getKey()]);
$frontier = $affectedDateIds;
while ($frontier->isNotEmpty()) {
$predecessors = $tenant->eventDates()
->whereIn('rescheduled_to_event_date_id', $frontier)
->pluck('id')
->diff($affectedDateIds)
->values();
$affectedDateIds = $affectedDateIds->merge($predecessors)->unique()->values();
$frontier = $predecessors;
}
return $affectedDateIds;
}
private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void
{
$affectedDateIds = $this->affectedDateIds($tenant, $suspendedDate);
$variants = Variant::withTrashed()
->where(function ($query) use ($affectedDateIds): void {
$query->whereIn('event_date_id', $affectedDateIds)
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->whereIn('event_dates.id', $affectedDateIds));
})
->with(['eventDates', 'eventDate'])
->get();
foreach ($variants as $variant) {
$hasUsableDate = $variant->selectedEventDates()->contains(
fn (EventDate $candidate): bool => $this->effectiveEventDateResolver->resolve($candidate) !== null
);
if ($hasUsableDate) {
continue;
}
Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('source_variant_id', $variant->getKey())
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at')
->lockForUpdate()
->get()
->each(function (Ticket $ticket): void {
$ticket->markAsDisabled();
$ticket->save();
});
}
}
/**
* @param array{date: string, start_time: string, end_time: string} $data
* @return array{date: string, time_start: string, time_end: string}
*/
private function dateAttributes(array $data): array
{
return [
'date' => $data['date'],
'time_start' => $data['start_time'].':00',
'time_end' => $data['end_time'].':00',
];
}
/** @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

@@ -1,37 +0,0 @@
# Dominio Event
## Propósito
Administra la configuración temporal de un tenant orientado a eventos y sus fechas disponibles.
## Componentes
- `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.
## Endpoints
Bajo `/v1/adminapp/tenant/event`, protegidos por `auth:sanctum` y `adminapp.tenant`:
- `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
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,14 +0,0 @@
<?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']);
Route::post('event-dates', [EventController::class, 'storeDate']);
Route::post('event-dates/{eventDate}/reschedule', [EventController::class, 'rescheduleDate']);
Route::post('event-dates/{eventDate}/suspend', [EventController::class, 'suspendDate']);
});

View File

@@ -1,11 +0,0 @@
<?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'],
);