27 Commits

Author SHA1 Message Date
8aa3a26ee7 feat: Add event association to purchases; implement event relationship in models, update migration, and enhance tests 2026-08-04 11:45:03 -03:00
1de08c1ca4 feat: Implement sale management features including controllers, services, resources, and routes; add PDF generation for sales and modifications 2026-08-04 11:44:13 -03:00
3f9bcd84c4 feat: Add tenant code to value changes; implement PurchaseController and related services for tenant-specific purchase management 2026-08-04 10:27:33 -03:00
96f26e2e9d feat: Remove 'in_review' status from purchase workflow; implement logging for status changes and update related tests 2026-08-03 17:04:39 -03:00
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
4a51f3afde feat(website-extras): enhance toggle functionality and update related tests; add additional info configuration to tenant extras 2026-08-03 09:10:50 -03:00
f1df6a5918 feat(seeder): update MenuSeeder and tests to reflect new admin menu structure; change border color in WebsiteTypeSeeder 2026-08-03 09:07:49 -03:00
dbeaf19513 feat(auth): implement AdminAppMeController and AdminAppMeResource; add routes and tests for user context retrieval 2026-07-31 16:11:47 -03:00
dd76d5d951 feat(website-type): add footer logo support in WebsiteType model, controller, and resource; update seeder and tests 2026-07-31 14:16:58 -03:00
f603a82b5e feat(website-type): implement BootstrapWebsiteTypeController and related request/resource for domain-based bootstrapping 2026-07-31 13:39:31 -03:00
b478c23f3b feat(website-type): add new color fields and update related methods in WebsiteType model and service 2026-07-31 12:55:40 -03:00
6858b387c3 feat(website-type): add 'dominio' field to WebsiteType model and update related tests 2026-07-31 12:06:27 -03:00
893bea1492 feat(website-type): add login_header_footer_color field and update related tests 2026-07-31 12:06:13 -03:00
ec1d80dff2 feat(seeder): update image paths in ProductCatalogFromImagesSeeder and TenantSeeder, add new catalog images 2026-07-31 11:45:29 -03:00
3968a10f6a feat(website-type): add presentation fields and site logo relationship to WebsiteType model 2026-07-31 11:39:17 -03:00
cf2beb2ff5 feat(auth): implement AdminApp login functionality with controller, request, routes, and tests 2026-07-31 10:47:55 -03:00
484fa204b3 feat(tenant): add BootstrapTenantController and corresponding tests for tenant bootstrapping functionality 2026-07-31 10:36:46 -03:00
6dd057874a feat(website-extras): add showExtra method and resource for individual website extras, update routes and tests 2026-07-31 09:51:27 -03:00
40cbac17c6 feat(website-extras): implement toggle functionality for website extras and update related tests 2026-07-31 09:47:56 -03:00
45e56c43a6 feat(website-extras): add 'is_enabled' column to websites_extras table and update model and tests 2026-07-31 09:38:30 -03:00
cbb7b1c3a1 feat(database): remove 'database_rules' from WebsiteTypeExtra configurations and related tests 2026-07-31 09:28:12 -03:00
6b3a12b624 feat(website-extras): refactor WebsiteExtra management to use 'codigo' for identification, update routes, requests, and services 2026-07-31 09:27:35 -03:00
165 changed files with 4661 additions and 384 deletions

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\AdminAppLoginRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class AdminAppLoginController extends Controller
{
public function __construct(
private readonly PasswordLoginService $passwordLoginService,
) {}
public function __invoke(AdminAppLoginRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = $this->passwordLoginService->authenticateAdminApp(
$credentials['email'],
$credentials['password'],
$request->ip(),
$request->userAgent(),
);
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(
'adminapp-token',
['adminapp'],
now()->addMinutes($expirationMinutes),
)->plainTextToken;
return response()->json([
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $token,
'token_type' => 'Bearer',
'user' => UserResource::make($user),
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Resources\AdminAppMeResource;
use App\Domains\Auth\Services\AdminAppContextService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class AdminAppMeController extends Controller
{
public function __construct(
private readonly AdminAppContextService $adminAppContextService,
) {}
public function __invoke(Request $request): AdminAppMeResource
{
/** @var User $user */
$user = $request->user();
return AdminAppMeResource::make(
$this->adminAppContextService->load($user)
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class AdminAppLoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge([
'email' => Str::lower(trim($email)),
]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'password' => ['required', 'string'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Auth\Resources;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Resources\TenantResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin User
*/
class AdminAppMeResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'user' => UserResource::make($this->resource),
'tenant' => TenantResource::make($this->tenant),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
class AdminAppContextService
{
public function load(User $user): User
{
$tenant = $user->tenant()
->with([
'menues' => fn ($query) => $query
->whereHas(
'roles',
fn ($query) => $query->where('codigo', RoleCode::AdminApp->value)
),
])
->firstOrFail();
$user->setRelation('tenant', $tenant);
return $user;
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Services;
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
@@ -22,6 +23,48 @@ class PasswordLoginService
string $tenantCode,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
$tenantCode,
$ipAddress,
$userAgent,
);
}
/**
* Authenticate a tenant-bound AdminApp user without requiring the caller
* to know their tenant code beforehand.
*
* @throws AccountLockedException
* @throws ValidationException
*/
public function authenticateAdminApp(
string $email,
string $password,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
null,
$ipAddress,
$userAgent,
RoleCode::AdminApp,
true,
);
}
private function authenticateUser(
string $email,
string $password,
?string $tenantCode,
?string $ipAddress,
?string $userAgent,
RoleCode $requiredRole = RoleCode::User,
bool $requiresTenant = false,
): User {
$normalizedEmail = mb_strtolower(trim($email));
$now = CarbonImmutable::now();
@@ -34,17 +77,25 @@ class PasswordLoginService
$ipAddress,
$userAgent,
$now,
$requiredRole,
$requiresTenant,
): array {
$user = User::query()
->where('email', $normalizedEmail)
->where('rol_codigo', $requiredRole->value)
->when(
$requiresTenant,
fn ($query) => $query->whereNotNull('tenant_codigo'),
)
->lockForUpdate()
->first();
$attemptTenantCode = $tenantCode ?? $user?->tenant_codigo;
if ($user?->locked_until?->isFuture()) {
$this->recordAttempt(
$user,
$normalizedEmail,
$tenantCode,
$attemptTenantCode,
LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
$ipAddress,
$userAgent,
@@ -76,7 +127,7 @@ class PasswordLoginService
$this->recordAttempt(
$user,
$normalizedEmail,
$tenantCode,
$attemptTenantCode,
$outcome,
$ipAddress,
$userAgent,
@@ -100,7 +151,7 @@ class PasswordLoginService
$this->recordAttempt(
$user,
$normalizedEmail,
$tenantCode,
$attemptTenantCode,
LoginAttempt::OUTCOME_SUCCESS,
$ipAddress,
$userAgent,
@@ -150,7 +201,7 @@ class PasswordLoginService
private function recordAttempt(
?User $user,
string $normalizedEmail,
string $tenantCode,
?string $tenantCode,
string $outcome,
?string $ipAddress,
?string $userAgent,

View File

@@ -0,0 +1,11 @@
<?php
use App\Domains\Auth\Controllers\AdminAppLoginController;
use App\Domains\Auth\Controllers\AdminAppMeController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp')->group(function (): void {
Route::post('login', AdminAppLoginController::class)->middleware('throttle:login');
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
->get('me', AdminAppMeController::class);
});

View File

@@ -23,3 +23,5 @@ Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
Route::middleware('auth:sanctum')->get('/me', MeController::class);
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
require __DIR__.'/adminapp.php';

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,48 @@
<?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

@@ -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,17 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\SaleFormResource;
use App\Domains\Forms\Services\SaleFormService;
use App\Http\Controllers\Controller;
class SaleFormController extends Controller
{
public function __construct(protected SaleFormService $saleFormService) {}
public function __invoke(): SaleFormResource
{
return SaleFormResource::make($this->saleFormService->get());
}
}

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,17 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SaleFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'statuses' => $this->resource['statuses'],
];
}
}

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,31 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Purchase\Models\Purchase;
class SaleFormService
{
/** @return array{statuses: list<array{code: string, name: string}>} */
public function get(): array
{
$names = [
Purchase::STATUS_CREATED => 'Creada',
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
Purchase::STATUS_PAID => 'Confirmada',
Purchase::STATUS_CANCELLED => 'Cancelada',
Purchase::STATUS_REJECTED => 'Rechazada',
Purchase::STATUS_EXPIRED => 'Vencida',
];
return [
'statuses' => array_map(
fn (string $status): array => [
'code' => $status,
'name' => $names[$status],
],
Purchase::statuses(),
),
];
}
}

View File

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

View File

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

View File

@@ -57,13 +57,9 @@ class TelepagosWebhookService
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->where('payment_method', 'transfer')
->where('total', $amount)
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [
Purchase::STATUS_IN_REVIEW,
])
->latest()
->first();
@@ -97,7 +93,6 @@ class TelepagosWebhookService
if (! in_array($compra->status, [
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
], true)) {
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");

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,65 @@
<?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
{
abstract protected function valueChangeTenantCode(): string;
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([
'tenant_code' => $model->valueChangeTenantCode(),
'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,55 @@
<?php
namespace App\Domains\Logging\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable([
'tenant_code',
'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);
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
protected function casts(): array
{
return [
'trackable_id' => 'integer',
'changed_at' => 'datetime',
'actor_type' => ValueChangeActorType::class,
'user_id' => 'integer',
];
}
}

View File

@@ -15,6 +15,7 @@ use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -124,14 +125,28 @@ class PurchaseController extends Controller
$purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
}
$updated = Purchase::query()
->whereKey($compra->getKey())
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->where(function ($query): void {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->update($purchaseUpdate);
$updated = DB::transaction(function () use ($compra, $purchaseUpdate): bool {
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->whereKey($compra->getKey())
->lockForUpdate()
->first();
if (
$purchase === null
|| ! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
return false;
}
$purchase->update($purchaseUpdate);
return true;
});
if ($updated === 0) {
throw ValidationException::withMessages([

View File

@@ -4,6 +4,8 @@ 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;
use App\Domains\Ticket\Models\Ticket;
@@ -18,6 +20,7 @@ use Illuminate\Support\Facades\DB;
#[Fillable([
'cart_id',
'tenant_codigo',
'event_id',
'user_id',
'status',
'payment_method',
@@ -31,14 +34,12 @@ use Illuminate\Support\Facades\DB;
])]
class Purchase extends Model
{
use HasFactory;
use HasFactory, LogsValueChanges;
public const STATUS_CREATED = 'created';
public const STATUS_PENDING_PAYMENT = 'pending_payment';
public const STATUS_IN_REVIEW = 'in_review';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';
@@ -47,12 +48,31 @@ class Purchase extends Model
public const STATUS_EXPIRED = 'expired';
/** @return list<string> */
public static function statuses(): array
{
return [
self::STATUS_CREATED,
self::STATUS_PENDING_PAYMENT,
self::STATUS_PAID,
self::STATUS_CANCELLED,
self::STATUS_REJECTED,
self::STATUS_EXPIRED,
];
}
protected $table = 'compras';
/** @var array<int, string> */
protected array $loggedAttributes = [
'status',
];
protected function casts(): array
{
return [
'cart_id' => 'integer',
'event_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
@@ -67,6 +87,12 @@ 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>
*/
@@ -133,6 +159,11 @@ class Purchase extends Model
return (float) $this->items()->sum('total');
}
protected function valueChangeTenantCode(): string
{
return $this->tenant_codigo;
}
public function markAsPendingPayment(): void
{
$this->update([

View File

@@ -42,6 +42,7 @@ 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

@@ -26,6 +26,11 @@ class CheckoutService
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
{
return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase {
/** @var Tenant $tenant */
$tenant = Tenant::query()
->lockForUpdate()
->findOrFail($tenant->getKey());
$directItem = $purchaseData['direct_item'] ?? null;
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
unset($purchaseData['direct_item'], $purchaseData['cart_id']);
@@ -70,7 +75,6 @@ class CheckoutService
if (in_array($purchase->status, [
Purchase::STATUS_PAID,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
@@ -96,7 +100,6 @@ class CheckoutService
->findOrFail($purchase->getKey());
if (in_array($purchase->status, [
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_PAID,
], true)) {
return $this->loadPurchase($purchase);
@@ -112,7 +115,6 @@ class CheckoutService
}
$purchase->update([
'status' => Purchase::STATUS_IN_REVIEW,
'expires_at' => null,
]);
@@ -618,6 +620,7 @@ class CheckoutService
...$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

@@ -0,0 +1,61 @@
<?php
namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class SaleController extends Controller
{
public function __construct(
protected AdminAppSaleService $saleService,
protected AdminAppSalePdfService $salePdfService,
) {}
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
return SaleResource::collection(
$this->saleService->sales($tenant, $request->validated())
)->additional([
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
]);
}
public function modifications(Request $request): AnonymousResourceCollection
{
return SaleModificationResource::collection(
$this->saleService->modifications(
$request->user()->tenant()->firstOrFail()
)
);
}
public function downloadPdf(AdminAppSaleIndexRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadSales(
$tenant,
$this->saleService->salesForExport($tenant, $request->validated()),
);
}
public function downloadModificationsPdf(Request $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant),
);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminAppSaleIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())],
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ValueChange */
class SaleModificationResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
/** @var Purchase|null $sale */
$sale = $this->whenLoaded('trackable');
$user = $this->whenLoaded('user');
return [
'id' => $this->id,
'sale_id' => $this->trackable_id,
'attribute' => $this->attribute,
'old_value' => $this->old_value,
'new_value' => $this->new_value,
'date' => $this->changed_at->format('Y-m-d'),
'time' => $this->changed_at->format('H:i:s'),
'actor_type' => $this->actor_type->value,
'sale' => $sale instanceof Purchase ? [
'id' => $sale->id,
'customer_name' => $sale->nombre_apellido,
'status' => $sale->status,
] : null,
'modified_by' => $user ? [
'id' => $user->id,
'name' => $user->nombre_apellido,
'email' => $user->email,
] : null,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Purchase */
class SaleResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$ticketsCount = (int) ($this->tickets_count ?? 0);
return [
'id' => $this->id,
'created_at' => $this->created_at,
'customer_name' => $this->nombre_apellido,
'quantity' => (int) ($this->quantity ?? 0),
'status' => $this->status,
'total' => number_format((float) $this->total, 2, '.', ''),
'tickets_count' => $ticketsCount,
'has_generated_tickets' => $ticketsCount > 0,
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Barryvdh\DomPDF\Facade\Pdf;
use Barryvdh\DomPDF\PDF as DomPdf;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
class AdminAppSalePdfService
{
/** @param Collection<int, Purchase> $sales */
public function downloadSales(Tenant $tenant, Collection $sales): Response
{
$pdf = Pdf::loadView('pdf.adminapp.sales', [
'tenant' => $tenant,
'sales' => $sales,
'generatedAt' => now(),
'confirmedSalesTotal' => number_format(
(float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'),
2,
'.',
'',
),
])->setPaper('a4', 'landscape');
$this->addPageNumbers($pdf);
return $pdf->download(
'ventas_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
);
}
/** @param Collection<int, ValueChange> $modifications */
public function downloadModifications(Tenant $tenant, Collection $modifications): Response
{
$pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [
'tenant' => $tenant,
'modifications' => $modifications,
'generatedAt' => now(),
])->setPaper('a4', 'landscape');
$this->addPageNumbers($pdf);
return $pdf->download(
'historial_modificaciones_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
);
}
private function addPageNumbers(DomPdf $pdf): void
{
$pdf->render();
$domPdf = $pdf->getDomPDF();
$font = $domPdf->getFontMetrics()->getFont('DejaVu Sans');
$domPdf->getCanvas()->page_text(
385,
575,
'Página {PAGE_NUM} de {PAGE_COUNT}',
$font,
7,
[0.48, 0.52, 0.49],
);
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class AdminAppSaleService
{
public function confirmedSalesTotal(Tenant $tenant): string
{
$total = Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', Purchase::STATUS_PAID)
->sum('total');
return number_format((float) $total, 2, '.', '');
}
/**
* @param array{
* q?: string|null,
* id?: int|null,
* sale_date?: string|null,
* status?: string|null,
* sort_by?: string,
* sort_direction?: string
* } $filters
* @return LengthAwarePaginator<Purchase>
*/
public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator
{
return $this->salesQuery($tenant, $filters)
->paginateFromRequest()
->withQueryString();
}
/**
* @param array<string, mixed> $filters
* @return Collection<int, Purchase>
*/
public function salesForExport(Tenant $tenant, array $filters = []): Collection
{
return $this->salesQuery($tenant, $filters)->get();
}
/** @return LengthAwarePaginator<ValueChange> */
public function modifications(Tenant $tenant): LengthAwarePaginator
{
return $this->modificationsQuery($tenant)
->paginateFromRequest()
->withQueryString();
}
/** @return Collection<int, ValueChange> */
public function modificationsForExport(Tenant $tenant): Collection
{
return $this->modificationsQuery($tenant)->get();
}
/** @param array<string, mixed> $filters */
protected function salesQuery(Tenant $tenant, array $filters): Builder
{
$sortColumns = [
'id' => 'id',
'date' => 'created_at',
'customer_name' => 'nombre_apellido',
'quantity' => 'quantity',
'status' => 'status',
'total' => 'total',
];
$requestedSort = $filters['sort_by'] ?? 'date';
$sortBy = array_key_exists($requestedSort, $sortColumns) ? $requestedSort : 'date';
$requestedDirection = $filters['sort_direction'] ?? 'desc';
$sortDirection = in_array($requestedDirection, ['asc', 'desc'], true)
? $requestedDirection
: 'desc';
return Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search);
$query->where(function (Builder $query) use ($term): void {
$query
->where('id', 'like', "%{$term}%")
->orWhere('nombre_apellido', 'like', "%{$term}%")
->orWhere('created_at', 'like', "%{$term}%");
});
})
->when($filters['id'] ?? null, fn (Builder $query, int $id): Builder => $query->whereKey($id))
->when(
$filters['sale_date'] ?? null,
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', $date)
)
->when(
$filters['status'] ?? null,
fn (Builder $query, string $status): Builder => $query->where('status', $status)
)
->withSum('items as quantity', 'cantidad')
->withCount('tickets')
->orderBy($sortColumns[$sortBy], $sortDirection)
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
}
/** @return Builder<ValueChange> */
protected function modificationsQuery(Tenant $tenant): Builder
{
return ValueChange::query()
->where('tenant_code', $tenant->codigo)
->where('trackable_type', (new Purchase)->getMorphClass())
->with(['trackable', 'user'])
->orderByDesc('changed_at')
->orderByDesc('id');
}
}

View File

@@ -0,0 +1,13 @@
<?php
use App\Domains\Sale\Controllers\AdminApp\SaleController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('sales', [SaleController::class, 'index']);
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
Route::get('sales/modifications', [SaleController::class, 'modifications']);
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
});

View File

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

View File

@@ -4,7 +4,8 @@ namespace App\Domains\Tenant\Controllers\AdminApp;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtrasRequest;
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtraRequest;
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtraResource;
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtrasResource;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Domains\Tenant\Services\WebsiteExtraService;
@@ -25,13 +26,30 @@ class WebsiteExtraController extends Controller
);
}
public function update(UpdateWebsiteExtrasRequest $request): WebsiteExtrasResource
public function showExtra(Request $request, string $websiteExtraCode): WebsiteExtraResource
{
$tenant = $this->loadTenant($request->user());
$definition = $this->websiteExtraService->definitionForTenant($tenant, $websiteExtraCode);
$websiteExtra = $tenant->websiteExtras
->firstWhere('website_type_extra_id', $definition->id);
if (! $websiteExtra) {
abort(404);
}
return WebsiteExtraResource::make($websiteExtra);
}
public function update(
UpdateWebsiteExtraRequest $request,
string $websiteExtraCode
): WebsiteExtrasResource {
$tenant = $request->user()->tenant()->firstOrFail();
$this->websiteExtraService->replaceForTenant(
$this->websiteExtraService->updateForTenant(
$tenant,
$request->validated('extras', [])
$websiteExtraCode,
$request->validated('config')
);
return WebsiteExtrasResource::make(
@@ -39,6 +57,20 @@ class WebsiteExtraController extends Controller
);
}
public function toggle(Request $request, string $websiteExtraCode): WebsiteExtrasResource
{
$tenant = $request->user()->tenant()->firstOrFail();
$this->websiteExtraService->toggleForTenant($tenant, $websiteExtraCode);
return WebsiteExtrasResource::make(
$this->loadTenant($request->user())
)->additional([
'code' => 'tenant.website_extra_toggled',
'message' => __('api.tenant.website_extra_toggled'),
]);
}
private function loadTenant(User $user): Tenant
{
$tenant = $user->tenant()->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

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'website_code',
'website_type_extra_id',
'config',
'is_enabled',
])]
class WebsiteExtra extends Model
{
@@ -30,6 +31,7 @@ class WebsiteExtra extends Model
return [
'website_type_extra_id' => 'integer',
'config' => 'array',
'is_enabled' => 'boolean',
];
}

View File

@@ -2,14 +2,30 @@
namespace App\Domains\Tenant\Models;
use App\Domains\Attachable\Models\Attachment;
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([
'codigo',
'nombre',
'dominio',
'primary_color',
'secondary_color',
'danger_color',
'success_color',
'warning_color',
'body_color',
'darker_body_color',
'surface_color',
'background_color',
'border_color',
'login_header_footer_color',
'site_logo',
'footer_logo',
])]
class WebsiteType extends Model
{
@@ -17,6 +33,22 @@ class WebsiteType extends Model
protected $table = 'website_type';
/**
* @return BelongsTo<Attachment, $this>
*/
public function siteLogo(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'site_logo');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function footerLogo(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'footer_logo');
}
/**
* @return HasMany<WebsiteTypeExtra, $this>
*/

View File

@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'website_type_code',
'codigo',
'nombre',
'descripcion',
'is_required',

View File

@@ -5,7 +5,7 @@ namespace App\Domains\Tenant\Requests\AdminApp;
use App\Domains\Tenant\Services\WebsiteExtraService;
use Illuminate\Foundation\Http\FormRequest;
class UpdateWebsiteExtrasRequest extends FormRequest
class UpdateWebsiteExtraRequest extends FormRequest
{
public function authorize(): bool
{
@@ -17,8 +17,11 @@ class UpdateWebsiteExtrasRequest extends FormRequest
*/
public function rules(): array
{
return app(WebsiteExtraService::class)->requestRules(
$this->user()?->tenant?->website_type_code
$tenant = $this->user()->tenant()->firstOrFail();
return app(WebsiteExtraService::class)->requestRulesForExtra(
$tenant,
(string) $this->route('websiteExtraCode')
);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Domains\Tenant\Resources\AdminApp;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\WebsiteExtra;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin WebsiteExtra
*/
class WebsiteExtraResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'codigo' => $this->websiteTypeExtra->codigo,
'nombre' => $this->websiteTypeExtra->nombre,
'descripcion' => $this->websiteTypeExtra->descripcion,
'is_required' => $this->websiteTypeExtra->is_required,
'is_enabled' => $this->is_enabled,
'request_rules' => $this->websiteTypeExtra->config_schema['request_rules'] ?? [],
'config' => $this->formatConfig(
$this->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->key
),
'resolved_config' => $this->formatConfig(
$this->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
),
];
}
private function formatConfig(mixed $value, callable $formatAttachment): mixed
{
if ($value instanceof Attachment) {
return $formatAttachment($value);
}
if (! is_array($value)) {
return $value;
}
return array_map(
fn (mixed $item): mixed => $this->formatConfig($item, $formatAttachment),
$value
);
}
}

View File

@@ -18,7 +18,7 @@ class WebsiteExtrasResource extends JsonResource
public function toArray(Request $request): array
{
$websiteExtras = $this->websiteExtras->keyBy(
fn ($extra) => $extra->websiteTypeExtra->nombre
fn ($extra) => $extra->websiteTypeExtra->codigo
);
return [
@@ -28,20 +28,24 @@ class WebsiteExtrasResource extends JsonResource
] : null,
'definitions' => $this->websiteType?->extras
->mapWithKeys(fn ($definition) => [
$definition->nombre => [
$definition->codigo => [
'codigo' => $definition->codigo,
'nombre' => $definition->nombre,
'descripcion' => $definition->descripcion,
'is_required' => $definition->is_required,
'is_enabled' => $websiteExtras
->get($definition->codigo)?->is_enabled,
'request_rules' => $definition->config_schema['request_rules'] ?? [],
],
]) ?? [],
'extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->nombre => $this->formatConfig(
$extra->websiteTypeExtra->codigo => $this->formatConfig(
$extra->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->key
),
]),
'resolved_extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->nombre => $this->formatConfig(
$extra->websiteTypeExtra->codigo => $this->formatConfig(
$extra->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
),

View File

@@ -32,20 +32,29 @@ 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,
'website_type' => $this->whenLoaded(
'websiteType',
fn () => $this->websiteType ? [
'codigo' => $this->websiteType->codigo,
'nombre' => $this->websiteType->nombre,
] : null
),
'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->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->nombre => $this->formatExtraConfig(
$extra->resolvedConfig()
),
])
fn () => $this->websiteExtras
->filter(fn ($extra) => $extra->is_enabled)
->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->codigo => $this->formatExtraConfig(
$extra->resolvedConfig()
),
])
),
// 1 day
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),

View File

@@ -14,8 +14,8 @@ class TenantInformationService
'headerLogo',
'footerLogo',
'socialMedia',
'websiteType',
'websiteExtras.websiteTypeExtra',
'activeEvent.dates',
];
/**

View File

@@ -7,11 +7,11 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteExtra;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Tenant\Models\WebsiteTypeExtra;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
@@ -34,7 +34,7 @@ class WebsiteExtraService
}
$definitions = $this->definitionsFor($websiteTypeCode);
$allowedNames = $definitions->pluck('nombre')->all();
$allowedCodes = $definitions->pluck('codigo')->all();
$hasRequiredExtras = $definitions->contains(
fn (WebsiteTypeExtra $definition): bool => $definition->is_required
);
@@ -43,17 +43,17 @@ class WebsiteExtraService
'extras' => [
$hasRequiredExtras ? 'required' : 'sometimes',
'array',
function (string $attribute, mixed $value, \Closure $fail) use ($allowedNames): void {
function (string $attribute, mixed $value, \Closure $fail) use ($allowedCodes): void {
if (! is_array($value)) {
return;
}
$unknownNames = array_diff(array_keys($value), $allowedNames);
$unknownCodes = array_diff(array_keys($value), $allowedCodes);
if ($unknownNames !== []) {
if ($unknownCodes !== []) {
$fail(
'The '.$attribute.' field contains extras not supported by the website type: '
.implode(', ', $unknownNames).'.'
.implode(', ', $unknownCodes).'.'
);
}
},
@@ -69,14 +69,14 @@ class WebsiteExtraService
));
array_unshift($rootRules, $definition->is_required ? 'required' : 'sometimes');
$rules["extras.{$definition->nombre}"] = $rootRules;
$rules["extras.{$definition->codigo}"] = $rootRules;
foreach ($schemaRules as $path => $pathRules) {
if ($path === '$') {
continue;
}
$rules[$this->requestAttribute($definition->nombre, $path)] = $this->compileRules($pathRules);
$rules[$this->requestAttribute($definition->codigo, $path)] = $this->compileRules($pathRules);
}
}
@@ -95,7 +95,7 @@ class WebsiteExtraService
}
$definitions = $this->definitionsFor((string) $tenant->website_type_code)
->keyBy('nombre');
->keyBy('codigo');
foreach ($extras as $name => $config) {
/** @var WebsiteTypeExtra|null $definition */
@@ -107,9 +107,13 @@ class WebsiteExtraService
]);
}
$transformedConfig = $this->applyTransforms($tenant, $definition, $config);
$this->validateDatabaseConfig($definition, $transformedConfig);
$requestRoot = "extras.{$definition->codigo}";
$transformedConfig = $this->applyTransforms(
$tenant,
$definition,
$config,
$requestRoot
);
$tenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => $transformedConfig,
@@ -120,18 +124,80 @@ class WebsiteExtraService
}
/**
* Replace all configured extras for a tenant.
* Build request rules for one extra addressed by its stable code.
*
* @param array<string, mixed> $extras
* @return array<string, mixed>
*/
public function replaceForTenant(Tenant $tenant, array $extras): void
public function requestRulesForExtra(Tenant $tenant, string $extraCode): array
{
DB::transaction(function () use ($tenant, $extras): void {
$tenant->websiteExtras()->delete();
$this->createForTenant($tenant, $extras);
});
$definition = $this->definitionForTenant($tenant, $extraCode);
$schemaRules = $definition->config_schema['request_rules'] ?? [];
$rootRules = $this->compileRules($schemaRules['$'] ?? []);
$rootRules = array_values(array_filter(
$rootRules,
fn (mixed $rule): bool => ! in_array($rule, ['required', 'sometimes'], true)
));
array_unshift($rootRules, 'required');
$tenant->unsetRelation('websiteExtras');
$rules = ['config' => $rootRules];
foreach ($schemaRules as $path => $pathRules) {
if ($path === '$') {
continue;
}
$rules[$this->configAttribute('config', $path)] = $this->compileRules($pathRules);
}
return $rules;
}
public function updateForTenant(Tenant $tenant, string $extraCode, mixed $config): WebsiteExtra
{
$definition = $this->definitionForTenant($tenant, $extraCode);
return DB::transaction(function () use ($tenant, $definition, $config): WebsiteExtra {
$transformedConfig = $this->applyTransforms($tenant, $definition, $config, 'config');
return $tenant->websiteExtras()->updateOrCreate(
['website_type_extra_id' => $definition->id],
['config' => $transformedConfig]
);
});
}
public function toggleForTenant(Tenant $tenant, string $extraCode): WebsiteExtra
{
$definition = $this->definitionForTenant($tenant, $extraCode);
return DB::transaction(function () use ($tenant, $definition): WebsiteExtra {
$websiteExtra = $tenant->websiteExtras()
->where('website_type_extra_id', $definition->id)
->lockForUpdate()
->first();
if (! $websiteExtra) {
return $tenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => [],
'is_enabled' => true,
]);
}
$websiteExtra->update([
'is_enabled' => ! $websiteExtra->is_enabled,
]);
return $websiteExtra;
});
}
public function definitionForTenant(Tenant $tenant, string $extraCode): WebsiteTypeExtra
{
return WebsiteTypeExtra::query()
->where('website_type_code', $tenant->website_type_code)
->where('codigo', $extraCode)
->firstOrFail();
}
/**
@@ -163,23 +229,29 @@ class WebsiteExtraService
);
}
private function requestAttribute(string $extraName, string $path): string
private function requestAttribute(string $extraCode, string $path): string
{
return $this->configAttribute("extras.{$extraCode}", $path);
}
private function configAttribute(string $root, string $path): string
{
if ($path === '$') {
return "extras.{$extraName}";
return $root;
}
if (str_starts_with($path, '$.')) {
$path = substr($path, 2);
}
return "extras.{$extraName}.{$path}";
return "{$root}.{$path}";
}
private function applyTransforms(
Tenant $tenant,
WebsiteTypeExtra $definition,
mixed $config
mixed $config,
string $requestRoot
): mixed {
foreach ($definition->config_schema['transforms'] ?? [] as $path => $transform) {
$segments = $this->pathSegments($path);
@@ -191,7 +263,8 @@ class WebsiteExtraService
$definition,
$path,
$value,
$transform
$transform,
$requestRoot
)
);
}
@@ -245,7 +318,8 @@ class WebsiteExtraService
WebsiteTypeExtra $definition,
string $path,
mixed $value,
array $transform
array $transform,
string $requestRoot
): mixed {
if ($value === null) {
return null;
@@ -253,7 +327,7 @@ class WebsiteExtraService
if (($transform['handler'] ?? null) !== 'attachment') {
throw new InvalidArgumentException(
"Unsupported transform handler for {$definition->nombre}: ".($transform['handler'] ?? 'null')
"Unsupported transform handler for {$definition->codigo}: ".($transform['handler'] ?? 'null')
);
}
@@ -262,7 +336,7 @@ class WebsiteExtraService
if (! $attachment) {
throw ValidationException::withMessages([
$this->requestAttribute($definition->nombre, $path) => [
$this->configAttribute($requestRoot, $path) => [
'The selected attachment does not exist.',
],
]);
@@ -270,7 +344,7 @@ class WebsiteExtraService
} else {
$attachment = $this->attachmentService->store(
$value,
"tenants/{$tenant->codigo}/extras/{$definition->nombre}"
"tenants/{$tenant->codigo}/extras/{$definition->codigo}"
);
}
@@ -282,7 +356,7 @@ class WebsiteExtraService
&& $attachment->type->value !== $expectedType
) {
throw ValidationException::withMessages([
$this->requestAttribute($definition->nombre, $path) => [
$this->configAttribute($requestRoot, $path) => [
"The attachment must be of type {$expectedType}.",
],
]);
@@ -290,32 +364,4 @@ class WebsiteExtraService
return $attachment->id;
}
private function validateDatabaseConfig(
WebsiteTypeExtra $definition,
mixed $config
): void {
$schemaRules = $definition->config_schema['database_rules'] ?? [];
$rules = [];
foreach ($schemaRules as $path => $pathRules) {
$attribute = $path === '$'
? 'config'
: 'config.'.ltrim(str_starts_with($path, '$.') ? substr($path, 2) : $path, '.');
$rules[$attribute] = $this->compileRules($pathRules);
}
$validator = Validator::make(['config' => $config], $rules);
if ($validator->fails()) {
$messages = [];
foreach ($validator->errors()->toArray() as $attribute => $errors) {
$suffix = $attribute === 'config' ? '' : substr($attribute, strlen('config'));
$messages["extras.{$definition->nombre}{$suffix}"] = $errors;
}
throw ValidationException::withMessages($messages);
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class WebsiteTypeService
{
public function __construct(
protected AttachmentService $attachmentService,
) {}
/**
* Create a website type and store its logos.
*
* @param array<string, mixed> $data
*/
public function create(array $data): WebsiteType
{
return $this->save(new WebsiteType, $data);
}
/**
* Create or update a website type and replace its logos when provided.
*
* @param array<string, mixed> $attributes
* @param array<string, mixed> $values
*/
public function updateOrCreate(array $attributes, array $values = []): WebsiteType
{
/** @var WebsiteType $websiteType */
$websiteType = WebsiteType::query()->firstOrNew($attributes);
return $this->save($websiteType, [...$attributes, ...$values]);
}
/**
* @param array<string, mixed> $data
*/
private function save(WebsiteType $websiteType, array $data): WebsiteType
{
return DB::transaction(function () use ($websiteType, $data): WebsiteType {
$previousLogos = [];
foreach (['site_logo' => 'siteLogo', 'footer_logo' => 'footerLogo'] as $field => $relation) {
if (! array_key_exists($field, $data)) {
continue;
}
$logo = $data[$field];
$previousLogos[$field] = $websiteType->exists
? $websiteType->{$relation}()->first()
: null;
unset($data[$field]);
$attachment = null;
if ($logo) {
$attachment = is_string($logo) && Str::isUuid($logo)
? Attachment::query()->where('key', $logo)->first()
: $this->attachmentService->store($logo, 'website-types');
}
$data[$field] = $attachment?->id;
}
$websiteType->fill($data)->save();
foreach ($previousLogos as $field => $previousLogo) {
if (
$previousLogo instanceof Attachment
&& $previousLogo->id !== $websiteType->{$field}
&& $previousLogo->id !== $websiteType->site_logo
&& $previousLogo->id !== $websiteType->footer_logo
) {
$this->attachmentService->delete($previousLogo);
}
}
return $websiteType;
});
}
}

View File

@@ -6,8 +6,8 @@ use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('website-extras', [WebsiteExtraController::class, 'show'])
->name('adminapp.tenant.website-extras.show');
Route::put('website-extras', [WebsiteExtraController::class, 'update'])
->name('adminapp.tenant.website-extras.update');
Route::get('website-extras', [WebsiteExtraController::class, 'show']);
Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']);
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);
Route::patch('website-extras/{websiteExtraCode}/toggle', [WebsiteExtraController::class, 'toggle']);
});

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,52 @@
<?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('website_type_extras', function (Blueprint $table): void {
$table->string('codigo')->nullable()->after('website_type_code');
});
Schema::table('websites_extras', function (Blueprint $table): void {
$table->unique(
['website_code', 'website_type_extra_id'],
'websites_extras_website_definition_unique'
);
});
DB::table('website_type_extras')
->select(['id', 'nombre'])
->orderBy('id')
->each(function (object $extra): void {
DB::table('website_type_extras')
->where('id', $extra->id)
->update(['codigo' => $extra->nombre]);
});
Schema::table('website_type_extras', function (Blueprint $table): void {
$table->string('codigo')->nullable(false)->change();
$table->unique(
['website_type_code', 'codigo'],
'website_type_extras_type_code_unique'
);
});
}
public function down(): void
{
Schema::table('websites_extras', function (Blueprint $table): void {
$table->dropUnique('websites_extras_website_definition_unique');
});
Schema::table('website_type_extras', function (Blueprint $table): void {
$table->dropUnique('website_type_extras_type_code_unique');
$table->dropColumn('codigo');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('website_type_extras')
->select(['id', 'config_schema'])
->orderBy('id')
->each(function (object $definition): void {
$schema = json_decode($definition->config_schema, true);
if (! is_array($schema) || ! array_key_exists('database_rules', $schema)) {
return;
}
unset($schema['database_rules']);
DB::table('website_type_extras')
->where('id', $definition->id)
->update(['config_schema' => json_encode($schema)]);
});
}
public function down(): void
{
// Removed rules cannot be reconstructed generically.
}
};

View File

@@ -0,0 +1,22 @@
<?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('websites_extras', function (Blueprint $table): void {
$table->boolean('is_enabled')->nullable()->after('config');
});
}
public function down(): void
{
Schema::table('websites_extras', function (Blueprint $table): void {
$table->dropColumn('is_enabled');
});
}
};

View File

@@ -0,0 +1,57 @@
<?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('website_type', function (Blueprint $table): void {
$table->string('dominio')->nullable()->unique()->after('nombre');
$table->string('primary_color')->nullable()->after('dominio');
$table->string('secondary_color')->nullable()->after('primary_color');
$table->string('danger_color')->nullable()->after('secondary_color');
$table->string('success_color')->nullable()->after('danger_color');
$table->string('warning_color')->nullable()->after('success_color');
$table->string('body_color')->nullable()->after('warning_color');
$table->string('darker_body_color')->nullable()->after('body_color');
$table->string('surface_color')->nullable()->after('darker_body_color');
$table->string('background_color')->nullable()->after('surface_color');
$table->string('border_color')->nullable()->after('background_color');
$table->string('login_header_footer_color')->nullable()->after('border_color');
$table->unsignedBigInteger('site_logo')->nullable()->after('login_header_footer_color');
$table->foreign('site_logo')
->references('id')
->on('attachments')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('website_type', function (Blueprint $table): void {
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
$table->dropForeign(['site_logo']);
}
$table->dropColumn([
'dominio',
'primary_color',
'secondary_color',
'danger_color',
'success_color',
'warning_color',
'body_color',
'darker_body_color',
'surface_color',
'background_color',
'border_color',
'login_header_footer_color',
'site_logo',
]);
});
}
};

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::table('website_type', function (Blueprint $table): void {
$table->unsignedBigInteger('footer_logo')->nullable()->after('site_logo');
$table->foreign('footer_logo')
->references('id')
->on('attachments')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('website_type', function (Blueprint $table): void {
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
$table->dropForeign(['footer_logo']);
}
$table->dropColumn('footer_logo');
});
}
};

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

@@ -0,0 +1,19 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('compras')
->where('status', 'in_review')
->update(['status' => 'pending_payment']);
}
public function down(): void
{
// This cleanup cannot be reversed without changing legitimate pending purchases.
}
};

View File

@@ -0,0 +1,50 @@
<?php
use App\Domains\Purchase\Models\Purchase;
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('value_changes', function (Blueprint $table): void {
$table->string('tenant_code')->nullable()->after('id');
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->nullOnDelete();
$table->index(['tenant_code', 'changed_at']);
});
DB::table('value_changes')
->where('trackable_type', (new Purchase)->getMorphClass())
->whereNull('tenant_code')
->orderBy('id')
->chunkById(500, function ($changes): void {
foreach ($changes as $change) {
$tenantCode = DB::table('compras')
->where('id', $change->trackable_id)
->value('tenant_codigo');
if ($tenantCode !== null) {
DB::table('value_changes')
->where('id', $change->id)
->update(['tenant_code' => $tenantCode]);
}
}
});
}
public function down(): void
{
Schema::table('value_changes', function (Blueprint $table): void {
$table->dropForeign(['tenant_code']);
$table->dropIndex(['tenant_code', 'changed_at']);
$table->dropColumn('tenant_code');
});
}
};

View File

@@ -0,0 +1,26 @@
<?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('compras', function (Blueprint $table): void {
$table->foreignId('event_id')
->nullable()
->after('tenant_codigo')
->constrained('events')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
});
}
};

View File

@@ -17,83 +17,79 @@ 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',
'type' => FieldType::Select->value,
'is_required' => true,
'metadata_schema' => [
'hex' => ['type' => 'string'],
$this->seedAttribute($tenant, [
'codigo' => 'color',
'nombre' => 'Color',
'type' => FieldType::Select->value,
'is_required' => true,
'metadata_schema' => [
'hex' => ['type' => 'string'],
],
'options' => [
[
'value' => 'Negro',
'label' => 'Negro',
'sort_order' => 1,
'metadata' => ['hex' => '#000000'],
],
'options' => [
[
'value' => 'Negro',
'label' => 'Negro',
'sort_order' => 1,
'metadata' => ['hex' => '#000000'],
],
[
'value' => 'Gris',
'label' => 'Gris',
'sort_order' => 2,
'metadata' => ['hex' => '#808080'],
],
[
'value' => 'Blanco',
'label' => 'Blanco',
'sort_order' => 3,
'metadata' => ['hex' => '#FFFFFF'],
],
[
'value' => 'Azul',
'label' => 'Azul',
'sort_order' => 4,
'metadata' => ['hex' => '#0000FF'],
],
[
'value' => 'Gris',
'label' => 'Gris',
'sort_order' => 2,
'metadata' => ['hex' => '#808080'],
],
]);
}
[
'value' => 'Blanco',
'label' => 'Blanco',
'sort_order' => 3,
'metadata' => ['hex' => '#FFFFFF'],
],
[
'value' => 'Azul',
'label' => 'Azul',
'sort_order' => 4,
'metadata' => ['hex' => '#0000FF'],
],
],
]);
// Seed Talle (Size - Text options) attribute
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
$this->seedAttribute($tenant, [
'codigo' => 'talle',
'nombre' => 'Talle',
'type' => FieldType::Select->value,
'is_required' => true,
'options' => [
['value' => 'S', 'label' => 'S', 'sort_order' => 1],
['value' => 'M', 'label' => 'M', 'sort_order' => 2],
['value' => 'L', 'label' => 'L', 'sort_order' => 3],
['value' => 'XL', 'label' => 'XL', 'sort_order' => 4],
],
]);
}
$this->seedAttribute($tenant, [
'codigo' => 'talle',
'nombre' => 'Talle',
'type' => FieldType::Select->value,
'is_required' => true,
'options' => [
['value' => 'S', 'label' => 'S', 'sort_order' => 1],
['value' => 'M', 'label' => 'M', 'sort_order' => 2],
['value' => 'L', 'label' => 'L', 'sort_order' => 3],
['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',
'type' => FieldType::Select->value,
'is_required' => true,
'options' => [
['value' => '38', 'label' => '38', 'sort_order' => 1],
['value' => '40', 'label' => '40', 'sort_order' => 2],
['value' => '42', 'label' => '42', 'sort_order' => 3],
['value' => '44', 'label' => '44', 'sort_order' => 4],
],
]);
}
$this->seedAttribute($tenant, [
'codigo' => 'talle_numerico',
'nombre' => 'Talle Numérico',
'type' => FieldType::Select->value,
'is_required' => true,
'options' => [
['value' => '38', 'label' => '38', 'sort_order' => 1],
['value' => '40', 'label' => '40', 'sort_order' => 2],
['value' => '42', 'label' => '42', 'sort_order' => 3],
['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

@@ -23,16 +23,54 @@ class MenuSeeder extends Seeder
'route' => '/product/:id',
],
['code' => 'checkout', 'label' => 'Finalizar compra', 'route' => '/checkout'],
['code' => 'admin.event', 'label' => 'Eventos', 'route' => '/admin/event'],
['code' => 'admin.catalog', 'label' => 'Catálogo', 'route' => '/admin/catalog'],
['code' => 'admin.combos', 'label' => 'Combos', 'route' => '/admin/combos'],
[
'code' => 'admin.categories',
'code' => 'main.adminapp',
'label' => 'Administración',
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'route' => '/',
],
[
'code' => 'adminapp.event',
'label' => 'Eventos',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/event',
],
[
'code' => 'adminapp.inicio',
'label' => 'Inicio',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/inicio',
],
[
'code' => 'adminapp.catalog',
'label' => 'Catálogo',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/catalog',
],
[
'code' => 'adminapp.combos',
'label' => 'Combos',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/combos',
],
[
'code' => 'adminapp.categories',
'label' => 'Categorías',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/categories',
],
['code' => 'admin.ventas', 'label' => 'Ventas', 'route' => '/admin/ventas'],
['code' => 'admin.staff', 'label' => 'Staff', 'route' => '/admin/staff'],
[
'code' => 'adminapp.ventas',
'label' => 'Ventas',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/ventas',
],
[
'code' => 'adminapp.staff',
'label' => 'Staff',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/staff',
],
[
'code' => 'account',
'label' => 'Mi cuenta',
@@ -135,9 +173,30 @@ class MenuSeeder extends Seeder
->whereIn('code', ['profile', 'purchases', 'tickets'])
->delete();
Menu::query()
->whereIn('code', [
'admin.event',
'admin.catalog',
'admin.combos',
'admin.categories',
'admin.ventas',
'admin.staff',
'event',
'catalog',
'combos',
'categories',
'ventas',
'staff',
])
->delete();
$allRoleMenuCodes = Menu::query()->pluck('code');
$adminAppMenuCodes = Menu::query()
->where('code', 'main.adminapp')
->orWhere('parent_menu_code', 'main.adminapp')
->pluck('code');
$userMenuCodes = Menu::query()
->where('code', 'not like', 'admin.%')
->whereNotIn('code', $adminAppMenuCodes)
->pluck('code');
Role::query()

View File

@@ -80,7 +80,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
throw new RuntimeException("Tenant 'sonder' not found.");
}
$catalogPath = storage_path('public/catalog');
$catalogPath = public_path('images/catalog');
if (! is_dir($catalogPath)) {
throw new RuntimeException("Catalog directory not found at path: {$catalogPath}");

View File

@@ -56,34 +56,34 @@ class TenantSeeder extends Seeder
'success_color' => '#198754',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#313131',
'header_logo' => $this->uploadedImage('images/sonder_header.png', 'sonder_header.png'),
'footer_logo' => $this->uploadedImage('images/sonder_footer.png', 'sonder_footer.png'),
'header_logo' => $this->uploadedImage('images/tennants/sonder/sonder_header.png', 'sonder_header.png'),
'footer_logo' => $this->uploadedImage('images/tennants/sonder/sonder_footer.png', 'sonder_footer.png'),
'social_media' => self::SOCIAL_MEDIA,
'website_type_code' => 'shopit',
'extras' => [
'carousel' => [
$this->uploadedImage(
'images/sonder-main-carousel/01-urban-team.png',
'images/tennants/sonder/sonder-main-carousel/01-urban-team.png',
'01-urban-team.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/02-running-shoes.png',
'images/tennants/sonder/sonder-main-carousel/02-running-shoes.png',
'02-running-shoes.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/03-streetwear.png',
'images/tennants/sonder/sonder-main-carousel/03-streetwear.png',
'03-streetwear.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/04-football-training.png',
'images/tennants/sonder/sonder-main-carousel/04-football-training.png',
'04-football-training.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/05-activewear-essentials.png',
'images/tennants/sonder/sonder-main-carousel/05-activewear-essentials.png',
'05-activewear-essentials.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/06-city-runners.png',
'images/tennants/sonder/sonder-main-carousel/06-city-runners.png',
'06-city-runners.png',
),
],
@@ -108,11 +108,11 @@ class TenantSeeder extends Seeder
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#015327',
'header_logo' => $this->uploadedImage(
'images/futbol_infantil_header.png',
'images/tennants/fiesta_futbol_infantil/futbol_infantil_header.png',
'futbol_infantil_header.png',
),
'footer_logo' => $this->uploadedImage(
'images/futbol_infantil_footer.png',
'images/tennants/fiesta_futbol_infantil/futbol_infantil_footer.png',
'futbol_infantil_footer.png',
),
'social_media' => self::SOCIAL_MEDIA,
@@ -124,7 +124,7 @@ class TenantSeeder extends Seeder
'button_text' => 'Comprar entradas',
'button_href' => '/tickets',
'background_image_id' => $this->uploadedImage(
'images/futbol_infantil_hero.jpg',
'images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.jpg',
'futbol_infantil_hero.jpg',
),
],
@@ -133,10 +133,46 @@ class TenantSeeder extends Seeder
'location' => 'Rosario, Santa Fe',
'dates_text' => '9, 10, 11 y 12 de Octubre 2026',
'dates' => [
'2026-12-05',
'2026-12-06',
[
'date' => '2026-12-05',
'start_time' => '09:00',
'end_time' => '18:00',
],
[
'date' => '2026-12-06',
'start_time' => '09:00',
'end_time' => '18:00',
],
],
],
'additionalInfoConfig' => [
'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

@@ -2,21 +2,45 @@
namespace Database\Seeders;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Tenant\Services\WebsiteTypeService;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class WebsiteTypeSeeder extends Seeder
{
private const PRESENTATION = [
'primary_color' => '#FF7006',
'secondary_color' => '#777777',
'danger_color' => '#E04A4A',
'success_color' => '#81BC73',
'warning_color' => '#81BC73',
'body_color' => '#666666',
'darker_body_color' => '#333333',
'surface_color' => '#ffffff',
'background_color' => '#f8f8f8',
'border_color' => '#EAEAEA',
];
public function __construct(private readonly WebsiteTypeService $websiteTypeService) {}
public function run(): void
{
$shopIt = WebsiteType::query()->updateOrCreate(
$shopIt = $this->websiteTypeService->updateOrCreate(
['codigo' => 'shopit'],
['nombre' => 'ShopIt'],
[
'nombre' => 'ShopIt',
'dominio' => 'localhost',
...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(),
],
);
$shopIt->extras()->updateOrCreate(
['nombre' => 'carousel'],
['codigo' => 'carousel'],
[
'nombre' => 'Carrusel principal',
'descripcion' => 'Listado de attachments que se muestran en el carousel principal.',
'is_required' => false,
'config_schema' => [
@@ -30,22 +54,25 @@ class WebsiteTypeSeeder extends Seeder
'attachment_type' => 'image',
],
],
'database_rules' => [
'$' => 'required|array',
'$.*' => 'required|integer|distinct|exists:attachments,id',
],
],
],
);
$onTicket = WebsiteType::query()->updateOrCreate(
$onTicket = $this->websiteTypeService->updateOrCreate(
['codigo' => 'onticket'],
['nombre' => 'OnTicket'],
[
'nombre' => 'OnTicket',
'dominio' => 'onticket.localhost',
...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(),
],
);
$onTicket->extras()->updateOrCreate(
['nombre' => 'heroConfig'],
['codigo' => 'heroConfig'],
[
'nombre' => 'Configuración del hero',
'descripcion' => 'Configuracion del hero principal del evento.',
'is_required' => false,
'config_schema' => [
@@ -63,21 +90,14 @@ class WebsiteTypeSeeder extends Seeder
'attachment_type' => 'image',
],
],
'database_rules' => [
'$' => 'required|array',
'title_html' => 'nullable|string',
'description_html' => 'nullable|string',
'button_text' => 'nullable|string',
'button_href' => 'nullable|string',
'background_image_id' => 'nullable|integer|exists:attachments,id',
],
],
],
);
$onTicket->extras()->updateOrCreate(
['nombre' => 'eventConfig'],
['codigo' => 'eventConfig'],
[
'nombre' => 'Información del evento',
'descripcion' => 'Informacion principal del evento.',
'is_required' => false,
'config_schema' => [
@@ -87,19 +107,68 @@ class WebsiteTypeSeeder extends Seeder
'location' => 'nullable|string',
'dates_text' => 'nullable|string|max:255',
'dates' => 'nullable|array',
'dates.*' => 'required|date_format:Y-m-d|distinct',
'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',
'contact' => 'nullable|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',
],
'transforms' => [],
'database_rules' => [
],
],
);
$onTicket->extras()->updateOrCreate(
['codigo' => 'additionalInfoConfig'],
[
'nombre' => 'Información adicional',
'descripcion' => 'Información adicional visible en la página del evento.',
'is_required' => false,
'config_schema' => [
'request_rules' => [
'$' => 'required|array',
'title' => 'nullable|string',
'location' => 'nullable|string',
'dates_text' => 'nullable|string|max:255',
'dates' => 'nullable|array',
'dates.*' => 'required|date_format:Y-m-d|distinct',
'description' => 'nullable|string',
],
'transforms' => [],
],
],
);
}
private function onTicketLogo(): UploadedFile
{
$path = public_path('images/website_types/onticket_logo.png');
if (! file_exists($path)) {
throw new RuntimeException("OnTicket logo not found at path: {$path}");
}
return new UploadedFile(
$path,
'onticket_logo.png',
'image/png',
null,
true,
);
}
private function onTicketFooterLogo(): UploadedFile
{
$path = public_path('images/website_types/onticket_footer_logo.png');
if (! file_exists($path)) {
throw new RuntimeException("OnTicket footer logo not found at path: {$path}");
}
return new UploadedFile(
$path,
'onticket_footer_logo.png',
'image/png',
null,
true,
);
}
}

View File

@@ -94,6 +94,9 @@ return [
'schema_required' => 'The schema is required for static menus.',
'not_found' => 'The specified menu does not exist.',
],
'tenant' => [
'website_extra_toggled' => 'The section status was updated successfully.',
],
'errors' => [
'forbidden' => 'You do not have permission to perform this action.',
'not_found' => 'The requested resource was not found.',

View File

@@ -94,6 +94,9 @@ return [
'schema_required' => 'El schema es obligatorio para los menús estáticos.',
'not_found' => 'El menú indicado no existe.',
],
'tenant' => [
'website_extra_toggled' => 'El estado de la sección se actualizó correctamente.',
],
'errors' => [
'forbidden' => 'No tienes permiso para realizar esta acción.',
'not_found' => 'El recurso solicitado no fue encontrado.',

View File

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Some files were not shown because too many files have changed in this diff Show More