6 Commits

Author SHA1 Message Date
c4b317d5fc feat: Implement value change logging functionality with models, traits, and migration; add tests for logging behavior and value change mapping 2026-08-03 15:43:50 -03:00
3b2977a330 feat: Enhance tenant website extras by adding detailed description HTML for hero and additional info configurations; update related tests for validation 2026-08-03 15:29:14 -03:00
3b36abd46d feat: Refactor event form functionality by removing social media data from AdminAppBootstrapResource and AdminAppBootstrapService; add EventFormController, EventFormResource, and EventFormService; implement routes and tests for event form 2026-08-03 14:02:08 -03:00
12697c2268 feat: Refactor bootstrap functionality by adding AdminApp and Tenant controllers, requests, resources, and services; remove deprecated event form components 2026-08-03 13:37:37 -03:00
f4638bc984 feat: Implement event management functionality with API endpoints and validation 2026-08-03 13:32:31 -03:00
c3713c62a6 feat: Add event management to tenant and catalog
- Introduced Event and EventDate models with relationships to Tenant and CatalogItem.
- Added active_event_id to Tenant model to track the currently active event.
- Updated TenantResource to include active event details in the response.
- Enhanced Ticket model to link to CatalogItem and Variant, allowing for event-specific ticketing.
- Implemented migrations to create events and link them to catalog items and variants.
- Updated seeders to populate events and their associated dates for the Fiesta Futbol Infantil tenant.
- Modified Ticket generation logic to respect event dates over standard ticket dates.
- Added tests for event and ticket functionalities, ensuring proper relationships and date handling.
2026-08-03 12:01:20 -03:00
72 changed files with 2017 additions and 259 deletions

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Bootstrap\Controllers;
use App\Domains\Bootstrap\Requests\AdminAppBootstrapRequest;
use App\Domains\Bootstrap\Resources\AdminAppBootstrapResource;
use App\Domains\Bootstrap\Services\AdminAppBootstrapService;
use App\Http\Controllers\Controller;
class AdminAppBootstrapController extends Controller
{
public function __construct(protected AdminAppBootstrapService $bootstrapService) {}
public function __invoke(AdminAppBootstrapRequest $request): AdminAppBootstrapResource
{
return AdminAppBootstrapResource::make(
$this->bootstrapService->get((string) $request->validated('dominio'))
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Bootstrap\Controllers;
use App\Domains\Bootstrap\Requests\TenantBootstrapRequest;
use App\Domains\Bootstrap\Services\TenantBootstrapService;
use App\Domains\Tenant\Resources\TenantResource;
use App\Http\Controllers\Controller;
class TenantBootstrapController extends Controller
{
public function __construct(protected TenantBootstrapService $bootstrapService) {}
public function __invoke(TenantBootstrapRequest $request): TenantResource
{
return TenantResource::make(
$this->bootstrapService->get((string) $request->validated('dominio'))
);
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Domains\Bootstrap\Requests;
class AdminAppBootstrapRequest extends TenantBootstrapRequest {}

View File

@@ -1,12 +1,12 @@
<?php
namespace App\Domains\Tenant\Requests;
namespace App\Domains\Bootstrap\Requests;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
class BootstrapTenantRequest extends FormRequest
class TenantBootstrapRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
@@ -23,14 +23,10 @@ class BootstrapTenantRequest extends FormRequest
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
$this->merge([
'dominio' => $normalizedDomain,
]);
$this->merge(['dominio' => $normalizedDomain]);
}
/**
* @return array<string, mixed>
*/
/** @return array<string, mixed> */
public function rules(): array
{
return [

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domains\Bootstrap\Resources;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin array{website_type: WebsiteType} */
class AdminAppBootstrapResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
/** @var WebsiteType $websiteType */
$websiteType = $this->resource['website_type'];
return [
'website_type_code' => $websiteType->codigo,
'primary_color' => $websiteType->primary_color,
'secondary_color' => $websiteType->secondary_color,
'danger_color' => $websiteType->danger_color,
'success_color' => $websiteType->success_color,
'warning_color' => $websiteType->warning_color,
'body_color' => $websiteType->body_color,
'darker_body_color' => $websiteType->darker_body_color,
'surface_color' => $websiteType->surface_color,
'background_color' => $websiteType->background_color,
'border_color' => $websiteType->border_color,
'login_header_footer_color' => $websiteType->login_header_footer_color,
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Bootstrap\Services;
use App\Domains\Tenant\Models\WebsiteType;
class AdminAppBootstrapService
{
/** @return array{website_type: WebsiteType} */
public function get(string $domain): array
{
return [
'website_type' => WebsiteType::query()
->with(['siteLogo', 'footerLogo'])
->where('dominio', $domain)
->firstOrFail(),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Bootstrap\Services;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantInformationService;
class TenantBootstrapService
{
public function __construct(protected TenantInformationService $tenantInformationService) {}
public function get(string $domain): Tenant
{
return $this->tenantInformationService->load(
Tenant::query()->where('dominio', $domain)->firstOrFail(),
[
'menues' => fn ($query) => $query->whereHas(
'roles',
fn ($query) => $query->where('codigo', RoleCode::User->value)
),
'categories' => fn ($query) => $query->orderBy('nombre'),
]
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
use App\Domains\Bootstrap\Controllers\AdminAppBootstrapController;
use Illuminate\Support\Facades\Route;
Route::get(
'v1/adminapp/bootstrap/{dominio}',
AdminAppBootstrapController::class
);

View File

@@ -0,0 +1,9 @@
<?php
use App\Domains\Bootstrap\Controllers\TenantBootstrapController;
use Illuminate\Support\Facades\Route;
Route::get('tenants/bootstrap/{dominio}', TenantBootstrapController::class)
->where('dominio', '.*');
require __DIR__.'/adminapp.php';

View File

@@ -178,6 +178,7 @@ class CatalogController extends Controller
'catalogItem.attachments',
'catalogItem.variants.inventory',
'catalogItem.variants.attachments',
'catalogItem.variants.eventDate',
'catalogItem.variants.definitions.itemAttribute.attribute',
'catalogItem.bundleComponents.catalogItem',
'catalogItem.bundleComponents.variant.catalogItem',

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Catalog\Enums;
enum EventProductType: string
{
case Entry = 'entrada';
case Product = 'producto';
/** @return list<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -4,9 +4,12 @@ namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
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 Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -17,6 +20,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'event_id',
'event_product_type',
'category_id',
'brand_id',
'inventory_id',
@@ -50,6 +55,8 @@ 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',
'inventory_policy' => InventoryPolicy::class,
@@ -65,6 +72,12 @@ 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
{
@@ -101,6 +114,12 @@ class CatalogItem extends Model
return $this->hasMany(Variant::class);
}
/** @return HasMany<Ticket, $this> */
public function sourceTickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_catalog_item_id');
}
/** @return BelongsToMany<Attribute, $this> */
public function attributes(): BelongsToMany
{

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -13,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'catalog_item_id',
'event_date_id',
'inventory_id',
'minimum_use_date',
'maximum_use_date',
@@ -29,6 +32,7 @@ class Variant extends Model
{
return [
'catalog_item_id' => 'integer',
'event_date_id' => 'integer',
'inventory_id' => 'integer',
'minimum_use_date' => 'datetime',
'maximum_use_date' => 'datetime',
@@ -41,6 +45,18 @@ class Variant extends Model
return $this->belongsTo(CatalogItem::class);
}
/** @return BelongsTo<EventDate, $this> */
public function eventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class);
}
/** @return HasMany<Ticket, $this> */
public function sourceTickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_variant_id');
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
@@ -80,7 +96,7 @@ class Variant extends Model
public function getName(): string
{
$name = $this->catalogItem->nombre;
$this->loadMissing('definitions.itemAttribute.attribute');
$this->loadMissing(['definitions.itemAttribute.attribute', 'eventDate']);
$definitions = $this->definitions
->map(function (VariantDefinition $definition): ?string {
$attributeName = $definition->itemAttribute?->attribute?->nombre;
@@ -89,21 +105,28 @@ class Variant extends Model
? "{$attributeName}: {$definition->value}"
: $definition->value;
})
->filter()
->implode(', ');
->filter();
return $definitions === '' ? $name : "{$name} ({$definitions})";
if ($this->eventDate !== null) {
$definitions->push('Fecha: '.$this->eventDate->date->format('Y-m-d'));
}
$description = $definitions->implode(', ');
return $description === '' ? $name : "{$name} ({$description})";
}
public function getMinimumUseDate(): ?CarbonInterface
{
return $this->minimum_use_date
return $this->eventDate?->startsAt()
?? $this->minimum_use_date
?? $this->catalogItem->getMinimumUseDate();
}
public function getMaximumUseDate(): ?CarbonInterface
{
return $this->maximum_use_date
return $this->eventDate?->endsAt()
?? $this->maximum_use_date
?? $this->catalogItem->getMaximumUseDate();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
@@ -25,6 +26,20 @@ 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' => [
'sometimes',
'nullable',
@@ -73,6 +88,14 @@ class StoreCatalogItemRequest extends FormRequest
'images.*' => ['required', new ImageOrBase64Rule],
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.event_date_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('event_id', $this->input('event_id'))
),
],
'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['prohibited'],

View File

@@ -32,6 +32,8 @@ class CatalogFeaturedItemResource extends JsonResource
'variants' => $catalogItem->variants
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),

View File

@@ -22,6 +22,8 @@ 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,
'slug' => $this->slug,
@@ -110,12 +112,16 @@ class CatalogItemDetailResource extends JsonResource
{
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),
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->minimum_use_date
'effective_minimum_use_date' => $variant->eventDate?->startsAt()
?? $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->maximum_use_date
'effective_maximum_use_date' => $variant->eventDate?->endsAt()
?? $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [

View File

@@ -15,6 +15,8 @@ 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,
'slug' => $this->slug,
@@ -32,12 +34,16 @@ class CatalogItemResource extends JsonResource
'variants' => $this->whenLoaded('variants', fn () => $this->variants
->map(fn ($variant) => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'real_stock' => $variant->inventory?->real_stock,
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->minimum_use_date
'effective_minimum_use_date' => $variant->eventDate?->startsAt()
?? $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->maximum_use_date
'effective_maximum_use_date' => $variant->eventDate?->endsAt()
?? $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [

View File

@@ -30,6 +30,8 @@ class CatalogSearchItemResource extends JsonResource
'variants' => $this->variants
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),

View File

@@ -5,12 +5,15 @@ namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
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;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -39,7 +42,12 @@ class CatalogService
$hasDirectStock = array_key_exists('real_stock', $data);
$realStock = (int) ($data['real_stock'] ?? 0);
$hasVariants = $attributeCodes !== [];
$hasEventDateVariants = $variants !== [] && collect($variants)->every(
fn (array $variant): bool => ! empty($variant['event_date_id'])
);
$hasVariants = $attributeCodes !== [] || $hasEventDateVariants;
$this->validateEventData($data);
if ($type === CatalogItemType::Bundle) {
$this->validateBundleData($data, $components);
@@ -121,9 +129,11 @@ class CatalogService
'inventory',
'category',
'brand',
'event',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
@@ -138,10 +148,12 @@ class CatalogService
'inventory',
'category',
'brand',
'event',
'itemAttributes.attribute.options',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions' => fn ($query) => $query->orderBy('id'),
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem.inventory',
@@ -195,6 +207,7 @@ class CatalogService
'inventory',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
@@ -226,6 +239,7 @@ class CatalogService
'inventory',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
@@ -472,8 +486,28 @@ class CatalogService
}
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
$eventDateId = $data['event_date_id'] ?? null;
if (
$eventDateId !== null
&& (
$catalogItem->event_id === null
|| ! EventDate::query()
->whereKey($eventDateId)
->where('event_id', $catalogItem->event_id)
->exists()
)
) {
throw ValidationException::withMessages([
"variants.{$index}.event_date_id" => [
'The event date must belong to the catalog item event.',
],
]);
}
$variant = $catalogItem->variants()->create([
'inventory_id' => $inventory->id,
'event_date_id' => $eventDateId,
'minimum_use_date' => $data['minimum_use_date'] ?? null,
'maximum_use_date' => $data['maximum_use_date'] ?? null,
]);
@@ -501,6 +535,38 @@ class CatalogService
return $variant;
}
/** @param array<string, mixed> $data */
private function validateEventData(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) {
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.'],
]);
}
}
private function validateVariantUseDates(Variant $variant, int $index): void
{
$minimumUseDate = $variant->getMinimumUseDate();

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Event\Controllers\AdminApp;
use App\Domains\Event\Requests\UpdateEventRequest;
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->activeForTenant($request->user()->tenant()->firstOrFail())
);
}
public function update(UpdateEventRequest $request): EventResource
{
return EventResource::make(
$this->eventService->updateActiveForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated()
)
);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\CatalogItem;
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);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant;
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\HasMany;
use Illuminate\Support\Carbon;
#[Fillable([
'event_id',
'date',
'time_start',
'time_end',
])]
class EventDate extends Model
{
use HasFactory;
public $timestamps = false;
protected function casts(): array
{
return [
'event_id' => 'integer',
'date' => 'date:Y-m-d',
];
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasMany(Variant::class);
}
public function startsAt(): CarbonInterface
{
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start);
}
public function endsAt(): CarbonInterface
{
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
}
}

View File

@@ -0,0 +1,60 @@
<?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'],
'dates' => ['required', 'array', 'min:1'],
'dates.*' => ['required', 'array:date,start_time,end_time'],
'dates.*.date' => ['required', 'date_format:Y-m-d', 'distinct'],
'dates.*.start_time' => ['required', 'date_format:H:i'],
'dates.*.end_time' => ['required', 'date_format:H:i'],
'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'],
];
}
/** @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.'
);
}
},
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\Event;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Event */
class EventResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$socialMedia = $this->tenant->socialMedia->keyBy('code');
return [
'id' => $this->id,
'title' => $this->name,
'location' => $this->address,
'dates' => $this->dates->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 => [
'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

@@ -0,0 +1,120 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
class EventService
{
private const CONTACT_CODES = [
'whatsapp_url' => 'whatsapp',
'instagram_url' => 'instagram',
'facebook_url' => 'facebook',
];
public function activeForTenant(Tenant $tenant): Event
{
return $tenant->events()
->whereKey($tenant->active_event_id)
->with(['dates', 'tenant.socialMedia'])
->firstOrFail();
}
/** @param array<string, mixed> $data */
public function updateActiveForTenant(Tenant $tenant, array $data): Event
{
return DB::transaction(function () use ($tenant, $data): Event {
$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'],
]);
if ($tenant->active_event_id === null) {
$tenant->update(['active_event_id' => $event->id]);
}
$this->syncDates($event, $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']);
});
}
/** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */
private function syncDates(Event $event, array $dates): void
{
$existingDates = $event->dates()->get()->values();
foreach (array_values($dates) as $index => $date) {
$attributes = [
'date' => $date['date'],
'time_start' => $date['start_time'],
'time_end' => $date['end_time'],
];
$existingDate = $existingDates->get($index);
if ($existingDate) {
$existingDate->update($attributes);
} else {
$event->dates()->create($attributes);
}
}
$existingDates->slice(count($dates))->each->delete();
$event->unsetRelation('dates');
}
/** @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

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

View File

@@ -0,0 +1,3 @@
<?php
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\EventFormResource;
use App\Domains\Forms\Services\EventFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class EventFormController extends Controller
{
public function __construct(protected EventFormService $eventFormService) {}
public function __invoke(Request $request): EventFormResource
{
return EventFormResource::make(
$this->eventFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'social_media' => SocialMediaOptionResource::collection(
$this->resource['social_media']
),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Tenant\Models\SocialMedia;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin SocialMedia */
class SocialMediaOptionResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'code' => $this->code,
'name' => $this->name,
'icon' => $this->icon,
'url' => $this->url,
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Tenant\Models\SocialMedia;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
class EventFormService
{
/** @return array{social_media: Collection<int, SocialMedia>} */
public function get(Tenant $tenant): array
{
$urls = $tenant->socialMedia()
->pluck('tenant_social_media.url', 'social_media.code');
return [
'social_media' => SocialMedia::query()
->orderBy('id')
->get()
->each(fn (SocialMedia $item) => $item->setAttribute(
'url',
$urls->get($item->code)
)),
];
}
}

View File

@@ -0,0 +1,10 @@
<?php
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/forms')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('event', EventFormController::class);
});

View File

@@ -0,0 +1,3 @@
<?php
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Logging\Enums;
enum ValueChangeActorType: string
{
case User = 'user';
case System = 'system';
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Domains\Logging\Models\Concerns;
use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Logging\Models\ValueChange;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Auth;
use LogicException;
trait LogsValueChanges
{
public static function bootLogsValueChanges(): void
{
static::updated(function (Model $model): void {
$changedAttributes = array_values(array_intersect(
$model->getLoggedAttributes(),
array_keys($model->getChanges()),
));
if ($changedAttributes === []) {
return;
}
$userId = Auth::id();
$actorType = $userId === null
? ValueChangeActorType::System
: ValueChangeActorType::User;
foreach ($changedAttributes as $attribute) {
$model->valueChanges()->create([
'attribute' => $attribute,
'old_value' => $model->getRawOriginal($attribute),
'new_value' => $model->getAttributes()[$attribute] ?? null,
'changed_at' => now(),
'actor_type' => $actorType,
'user_id' => $userId,
]);
}
});
}
/** @return array<int, string> */
public function getLoggedAttributes(): array
{
if (! property_exists($this, 'loggedAttributes')) {
throw new LogicException(sprintf(
'The [%s] model must define a $loggedAttributes property.',
static::class,
));
}
return array_values(array_unique($this->loggedAttributes));
}
/** @return MorphMany<ValueChange, $this> */
public function valueChanges(): MorphMany
{
return $this->morphMany(ValueChange::class, 'trackable');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Logging\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Logging\Enums\ValueChangeActorType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable([
'trackable_type',
'trackable_id',
'attribute',
'old_value',
'new_value',
'changed_at',
'actor_type',
'user_id',
])]
class ValueChange extends Model
{
public $timestamps = false;
/** @return MorphTo<Model, $this> */
public function trackable(): MorphTo
{
return $this->morphTo();
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
protected function casts(): array
{
return [
'trackable_id' => 'integer',
'changed_at' => 'datetime',
'actor_type' => ValueChangeActorType::class,
'user_id' => 'integer',
];
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Domains\Tenant\Controllers\AdminApp;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Tenant\Requests\BootstrapAdminAppRequest;
use App\Domains\Tenant\Resources\AdminApp\BootstrapAdminAppResource;
use App\Http\Controllers\Controller;
class BootstrapAdminAppController extends Controller
{
public function __invoke(
BootstrapAdminAppRequest $request
): BootstrapAdminAppResource {
/** @var string $domain */
$domain = $request->validated('dominio');
return BootstrapAdminAppResource::make(
WebsiteType::query()
->with(['siteLogo', 'footerLogo'])
->where('dominio', $domain)
->firstOrFail()
);
}
}

View File

@@ -1,38 +0,0 @@
<?php
namespace App\Domains\Tenant\Controllers;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Requests\BootstrapTenantRequest;
use App\Domains\Tenant\Resources\TenantResource;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Http\Controllers\Controller;
class BootstrapTenantController extends Controller
{
public function __construct(
protected TenantInformationService $tenantInformationService
) {}
public function __invoke(BootstrapTenantRequest $request): TenantResource
{
/** @var string $dominio */
$dominio = $request->validated('dominio');
return TenantResource::make(
$this->tenantInformationService->load(
Tenant::query()
->where('dominio', $dominio)
->firstOrFail(),
[
'menues' => fn ($query) => $query->whereHas(
'roles',
fn ($query) => $query->where('codigo', RoleCode::User->value)
),
'categories' => fn ($query) => $query->orderBy('nombre'),
]
)
);
}
}

View File

@@ -7,6 +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\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -32,6 +33,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'search_product_layout',
'search_group_layout',
'search_items_per_page',
'active_event_id',
])]
class Tenant extends Model
{
@@ -59,6 +61,7 @@ class Tenant extends Model
'search_product_layout' => ProductLayout::class,
'search_group_layout' => GroupLayout::class,
'search_items_per_page' => 'integer',
'active_event_id' => 'integer',
];
}
@@ -91,6 +94,18 @@ class Tenant extends Model
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
}
/** @return HasMany<Event, $this> */
public function events(): 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 HasMany<Category, $this>
*/

View File

@@ -1,5 +0,0 @@
<?php
namespace App\Domains\Tenant\Requests;
class BootstrapAdminAppRequest extends BootstrapTenantRequest {}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Domains\Tenant\Resources\AdminApp;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin WebsiteType
*/
class BootstrapAdminAppResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'website_type_code' => $this->codigo,
'primary_color' => $this->primary_color,
'secondary_color' => $this->secondary_color,
'danger_color' => $this->danger_color,
'success_color' => $this->success_color,
'warning_color' => $this->warning_color,
'body_color' => $this->body_color,
'darker_body_color' => $this->darker_body_color,
'surface_color' => $this->surface_color,
'background_color' => $this->background_color,
'border_color' => $this->border_color,
'login_header_footer_color' => $this->login_header_footer_color,
'site_logo' => $this->siteLogo?->getTemporaryUrl(1440),
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
];
}
}

View File

@@ -32,6 +32,20 @@ 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
? null
: [
'id' => $this->activeEvent->id,
'name' => $this->activeEvent->name,
'address' => $this->activeEvent->address,
'dates' => $this->activeEvent->dates->map(fn ($eventDate): array => [
'id' => $eventDate->id,
'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start,
'time_end' => $eventDate->time_end,
])->values(),
]),
'extras' => $this->whenLoaded(
'websiteExtras',
fn () => $this->websiteExtras

View File

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

View File

@@ -1,14 +1,8 @@
<?php
use App\Domains\Tenant\Controllers\AdminApp\BootstrapAdminAppController;
use App\Domains\Tenant\Controllers\AdminApp\WebsiteExtraController;
use Illuminate\Support\Facades\Route;
Route::get(
'v1/adminapp/bootstrap/{dominio}',
BootstrapAdminAppController::class
);
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {

View File

@@ -1,12 +1,8 @@
<?php
use App\Domains\Tenant\Controllers\BootstrapTenantController;
use App\Domains\Tenant\Controllers\TenantController;
use Illuminate\Support\Facades\Route;
Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
->where('dominio', '.*');
Route::apiResource('tenants', TenantController::class);
require __DIR__.'/adminapp.php';

View File

@@ -22,6 +22,7 @@ class TicketController extends Controller
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->with('sourceVariant.eventDate', 'sourceVariant.catalogItem')
->orderByDesc('id')
->get();
@@ -35,6 +36,7 @@ class TicketController extends Controller
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->whereIn('id', $ticketIds)
->with('sourceVariant.eventDate', 'sourceVariant.catalogItem')
->orderByDesc('id')
->get();

View File

@@ -3,8 +3,11 @@
namespace App\Domains\Ticket\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -66,13 +69,27 @@ class Ticket extends Model
return $this->belongsTo(Purchase::class, 'source_purchase_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function sourceCatalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'source_catalog_item_id');
}
/** @return BelongsTo<Variant, $this> */
public function sourceVariant(): BelongsTo
{
return $this->belongsTo(Variant::class, 'source_variant_id');
}
public function isValid(): bool
{
$now = now();
$startsAt = $this->getEffectiveStartsAt();
$expiresAt = $this->getEffectiveExpiresAt();
return $this->used_at === null
&& ($this->starts_at === null || $this->starts_at->lessThanOrEqualTo($now))
&& ($this->expires_at === null || $this->expires_at->greaterThan($now));
&& ($startsAt === null || $startsAt->lessThanOrEqualTo($now))
&& ($expiresAt === null || $expiresAt->greaterThan($now));
}
public function getIsValidAttribute(): bool
@@ -82,13 +99,27 @@ class Ticket extends Model
public function getIsExpiredAttribute(): bool
{
$expiresAt = $this->getEffectiveExpiresAt();
return $this->used_at === null
&& $this->expires_at !== null
&& $this->expires_at->lessThanOrEqualTo(now());
&& $expiresAt !== null
&& $expiresAt->lessThanOrEqualTo(now());
}
public function getIsUsedAttribute(): bool
{
return $this->used_at !== null;
}
public function getEffectiveStartsAt(): ?CarbonInterface
{
return $this->sourceVariant?->getMinimumUseDate()
?? $this->starts_at;
}
public function getEffectiveExpiresAt(): ?CarbonInterface
{
return $this->sourceVariant?->getMaximumUseDate()
?? $this->expires_at;
}
}

View File

@@ -20,8 +20,8 @@ class TicketResource extends JsonResource
'description' => $this->description,
'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id,
'starts_at' => $this->starts_at,
'expires_at' => $this->expires_at,
'starts_at' => $this->getEffectiveStartsAt(),
'expires_at' => $this->getEffectiveExpiresAt(),
'used_at' => $this->used_at,
'is_valid' => $this->is_valid,
'is_expired' => $this->is_expired,

View File

@@ -35,8 +35,6 @@ class TicketGeneratorService
);
return $targets->map(function (array $target) use (
$catalogItem,
$sourceVariantId,
$sourcePurchaseId,
$user,
): Ticket {
@@ -49,8 +47,8 @@ class TicketGeneratorService
'name' => $item->nombre,
'description' => (string) ($item->descripcion ?? ''),
'source_purchase_id' => $sourcePurchaseId,
'source_catalog_item_id' => $catalogItem->getKey(),
'source_variant_id' => $sourceVariantId,
'source_catalog_item_id' => $item->getKey(),
'source_variant_id' => $target['variant']?->getKey(),
'starts_at' => $selectedItem->getMinimumUseDate(),
'expires_at' => $selectedItem->getMaximumUseDate(),
'used_at' => null,

View File

@@ -0,0 +1,80 @@
<?php
use App\Domains\Catalog\Enums\EventProductType;
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('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::create('event_dates', function (Blueprint $table): void {
$table->id();
$table->foreignId('event_id')->constrained('events')->cascadeOnDelete();
$table->date('date');
$table->time('time_start');
$table->time('time_end');
});
Schema::table('tenants', function (Blueprint $table): void {
$table->foreignId('active_event_id')
->nullable()
->constrained('events')
->nullOnDelete();
});
Schema::table('catalog_items', function (Blueprint $table): void {
$table->foreignId('event_id')
->nullable()
->after('tenant_code')
->constrained('events')
->nullOnDelete();
$table->enum('event_product_type', EventProductType::values())
->nullable()
->after('event_id');
});
Schema::table('variantes', function (Blueprint $table): void {
$table->foreignId('event_date_id')
->nullable()
->after('catalog_item_id')
->constrained('event_dates')
->nullOnDelete();
$table->unique(['catalog_item_id', 'event_date_id']);
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->dropConstrainedForeignId('active_event_id');
});
Schema::table('variantes', function (Blueprint $table): void {
$table->dropUnique(['catalog_item_id', 'event_date_id']);
$table->dropConstrainedForeignId('event_date_id');
});
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
$table->dropColumn('event_product_type');
});
Schema::dropIfExists('event_dates');
Schema::dropIfExists('events');
}
};

View File

@@ -0,0 +1,43 @@
<?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
{
DB::table('tickets')
->whereNotNull('source_variant_id')
->whereNotIn('source_variant_id', DB::table('variantes')->select('id'))
->update(['source_variant_id' => null]);
DB::table('tickets')
->whereNotNull('source_catalog_item_id')
->whereNotIn('source_catalog_item_id', DB::table('catalog_items')->select('id'))
->update(['source_catalog_item_id' => null]);
Schema::table('tickets', function (Blueprint $table): void {
$table->foreign('source_catalog_item_id')
->references('id')
->on('catalog_items')
->cascadeOnUpdate()
->nullOnDelete();
$table->foreign('source_variant_id')
->references('id')
->on('variantes')
->cascadeOnUpdate()
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table): void {
$table->dropForeign(['source_catalog_item_id']);
$table->dropForeign(['source_variant_id']);
});
}
};

View File

@@ -0,0 +1,31 @@
<?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('value_changes', function (Blueprint $table): void {
$table->id();
$table->morphs('trackable');
$table->string('attribute');
$table->text('old_value')->nullable();
$table->text('new_value')->nullable();
$table->timestamp('changed_at');
$table->string('actor_type');
$table->foreignId('user_id')
->nullable()
->constrained('users')
->cascadeOnUpdate()
->nullOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('value_changes');
}
};

View File

@@ -17,15 +17,16 @@ class AttributeSeeder extends Seeder
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
// Fiesta Futbol Infantil only uses the Fecha attribute.
// Event dates replace catalog attributes for Fiesta Futbol Infantil.
if ($tenant->codigo === 'fiesta_futbol_infantil') {
Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', ['color', 'talle', 'talle_numerico'])
->whereIn('codigo', ['color', 'talle', 'talle_numerico', 'fecha'])
->delete();
continue;
}
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
$this->seedAttribute($tenant, [
'codigo' => 'color',
'nombre' => 'Color',
@@ -61,10 +62,8 @@ class AttributeSeeder extends Seeder
],
],
]);
}
// Seed Talle (Size - Text options) attribute
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
$this->seedAttribute($tenant, [
'codigo' => 'talle',
'nombre' => 'Talle',
@@ -77,10 +76,8 @@ class AttributeSeeder extends Seeder
['value' => 'XL', 'label' => 'XL', 'sort_order' => 4],
],
]);
}
// Seed Talle Numérico (Numeric Size options) attribute
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
$this->seedAttribute($tenant, [
'codigo' => 'talle_numerico',
'nombre' => 'Talle Numérico',
@@ -93,7 +90,6 @@ class AttributeSeeder extends Seeder
['value' => '44', 'label' => '44', 'sort_order' => 4],
],
]);
}
// Seed Fecha attribute
$this->seedAttribute($tenant, [

View File

@@ -3,6 +3,7 @@
namespace Database\Seeders;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\ProductLayout;
@@ -10,6 +11,7 @@ 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 Illuminate\Database\Seeder;
use RuntimeException;
@@ -28,6 +30,29 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->deleteExistingCatalog($tenant);
$event = Event::query()->updateOrCreate(
[
'tenant_code' => $tenant->codigo,
'name' => 'Fiesta Nacional del Fútbol Infantil',
],
['address' => 'Sunchales, Santa Fe'],
);
$event->dates()->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([
'date' => $date,
'time_start' => '00:00:00',
'time_end' => '23:59:59',
]);
return [$date => $eventDate];
});
$tenant->active_event_id = $event->id;
$tenant->save();
$ticketCategory = Category::query()->firstOrCreate([
'nombre' => 'Entradas',
'tenant_code' => $tenant->codigo,
@@ -41,12 +66,14 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'tenant_code' => $tenant->codigo,
]);
$dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'];
$dates = $eventDates->keys()->all();
$minimumUseDate = $dates[0].' 00:00:00';
$maximumUseDate = $dates[array_key_last($dates)].' 23:59:59';
$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',
'nombre' => 'Entrada General',
@@ -56,13 +83,10 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'has_tickets' => true,
'minimum_use_date' => $minimumUseDate,
'maximum_use_date' => $maximumUseDate,
'attribute_codes' => ['fecha'],
'variants' => array_map(
fn (string $date): array => [
'real_stock' => 0,
'minimum_use_date' => $date.' 00:00:00',
'maximum_use_date' => $date.' 23:59:59',
'values' => ['fecha' => $date],
'event_date_id' => $eventDates->get($date)->id,
],
$dates,
),
@@ -81,6 +105,8 @@ 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,
'real_stock' => 0,
@@ -92,6 +118,8 @@ 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',
'nombre' => 'Entrada General - Todos los días',
@@ -109,6 +137,8 @@ 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',
'nombre' => 'Combo 2 Panchos + 2 Hamburguesas',

View File

@@ -120,6 +120,7 @@ class TenantSeeder extends Seeder
'extras' => [
'heroConfig' => [
'title_html' => '<h1>Fiesta Fútbol Infantil</h1>',
'description_html' => '<p>Viví una jornada inolvidable de fútbol infantil.</p>',
'button_text' => 'Comprar entradas',
'button_href' => '/tickets',
'background_image_id' => $this->uploadedImage(
@@ -145,7 +146,32 @@ class TenantSeeder extends Seeder
],
],
'additionalInfoConfig' => [
'description' => '<p>Viví una jornada inolvidable de fútbol infantil.</p>',
'description' => <<<'HTML'
<div class="container-fluid px-0">
<div class="row g-4 text-start">
<div class="col-12 col-md-6">
<ul class="mb-0 ps-3">
<li class="mb-3">
<strong>Menores y Accesos:</strong> Los niños menores de [X] años ingresan gratis acompañados por un adulto con entrada válida.
</li>
<li>
<strong>Jugadores y Accesos:</strong> Los niños que participen del torneo ingresan según la acreditación otorgada a su club.
</li>
</ul>
</div>
<div class="col-12 col-md-6">
<ul class="mb-0 ps-3">
<li class="mb-3">
<strong>Elementos prohibidos:</strong> No se permite el ingreso con pirotecnia, objetos cortantes, envases de vidrio ni bebidas alcohólicas.
</li>
<li>
<strong>Entradas:</strong> No es necesario que imprimas tu entrada. Podés mostrar el código QR directamente desde tu celular. No tienen cambio ni devolución.
</li>
</ul>
</div>
</div>
</div>
HTML,
],
],
]);

View File

@@ -176,11 +176,11 @@
</td>
<td class="meta-cell">
<div class="meta-label">Desde</div>
<div class="meta-value">{{ $ticket->starts_at?->format('d/m/Y') ?? 'Sin fecha inicial' }}</div>
<div class="meta-value">{{ $ticket->getEffectiveStartsAt()?->format('d/m/Y') ?? 'Sin fecha inicial' }}</div>
</td>
<td class="meta-cell">
<div class="meta-label">Hasta</div>
<div class="meta-value">{{ $ticket->expires_at?->format('d/m/Y') ?? 'Sin vencimiento' }}</div>
<div class="meta-value">{{ $ticket->getEffectiveExpiresAt()?->format('d/m/Y') ?? 'Sin vencimiento' }}</div>
</td>
</tr>
</table>

View File

@@ -10,3 +10,6 @@ require __DIR__.'/../app/Domains/Tenant/routes/api.php';
require __DIR__.'/../app/Domains/Integration/routes/api.php';
require __DIR__.'/../app/Domains/Menu/routes/api.php';
require __DIR__.'/../app/Domains/Ticket/routes/api.php';
require __DIR__.'/../app/Domains/Event/routes/api.php';
require __DIR__.'/../app/Domains/Bootstrap/routes/api.php';
require __DIR__.'/../app/Domains/Forms/routes/api.php';

View File

@@ -32,6 +32,8 @@ class CatalogSchemaTest extends TestCase
$this->assertEqualsCanonicalizing([
'id',
'tenant_code',
'event_id',
'event_product_type',
'category_id',
'brand_id',
'inventory_id',
@@ -178,8 +180,27 @@ class CatalogSchemaTest extends TestCase
public function test_variants_can_override_catalog_item_use_dates(): void
{
$this->assertTrue(Schema::hasColumns('variantes', [
'event_date_id',
'minimum_use_date',
'maximum_use_date',
]));
}
public function test_events_and_event_dates_are_linked_to_the_catalog(): void
{
$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'));
}
}

View File

@@ -0,0 +1,270 @@
<?php
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;
use Database\Seeders\SocialMediaSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppEventControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed([AuthorizationSeeder::class, SocialMediaSeeder::class]);
WebsiteType::query()->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
]);
}
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/tenant/event')->assertUnauthorized();
$this->putJson('/api/v1/adminapp/tenant/event', $this->eventPayload())->assertUnauthorized();
}
public function test_an_adminapp_user_can_create_the_active_event_and_contact_information(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$response = $this->putJson('/api/v1/adminapp/tenant/event', $this->eventPayload())
->assertOk()
->assertJsonPath('data.title', 'Festival Acme')
->assertJsonPath('data.location', 'Predio Ferial, Rosario')
->assertJsonPath('data.dates.0.date', '2026-10-09')
->assertJsonPath('data.dates.0.start_time', '09:00')
->assertJsonPath('data.dates.0.end_time', '18:30')
->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101')
->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($eventId, $tenant->fresh()->active_event_id);
$this->assertDatabaseHas('event_dates', [
'event_id' => $eventId,
'date' => '2026-10-09',
'time_start' => '09:00:00',
'time_end' => '18:30:00',
]);
$this->assertDatabaseHas('tenant_social_media', [
'tenant_code' => $tenant->codigo,
'social_media_code' => 'whatsapp',
'url' => 'https://wa.me/5493415550101',
]);
}
public function test_an_adminapp_user_can_read_only_its_tenant_active_event(): void
{
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
$this->createActiveEvent($tenant, 'Acme Event');
$this->createActiveEvent($otherTenant, 'Other Event');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/event')
->assertOk()
->assertJsonPath('data.title', 'Acme Event')
->assertJsonMissing(['title' => 'Other Event']);
}
public function test_reading_a_tenant_without_an_active_event_returns_not_found(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/event')->assertNotFound();
}
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([
'date' => '2026-10-01',
'time_start' => '08:00',
'time_end' => '12:00',
]);
$removedDate = $event->dates()->create([
'date' => '2026-10-02',
'time_start' => '08:00',
'time_end' => '12:00',
]);
$tenant->socialMedia()->attach('facebook', [
'url' => 'https://facebook.com/old',
'orden' => 2,
]);
$tenant->socialMedia()->attach('linkedin', [
'url' => 'https://linkedin.com/company/acme',
'orden' => 3,
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$payload = $this->eventPayload();
$payload['dates'] = [[
'date' => '2026-11-15',
'start_time' => '10:00',
'end_time' => '20:00',
]];
$this->putJson('/api/v1/adminapp/tenant/event', $payload)
->assertOk()
->assertJsonPath('data.id', $event->id)
->assertJsonPath('data.dates.0.id', $firstDate->id)
->assertJsonPath('data.contact.facebook_url', null);
$this->assertDatabaseHas('event_dates', [
'id' => $firstDate->id,
'date' => '2026-11-15',
]);
$this->assertDatabaseMissing('event_dates', ['id' => $removedDate->id]);
$this->assertDatabaseMissing('tenant_social_media', [
'tenant_code' => $tenant->codigo,
'social_media_code' => 'facebook',
]);
$this->assertDatabaseHas('tenant_social_media', [
'tenant_code' => $tenant->codigo,
'social_media_code' => 'linkedin',
'url' => 'https://linkedin.com/company/acme',
]);
}
public function test_update_validates_event_dates_and_contact_urls(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->putJson('/api/v1/adminapp/tenant/event', [
'title' => '',
'location' => '',
'dates' => [
['date' => '09/10/2026', 'start_time' => '9am', 'end_time' => '18:00'],
['date' => '09/10/2026', 'start_time' => '09:00', 'end_time' => '18:00'],
],
'contact' => [
'whatsapp_url' => 'not-a-url',
'instagram_url' => null,
'facebook_url' => null,
],
])
->assertUnprocessable()
->assertJsonValidationErrors([
'title',
'location',
'dates.0.date',
'dates.0.start_time',
'dates.1.date',
'contact.whatsapp_url',
]);
$this->assertDatabaseCount('events', 0);
}
public function test_social_media_accepts_any_registered_code_and_rejects_unknown_codes(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$payload = $this->eventPayload();
unset($payload['contact']);
$payload['social_media'] = [[
'code' => 'linkedin',
'url' => 'https://linkedin.com/company/acme',
'orden' => 5,
]];
$this->putJson('/api/v1/adminapp/tenant/event', $payload)
->assertOk()
->assertJsonPath('data.social_media.0.code', 'linkedin')
->assertJsonPath('data.social_media.0.url', 'https://linkedin.com/company/acme')
->assertJsonPath('data.social_media.0.orden', 5);
$this->assertDatabaseHas('tenant_social_media', [
'tenant_code' => $tenant->codigo,
'social_media_code' => 'linkedin',
'url' => 'https://linkedin.com/company/acme',
'orden' => 5,
]);
$payload['social_media'][0]['code'] = 'unknown';
$this->putJson('/api/v1/adminapp/tenant/event', $payload)
->assertUnprocessable()
->assertJsonValidationErrors(['social_media.0.code']);
}
public function test_a_customer_cannot_manage_an_event(): void
{
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::User->value,
'tenant_codigo' => null,
]));
$this->getJson('/api/v1/adminapp/tenant/event')->assertForbidden();
}
/** @return array<string, mixed> */
private function eventPayload(): array
{
return [
'title' => 'Festival Acme',
'location' => 'Predio Ferial, Rosario',
'dates' => [[
'date' => '2026-10-09',
'start_time' => '09:00',
'end_time' => '18:30',
]],
'contact' => [
'whatsapp_url' => 'https://wa.me/5493415550101',
'instagram_url' => 'https://instagram.com/acme',
'facebook_url' => null,
],
];
}
private function createTenant(string $code): Tenant
{
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'website_type_code' => 'onticket',
]);
}
private function createAdminAppUser(Tenant $tenant): User
{
return User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
}
private function createActiveEvent(Tenant $tenant, string $name): Event
{
$event = $tenant->events()->create([
'name' => $name,
'address' => 'Rosario',
]);
$tenant->update(['active_event_id' => $event->id]);
return $event;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Tests\Feature\Forms;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Database\Seeders\SocialMediaSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppEventFormControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed([AuthorizationSeeder::class, SocialMediaSeeder::class]);
WebsiteType::query()->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
]);
}
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/forms/event')->assertUnauthorized();
}
public function test_an_adminapp_user_can_get_the_event_form(): void
{
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.test',
'website_type_code' => 'onticket',
]);
$tenant->socialMedia()->attach('instagram', [
'url' => 'https://instagram.com/acme',
'orden' => 1,
]);
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]));
$this->getJson('/api/v1/adminapp/forms/event')
->assertOk()
->assertJsonCount(4, 'data.social_media')
->assertJsonPath('data.social_media.0.code', 'facebook')
->assertJsonPath('data.social_media.0.name', 'Facebook')
->assertJsonPath('data.social_media.0.icon', 'fa-brands fa-facebook')
->assertJsonPath('data.social_media.0.url', null)
->assertJsonPath('data.social_media.1.code', 'instagram')
->assertJsonPath('data.social_media.1.url', 'https://instagram.com/acme')
->assertJsonPath('data.social_media.3.code', 'linkedin');
}
public function test_a_customer_cannot_get_the_event_form(): void
{
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::User->value,
'tenant_codigo' => null,
]));
$this->getJson('/api/v1/adminapp/forms/event')->assertForbidden();
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace Tests\Feature\Logging;
use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Logging\Models\ValueChange;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class LogsValueChangesTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('database.default', 'logging_test');
config()->set('database.connections.logging_test', [
'driver' => 'sqlite',
'database' => ':memory:',
'foreign_key_constraints' => true,
]);
Schema::create('users', function (Blueprint $table): void {
$table->id();
});
Schema::create('logging_test_products', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->unsignedInteger('price');
$table->text('description')->nullable();
$table->timestamps();
});
$migration = require database_path('migrations/2026_08_03_000200_create_value_changes_table.php');
$migration->up();
}
public function test_it_creates_one_system_record_per_configured_change(): void
{
$product = LoggingTestProduct::create([
'name' => 'Original',
'price' => 100,
'description' => 'Old description',
]);
$product->update([
'name' => 'Updated',
'price' => 150,
'description' => 'New description',
]);
$this->assertDatabaseCount('value_changes', 2);
$this->assertDatabaseHas('value_changes', [
'attribute' => 'name',
'old_value' => 'Original',
'new_value' => 'Updated',
'actor_type' => ValueChangeActorType::System->value,
'user_id' => null,
]);
$this->assertDatabaseHas('value_changes', [
'attribute' => 'price',
'old_value' => '100',
'new_value' => '150',
'actor_type' => ValueChangeActorType::System->value,
'user_id' => null,
]);
$this->assertTrue(ValueChange::firstOrFail()->trackable->is($product));
}
public function test_it_associates_an_authenticated_user_with_the_change(): void
{
Schema::getConnection()->table('users')->insert(['id' => 7]);
Auth::shouldReceive('id')->once()->andReturn(7);
$product = LoggingTestProduct::create([
'name' => 'Original',
'price' => 100,
]);
$product->update(['price' => 200]);
$change = ValueChange::firstOrFail();
$this->assertSame(ValueChangeActorType::User, $change->actor_type);
$this->assertSame(7, $change->user_id);
}
public function test_it_does_not_log_updates_to_unconfigured_attributes(): void
{
$product = LoggingTestProduct::create([
'name' => 'Original',
'price' => 100,
'description' => 'Old description',
]);
$product->update(['description' => 'New description']);
$this->assertDatabaseCount('value_changes', 0);
}
}
#[Fillable(['name', 'price', 'description'])]
class LoggingTestProduct extends Model
{
use LogsValueChanges;
protected $table = 'logging_test_products';
/** @var array<int, string> */
protected array $loggedAttributes = [
'name',
'price',
];
}

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Seeders;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\Attribute;
@@ -12,6 +13,8 @@ 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 Database\Seeders\AttributeSeeder;
use Database\Seeders\FiestaFutbolInfantilProductSeeder;
@@ -62,24 +65,34 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
$this->assertFalse(Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', ['color', 'talle', 'talle_numerico'])
->exists());
$this->assertSame(
['fecha'],
Attribute::query()->where('tenant_codigo', $tenant->codigo)->pluck('codigo')->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());
$generalAdmission = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'entrada-general')
->with('variants.definitions')
->with('variants.eventDate', 'itemAttributes')
->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);
$this->assertEqualsCanonicalizing(
['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
$generalAdmission->variants->map(fn ($variant) => $variant->definitions->sole()->value)->all()
$generalAdmission->variants
->map(fn ($variant) => $variant->eventDate->date->format('Y-m-d'))
->all()
);
$this->assertSame(
[
@@ -89,10 +102,10 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
['2026-10-12 00:00:00', '2026-10-12 23:59:59'],
],
$generalAdmission->variants
->sortBy(fn ($variant) => $variant->definitions->sole()->value)
->sortBy(fn ($variant) => $variant->eventDate->date)
->map(fn ($variant): array => [
$variant->minimum_use_date->format('Y-m-d H:i:s'),
$variant->maximum_use_date->format('Y-m-d H:i:s'),
$variant->getMinimumUseDate()->format('Y-m-d H:i:s'),
$variant->getMaximumUseDate()->format('Y-m-d H:i:s'),
])
->values()
->all()
@@ -105,6 +118,7 @@ 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->minimum_use_date->format('Y-m-d H:i:s'),
@@ -118,18 +132,20 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
$allDaysItem = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', 'Entrada General - Todos los días')
->with('bundleComponents.variant.definitions')
->with('bundleComponents.variant.eventDate')
->sole();
$this->assertSame(CatalogItemType::Bundle, $allDaysItem->type);
$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(
['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
$allDaysItem->bundleComponents
->map(fn ($component) => $component->variant->definitions->sole()->value)
->map(fn ($component) => $component->variant->eventDate->date->format('Y-m-d'))
->all()
);
@@ -142,6 +158,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
$this->assertSame(CatalogItemType::Bundle, $foodCombo->type);
$this->assertSame('24000.00', $foodCombo->precio);
$this->assertNull($foodCombo->inventory_id);
$this->assertSame(EventProductType::Product, $foodCombo->event_product_type);
$this->assertSame(
[
'hamburguesa-papa-frita' => 2,

View File

@@ -90,7 +90,10 @@ class TenantSeederTest extends TestCase
$heroConfig = $extras->get('heroConfig')->config;
$this->assertSame('<h1>Fiesta Fútbol Infantil</h1>', $heroConfig['title_html']);
$this->assertArrayNotHasKey('description_html', $heroConfig);
$this->assertSame(
'<p>Viví una jornada inolvidable de fútbol infantil.</p>',
$heroConfig['description_html'],
);
$this->assertIsInt($heroConfig['background_image_id']);
$this->assertDatabaseHas('attachments', [
'id' => $heroConfig['background_image_id'],
@@ -115,8 +118,13 @@ class TenantSeederTest extends TestCase
],
], $extras->get('eventConfig')->config);
$this->assertSame([
'description' => '<p>Viví una jornada inolvidable de fútbol infantil.</p>',
], $extras->get('additionalInfoConfig')->config);
$additionalInfo = $extras->get('additionalInfoConfig')->config['description'];
$this->assertStringContainsString('class="row g-4 text-start"', $additionalInfo);
$this->assertSame(2, substr_count($additionalInfo, 'class="col-12 col-md-6"'));
$this->assertStringContainsString('<strong>Menores y Accesos:</strong>', $additionalInfo);
$this->assertStringContainsString('<strong>Jugadores y Accesos:</strong>', $additionalInfo);
$this->assertStringContainsString('<strong>Elementos prohibidos:</strong>', $additionalInfo);
$this->assertStringContainsString('<strong>Entradas:</strong>', $additionalInfo);
}
}

View File

@@ -170,6 +170,53 @@ class AdminAppWebsiteExtraControllerTest extends TestCase
);
}
public function test_adminapp_user_saves_the_banner_description_in_the_hero_extra(): void
{
$heroDefinition = $this->websiteType->extras()->create([
'codigo' => 'heroConfig',
'nombre' => 'Banner principal',
'descripcion' => 'Configuración del banner principal.',
'is_required' => false,
'config_schema' => [
'request_rules' => [
'$' => 'required|array',
'title_html' => 'nullable|string',
'description_html' => 'nullable|string',
'button_text' => 'nullable|string',
'button_href' => 'nullable|string',
'background_image_id' => 'nullable|string',
],
'transforms' => [],
],
]);
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->putJson('/api/v1/adminapp/tenant/website-extras/heroConfig', [
'config' => [
'title_html' => '<h1>Evento</h1>',
'description_html' => '<p>Nueva descripción del banner</p>',
'button_text' => 'Comprar',
'button_href' => '/tickets',
'background_image_id' => null,
],
])
->assertOk()
->assertJsonPath(
'data.extras.heroConfig.description_html',
'<p>Nueva descripción del banner</p>'
);
$this->assertSame(
'<p>Nueva descripción del banner</p>',
$tenant->websiteExtras()
->where('website_type_extra_id', $heroDefinition->id)
->firstOrFail()
->config['description_html']
);
}
public function test_update_returns_not_found_for_an_unsupported_extra_code(): void
{
$tenant = $this->createTenant('acme');

View File

@@ -41,6 +41,7 @@ class BootstrapAdminAppControllerTest extends TestCase
->assertJsonPath('data.login_header_footer_color', '#313131')
->assertJsonPath('data.site_logo', null)
->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440))
->assertJsonMissingPath('data.forms')
->assertJsonMissingPath('data.codigo')
->assertJsonMissingPath('data.nombre')
->assertJsonMissingPath('data.dominio');

View File

@@ -128,7 +128,8 @@ class TicketGeneratorServiceTest extends TestCase
$tickets = $this->service->generate($bundle, $this->user, 2);
$this->assertCount(6, $tickets);
$this->assertCount(6, $tickets->where('source_catalog_item_id', $bundle->id));
$this->assertCount(4, $tickets->where('source_catalog_item_id', $first->id));
$this->assertCount(2, $tickets->where('source_catalog_item_id', $second->id));
$this->assertCount(4, $tickets->where('name', $first->nombre));
$this->assertCount(2, $tickets->where('name', $second->nombre));
}
@@ -157,6 +158,8 @@ class TicketGeneratorServiceTest extends TestCase
->generate($bundle, $this->user)
->firstOrFail();
$this->assertSame($component->id, $ticket->source_catalog_item_id);
$this->assertSame($variant->id, $ticket->source_variant_id);
$this->assertTrue($ticket->starts_at->equalTo($variant->minimum_use_date));
$this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date));
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Tests\Unit\Bootstrap;
use App\Domains\Bootstrap\Resources\AdminAppBootstrapResource;
use App\Domains\Tenant\Models\WebsiteType;
use Tests\TestCase;
class AdminAppBootstrapResourceTest extends TestCase
{
public function test_it_exposes_the_website_type(): void
{
$websiteType = new WebsiteType([
'codigo' => 'shopit',
'nombre' => 'ShopIt',
'dominio' => 'admin.shopit.test',
]);
$websiteType->setRelation('siteLogo', null);
$websiteType->setRelation('footerLogo', null);
$data = AdminAppBootstrapResource::make([
'website_type' => $websiteType,
])->resolve(request());
$this->assertSame('shopit', $data['website_type_code']);
$this->assertArrayNotHasKey('forms', $data);
}
}

View File

@@ -4,6 +4,7 @@ namespace Tests\Unit\Catalog;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\ProductLayout;
@@ -19,8 +20,11 @@ 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;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon;
use Tests\TestCase;
@@ -61,6 +65,8 @@ 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',
'inventory_policy' => InventoryPolicy::Tracked->value,
@@ -72,17 +78,21 @@ 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());
$this->assertInstanceOf(BundleComponent::class, $item->bundleComponents()->getRelated());
$this->assertInstanceOf(BundleComponent::class, $item->bundleComponentUsages()->getRelated());
$this->assertInstanceOf(Variant::class, $item->variants()->getRelated());
$this->assertInstanceOf(Ticket::class, $item->sourceTickets()->getRelated());
$this->assertInstanceOf(Attribute::class, $item->attributes()->getRelated());
$this->assertInstanceOf(ItemAttribute::class, $item->itemAttributes()->getRelated());
$this->assertInstanceOf(FeaturedItem::class, $item->featuredItems()->getRelated());
@@ -125,13 +135,20 @@ class CatalogModelsTest extends TestCase
public function test_variant_has_direct_catalog_and_inventory_relations(): void
{
$variant = new Variant;
$variant->setRawAttributes(['catalog_item_id' => '10', 'inventory_id' => '20']);
$variant->setRawAttributes([
'catalog_item_id' => '10',
'event_date_id' => '15',
'inventory_id' => '20',
]);
$this->assertSame('variantes', $variant->getTable());
$this->assertSame(10, $variant->catalog_item_id);
$this->assertSame(20, $variant->inventory_id);
$this->assertSame(15, $variant->event_date_id);
$this->assertInstanceOf(CatalogItem::class, $variant->catalogItem()->getRelated());
$this->assertInstanceOf(Inventory::class, $variant->inventory()->getRelated());
$this->assertInstanceOf(EventDate::class, $variant->eventDate()->getRelated());
$this->assertInstanceOf(Ticket::class, $variant->sourceTickets()->getRelated());
$this->assertInstanceOf(VariantDefinition::class, $variant->definitions()->getRelated());
$this->assertInstanceOf(Attachment::class, $variant->attachments()->getRelated());
$this->assertSame('catalog_items_attachments', $variant->attachments()->getTable());
@@ -155,6 +172,28 @@ class CatalogModelsTest extends TestCase
);
}
public function test_event_date_identifies_a_variant_without_catalog_attributes(): void
{
$item = new CatalogItem;
$item->nombre = 'Entrada General';
$eventDate = new EventDate;
$eventDate->date = '2026-10-09';
$eventDate->time_start = '09:00:00';
$eventDate->time_end = '18:00:00';
$variant = new Variant;
$variant->minimum_use_date = Carbon::parse('2026-10-09 08:00:00');
$variant->maximum_use_date = Carbon::parse('2026-10-09 20:00:00');
$variant->setRelation('catalogItem', $item);
$variant->setRelation('eventDate', $eventDate);
$variant->setRelation('definitions', new EloquentCollection);
$this->assertSame('Entrada General (Fecha: 2026-10-09)', $variant->getName());
$this->assertSame('2026-10-09 09:00:00', $variant->getMinimumUseDate()->format('Y-m-d H:i:s'));
$this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s'));
}
public function test_inventory_maps_stock_without_a_polymorphic_owner(): void
{
$inventory = $this->trackedInventory(realStock: 10, reservedStock: 3);

View File

@@ -0,0 +1,49 @@
<?php
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',
'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('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(Variant::class, $eventDate->variants()->getRelated());
}
public function test_tenant_has_many_events_and_one_active_event(): void
{
$tenant = new Tenant;
$this->assertInstanceOf(Event::class, $tenant->events()->getRelated());
$this->assertInstanceOf(Event::class, $tenant->activeEvent()->getRelated());
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Tests\Unit\Logging;
use App\Domains\Auth\Models\User;
use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Logging\Models\ValueChange;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Tests\TestCase;
class ValueChangeTest extends TestCase
{
public function test_value_change_maps_its_values_and_relations(): void
{
$valueChange = new ValueChange;
$valueChange->setRawAttributes([
'trackable_id' => '10',
'attribute' => 'status',
'old_value' => 'pending',
'new_value' => 'paid',
'changed_at' => '2026-08-03 15:30:00',
'actor_type' => ValueChangeActorType::User->value,
'user_id' => '20',
]);
$this->assertSame('value_changes', $valueChange->getTable());
$this->assertFalse($valueChange->usesTimestamps());
$this->assertSame(10, $valueChange->trackable_id);
$this->assertSame('status', $valueChange->attribute);
$this->assertSame('pending', $valueChange->old_value);
$this->assertSame('paid', $valueChange->new_value);
$this->assertSame('2026-08-03 15:30:00', $valueChange->changed_at->format('Y-m-d H:i:s'));
$this->assertSame(ValueChangeActorType::User, $valueChange->actor_type);
$this->assertSame(20, $valueChange->user_id);
$this->assertInstanceOf(MorphTo::class, $valueChange->trackable());
$this->assertInstanceOf(User::class, $valueChange->user()->getRelated());
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace Tests\Unit\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Tenant\Resources\AdminApp\BootstrapAdminAppResource;
use Tests\TestCase;
class BootstrapAdminAppResourceTest extends TestCase
{
public function test_it_exposes_the_website_type_code(): void
{
$websiteType = new WebsiteType([
'codigo' => 'shopit',
'nombre' => 'ShopIt',
'dominio' => 'admin.shopit.test',
]);
$websiteType->setRelation('siteLogo', null);
$data = BootstrapAdminAppResource::make($websiteType)->resolve(request());
$this->assertSame('shopit', $data['codigo']);
$this->assertSame('shopit', $data['website_type_code']);
}
}

View File

@@ -3,6 +3,9 @@
namespace Tests\Unit\Ticket;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Carbon;
@@ -39,6 +42,8 @@ class TicketTest extends TestCase
$this->assertSame(10, $ticket->user_id);
$this->assertInstanceOf(Tenant::class, $ticket->tenant()->getRelated());
$this->assertInstanceOf(User::class, $ticket->user()->getRelated());
$this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated());
$this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated());
}
public function test_unused_ticket_without_date_restrictions_is_valid(): void
@@ -114,4 +119,30 @@ class TicketTest extends TestCase
$this->assertFalse($ticket->is_expired);
$this->assertTrue($ticket->is_used);
}
public function test_event_date_has_priority_over_ticket_and_variant_dates(): void
{
Carbon::setTestNow('2026-10-09 19:00:00');
$eventDate = new EventDate;
$eventDate->date = '2026-10-09';
$eventDate->time_start = '09:00:00';
$eventDate->time_end = '18:00:00';
$variant = new Variant;
$variant->minimum_use_date = Carbon::parse('2026-10-09 08:00:00');
$variant->maximum_use_date = Carbon::parse('2026-10-09 22:00:00');
$variant->setRelation('eventDate', $eventDate);
$ticket = new Ticket([
'starts_at' => Carbon::parse('2026-10-09 07:00:00'),
'expires_at' => Carbon::parse('2026-10-10 23:59:59'),
]);
$ticket->setRelation('sourceVariant', $variant);
$this->assertSame('2026-10-09 09:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-10-09 18:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
$this->assertFalse($ticket->isValid());
$this->assertTrue($ticket->is_expired);
}
}