refactor(event): move event configuration onto tenant

This commit is contained in:
2026-08-07 09:08:14 -03:00
parent 60b8415c59
commit 7b7efeb4cc
27 changed files with 433 additions and 271 deletions

View File

@@ -7,7 +7,6 @@ use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\ValidityTime;
@@ -20,7 +19,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'event_id',
'event_product_type',
'category_id',
'brand_id',
@@ -55,7 +53,6 @@ class CatalogItem extends Model
'category_id' => 'integer',
'brand_id' => 'integer',
'inventory_id' => 'integer',
'event_id' => 'integer',
'event_product_type' => EventProductType::class,
'type' => CatalogItemType::class,
'precio' => 'decimal:2',
@@ -72,12 +69,6 @@ class CatalogItem extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{

View File

@@ -26,18 +26,9 @@ class StoreCatalogItemRequest extends FormRequest
return [
'tenant_code' => ['prohibited'],
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
'event_id' => [
'sometimes',
'nullable',
'required_with:event_product_type',
Rule::exists('events', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'event_product_type' => [
'sometimes',
'nullable',
'required_with:event_id',
Rule::enum(EventProductType::class),
],
'category_id' => [
@@ -93,7 +84,7 @@ class StoreCatalogItemRequest extends FormRequest
'nullable',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('event_id', $this->input('event_id'))
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.inventory_id' => ['prohibited'],

View File

@@ -23,7 +23,6 @@ class CatalogItemDetailResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'event_id' => $this->event_id,
'event_product_type' => $this->event_product_type?->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
@@ -38,9 +37,7 @@ class CatalogItemDetailResource extends JsonResource
'has_tickets' => $this->has_tickets,
'validity_time_id' => $this->validity_time_id,
'validity_time' => ValidityTimeResource::make($this->validityTime),
'attributes' => $this->itemAttributes
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
->values(),
'attributes' => $this->attributesData(),
'stock_tecnico' => $this->when(
$selectedVariant === null,
fn () => $this->availableStock(),
@@ -111,19 +108,70 @@ class CatalogItemDetailResource extends JsonResource
];
}
/** @return Collection<int, array<string, mixed>> */
private function attributesData(): Collection
{
$attributes = $this->itemAttributes
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute));
if ($this->variants->isEmpty() || $this->variants->contains(
fn (Variant $variant): bool => $variant->event_date_id === null
)) {
return $attributes->values();
}
$eventDateAttribute = [
'id' => -1,
'codigo' => 'event_date',
'nombre' => 'Fecha',
'is_required' => true,
'metadata_schema' => null,
'type' => 'event_date',
'options' => $this->variants
->pluck('eventDate')
->filter()
->unique('id')
->sortBy(fn ($eventDate): string => $eventDate->date->format('Y-m-d').' '.$eventDate->time_start)
->values()
->map(fn ($eventDate, int $index): array => [
'id' => $eventDate->id,
'value' => (string) $eventDate->id,
'label' => $eventDate->date->format('d/m/Y').' · '
.substr($eventDate->time_start, 0, 5).' a '
.substr($eventDate->time_end, 0, 5),
'sort_order' => $index,
'validity_time_id' => null,
'validity_time' => null,
'metadata' => [
'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start,
'time_end' => $eventDate->time_end,
],
]),
];
return collect([$eventDateAttribute])->concat($attributes)->values();
}
/** @return array<string, mixed> */
private function variantData(Variant $variant): array
{
$values = $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null);
if ($variant->event_date_id !== null) {
$values->put('event_date', (string) $variant->event_date_id);
}
return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'stock_tecnico' => $this->variantStock($variant),
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null),
'values' => $values,
];
}

View File

@@ -16,7 +16,6 @@ class CatalogItemResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'event_id' => $this->event_id,
'event_product_type' => $this->event_product_type?->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,

View File

@@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
@@ -47,7 +46,14 @@ class CatalogService
);
$hasVariants = $attributeCodes !== [] || $hasEventDateVariants;
$this->validateEventData($data);
if ($hasEventDateVariants && in_array('event_date', $attributeCodes, true)) {
throw ValidationException::withMessages([
'attribute_codes' => ['The event_date code is reserved for event dates.'],
]);
}
$this->validateEventProductType($data);
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
if ($type === CatalogItemType::Bundle) {
$this->validateBundleData($data, $components);
@@ -127,7 +133,6 @@ class CatalogService
'inventory',
'category',
'brand',
'event',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
@@ -147,7 +152,6 @@ class CatalogService
'inventory',
'category',
'brand',
'event',
'validityTime',
'itemAttributes.attribute.options.validityTime',
'variants' => fn ($query) => $query->orderBy('id'),
@@ -492,16 +496,15 @@ class CatalogService
if (
$eventDateId !== null
&& (
$catalogItem->event_id === null
|| ! EventDate::query()
! EventDate::query()
->whereKey($eventDateId)
->where('event_id', $catalogItem->event_id)
->where('tenant_code', $catalogItem->tenant_code)
->exists()
)
) {
throw ValidationException::withMessages([
"variants.{$index}.event_date_id" => [
'The event date must belong to the catalog item event.',
'The event date must belong to the catalog item tenant.',
],
]);
}
@@ -533,30 +536,14 @@ class CatalogService
}
/** @param array<string, mixed> $data */
private function validateEventData(array $data): void
private function validateEventProductType(array $data): void
{
$eventId = $data['event_id'] ?? null;
$eventProductType = $data['event_product_type'] ?? null;
if (($eventId === null) !== ($eventProductType === null)) {
throw ValidationException::withMessages([
'event_id' => ['Event and event product type must be provided together.'],
]);
}
if ($eventId === null) {
if ($eventProductType === null) {
return;
}
if (! Event::query()
->whereKey($eventId)
->where('tenant_code', $data['tenant_code'] ?? null)
->exists()) {
throw ValidationException::withMessages([
'event_id' => ['The event must belong to the catalog item tenant.'],
]);
}
if (! in_array($eventProductType, EventProductType::values(), true)) {
throw ValidationException::withMessages([
'event_product_type' => ['The event product type is invalid.'],
@@ -564,6 +551,35 @@ class CatalogService
}
}
/**
* @param array<int, array<string, mixed>> $variants
* @param array<int, string> $attributeCodes
*/
private function validateUniqueVariantCombinations(array $variants, array $attributeCodes): void
{
$seen = [];
$attributeCodes = array_values(array_unique($attributeCodes));
sort($attributeCodes);
foreach (array_values($variants) as $index => $variant) {
$combination = [(string) ($variant['event_date_id'] ?? '')];
foreach ($attributeCodes as $attributeCode) {
$value = trim((string) ($variant['values'][$attributeCode] ?? ''));
$combination[] = Str::ascii(mb_strtolower($value));
}
$key = implode('|', $combination);
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"variants.{$index}" => ['The variant combination must be unique.'],
]);
}
$seen[$key] = true;
}
}
/**
* @param array<string, mixed> $data
* @param array<int, array<string, mixed>> $variants

View File

@@ -15,14 +15,14 @@ class EventController extends Controller
public function show(Request $request): EventResource
{
return EventResource::make(
$this->eventService->activeForTenant($request->user()->tenant()->firstOrFail())
$this->eventService->forTenant($request->user()->tenant()->firstOrFail())
);
}
public function update(UpdateEventRequest $request): EventResource
{
return EventResource::make(
$this->eventService->updateActiveForTenant(
$this->eventService->updateForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated()
)

View File

@@ -1,48 +0,0 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
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\HasMany;
#[Fillable([
'tenant_code',
'name',
'address',
])]
class Event extends Model
{
use HasFactory;
public $timestamps = false;
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return HasMany<EventDate, $this> */
public function dates(): HasMany
{
return $this->hasMany(EventDate::class)->orderBy('date')->orderBy('time_start');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
/** @return HasMany<Purchase, $this> */
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
#[Fillable([
'event_id',
'tenant_code',
'date',
'time_start',
'time_end',
@@ -26,15 +27,14 @@ class EventDate extends Model
protected function casts(): array
{
return [
'event_id' => 'integer',
'date' => 'date:Y-m-d',
];
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Event::class);
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return HasMany<Variant, $this> */

View File

@@ -2,29 +2,29 @@
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Event */
/** @mixin Tenant */
class EventResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$socialMedia = $this->tenant->socialMedia->keyBy('code');
$socialMedia = $this->socialMedia->keyBy('code');
return [
'id' => $this->id,
'title' => $this->name,
'location' => $this->address,
'dates' => $this->dates->map(fn ($eventDate): array => [
'title' => $this->event_title,
'location' => $this->event_location,
'dates' => $this->eventDates->map(fn ($eventDate): array => [
'id' => $eventDate->id,
'date' => $eventDate->date->format('Y-m-d'),
'start_time' => substr($eventDate->time_start, 0, 5),
'end_time' => substr($eventDate->time_end, 0, 5),
])->values(),
'social_media' => $this->tenant->socialMedia->map(fn ($item): array => [
'social_media' => $this->socialMedia->map(fn ($item): array => [
'code' => $item->code,
'url' => $item->pivot->url,
'orden' => $item->pivot->orden,

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
@@ -14,53 +13,36 @@ class EventService
'facebook_url' => 'facebook',
];
public function activeForTenant(Tenant $tenant): Event
public function forTenant(Tenant $tenant): Tenant
{
return $tenant->events()
->whereKey($tenant->active_event_id)
->with(['dates', 'tenant.socialMedia'])
->firstOrFail();
return $tenant->load(['eventDates', 'socialMedia']);
}
/** @param array<string, mixed> $data */
public function updateActiveForTenant(Tenant $tenant, array $data): Event
public function updateForTenant(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Event {
return DB::transaction(function () use ($tenant, $data): Tenant {
$tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
$event = $tenant->active_event_id === null
? $tenant->events()->create([
'name' => $data['title'],
'address' => $data['location'],
])
: $tenant->events()
->whereKey($tenant->active_event_id)
->lockForUpdate()
->firstOrFail();
$event->update([
'name' => $data['title'],
'address' => $data['location'],
$tenant->update([
'event_title' => $data['title'],
'event_location' => $data['location'],
]);
if ($tenant->active_event_id === null) {
$tenant->update(['active_event_id' => $event->id]);
}
$this->syncDates($event, $data['dates']);
$this->syncDates($tenant, $data['dates']);
if (array_key_exists('social_media', $data)) {
$this->syncSocialMedia($tenant, $data['social_media']);
} else {
$this->syncLegacyContact($tenant, $data['contact']);
}
return $event->load(['dates', 'tenant.socialMedia']);
return $tenant->load(['eventDates', 'socialMedia']);
});
}
/** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */
private function syncDates(Event $event, array $dates): void
private function syncDates(Tenant $tenant, array $dates): void
{
$existingDates = $event->dates()->get()->values();
$existingDates = $tenant->eventDates()->get()->values();
foreach (array_values($dates) as $index => $date) {
$attributes = [
@@ -74,12 +56,12 @@ class EventService
if ($existingDate) {
$existingDate->update($attributes);
} else {
$event->dates()->create($attributes);
$tenant->eventDates()->create($attributes);
}
}
$existingDates->slice(count($dates))->each->delete();
$event->unsetRelation('dates');
$tenant->unsetRelation('eventDates');
}
/** @param array<string, string|null> $contact */

View File

@@ -4,7 +4,6 @@ namespace App\Domains\Purchase\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Event\Models\Event;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Tenant\Models\Tenant;
@@ -20,7 +19,6 @@ use Illuminate\Support\Facades\DB;
#[Fillable([
'cart_id',
'tenant_codigo',
'event_id',
'user_id',
'status',
'payment_method',
@@ -72,7 +70,6 @@ class Purchase extends Model
{
return [
'cart_id' => 'integer',
'event_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
@@ -87,12 +84,6 @@ class Purchase extends Model
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/**
* @return BelongsTo<User, $this>
*/

View File

@@ -42,7 +42,6 @@ class PurchaseResource extends JsonResource
'id' => $this->id,
'cart_id' => $this->cart_id,
'tenant_codigo' => $this->tenant_codigo,
'event_id' => $this->event_id,
'user_id' => $this->user_id,
'created_at' => $this->created_at,
'status' => $this->status,

View File

@@ -229,7 +229,6 @@ class StartCheckoutService
...$purchaseData,
'cart_id' => $cartId,
'tenant_codigo' => $tenant->codigo,
'event_id' => $tenant->active_event_id,
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,

View File

@@ -7,7 +7,7 @@ use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -33,7 +33,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'search_product_layout',
'search_group_layout',
'search_items_per_page',
'active_event_id',
'event_title',
'event_location',
])]
class Tenant extends Model
{
@@ -61,7 +62,6 @@ class Tenant extends Model
'search_product_layout' => ProductLayout::class,
'search_group_layout' => GroupLayout::class,
'search_items_per_page' => 'integer',
'active_event_id' => 'integer',
];
}
@@ -94,16 +94,12 @@ class Tenant extends Model
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
}
/** @return HasMany<Event, $this> */
public function events(): HasMany
/** @return HasMany<EventDate, $this> */
public function eventDates(): HasMany
{
return $this->hasMany(Event::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function activeEvent(): BelongsTo
{
return $this->belongsTo(Event::class, 'active_event_id');
return $this->hasMany(EventDate::class, 'tenant_code', 'codigo')
->orderBy('date')
->orderBy('time_start');
}
/**

View File

@@ -32,14 +32,12 @@ class TenantResource extends JsonResource
'header_bg_color' => $this->header_bg_color,
'footer_bg_color' => $this->footer_bg_color,
'website_type_code' => $this->website_type_code,
'active_event_id' => $this->active_event_id,
'active_event' => $this->whenLoaded('activeEvent', fn () => $this->activeEvent === null
'event' => $this->whenLoaded('eventDates', fn () => $this->event_title === null
? null
: [
'id' => $this->activeEvent->id,
'name' => $this->activeEvent->name,
'address' => $this->activeEvent->address,
'dates' => $this->activeEvent->dates->map(fn ($eventDate): array => [
'title' => $this->event_title,
'location' => $this->event_location,
'dates' => $this->eventDates->map(fn ($eventDate): array => [
'id' => $eventDate->id,
'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start,

View File

@@ -15,7 +15,7 @@ class TenantInformationService
'footerLogo',
'socialMedia',
'websiteExtras.websiteTypeExtra',
'activeEvent.dates',
'eventDates',
];
/**

View File

@@ -0,0 +1,125 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->string('event_title')->nullable()->after('nombre');
$table->string('event_location')->nullable()->after('event_title');
});
Schema::table('event_dates', function (Blueprint $table): void {
$table->string('tenant_code')->nullable()->after('id');
});
DB::table('tenants')
->whereNotNull('active_event_id')
->orderBy('id')
->each(function (object $tenant): void {
$event = DB::table('events')->where('id', $tenant->active_event_id)->first();
if ($event === null) {
return;
}
DB::table('tenants')->where('id', $tenant->id)->update([
'event_title' => $event->name,
'event_location' => $event->address,
]);
});
DB::table('events')->orderBy('id')->each(function (object $event): void {
DB::table('event_dates')
->where('event_id', $event->id)
->update(['tenant_code' => $event->tenant_code]);
});
Schema::table('tenants', function (Blueprint $table): void {
$table->dropConstrainedForeignId('active_event_id');
});
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
});
Schema::table('compras', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
});
Schema::table('event_dates', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
$table->string('tenant_code')->nullable(false)->change();
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
});
Schema::dropIfExists('events');
}
public function down(): void
{
Schema::create('events', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->string('name');
$table->string('address');
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
});
Schema::table('tenants', function (Blueprint $table): void {
$table->foreignId('active_event_id')->nullable();
});
Schema::table('catalog_items', function (Blueprint $table): void {
$table->foreignId('event_id')->nullable();
});
Schema::table('compras', function (Blueprint $table): void {
$table->foreignId('event_id')->nullable();
});
Schema::table('event_dates', function (Blueprint $table): void {
$table->foreignId('event_id')->nullable();
});
DB::table('tenants')->orderBy('id')->each(function (object $tenant): void {
if ($tenant->event_title === null && $tenant->event_location === null) {
return;
}
$eventId = DB::table('events')->insertGetId([
'tenant_code' => $tenant->codigo,
'name' => $tenant->event_title ?? $tenant->nombre,
'address' => $tenant->event_location ?? '',
]);
DB::table('tenants')->where('id', $tenant->id)->update(['active_event_id' => $eventId]);
DB::table('catalog_items')->where('tenant_code', $tenant->codigo)->update(['event_id' => $eventId]);
DB::table('compras')->where('tenant_codigo', $tenant->codigo)->update(['event_id' => $eventId]);
DB::table('event_dates')->where('tenant_code', $tenant->codigo)->update(['event_id' => $eventId]);
});
Schema::table('tenants', function (Blueprint $table): void {
$table->foreign('active_event_id')->references('id')->on('events')->nullOnDelete();
$table->dropColumn(['event_title', 'event_location']);
});
Schema::table('catalog_items', function (Blueprint $table): void {
$table->foreign('event_id')->references('id')->on('events')->nullOnDelete();
});
Schema::table('compras', function (Blueprint $table): void {
$table->foreign('event_id')->references('id')->on('events')->nullOnDelete();
});
Schema::table('event_dates', function (Blueprint $table): void {
$table->dropForeign(['tenant_code']);
$table->dropColumn('tenant_code');
$table->foreign('event_id')->references('id')->on('events')->cascadeOnDelete();
});
}
};

View File

@@ -0,0 +1,30 @@
<?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::table('variantes', function (Blueprint $table): void {
$table->index(['catalog_item_id', 'event_date_id']);
});
Schema::table('variantes', function (Blueprint $table): void {
$table->dropUnique(['catalog_item_id', 'event_date_id']);
});
}
public function down(): void
{
Schema::table('variantes', function (Blueprint $table): void {
$table->unique(['catalog_item_id', 'event_date_id']);
});
Schema::table('variantes', function (Blueprint $table): void {
$table->dropIndex(['catalog_item_id', 'event_date_id']);
});
}
};

View File

@@ -12,7 +12,6 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
@@ -38,18 +37,15 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
->update(['categoria_id' => null]);
Category::query()->where('tenant_code', $tenant->codigo)->delete();
$event = Event::query()->updateOrCreate(
[
'tenant_code' => $tenant->codigo,
'name' => 'Fiesta Nacional del Fútbol Infantil',
],
['address' => 'Sunchales, Santa Fe'],
);
$event->dates()->delete();
$tenant->update([
'event_title' => 'Fiesta Nacional del Fútbol Infantil',
'event_location' => 'Sunchales, Santa Fe',
]);
$tenant->eventDates()->delete();
$eventDates = collect(['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'])
->mapWithKeys(function (string $date) use ($event): array {
$eventDate = $event->dates()->create([
->mapWithKeys(function (string $date) use ($tenant): array {
$eventDate = $tenant->eventDates()->create([
'date' => $date,
'time_start' => '00:00:00',
'time_end' => '23:59:59',
@@ -58,9 +54,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
return [$date => $eventDate];
});
$tenant->active_event_id = $event->id;
$tenant->save();
$ticketCategory = Category::query()->firstOrCreate([
'nombre' => 'Entradas',
'tenant_code' => $tenant->codigo,
@@ -89,7 +82,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$generalAdmission = $this->catalogService->create([
'tenant_code' => $tenant->codigo,
'event_id' => $event->id,
'event_product_type' => EventProductType::Entry->value,
'category_id' => $ticketCategory->id,
'slug' => 'entrada-general',
@@ -121,7 +113,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
foreach ($items as $item) {
$createdItems[$item['slug']] = $this->catalogService->create([
'tenant_code' => $tenant->codigo,
'event_id' => $event->id,
'event_product_type' => EventProductType::Product->value,
'descripcion' => $item['descripcion'] ?? $item['nombre'],
'inventory_policy' => InventoryPolicy::Unlimited->value,
@@ -133,7 +124,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->catalogService->create([
'tenant_code' => $tenant->codigo,
'event_id' => $event->id,
'event_product_type' => EventProductType::Entry->value,
'type' => CatalogItemType::Bundle->value,
'slug' => 'entrada-general-todos-los-dias',
@@ -152,7 +142,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->catalogService->create([
'tenant_code' => $tenant->codigo,
'event_id' => $event->id,
'event_product_type' => EventProductType::Product->value,
'type' => CatalogItemType::Bundle->value,
'slug' => 'combo-2-panchos-2-hamburguesas',

View File

@@ -170,6 +170,33 @@ class CatalogItemDetailControllerTest extends TestCase
->assertJsonPath('data.variants.0.stock_tecnico', null);
}
public function test_it_exposes_event_dates_as_a_dynamic_variant_attribute(): void
{
$tenant = $this->createTenant('detail-event-date');
$tenant->update([
'event_title' => 'Festival',
'event_location' => 'Rosario',
]);
$eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '09:00',
'time_end' => '18:00',
]);
$item = $this->createItem($tenant, 'Entry');
$variant = $this->createVariant($item, 10, 0);
$variant->update(['event_date_id' => $eventDate->id]);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
->assertOk()
->assertJsonPath('data.attributes.0.id', -1)
->assertJsonPath('data.attributes.0.codigo', 'event_date')
->assertJsonPath('data.attributes.0.type', 'event_date')
->assertJsonPath('data.attributes.0.options.0.id', $eventDate->id)
->assertJsonPath('data.attributes.0.options.0.value', (string) $eventDate->id)
->assertJsonPath('data.attributes.0.options.0.label', '09/10/2026 · 09:00 a 18:00')
->assertJsonPath('data.variants.0.values.event_date', (string) $eventDate->id);
}
private function createItem(
Tenant $tenant,
string $name,

View File

@@ -32,7 +32,6 @@ class CatalogSchemaTest extends TestCase
$this->assertEqualsCanonicalizing([
'id',
'tenant_code',
'event_id',
'event_product_type',
'category_id',
'brand_id',
@@ -186,21 +185,21 @@ class CatalogSchemaTest extends TestCase
]));
}
public function test_events_and_event_dates_are_linked_to_the_catalog(): void
public function test_event_configuration_and_dates_belong_to_the_tenant(): void
{
$this->assertFalse(Schema::hasTable('events'));
$this->assertTrue(Schema::hasColumns('tenants', [
'event_title',
'event_location',
]));
$this->assertEqualsCanonicalizing([
'id',
'tenant_code',
'name',
'address',
], Schema::getColumnListing('events'));
$this->assertEqualsCanonicalizing([
'id',
'event_id',
'date',
'time_start',
'time_end',
], Schema::getColumnListing('event_dates'));
$this->assertTrue(Schema::hasColumn('tenants', 'active_event_id'));
$this->assertFalse(Schema::hasColumn('catalog_items', 'event_id'));
$this->assertFalse(Schema::hasColumn('compras', 'event_id'));
}
}

View File

@@ -93,6 +93,68 @@ class CatalogServiceTest extends TestCase
}
}
public function test_it_allows_the_same_event_date_with_different_attribute_values(): void
{
$sector = $this->createAttribute('sector');
$eventDate = $this->tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '09:00',
'time_end' => '18:00',
]);
$item = $this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'entry-by-sector',
'nombre' => 'Entry by sector',
'precio' => 100,
'attribute_codes' => [$sector->codigo],
'variants' => [
[
'event_date_id' => $eventDate->id,
'values' => ['sector' => 'General'],
],
[
'event_date_id' => $eventDate->id,
'values' => ['sector' => 'VIP'],
],
],
]);
$this->assertCount(2, $item->variants);
$this->assertSame(
[$eventDate->id, $eventDate->id],
$item->variants->pluck('event_date_id')->all(),
);
}
public function test_it_rejects_duplicate_variant_combinations(): void
{
$sector = $this->createAttribute('sector');
$eventDate = $this->tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '09:00',
'time_end' => '18:00',
]);
try {
$this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'duplicate-entry',
'nombre' => 'Duplicate entry',
'precio' => 100,
'attribute_codes' => [$sector->codigo],
'variants' => [
['event_date_id' => $eventDate->id, 'values' => ['sector' => 'VIP']],
['event_date_id' => $eventDate->id, 'values' => ['sector' => ' vip ']],
],
]);
$this->fail('A validation exception was not thrown.');
} catch (ValidationException $exception) {
$this->assertArrayHasKey('variants.1', $exception->errors());
}
}
public function test_it_rejects_direct_inventory_together_with_variants(): void
{
$attribute = $this->createAttribute('size');

View File

@@ -4,7 +4,6 @@ namespace Tests\Feature\Event;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
@@ -50,17 +49,14 @@ class AdminAppEventControllerTest extends TestCase
->assertJsonPath('data.contact.instagram_url', 'https://instagram.com/acme')
->assertJsonPath('data.contact.facebook_url', null);
$eventId = $response->json('data.id');
$this->assertDatabaseHas('events', [
'id' => $eventId,
'tenant_code' => $tenant->codigo,
'name' => 'Festival Acme',
'address' => 'Predio Ferial, Rosario',
$this->assertSame($tenant->id, $response->json('data.id'));
$this->assertDatabaseHas('tenants', [
'id' => $tenant->id,
'event_title' => 'Festival Acme',
'event_location' => 'Predio Ferial, Rosario',
]);
$this->assertSame($eventId, $tenant->fresh()->active_event_id);
$this->assertDatabaseHas('event_dates', [
'event_id' => $eventId,
'tenant_code' => $tenant->codigo,
'date' => '2026-10-09',
'time_start' => '09:00:00',
'time_end' => '18:30:00',
@@ -86,24 +82,27 @@ class AdminAppEventControllerTest extends TestCase
->assertJsonMissing(['title' => 'Other Event']);
}
public function test_reading_a_tenant_without_an_active_event_returns_not_found(): void
public function test_reading_a_tenant_without_event_configuration_returns_empty_values(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/event')->assertNotFound();
$this->getJson('/api/v1/adminapp/tenant/event')
->assertOk()
->assertJsonPath('data.title', null)
->assertJsonCount(0, 'data.dates');
}
public function test_updating_reuses_the_active_event_and_synchronizes_dates_and_contact(): void
{
$tenant = $this->createTenant('acme');
$event = $this->createActiveEvent($tenant, 'Old Event');
$firstDate = $event->dates()->create([
$eventTenant = $this->createActiveEvent($tenant, 'Old Event');
$firstDate = $eventTenant->eventDates()->create([
'date' => '2026-10-01',
'time_start' => '08:00',
'time_end' => '12:00',
]);
$removedDate = $event->dates()->create([
$removedDate = $eventTenant->eventDates()->create([
'date' => '2026-10-02',
'time_start' => '08:00',
'time_end' => '12:00',
@@ -127,7 +126,7 @@ class AdminAppEventControllerTest extends TestCase
$this->putJson('/api/v1/adminapp/tenant/event', $payload)
->assertOk()
->assertJsonPath('data.id', $event->id)
->assertJsonPath('data.id', $tenant->id)
->assertJsonPath('data.dates.0.id', $firstDate->id)
->assertJsonPath('data.contact.facebook_url', null);
@@ -175,7 +174,7 @@ class AdminAppEventControllerTest extends TestCase
'contact.whatsapp_url',
]);
$this->assertDatabaseCount('events', 0);
$this->assertNull($tenant->fresh()->event_title);
}
public function test_social_media_accepts_any_registered_code_and_rejects_unknown_codes(): void
@@ -257,14 +256,13 @@ class AdminAppEventControllerTest extends TestCase
]);
}
private function createActiveEvent(Tenant $tenant, string $name): Event
private function createActiveEvent(Tenant $tenant, string $name): Tenant
{
$event = $tenant->events()->create([
'name' => $name,
'address' => 'Rosario',
$tenant->update([
'event_title' => $name,
'event_location' => 'Rosario',
]);
$tenant->update(['active_event_id' => $event->id]);
return $event;
return $tenant->fresh();
}
}

View File

@@ -11,7 +11,6 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\Event;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
@@ -34,12 +33,10 @@ class StorePurchaseTest extends TestCase
public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$event = Event::query()->create([
'tenant_code' => $tenant->codigo,
'name' => 'Sonder Fest',
'address' => 'Test address',
$tenant->update([
'event_title' => 'Sonder Fest',
'event_location' => 'Test address',
]);
$tenant->update(['active_event_id' => $event->id]);
$user = User::factory()->create([
'email' => 'buyer@example.com',
]);
@@ -92,7 +89,6 @@ class StorePurchaseTest extends TestCase
$response->assertJsonPath('data.nombre_apellido', null);
$response->assertJsonPath('data.email', null);
$response->assertJsonPath('data.tenant_codigo', 'sonder');
$response->assertJsonPath('data.event_id', $event->id);
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
$response->assertJsonPath('data.items_source', 'purchase');
$response->assertJsonCount(1, 'data.items');
@@ -105,7 +101,6 @@ class StorePurchaseTest extends TestCase
'id' => $purchaseId,
'cart_id' => $cartId,
'tenant_codigo' => 'sonder',
'event_id' => $event->id,
'user_id' => $user->id,
'dni' => null,
'telefono' => null,

View File

@@ -14,7 +14,6 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
@@ -96,15 +95,11 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
->all(),
);
$event = Event::query()
->where('tenant_code', $tenant->codigo)
->with('dates')
->sole();
$this->assertSame($event->id, $tenant->fresh()->active_event_id);
$this->assertCount(4, $event->dates);
$this->assertSame(1, Event::query()->where('tenant_code', $tenant->codigo)->count());
$this->assertSame(4, EventDate::query()->where('event_id', $event->id)->count());
$tenant->refresh()->load('eventDates');
$this->assertSame('Fiesta Nacional del Fútbol Infantil', $tenant->event_title);
$this->assertSame('Sunchales, Santa Fe', $tenant->event_location);
$this->assertCount(4, $tenant->eventDates);
$this->assertSame(4, EventDate::query()->where('tenant_code', $tenant->codigo)->count());
$generalAdmission = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
@@ -113,7 +108,6 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
->sole();
$this->assertNull($generalAdmission->inventory_id);
$this->assertTrue($generalAdmission->event->is($event));
$this->assertSame(EventProductType::Entry, $generalAdmission->event_product_type);
$this->assertCount(0, $generalAdmission->itemAttributes);
$this->assertCount(4, $generalAdmission->variants);
@@ -133,7 +127,6 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
$this->assertCount(7, $standardItems);
foreach ($standardItems as $standardItem) {
$this->assertSame($event->id, $standardItem->event_id);
$this->assertSame(
'2026-10-09 00:00:00',
$standardItem->validityTime->fixed_starts_at->format('Y-m-d H:i:s'),
@@ -154,7 +147,6 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
$this->assertSame('40000.00', $allDaysItem->precio);
$this->assertNull($allDaysItem->inventory_id);
$this->assertFalse($allDaysItem->has_tickets);
$this->assertSame($event->id, $allDaysItem->event_id);
$this->assertSame(EventProductType::Entry, $allDaysItem->event_product_type);
$this->assertCount(4, $allDaysItem->bundleComponents);
$this->assertEqualsCanonicalizing(

View File

@@ -21,7 +21,6 @@ use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\VariantDefinition;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
@@ -65,7 +64,6 @@ class CatalogModelsTest extends TestCase
'category_id' => '10',
'brand_id' => '20',
'inventory_id' => '30',
'event_id' => '40',
'event_product_type' => EventProductType::Entry->value,
'type' => CatalogItemType::Standard->value,
'precio' => '12.50',
@@ -78,14 +76,12 @@ class CatalogModelsTest extends TestCase
$this->assertSame(10, $item->category_id);
$this->assertSame(20, $item->brand_id);
$this->assertSame(30, $item->inventory_id);
$this->assertSame(40, $item->event_id);
$this->assertSame(EventProductType::Entry, $item->event_product_type);
$this->assertSame(CatalogItemType::Standard, $item->type);
$this->assertSame('12.50', $item->precio);
$this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy);
$this->assertTrue($item->has_tickets);
$this->assertInstanceOf(Tenant::class, $item->tenant()->getRelated());
$this->assertInstanceOf(Event::class, $item->event()->getRelated());
$this->assertInstanceOf(Category::class, $item->category()->getRelated());
$this->assertInstanceOf(Brand::class, $item->brand()->getRelated());
$this->assertInstanceOf(Inventory::class, $item->inventory()->getRelated());

View File

@@ -2,48 +2,35 @@
namespace Tests\Unit\Event;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Tests\TestCase;
class EventModelsTest extends TestCase
{
public function test_event_maps_its_tenant_dates_and_catalog_items(): void
{
$event = new Event;
$this->assertFalse($event->usesTimestamps());
$this->assertInstanceOf(Tenant::class, $event->tenant()->getRelated());
$this->assertInstanceOf(EventDate::class, $event->dates()->getRelated());
$this->assertInstanceOf(CatalogItem::class, $event->catalogItems()->getRelated());
}
public function test_event_date_maps_schedule_and_variants(): void
{
$eventDate = new EventDate;
$eventDate->setRawAttributes([
'event_id' => '10',
'tenant_code' => 'acme',
'date' => '2026-10-09',
'time_start' => '09:00:00',
'time_end' => '18:30:00',
]);
$this->assertFalse($eventDate->usesTimestamps());
$this->assertSame(10, $eventDate->event_id);
$this->assertSame('acme', $eventDate->tenant_code);
$this->assertSame('2026-10-09 09:00:00', $eventDate->startsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-10-09 18:30:00', $eventDate->endsAt()->format('Y-m-d H:i:s'));
$this->assertInstanceOf(Event::class, $eventDate->event()->getRelated());
$this->assertInstanceOf(Tenant::class, $eventDate->tenant()->getRelated());
$this->assertInstanceOf(Variant::class, $eventDate->variants()->getRelated());
}
public function test_tenant_has_many_events_and_one_active_event(): void
public function test_tenant_has_many_event_dates(): void
{
$tenant = new Tenant;
$this->assertInstanceOf(Event::class, $tenant->events()->getRelated());
$this->assertInstanceOf(Event::class, $tenant->activeEvent()->getRelated());
$this->assertInstanceOf(EventDate::class, $tenant->eventDates()->getRelated());
}
}