Compare commits

...

46 Commits

Author SHA1 Message Date
09827e55aa Refactor menu codes to remove 'onticket' prefix
- Updated route middleware and menu codes across various files to replace 'onticket.adminapp' with 'adminapp'.
- Adjusted API routes for Ticketing, FiestaFutbolInfantil, and Desfile to reflect the new menu structure.
- Modified database migrations to remove the 'onticket' prefix from existing menu codes.
- Updated tests to align with the new menu code structure, ensuring compatibility and correctness.
2026-10-02 10:04:55 -03:00
007ba9d572 feat(category): implement category configuration management with sorting and validation 2026-10-01 15:08:05 -03:00
5ddc304435 feat(category): add banner functionality to categories with related resource and migration tests 2026-10-01 14:29:31 -03:00
675d76fcb0 feat(colors): update admin website colors in migration and seeder, add migration test 2026-10-01 13:57:59 -03:00
35fd1277cb feat(contact): implement contact management with endpoints for retrieving and updating contact details 2026-10-01 11:37:45 -03:00
1986961942 Merge branch 'feature/shopit_admin' of https://gitea.quo.ar/tbianchini/shopit-back into feature/shopit_admin 2026-10-01 10:52:28 -03:00
3f76af8ec8 feat(phone): split phone number components into separate fields and update related models and resources 2026-10-01 10:51:37 -03:00
0ca2bc8d1b feat(menu): update contact menu to dynamic content type and preserve legacy data for rollback 2026-10-01 10:51:36 -03:00
c40f145b60 feat(phone): implement PhoneNumber and TenantPhoneNumber models with relationships and migration 2026-10-01 10:51:36 -03:00
0693e52321 feat: enhance address handling with coordinates and integrate into event resources 2026-10-01 10:51:36 -03:00
cfa7acabe0 feat(event): add address relationship and migration for event locations 2026-10-01 10:51:36 -03:00
1633c30a22 feat(address): implement Address and TenantAddress models with relationships
feat(migration): create addresses and tenant_addresses tables
feat(migration): add data migration for copying contact menu addresses
test: add ContactAddressesTest for address and tenant address functionality
2026-10-01 10:51:36 -03:00
7279f9c6e8 feat: manage tenant help menu settings 2026-10-01 10:51:36 -03:00
878382e867 feat: add tenant menu visibility 2026-10-01 10:51:36 -03:00
f47212fd2c fix: provision events before client migration 2026-10-01 10:51:36 -03:00
3472fb2e5b feat(brand): implement update functionality and validation for brand details 2026-10-01 10:51:36 -03:00
cb3da583be feat(brand): update footer color mapping and adjust tests 2026-10-01 10:51:36 -03:00
53acfda166 feat(brand): add header background color to brand resource 2026-10-01 10:51:36 -03:00
85c79514a3 feat: add admin brand endpoint 2026-10-01 10:51:36 -03:00
1aaf05c3b0 feat(menus): separate shopit and onticket admin menus 2026-10-01 10:51:36 -03:00
f7d66001a8 feat(website-assets): update ShopIt website assets and adjust favicon paths 2026-10-01 10:50:46 -03:00
1d58e0ad38 Merge branch 'feature/event_scoped_admin' into homo 2026-10-01 10:29:49 -03:00
856b9a6706 refactor: update menu code references from 'onticket.adminapp.tickets' to 'adminapp.tickets' 2026-10-01 10:29:06 -03:00
e7bece5bfd Merge branch 'refactor/inventory_stock' into homo 2026-10-01 10:00:54 -03:00
e2cf53eacc Merge branch 'refactor/inventory_stock' into homo 2026-10-01 10:00:41 -03:00
c7fc867905 Merge pull request 'feature/event_scoped_admin' (#14) from feature/event_scoped_admin into homo
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/14
2026-10-01 12:51:08 +00:00
3ddff7e0ee feat(tests): enhance AdminAppTicketEventScopeTest with soft deletes and active ticket filtering 2026-10-01 09:47:32 -03:00
22de61c8c0 feat(tests): add AdminAppTicketEventScopeTest for event-based ticket management 2026-10-01 09:46:47 -03:00
2d11263adc feat(ticket): add event_id support to ticket service methods; update search, cancel, and refund calculations 2026-10-01 09:46:29 -03:00
c975b5d51d feat(menu): update ticket routes to use new menu code; add tests for menu access and route validation 2026-10-01 09:41:26 -03:00
c7afb70196 feat(staff): implement event scope for staff management; update controllers, services, and resources to handle event_id; add tests for event-based access 2026-10-01 09:25:25 -03:00
31955f862d feat(sale): implement event scope for sales, totals, and modifications; add tests for event-based access 2026-10-01 09:20:43 -03:00
bb4495c6cc feat(users): add event_id to users table and update related resources 2026-10-01 08:56:35 -03:00
f3e226130f feat(phone): split phone number components into separate fields and update related models and resources 2026-09-30 13:39:24 -03:00
77dd0e2715 feat(menu): update contact menu to dynamic content type and preserve legacy data for rollback 2026-09-30 13:29:41 -03:00
6e1f0d731c Merge pull request 'homo' (#12) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/12
2026-09-30 11:37:24 +00:00
fb5221854c Merge branch 'refactor/inventory_stock' of https://gitea.quo.ar/tbianchini/shopit-back into refactor/inventory_stock 2026-09-29 08:22:42 -03:00
c5aeb19735 refactor(inventory): enhance error reporting for negative stock validation 2026-09-28 19:43:15 +00:00
9db955bdcf refactor(inventory): update stock validation rules and enforce stock_difference requirement 2026-09-28 19:43:15 +00:00
e988e55b77 feat(admin-stock): apply idempotent availability deltas 2026-09-28 19:43:15 +00:00
bc60f1caa1 refactor(inventory): record stock operations consistently 2026-09-28 19:43:15 +00:00
068d5d7f4f feat(inventory): add calculated availability and movement ledger 2026-09-28 19:43:15 +00:00
82c18e9e91 Merge branch 'tenant/smep' into homo 2026-09-28 19:27:39 +00:00
dfad6fb4b0 Merge pull request 'fix/events_integrations' (#11) from fix/events_integrations into homo
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/11
2026-09-28 19:15:30 +00:00
3f9b773367 feat(integrations): resolve payment context from events 2026-09-28 19:14:23 +00:00
7996d53e12 feat(events): associate purchases and integrations with events 2026-09-28 19:13:36 +00:00
79 changed files with 2763 additions and 316 deletions

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Commerce\Catalog\Controllers\AdminApp;
use App\Domains\Commerce\Catalog\Requests\AdminApp\UpdateCategoryConfigurationRequest;
use App\Domains\Commerce\Catalog\Resources\AdminApp\CategoryConfigurationResource;
use App\Domains\Commerce\Catalog\Services\CategoryConfigurationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class CategoryConfigurationController extends Controller
{
public function index(Request $request, CategoryConfigurationService $service): AnonymousResourceCollection
{
return CategoryConfigurationResource::collection(
$service->forTenant($request->user()->tenant()->firstOrFail())
);
}
public function update(UpdateCategoryConfigurationRequest $request, CategoryConfigurationService $service): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
$service->update($tenant, $request->validated());
return CategoryConfigurationResource::collection($service->forTenant($tenant));
}
}

View File

@@ -15,6 +15,7 @@ use App\Domains\Commerce\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Commerce\Catalog\Resources\CatalogItemDetailResource; use App\Domains\Commerce\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Commerce\Catalog\Resources\CatalogSearchItemResource; use App\Domains\Commerce\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Commerce\Catalog\Resources\CatalogVariantOptionsResource; use App\Domains\Commerce\Catalog\Resources\CatalogVariantOptionsResource;
use App\Domains\Commerce\Catalog\Resources\CategoryResource;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService; use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Commerce\Catalog\Services\CatalogService; use App\Domains\Commerce\Catalog\Services\CatalogService;
use App\Domains\Commerce\Catalog\Services\FeaturedGroupService; use App\Domains\Commerce\Catalog\Services\FeaturedGroupService;
@@ -78,11 +79,7 @@ class CatalogController extends Controller
$allowances->attach($items->getCollection(), $this->userId($request)); $allowances->attach($items->getCollection(), $this->userId($request));
return CatalogSearchItemResource::collection($items)->additional([ return CatalogSearchItemResource::collection($items)->additional([
'category' => [ 'category' => CategoryResource::make($category)->resolve($request),
'id' => $category->id,
'nombre' => $category->nombre,
'categoria_id' => $category->categoria_id,
],
'layout' => $tenant->search_product_layout->value, 'layout' => $tenant->search_product_layout->value,
'group_layout' => $tenant->search_group_layout->value, 'group_layout' => $tenant->search_group_layout->value,
]); ]);

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -16,6 +17,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'categoria_id', 'categoria_id',
'nombre', 'nombre',
'is_enabled', 'is_enabled',
'banner_id',
'banner_enabled',
'sort_order',
])] ])]
class Category extends Model class Category extends Model
{ {
@@ -25,6 +29,7 @@ class Category extends Model
protected $attributes = [ protected $attributes = [
'is_enabled' => true, 'is_enabled' => true,
'banner_enabled' => false,
]; ];
/** /**
@@ -35,6 +40,9 @@ class Category extends Model
return [ return [
'categoria_id' => 'integer', 'categoria_id' => 'integer',
'is_enabled' => 'boolean', 'is_enabled' => 'boolean',
'banner_id' => 'integer',
'banner_enabled' => 'boolean',
'sort_order' => 'integer',
]; ];
} }
@@ -59,6 +67,12 @@ class Category extends Model
return $this->belongsTo(self::class, 'categoria_id'); return $this->belongsTo(self::class, 'categoria_id');
} }
/** @return BelongsTo<Attachment, $this> */
public function banner(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'banner_id');
}
/** /**
* @return HasMany<Category, $this> * @return HasMany<Category, $this>
*/ */

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Domains\Commerce\Catalog\Requests\AdminApp;
use App\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Validator as ValidationValidator;
class UpdateCategoryConfigurationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'categories' => ['present', 'array', 'max:500'],
'deleted_ids' => ['present', 'array', 'max:1000'],
'deleted_ids.*' => ['required', 'integer', 'min:1', 'distinct'],
];
}
public function withValidator(ValidationValidator $validator): void
{
$validator->after(function (ValidationValidator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
$this->validateNodes($validator, $this->input('categories'), 'categories', 0);
});
}
private function validateNodes(ValidationValidator $validator, array $nodes, string $path, int $depth): void
{
if ($depth > 10) {
$validator->errors()->add($path, 'La jerarquía de categorías es demasiado profunda.');
return;
}
foreach ($nodes as $index => $node) {
$key = "{$path}.{$index}";
if (! is_array($node)) {
$validator->errors()->add($key, 'La categoría debe ser un objeto.');
continue;
}
$nested = Validator::make($node, [
'id' => ['present', 'nullable', 'integer', 'min:1'],
'nombre' => ['required', 'string', 'max:255'],
'is_enabled' => ['required', 'boolean'],
'banner_enabled' => ['required', 'boolean'],
'banner' => ['present', 'nullable', new ImageOrBase64Rule],
'subcategories' => ['present', 'array', 'max:500'],
]);
foreach ($nested->errors()->messages() as $field => $messages) {
foreach ($messages as $message) {
$validator->errors()->add("{$key}.{$field}", $message);
}
}
if (! $nested->fails()) {
$this->validateNodes($validator, $node['subcategories'], "{$key}.subcategories", $depth + 1);
}
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Commerce\Catalog\Resources\AdminApp;
use App\Domains\Commerce\Catalog\Models\Category;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Category */
class CategoryConfigurationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nombre' => $this->nombre,
'is_enabled' => $this->is_enabled,
'banner_enabled' => $this->banner_enabled,
'banner_key' => $this->banner?->key,
'banner_name' => $this->banner?->filename,
'banner_url' => $this->banner?->getTemporaryUrl(1440),
'subcategories' => self::collection($this->subCategories),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Commerce\Catalog\Resources;
use App\Domains\Commerce\Catalog\Models\Category;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Category */
class CategoryResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nombre' => $this->nombre,
'categoria_id' => $this->categoria_id,
'banner_url' => $this->banner_enabled
? $this->banner?->getTemporaryUrl(1440)
: null,
];
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Attachable\Enums\AttachmentType;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Throwable;
class CategoryConfigurationService
{
public function __construct(private readonly AttachmentService $attachments) {}
/** @return Collection<int, Category> */
public function forTenant(Tenant $tenant): Collection
{
$all = $tenant->categories()->with('banner')->orderBy('sort_order')->orderBy('id')->get();
$children = $all->groupBy('categoria_id');
foreach ($all as $category) {
$category->setRelation('subCategories', $children->get($category->id, collect()));
}
return $all->filter(fn (Category $category) => $category->categoria_id === null)->values();
}
/** @param array<string, mixed> $data */
public function update(Tenant $tenant, array $data): void
{
$created = [];
try {
DB::transaction(function () use ($tenant, $data, &$created): void {
// Serialize saves for this tenant, including creation of new categories.
Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail();
$existing = $tenant->categories()->with('banner')->lockForUpdate()->get()->keyBy('id');
$deleted = array_map('intval', $data['deleted_ids']);
$seen = [];
$allowedBannerKeys = $existing->pluck('banner.key')->filter()->all();
$this->validateNodes($data['categories'], $existing, $deleted, $seen, $allowedBannerKeys);
foreach ($deleted as $id) {
$category = $existing->get($id);
if ($category === null) {
$this->invalid('deleted_ids', 'Una categoría a eliminar no pertenece a esta tienda.');
}
if ($category->catalogItems()->withTrashed()->exists()
|| FeaturedGroup::query()->where('category_id', $id)->exists()) {
$this->invalid('deleted_ids', "No se puede eliminar «{$category->nombre}» porque tiene productos o grupos destacados asociados. Podés ocultarla.");
}
}
$this->saveNodes($tenant, $data['categories'], null, $existing, $created);
$remaining = $tenant->categories()->whereNotIn('id', $deleted)->get();
$parents = $remaining->pluck('categoria_id', 'id');
foreach ($remaining as $category) {
$visited = [$category->id => true];
$parentId = $category->categoria_id;
while ($parentId !== null) {
if (isset($visited[$parentId]) || ! $parents->has($parentId)) {
$this->invalid('categories', 'La jerarquía contiene un ciclo o una categoría padre eliminada.');
}
$visited[$parentId] = true;
$parentId = $parents->get($parentId);
}
}
// Detach only explicitly deleted nodes before removing parents and children together.
$tenant->categories()->whereIn('id', $deleted)->update(['categoria_id' => null]);
$tenant->categories()->whereIn('id', $deleted)->delete();
});
} catch (Throwable $error) {
foreach ($created as $attachment) {
Storage::disk('s3')->delete($attachment->path);
}
throw $error;
}
}
private function validateNodes(array $nodes, Collection $existing, array $deleted, array &$seen, array $bannerKeys, string $path = 'categories'): void
{
foreach ($nodes as $index => $node) {
$field = "{$path}.{$index}";
if ($node['id'] !== null) {
$id = (int) $node['id'];
if (! $existing->has($id) || isset($seen[$id]) || in_array($id, $deleted, true)) {
$this->invalid("{$field}.id", 'La categoría es ajena a la tienda, está repetida o fue marcada para eliminar.');
}
$seen[$id] = true;
}
if (trim($node['nombre']) === '') {
$this->invalid("{$field}.nombre", 'Completá el nombre de la categoría.');
}
if (is_string($node['banner']) && Str::isUuid($node['banner'])
&& ! in_array($node['banner'], $bannerKeys, true)) {
$this->invalid("{$field}.banner", 'El banner no pertenece a las categorías de esta tienda.');
}
$this->validateNodes($node['subcategories'], $existing, $deleted, $seen, $bannerKeys, "{$field}.subcategories");
}
}
private function saveNodes(Tenant $tenant, array $nodes, ?int $parentId, Collection $existing, array &$created): void
{
foreach (array_values($nodes) as $order => $node) {
$category = $node['id'] === null ? new Category : $existing->get((int) $node['id']);
$banner = null;
if ($node['banner'] !== null) {
if (is_string($node['banner']) && Str::isUuid($node['banner'])) {
$banner = Attachment::query()->where('key', $node['banner'])->firstOrFail();
if ($banner->type !== AttachmentType::Image) {
$this->invalid('categories', 'El banner debe ser una imagen.');
}
} else {
$banner = $this->attachments->store($node['banner'], "tenants/{$tenant->codigo}/categories");
$created[] = $banner;
}
}
$category->fill([
'tenant_code' => $tenant->codigo,
'categoria_id' => $parentId,
'nombre' => trim($node['nombre']),
'is_enabled' => $node['is_enabled'],
'banner_id' => $banner?->id,
'banner_enabled' => $node['banner_enabled'],
'sort_order' => $order,
])->save();
$this->saveNodes($tenant, $node['subcategories'], $category->id, $existing, $created);
}
}
private function invalid(string $field, string $message): never
{
throw ValidationException::withMessages([$field => $message]);
}
}

View File

@@ -31,6 +31,16 @@ Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, de
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados. Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
### Configuración de categorías de producto
`GET /v1/adminapp/tenant/categories` devuelve un árbol ordenado de las categorías del tenant autenticado, incluidas las ocultas. Cada nodo contiene `id`, `nombre`, `is_enabled`, `banner_enabled`, `banner_key`, `banner_name`, `banner_url` y `subcategories`. La URL administrativa se devuelve incluso si el banner está deshabilitado.
`PUT /v1/adminapp/tenant/categories` recibe `categories` y `deleted_ids`. Cada nodo enviado contiene `id` (null para altas), `nombre`, `is_enabled`, `banner_enabled`, `banner` y `subcategories`. `banner` es una clave UUID de un banner ya asociado a las categorías del tenant, una imagen base64/archivo para reemplazarlo o null para quitarlo. Deshabilitar un banner conserva el adjunto. Duplicar una categoría puede compartir su banner; los adjuntos previos no se eliminan al quitar o reemplazar esa referencia.
La posición de cada nodo en su lista determina `sort_order`; este campo no se expone en las respuestas públicas ni administrativas. La migración inicial conserva el orden alfabético existente. El bootstrap del storefront ordena por `sort_order` e ID y no muestra categorías ocultas ni sus descendientes.
El guardado es transaccional. Solo se eliminan los IDs explícitos, se rechazan IDs y banners ajenos, categorías repetidas y jerarquías inválidas. No se pueden eliminar categorías con productos (incluidos los archivados) o grupos destacados asociados. Los archivos recién subidos se limpian si falla la transacción. La respuesta devuelve el árbol persistido y los IDs asignados a las altas.
## Dependencias y reglas ## Dependencias y reglas
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad. Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.

View File

@@ -1,11 +1,14 @@
<?php <?php
use App\Domains\Commerce\Catalog\Controllers\AdminApp\CategoryConfigurationController;
use App\Domains\Commerce\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController; use App\Domains\Commerce\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void { ->group(function (): void {
Route::get('categories', [CategoryConfigurationController::class, 'index']);
Route::put('categories', [CategoryConfigurationController::class, 'update']);
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index']) Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
->name('adminapp.featured-groups.index'); ->name('adminapp.featured-groups.index');
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store']) Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])

View File

@@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Builder;
class PurchaseRefundSummaryService class PurchaseRefundSummaryService
{ {
public function totalForTenant(Tenant $tenant): string public function totalForTenant(Tenant $tenant, ?int $eventId = null): string
{ {
$total = TicketRefund::query() $total = TicketRefund::query()
->whereHas( ->whereHas(
'purchaseItem.purchase', 'purchaseItem.purchase',
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo) fn (Builder $query): Builder => $query
->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId))
) )
->sum('amount'); ->sum('amount');

View File

@@ -32,10 +32,10 @@ class SaleController extends Controller
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return SaleResource::collection( return SaleResource::collection(
$this->saleService->sales($tenant, $request->validated()) $this->saleService->sales($tenant, $request->validated(), $request->user()->event_id)
)->additional([ )->additional([
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant), 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant, $request->user()->event_id),
'refunded_total' => $this->saleService->refundedTotal($tenant), 'refunded_total' => $this->saleService->refundedTotal($tenant, $request->user()->event_id),
]); ]);
} }
@@ -43,7 +43,7 @@ class SaleController extends Controller
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new SaleDetailResource($this->saleService->detail($tenant, $sale)); return new SaleDetailResource($this->saleService->detail($tenant, $sale, $request->user()->event_id));
} }
public function tickets(Request $request, int $sale): AnonymousResourceCollection public function tickets(Request $request, int $sale): AnonymousResourceCollection
@@ -51,7 +51,7 @@ class SaleController extends Controller
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return SaleTicketResource::collection( return SaleTicketResource::collection(
$this->saleService->tickets($tenant, $sale) $this->saleService->tickets($tenant, $sale, $request->user()->event_id)
); );
} }
@@ -59,14 +59,14 @@ class SaleController extends Controller
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->confirm($tenant, $sale)); return new SaleResource($this->saleService->confirm($tenant, $sale, $request->user()->event_id));
} }
public function cancel(Request $request, int $sale): SaleResource public function cancel(Request $request, int $sale): SaleResource
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->cancel($tenant, $sale)); return new SaleResource($this->saleService->cancel($tenant, $sale, $request->user()->event_id));
} }
public function modifications( public function modifications(
@@ -76,6 +76,7 @@ class SaleController extends Controller
$this->saleService->modifications( $this->saleService->modifications(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
$request->user()->event_id,
) )
); );
} }
@@ -86,7 +87,7 @@ class SaleController extends Controller
return $this->salePdfService->downloadSales( return $this->salePdfService->downloadSales(
$tenant, $tenant,
$this->saleService->salesForExport($tenant, $request->validated()), $this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -97,7 +98,7 @@ class SaleController extends Controller
return $this->salePdfService->downloadModifications( return $this->salePdfService->downloadModifications(
$tenant, $tenant,
$this->saleService->modificationsForExport($tenant, $request->validated()), $this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -108,7 +109,7 @@ class SaleController extends Controller
return $this->saleExcelService->downloadSales( return $this->saleExcelService->downloadSales(
$tenant, $tenant,
$this->saleService->salesForExport($tenant, $request->validated()), $this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -120,7 +121,7 @@ class SaleController extends Controller
return $this->saleExcelService->downloadModifications( return $this->saleExcelService->downloadModifications(
$tenant, $tenant,
$this->saleService->modificationsForExport($tenant, $request->validated()), $this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Commerce\Sale\Services; namespace App\Domains\Commerce\Sale\Services;
use App\Shared\Logging\Models\ValueChange;
use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService;
@@ -10,6 +9,7 @@ use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
use App\Shared\Logging\Models\ValueChange;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -21,19 +21,18 @@ class AdminAppSaleService
protected PurchaseRefundSummaryService $refundSummaryService, protected PurchaseRefundSummaryService $refundSummaryService,
) {} ) {}
public function confirmedSalesTotal(Tenant $tenant): string public function confirmedSalesTotal(Tenant $tenant, ?int $eventId = null): string
{ {
$total = Purchase::query() $total = $this->purchasesQuery($tenant, $eventId)
->where('tenant_codigo', $tenant->codigo)
->where('status', Purchase::STATUS_PAID) ->where('status', Purchase::STATUS_PAID)
->sum('total'); ->sum('total');
return number_format((float) $total, 2, '.', ''); return number_format((float) $total, 2, '.', '');
} }
public function refundedTotal(Tenant $tenant): string public function refundedTotal(Tenant $tenant, ?int $eventId = null): string
{ {
return $this->refundSummaryService->totalForTenant($tenant); return $this->refundSummaryService->totalForTenant($tenant, $eventId);
} }
/** /**
@@ -47,43 +46,42 @@ class AdminAppSaleService
* } $filters * } $filters
* @return LengthAwarePaginator<Purchase> * @return LengthAwarePaginator<Purchase>
*/ */
public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator public function sales(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator
{ {
return $this->salesQuery($tenant, $filters) return $this->salesQuery($tenant, $filters, $eventId)
->paginateFromRequest() ->paginateFromRequest()
->withQueryString(); ->withQueryString();
} }
public function detail(Tenant $tenant, int $saleId): Purchase public function detail(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
{ {
return Purchase::query() return $this->purchasesQuery($tenant, $eventId)
->where('tenant_codigo', $tenant->codigo)
->with('items') ->with('items')
->findOrFail($saleId); ->findOrFail($saleId);
} }
/** @return Collection<int, Ticket> */ /** @return Collection<int, Ticket> */
public function tickets(Tenant $tenant, int $saleId): Collection public function tickets(Tenant $tenant, int $saleId, ?int $eventId = null): Collection
{ {
return $this->findForTenant($tenant, $saleId) return $this->findForTenant($tenant, $saleId, $eventId)
->tickets() ->tickets()
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund']) ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund'])
->orderBy('id') ->orderBy('id')
->get(); ->get();
} }
public function confirm(Tenant $tenant, int $saleId): Purchase public function confirm(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
{ {
$sale = $this->findForTenant($tenant, $saleId); $sale = $this->findForTenant($tenant, $saleId, $eventId);
return $this->saleForResponse( return $this->saleForResponse(
$this->checkoutService->confirmPaidPurchase($sale) $this->checkoutService->confirmPaidPurchase($sale)
); );
} }
public function cancel(Tenant $tenant, int $saleId): Purchase public function cancel(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
{ {
$sale = $this->findForTenant($tenant, $saleId); $sale = $this->findForTenant($tenant, $saleId, $eventId);
return $this->saleForResponse( return $this->saleForResponse(
$this->checkoutService->cancelPurchaseFromAdmin($sale) $this->checkoutService->cancelPurchaseFromAdmin($sale)
@@ -94,18 +92,18 @@ class AdminAppSaleService
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return Collection<int, Purchase> * @return Collection<int, Purchase>
*/ */
public function salesForExport(Tenant $tenant, array $filters = []): Collection public function salesForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
{ {
return $this->salesQuery($tenant, $filters)->get(); return $this->salesQuery($tenant, $filters, $eventId)->get();
} }
/** /**
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return LengthAwarePaginator<ValueChange> * @return LengthAwarePaginator<ValueChange>
*/ */
public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator public function modifications(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator
{ {
return $this->modificationsQuery($tenant, $filters) return $this->modificationsQuery($tenant, $filters, $eventId)
->paginateFromRequest() ->paginateFromRequest()
->withQueryString(); ->withQueryString();
} }
@@ -114,13 +112,13 @@ class AdminAppSaleService
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return Collection<int, ValueChange> * @return Collection<int, ValueChange>
*/ */
public function modificationsForExport(Tenant $tenant, array $filters = []): Collection public function modificationsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
{ {
return $this->modificationsQuery($tenant, $filters)->get(); return $this->modificationsQuery($tenant, $filters, $eventId)->get();
} }
/** @param array<string, mixed> $filters */ /** @param array<string, mixed> $filters */
protected function salesQuery(Tenant $tenant, array $filters): Builder protected function salesQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder
{ {
$sortColumns = [ $sortColumns = [
'id' => 'id', 'id' => 'id',
@@ -137,8 +135,7 @@ class AdminAppSaleService
? $requestedDirection ? $requestedDirection
: 'desc'; : 'desc';
return Purchase::query() return $this->purchasesQuery($tenant, $eventId)
->where('tenant_codigo', $tenant->codigo)
->when($filters['q'] ?? null, function (Builder $query, string $search): void { ->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search); $term = trim($search);
@@ -176,11 +173,18 @@ class AdminAppSaleService
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return Builder<ValueChange> * @return Builder<ValueChange>
*/ */
protected function modificationsQuery(Tenant $tenant, array $filters): Builder protected function modificationsQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder
{ {
return ValueChange::query() return ValueChange::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('trackable_type', (new Purchase)->getMorphClass()) ->where('trackable_type', (new Purchase)->getMorphClass())
->when($eventId !== null, fn (Builder $query): Builder => $query->whereHasMorph(
'trackable',
[Purchase::class],
fn (Builder $sales): Builder => $sales
->where('tenant_codigo', $tenant->codigo)
->where('event_id', $eventId),
))
->when($filters['q'] ?? null, function (Builder $query, string $search): void { ->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search); $term = trim($search);
@@ -221,11 +225,18 @@ class AdminAppSaleService
->orderByDesc('id'); ->orderByDesc('id');
} }
protected function findForTenant(Tenant $tenant, int $saleId): Purchase protected function findForTenant(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
{
return $this->purchasesQuery($tenant, $eventId)
->findOrFail($saleId);
}
/** @return Builder<Purchase> */
protected function purchasesQuery(Tenant $tenant, ?int $eventId): Builder
{ {
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->findOrFail($saleId); ->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
} }
protected function saleForResponse(Purchase $sale): Purchase protected function saleForResponse(Purchase $sale): Purchase

View File

@@ -29,4 +29,8 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel. La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
Cuando el usuario autenticado tiene `event_id`, el controlador lo pasa al servicio como alcance obligatorio para ventas, totales, historial y exportaciones. El alcance se combina con el tenant y no se obtiene de los filtros enviados por el cliente. Los administradores sin `event_id` conservan el alcance del tenant.
El detalle, los tickets de una venta, la confirmación y la cancelación buscan la compra dentro del mismo alcance. Una venta de otro evento o sin evento devuelve 404 para un administrador con `event_id`, antes de ejecutar cualquier acción en `CheckoutService`.
El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta. El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta.

View File

@@ -20,6 +20,7 @@ class AdminAppAdministratorController extends Controller
return AdministratorResource::collection($this->administratorService->list( return AdministratorResource::collection($this->administratorService->list(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->string('search')->trim()->toString() ?: null, $request->string('search')->trim()->toString() ?: null,
$request->user()->event_id,
)); ));
} }
@@ -28,6 +29,7 @@ class AdminAppAdministratorController extends Controller
return AdministratorResource::make($this->administratorService->create( return AdministratorResource::make($this->administratorService->create(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
$request->user()->event_id,
)); ));
} }
@@ -37,6 +39,7 @@ class AdminAppAdministratorController extends Controller
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$administrator, $administrator,
$request->validated(), $request->validated(),
$request->user()->event_id,
)); ));
} }

View File

@@ -18,6 +18,7 @@ class AdministratorResource extends JsonResource
'dni' => $this->dni, 'dni' => $this->dni,
'email' => $this->email, 'email' => $this->email,
'rol_codigo' => $this->rol_codigo, 'rol_codigo' => $this->rol_codigo,
'event_id' => $this->event_id,
'role' => $this->whenLoaded('role', fn () => [ 'role' => $this->whenLoaded('role', fn () => [
'codigo' => $this->role?->codigo, 'codigo' => $this->role?->codigo,
'nombre' => $this->role?->nombre, 'nombre' => $this->role?->nombre,

View File

@@ -19,9 +19,9 @@ class AdministratorService
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {} public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
/** @return Collection<int, User> */ /** @return Collection<int, User> */
public function list(Tenant $tenant, ?string $search = null): Collection public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection
{ {
return $this->query($tenant)->with('role') return $this->query($tenant, $eventId)->with('role')
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void { ->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
$query->where('nombre_apellido', 'like', "%{$search}%") $query->where('nombre_apellido', 'like', "%{$search}%")
->orWhere('dni', 'like', "%{$search}%") ->orWhere('dni', 'like', "%{$search}%")
@@ -31,14 +31,15 @@ class AdministratorService
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function create(Tenant $tenant, array $data): User public function create(Tenant $tenant, array $data, ?int $eventId = null): User
{ {
return DB::transaction(function () use ($tenant, $data): User { return DB::transaction(function () use ($tenant, $data, $eventId): User {
$administrator = User::query()->create([ $administrator = User::query()->create([
...$this->attributes($data), ...$this->attributes($data),
'password' => Str::random(64), 'password' => Str::random(64),
'rol_codigo' => RoleCode::AdminApp->value, 'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo, 'tenant_codigo' => $tenant->codigo,
'event_id' => $eventId,
]); ]);
$this->resetPasswordAttemptService->createForAdminAppEmail( $this->resetPasswordAttemptService->createForAdminAppEmail(
$administrator->email, $administrator->email,
@@ -50,10 +51,10 @@ class AdministratorService
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function update(Tenant $tenant, int $administratorId, array $data): User public function update(Tenant $tenant, int $administratorId, array $data, ?int $eventId = null): User
{ {
return DB::transaction(function () use ($tenant, $administratorId, $data): User { return DB::transaction(function () use ($tenant, $administratorId, $data, $eventId): User {
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId); $administrator = $this->query($tenant, $eventId)->lockForUpdate()->findOrFail($administratorId);
$administrator->update($this->attributes($data)); $administrator->update($this->attributes($data));
return $administrator->load('role'); return $administrator->load('role');
@@ -66,11 +67,11 @@ class AdministratorService
// Serialize deletions for this tenant, including requests already authenticated // Serialize deletions for this tenant, including requests already authenticated
// when another administrator removes their account. // when another administrator removes their account.
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail(); Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId); $administrator = $this->query($tenant, $actor->event_id)->lockForUpdate()->findOrFail($administratorId);
if ($administrator->is($actor)) { if ($administrator->is($actor)) {
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']); throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
} }
$activeAdministrators = $this->query($tenant)->lockForUpdate()->get(); $activeAdministrators = $this->query($tenant, $actor->event_id)->lockForUpdate()->get();
if ($activeAdministrators->count() <= 1) { if ($activeAdministrators->count() <= 1) {
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']); throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
} }
@@ -80,10 +81,11 @@ class AdministratorService
}); });
} }
private function query(Tenant $tenant): Builder private function query(Tenant $tenant, ?int $eventId = null): Builder
{ {
return User::query()->where('tenant_codigo', $tenant->codigo) return User::query()->where('tenant_codigo', $tenant->codigo)
->where('rol_codigo', RoleCode::AdminApp->value); ->where('rol_codigo', RoleCode::AdminApp->value)
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
} }
/** @param array<string, mixed> $data /** @param array<string, mixed> $data

View File

@@ -55,6 +55,8 @@ No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el fronten
## Verificación ## Verificación
Cuando el actor tiene `event_id`, los nuevos administradores heredan su evento y el listado, la búsqueda, la edición y la baja se limitan a ese evento dentro del tenant. La comprobación de administradores activos también usa ese alcance. El evento se toma del usuario autenticado, no del cuerpo de la solicitud; sin `event_id` se conserva el comportamiento por tenant.
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php` `php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de

View File

@@ -2,11 +2,12 @@
namespace App\Domains\Core\Auth\Models; namespace App\Domains\Core\Auth\Models;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Core\Authorization\Models\Role; use App\Domains\Core\Authorization\Models\Role;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Ticketing\Event\Models\EventDateChangeView;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Models\EventDateChangeView;
use App\Domains\Ticketing\Ticket\Models\ScanAttempt; use App\Domains\Ticketing\Ticket\Models\ScanAttempt;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -20,7 +21,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])] #[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo', 'event_id'])]
#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])] #[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
class User extends Authenticatable class User extends Authenticatable
{ {
@@ -86,6 +87,12 @@ class User extends Authenticatable
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
} }
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** @return BelongsToMany<Category, $this> */ /** @return BelongsToMany<Category, $this> */
public function scanCategories(): BelongsToMany public function scanCategories(): BelongsToMany
{ {
@@ -103,6 +110,7 @@ class User extends Authenticatable
protected function casts(): array protected function casts(): array
{ {
return [ return [
'event_id' => 'integer',
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'failed_login_attempts' => 'integer', 'failed_login_attempts' => 'integer',

View File

@@ -24,6 +24,7 @@ class UserResource extends JsonResource
'telefono' => $this->telefono, 'telefono' => $this->telefono,
'rol_codigo' => $this->rol_codigo, 'rol_codigo' => $this->rol_codigo,
'tenant_codigo' => $this->tenant_codigo, 'tenant_codigo' => $this->tenant_codigo,
'event_id' => $this->event_id,
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories 'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
->map(fn ($category) => [ ->map(fn ($category) => [
'id' => $category->id, 'id' => $category->id,

View File

@@ -11,6 +11,9 @@ class AdminAppContextService
{ {
$tenant = $user->tenant() $tenant = $user->tenant()
->with([ ->with([
'socialMedia',
'addresses',
'phoneNumbers',
'menues' => fn ($query) => $query 'menues' => fn ($query) => $query
->whereHas( ->whereHas(
'roles', 'roles',

View File

@@ -41,8 +41,9 @@ class TenantBootstrapService
'roles', 'roles',
fn ($query) => $query->where('codigo', RoleCode::User->value) fn ($query) => $query->where('codigo', RoleCode::User->value)
), ),
$tenant->storefront_website_type_code === 'onticket_multi_event' ...($tenant->storefront_website_type_code === 'onticket_multi_event'
? 'eventCategories' : 'categories' => fn ($query) => $query->orderBy('nombre'), ? ['eventCategories' => fn ($query) => $query->orderBy('nombre')]
: ['categories' => fn ($query) => $query->orderBy('sort_order')->orderBy('id')]),
] ]
); );
} }

View File

@@ -8,7 +8,27 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class PhoneNumber extends Model class PhoneNumber extends Model
{ {
protected $fillable = ['label', 'number']; protected $attributes = ['country_code' => '+54', 'mobile_prefix' => '9'];
protected $fillable = ['label', 'country_code', 'mobile_prefix', 'area_code', 'local_number'];
public function getNumberAttribute(?string $legacyNumber): string
{
if ($this->local_number === null) {
return $legacyNumber ?? '';
}
$areaCode = $this->mobile_prefix && $this->area_code
? '('.$this->area_code.')'
: $this->area_code;
return implode(' ', array_filter([
$this->country_code,
$this->mobile_prefix,
$areaCode,
$this->local_number,
], fn ($part): bool => $part !== null && $part !== ''));
}
public static function dialableNumber(string $number): string public static function dialableNumber(string $number): string
{ {
@@ -17,9 +37,20 @@ class PhoneNumber extends Model
public function telUrl(): string public function telUrl(): string
{ {
if ($this->country_code === null || $this->local_number === null) {
return 'tel:'.self::dialableNumber($this->number); return 'tel:'.self::dialableNumber($this->number);
} }
$areaCode = $this->area_code ?? '';
if ($this->country_code === '+54') {
$areaCode = ltrim($areaCode, '0');
}
return 'tel:'.self::dialableNumber(
$this->country_code.($this->mobile_prefix ?? '').$areaCode.$this->local_number
);
}
public function tenants(): BelongsToMany public function tenants(): BelongsToMany
{ {
return $this->belongsToMany(Tenant::class, 'tenant_phone_numbers', 'phone_number_id', 'tenant_codigo', 'id', 'codigo') return $this->belongsToMany(Tenant::class, 'tenant_phone_numbers', 'phone_number_id', 'tenant_codigo', 'id', 'codigo')

View File

@@ -12,6 +12,10 @@ class PhoneNumberResource extends JsonResource
return [ return [
'id' => $this->id, 'id' => $this->id,
'label' => $this->label, 'label' => $this->label,
'country_code' => $this->country_code,
'mobile_prefix' => $this->mobile_prefix,
'area_code' => $this->area_code,
'local_number' => $this->local_number,
'number' => $this->number, 'number' => $this->number,
'tel_url' => $this->resource->telUrl(), 'tel_url' => $this->resource->telUrl(),
'is_main' => $this->whenPivotLoaded('tenant_phone_numbers', fn () => (bool) $this->pivot->is_main), 'is_main' => $this->whenPivotLoaded('tenant_phone_numbers', fn () => (bool) $this->pivot->is_main),

View File

@@ -26,6 +26,7 @@ class AdminAppStaffController extends Controller
return StaffResource::collection($this->staffService->list( return StaffResource::collection($this->staffService->list(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->string('search')->trim()->toString() ?: null, $request->string('search')->trim()->toString() ?: null,
$request->user()->event_id,
)); ));
} }
@@ -34,6 +35,7 @@ class AdminAppStaffController extends Controller
return StaffResource::make($this->staffService->create( return StaffResource::make($this->staffService->create(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
$request->user()->event_id,
)); ));
} }
@@ -43,12 +45,13 @@ class AdminAppStaffController extends Controller
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$staff, $staff,
$request->validated(), $request->validated(),
$request->user()->event_id,
)); ));
} }
public function destroy(Request $request, int $staff): Response public function destroy(Request $request, int $staff): Response
{ {
$this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff); $this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff, $request->user()->event_id);
return response()->noContent(); return response()->noContent();
} }
@@ -60,6 +63,7 @@ class AdminAppStaffController extends Controller
$scanner = $this->staffService->find( $scanner = $this->staffService->find(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$staff, $staff,
$request->user()->event_id,
); );
return ScanAttemptResource::collection( return ScanAttemptResource::collection(

View File

@@ -18,6 +18,7 @@ class StaffResource extends JsonResource
'dni' => $this->dni, 'dni' => $this->dni,
'email' => $this->email, 'email' => $this->email,
'rol_codigo' => $this->rol_codigo, 'rol_codigo' => $this->rol_codigo,
'event_id' => $this->event_id,
'role' => $this->whenLoaded('role', fn () => [ 'role' => $this->whenLoaded('role', fn () => [
'codigo' => $this->role?->codigo, 'codigo' => $this->role?->codigo,
'nombre' => $this->role?->nombre, 'nombre' => $this->role?->nombre,

View File

@@ -2,11 +2,11 @@
namespace App\Domains\Core\Staff\Services; namespace App\Domains\Core\Staff\Services;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Auth\Models\ResetPasswordAttempt; use App\Domains\Core\Auth\Models\ResetPasswordAttempt;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\ResetPasswordAttemptService; use App\Domains\Core\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@@ -22,9 +22,9 @@ class StaffService
) {} ) {}
/** @return Collection<int, User> */ /** @return Collection<int, User> */
public function list(Tenant $tenant, ?string $search = null): Collection public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection
{ {
return $this->staffQuery($tenant) return $this->staffQuery($tenant, $eventId)
->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')]) ->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')])
->when($search, function (Builder $query, string $search): void { ->when($search, function (Builder $query, string $search): void {
$query->where(function (Builder $query) use ($search): void { $query->where(function (Builder $query) use ($search): void {
@@ -52,18 +52,19 @@ class StaffService
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function create(Tenant $tenant, array $data): User public function create(Tenant $tenant, array $data, ?int $eventId = null): User
{ {
$categoryIds = $this->categoryIdsFor($tenant, $data); $categoryIds = $this->categoryIdsFor($tenant, $data);
$this->assertCategoriesBelongToTenant($tenant, $categoryIds); $this->assertCategoriesBelongToTenant($tenant, $categoryIds);
return DB::transaction(function () use ($tenant, $data, $categoryIds): User { return DB::transaction(function () use ($tenant, $data, $categoryIds, $eventId): User {
$staff = User::query()->create([ $staff = User::query()->create([
...Arr::only($data, ['nombre_apellido', 'dni', 'email']), ...Arr::only($data, ['nombre_apellido', 'dni', 'email']),
'email' => mb_strtolower(trim((string) $data['email'])), 'email' => mb_strtolower(trim((string) $data['email'])),
'password' => Str::random(64), 'password' => Str::random(64),
'rol_codigo' => RoleCode::Scanner->value, 'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $tenant->codigo, 'tenant_codigo' => $tenant->codigo,
'event_id' => $eventId,
]); ]);
$staff->scanCategories()->sync($categoryIds); $staff->scanCategories()->sync($categoryIds);
$this->resetPasswordAttemptService->createForScannerEmail( $this->resetPasswordAttemptService->createForScannerEmail(
@@ -76,9 +77,9 @@ class StaffService
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function update(Tenant $tenant, int $staffId, array $data): User public function update(Tenant $tenant, int $staffId, array $data, ?int $eventId = null): User
{ {
$staff = $this->find($tenant, $staffId); $staff = $this->find($tenant, $staffId, $eventId);
$categoryIds = $this->categoryIdsFor($tenant, $data); $categoryIds = $this->categoryIdsFor($tenant, $data);
$this->assertCategoriesBelongToTenant($tenant, $categoryIds); $this->assertCategoriesBelongToTenant($tenant, $categoryIds);
@@ -92,9 +93,9 @@ class StaffService
}); });
} }
public function delete(Tenant $tenant, int $staffId): void public function delete(Tenant $tenant, int $staffId, ?int $eventId = null): void
{ {
$staff = $this->find($tenant, $staffId); $staff = $this->find($tenant, $staffId, $eventId);
DB::transaction(function () use ($staff): void { DB::transaction(function () use ($staff): void {
$staff->tokens()->delete(); $staff->tokens()->delete();
@@ -102,16 +103,17 @@ class StaffService
}); });
} }
public function find(Tenant $tenant, int $staffId): User public function find(Tenant $tenant, int $staffId, ?int $eventId = null): User
{ {
return $this->staffQuery($tenant)->findOrFail($staffId); return $this->staffQuery($tenant, $eventId)->findOrFail($staffId);
} }
private function staffQuery(Tenant $tenant): Builder private function staffQuery(Tenant $tenant, ?int $eventId = null): Builder
{ {
return User::query() return User::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->where('rol_codigo', RoleCode::Scanner->value); ->where('rol_codigo', RoleCode::Scanner->value)
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
} }
/** /**

View File

@@ -18,3 +18,5 @@ Recurso REST `/v1/adminapp/tenant/staff`, excepto detalle individual, protegido
## Dependencias y reglas ## Dependencias y reglas
Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado. Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado.
Si el administrador autenticado tiene `event_id`, el alta de scanners hereda ese valor y las búsquedas, ediciones, bajas y consultas de intentos de escaneo se limitan a personal del mismo evento. El cliente no puede elegir ni cambiar el evento. Sin `event_id`, se mantiene el alcance por tenant.

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Core\Tenant\Controllers\AdminApp;
use App\Domains\Core\Tenant\Requests\AdminApp\UpdateContactRequest;
use App\Domains\Core\Tenant\Resources\AdminApp\ContactResource;
use App\Domains\Core\Tenant\Services\ContactService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ContactController extends Controller
{
public function show(Request $request, ContactService $service): ContactResource
{
return ContactResource::make($service->load($request->user()->tenant()->firstOrFail()));
}
public function update(UpdateContactRequest $request, ContactService $service): ContactResource
{
return ContactResource::make($service->update($request->user()->tenant()->firstOrFail(), $request->validated()));
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Domains\Core\Tenant\Requests\AdminApp;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateContactRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$tenantCode = $this->user()->tenant_codigo;
return [
'social_media' => ['present', 'array', 'max:30'],
'social_media.*' => ['array:code,url'],
'social_media.*.code' => ['required', 'string', 'distinct', Rule::exists('social_media', 'code')],
'social_media.*.url' => ['required', 'url:http,https', 'max:255'],
'addresses' => ['present', 'array', 'list', 'max:50'],
'addresses.*' => ['array:id,address_text,is_main'],
'addresses.*.id' => ['nullable', 'integer', 'distinct', Rule::exists('tenant_addresses', 'address_id')->where('tenant_codigo', $tenantCode)],
'addresses.*.address_text' => ['required', 'string', 'max:1000'],
'addresses.*.is_main' => ['sometimes', 'boolean'],
'phone_numbers' => ['present', 'array', 'list', 'max:50'],
'phone_numbers.*' => ['array:id,country_code,mobile_prefix,area_code,local_number,is_main'],
'phone_numbers.*.id' => ['nullable', 'integer', 'distinct', Rule::exists('tenant_phone_numbers', 'phone_number_id')->where('tenant_codigo', $tenantCode)],
'phone_numbers.*.country_code' => ['present', 'nullable', 'regex:/^\+[0-9]{1,4}$/'],
'phone_numbers.*.mobile_prefix' => ['present', 'nullable', 'regex:/^[0-9]{1,4}$/'],
'phone_numbers.*.area_code' => ['present', 'nullable', 'regex:/^[0-9]{1,8}$/'],
'phone_numbers.*.local_number' => ['required', 'string', 'max:50', 'regex:/^[0-9+() .-]+$/', 'regex:/[0-9]/'],
'phone_numbers.*.is_main' => ['sometimes', 'boolean'],
];
}
public function attributes(): array
{
return [
'social_media' => 'redes sociales',
'social_media.*.code' => 'red social',
'social_media.*.url' => 'URL de la red social',
'addresses' => 'direcciones',
'addresses.*.id' => 'dirección',
'addresses.*.address_text' => 'dirección',
'addresses.*.is_main' => 'dirección principal',
'phone_numbers' => 'teléfonos',
'phone_numbers.*.id' => 'teléfono',
'phone_numbers.*.country_code' => 'código de país',
'phone_numbers.*.mobile_prefix' => 'prefijo móvil',
'phone_numbers.*.area_code' => 'código de área',
'phone_numbers.*.local_number' => 'número de teléfono',
'phone_numbers.*.is_main' => 'teléfono principal',
];
}
public function messages(): array
{
return [
'required' => 'Completá :attribute.',
'url' => 'La URL debe ser válida y comenzar con http:// o https://.',
'regex' => 'El formato de :attribute no es válido.',
'exists' => 'El valor de :attribute no está disponible para este comercio.',
'distinct' => 'Hay un valor duplicado en :attribute.',
'max.string' => 'El campo :attribute admite hasta :max caracteres.',
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Core\Tenant\Resources\AdminApp;
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
use App\Domains\Core\Tenant\Resources\TenantAddressResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ContactResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'social_media' => $this->socialMedia->map(fn ($network) => [
'code' => $network->code,
'url' => $network->pivot->url,
])->values(),
'addresses' => TenantAddressResource::collection($this->addresses),
'phone_numbers' => PhoneNumberResource::collection($this->phoneNumbers),
];
}
}

View File

@@ -4,8 +4,8 @@ namespace App\Domains\Core\Tenant\Resources;
use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Address\Resources\AddressResource; use App\Domains\Core\Address\Resources\AddressResource;
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Menu\Models\Menu;
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType; use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Event\Models\Event;
@@ -105,6 +105,13 @@ class TenantResource extends JsonResource
]) ])
->values() ->values()
), ),
'tenant_social_media' => $this->whenLoaded('socialMedia', fn () => $this->socialMedia
->map(fn ($socialMedia) => [
'code' => $socialMedia->code,
'icon' => $socialMedia->icon,
'name' => $socialMedia->name,
'url' => $socialMedia->pivot->url,
])->values()),
'menues' => $this->whenLoaded( 'menues' => $this->whenLoaded(
'menues', 'menues',
fn () => $this->menuTree($this->menues) fn () => $this->menuTree($this->menues)
@@ -200,12 +207,14 @@ class TenantResource extends JsonResource
'nombre' => $category->nombre, 'nombre' => $category->nombre,
'subcategories' => $childrenByParent 'subcategories' => $childrenByParent
->get($category->id, collect()) ->get($category->id, collect())
->filter(fn (Category $child) => $child->is_enabled)
->map($formatCategory) ->map($formatCategory)
->values(), ->values(),
]; ];
}; };
return $categories return $categories
->filter(fn (Category $category) => $category->is_enabled)
->filter(fn (Category $category) => $category->categoria_id === null ->filter(fn (Category $category) => $category->categoria_id === null
|| ! $categoryIds->has($category->categoria_id)) || ! $categoryIds->has($category->categoria_id))
->map($formatCategory) ->map($formatCategory)
@@ -233,7 +242,7 @@ class TenantResource extends JsonResource
'route' => $menu->route, 'route' => $menu->route,
]; ];
if ($menu->pivot?->static_content !== null) { if ($menu->content_type === Menu::CONTENT_TYPE_STATIC && $menu->pivot?->static_content !== null) {
$formatted['static_content'] = $menu->pivot->static_content; $formatted['static_content'] = $menu->pivot->static_content;
} }

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Domains\Core\Tenant\Services;
use App\Domains\Core\Address\Models\Address;
use App\Domains\Core\PhoneNumber\Models\PhoneNumber;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ContactService
{
public function load(Tenant $tenant): Tenant
{
return $tenant->load([
'socialMedia',
'addresses' => fn ($query) => $query->reorder()->orderByPivot('is_main', 'desc')->orderByPivot('id'),
'phoneNumbers',
]);
}
public function update(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Tenant {
// Serialize contact writes, including changes to the principal links.
Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail();
$social = [];
foreach ($data['social_media'] as $index => $item) {
$social[$item['code']] = ['url' => $item['url'], 'orden' => $index];
}
$tenant->socialMedia()->sync($social);
foreach (['addresses', 'phone_numbers'] as $collection) {
$relation = $collection === 'addresses' ? $tenant->addresses() : $tenant->phoneNumbers();
$existing = $relation->get()->keyBy('id');
$links = [];
foreach (array_values($data[$collection]) as $index => $item) {
$id = $item['id'] ?? null;
if ($id !== null && ! $existing->has($id)) {
throw ValidationException::withMessages(["{$collection}.{$index}.id" => 'El contacto no pertenece al comercio.']);
}
$record = $id === null
? ($collection === 'addresses' ? new Address : new PhoneNumber)
: $existing->get($id);
$attributes = collect($item)->except(['id', 'is_main'])->all();
$record->fill($attributes);
if (! $record->exists) {
$record->label = $collection === 'addresses' ? 'Dirección' : 'Teléfono';
}
if ($record->exists && $record->isDirty()) {
$shared = $record->tenants()->where('tenants.codigo', '!=', $tenant->codigo)->exists()
|| ($record instanceof Address && $record->events()->exists());
if ($shared) {
$record = $record->replicate();
}
// An edited street address must not retain coordinates for the old location.
if ($record instanceof Address && $record->isDirty('address_text')) {
$record->latitude = null;
$record->longitude = null;
}
}
$record->save();
$links[$record->id] = ['is_main' => $index === 0];
}
// Clear the previous principal before sync to respect the unique index.
$relation->newPivotStatement()->where('tenant_codigo', $tenant->codigo)->update(['is_main' => false]);
$relation->sync($links);
}
return $this->load($tenant);
});
}
}

View File

@@ -28,9 +28,16 @@ El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant
- `GET /tenants`, `GET /tenants/{codigo}` y `DELETE /tenants/{codigo}`. - `GET /tenants`, `GET /tenants/{codigo}` y `DELETE /tenants/{codigo}`.
- La creación y actualización general de tenants se administran mediante seeders o base de datos; no se exponen por API. - La creación y actualización general de tenants se administran mediante seeders o base de datos; no se exponen por API.
- Bajo `/v1/adminapp/tenant/website-extras`, con autenticación y contexto de tenant: consulta general, detalle, actualización y activación/desactivación. - Bajo `/v1/adminapp/tenant/website-extras`, con autenticación y contexto de tenant: consulta general, detalle, actualización y activación/desactivación.
- `GET /v1/adminapp/tenant/contact` y `PUT /v1/adminapp/tenant/contact`, con `auth:sanctum` y `adminapp.tenant`: consulta y reemplazo completo del contacto del comercio autenticado.
El PUT de contacto recibe `social_media` (`code`, `url`), `addresses` (`id` opcional para existentes, `address_text`) y `phone_numbers` (`id` opcional, `country_code`, `mobile_prefix`, `area_code`, `local_number`). Las tres colecciones son obligatorias; un array vacío elimina sus vínculos. El primer elemento enviado en cada lista de direcciones o teléfonos es siempre el principal: el backend calcula `is_main` por posición e ignora las marcas enviadas por clientes anteriores. GET y PUT devuelven el principal primero para conservarlo en el siguiente guardado. Los IDs existentes deben estar vinculados al comercio autenticado. Todos los cambios se realizan en una transacción, serializada por tenant, y la respuesta devuelve los registros persistidos y sus IDs.
Se conservan etiquetas y coordenadas de direcciones sin modificar; al cambiar el texto se limpian las coordenadas para evitar mostrar el mapa de una ubicación anterior. Las ediciones de registros compartidos crean una copia para el comercio, preservando otros comercios y eventos. Las bajas desvinculan registros sin borrar datos compartidos.
Contacto administra exclusivamente las redes del comercio. `TenantResource.tenant_social_media` siempre expone esas redes y alimenta Contacto y el footer públicos. `social_media` mantiene la selección histórica del evento activo; `event.social_media` sigue disponible para las vistas de evento. Guardar Contacto no modifica redes del evento.
## Dependencias y reglas ## Dependencias y reglas
Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral. Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral.
Los resources exponen `phone_numbers` (incluye `is_main`) y `main_phone_number`, con `id`, `label`, `number` y `tel_url`. Contacto muestra la colección y el footer usa el principal. La migración copia primero `tenants.phone` como principal y luego el teléfono de `help.contact`, si es distinto; si no existe teléfono del tenant, el del menú pasa a ser principal. Los duplicados por formato se comparan dentro de cada tenant sin inferir país ni código de área. Los campos anteriores se conservan como datos legacy, pero la tienda consume las relaciones nuevas. No hay endpoints de edición de teléfonos ni cambios del adminapp en esta etapa. Los resources exponen `phone_numbers` (incluye `is_main`) y `main_phone_number`, con `id`, `label`, `number` y `tel_url`. Contacto muestra la colección y el footer usa el principal. La migración copia primero `tenants.phone` como principal y luego el teléfono de `help.contact`, si es distinto; si no existe teléfono del tenant, el del menú pasa a ser principal. Los duplicados por formato se comparan dentro de cada tenant sin inferir país ni código de área. Los campos anteriores se conservan como datos legacy, pero la tienda consume las relaciones nuevas y el adminapp las edita mediante el endpoint de contacto.

View File

@@ -1,6 +1,7 @@
<?php <?php
use App\Domains\Core\Tenant\Controllers\AdminApp\BrandController; use App\Domains\Core\Tenant\Controllers\AdminApp\BrandController;
use App\Domains\Core\Tenant\Controllers\AdminApp\ContactController;
use App\Domains\Core\Tenant\Controllers\AdminApp\HelpMenuController; use App\Domains\Core\Tenant\Controllers\AdminApp\HelpMenuController;
use App\Domains\Core\Tenant\Controllers\AdminApp\WebsiteExtraController; use App\Domains\Core\Tenant\Controllers\AdminApp\WebsiteExtraController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -8,6 +9,8 @@ use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void { ->group(function (): void {
Route::get('contact', [ContactController::class, 'show']);
Route::put('contact', [ContactController::class, 'update']);
Route::get('brand', [BrandController::class, 'show']); Route::get('brand', [BrandController::class, 'show']);
Route::put('brand', [BrandController::class, 'update']); Route::put('brand', [BrandController::class, 'update']);
Route::get('help-menus', [HelpMenuController::class, 'index']); Route::get('help-menus', [HelpMenuController::class, 'index']);

View File

@@ -5,28 +5,28 @@ use App\Domains\Ticketing\Desfile\Controllers\EntryReservationController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'index']) Route::get('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'index'])
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.index'); ->name('adminapp.desfile.entry-reservations.index');
Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf']) Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf'])
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.pdf'); ->name('adminapp.desfile.entry-reservations.pdf');
Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel']) Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel'])
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.excel'); ->name('adminapp.desfile.entry-reservations.excel');
Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf']) Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf'])
->whereNumber('reservation') ->whereNumber('reservation')
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.ticket.pdf'); ->name('adminapp.desfile.entry-reservations.ticket.pdf');
Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store']) Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store'])
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.store'); ->name('adminapp.desfile.entry-reservations.store');
Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy']) Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy'])
->whereNumber('reservation') ->whereNumber('reservation')
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
->name('adminapp.desfile.entry-reservations.destroy'); ->name('adminapp.desfile.entry-reservations.destroy');
Route::prefix('v1/adminapp/tenant/desfile') Route::prefix('v1/adminapp/tenant/desfile')
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.entradas']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas'])
->group(function (): void { ->group(function (): void {
Route::get('entries', [EntryController::class, 'show']) Route::get('entries', [EntryController::class, 'show'])
->name('adminapp.desfile.entries.show'); ->name('adminapp.desfile.entries.show');

View File

@@ -19,48 +19,48 @@ Route::prefix('v1/adminapp/tenant')
->middleware("tenant.menu:{$menuCode}"); ->middleware("tenant.menu:{$menuCode}");
}; };
$visibilityRoutes('entries', 'entries', 'onticket.adminapp.fiesta-futbol-infantil.entradas'); $visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas');
$visibilityRoutes('foods', 'foods', 'onticket.adminapp.fiesta-futbol-infantil.comida'); $visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida');
$visibilityRoutes('accommodations', 'accommodations', 'onticket.adminapp.fiesta-futbol-infantil.alojamientos'); $visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos');
$visibilityRoutes('merchandise', 'merchandise', 'onticket.adminapp.fiesta-futbol-infantil.merchandising'); $visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising');
Route::get('entries', [EntryController::class, 'index']) Route::get('entries', [EntryController::class, 'index'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.index'); ->name('adminapp.fiesta-futbol-infantil.entries.index');
Route::post('entries', [EntryController::class, 'store']) Route::post('entries', [EntryController::class, 'store'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.store'); ->name('adminapp.fiesta-futbol-infantil.entries.store');
Route::delete('entries/{entry}', [EntryController::class, 'destroy']) Route::delete('entries/{entry}', [EntryController::class, 'destroy'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.destroy'); ->name('adminapp.fiesta-futbol-infantil.entries.destroy');
Route::get('foods', [FoodController::class, 'index']) Route::get('foods', [FoodController::class, 'index'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.index'); ->name('adminapp.fiesta-futbol-infantil.foods.index');
Route::post('foods', [FoodController::class, 'store']) Route::post('foods', [FoodController::class, 'store'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.store'); ->name('adminapp.fiesta-futbol-infantil.foods.store');
Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock']) Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update'); ->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update');
Route::delete('foods/{food}', [FoodController::class, 'destroy']) Route::delete('foods/{food}', [FoodController::class, 'destroy'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.destroy'); ->name('adminapp.fiesta-futbol-infantil.foods.destroy');
Route::get('accommodations', [AccommodationController::class, 'index']) Route::get('accommodations', [AccommodationController::class, 'index'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
->name('adminapp.fiesta-futbol-infantil.accommodations.index'); ->name('adminapp.fiesta-futbol-infantil.accommodations.index');
Route::post('accommodations', [AccommodationController::class, 'store']) Route::post('accommodations', [AccommodationController::class, 'store'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
->name('adminapp.fiesta-futbol-infantil.accommodations.store'); ->name('adminapp.fiesta-futbol-infantil.accommodations.store');
Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy']) Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
->name('adminapp.fiesta-futbol-infantil.accommodations.destroy'); ->name('adminapp.fiesta-futbol-infantil.accommodations.destroy');
Route::get('merchandise', [MerchandiseController::class, 'index']) Route::get('merchandise', [MerchandiseController::class, 'index'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
->name('adminapp.fiesta-futbol-infantil.merchandise.index'); ->name('adminapp.fiesta-futbol-infantil.merchandise.index');
Route::post('merchandise', [MerchandiseController::class, 'store']) Route::post('merchandise', [MerchandiseController::class, 'store'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
->name('adminapp.fiesta-futbol-infantil.merchandise.store'); ->name('adminapp.fiesta-futbol-infantil.merchandise.store');
Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy']) Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy'])
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
->name('adminapp.fiesta-futbol-infantil.merchandise.destroy'); ->name('adminapp.fiesta-futbol-infantil.merchandise.destroy');
}); });

View File

@@ -29,7 +29,7 @@ class TicketController extends Controller
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketCollection( return new AdminAppTicketCollection(
$this->ticketService->search($tenant, $request->validated()) $this->ticketService->search($tenant, $request->validated(), $request->user()->event_id)
); );
} }
@@ -37,7 +37,7 @@ class TicketController extends Controller
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket)); return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket, $request->user()->event_id));
} }
public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource
@@ -45,7 +45,7 @@ class TicketController extends Controller
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketRefundCalculationResource( return new AdminAppTicketRefundCalculationResource(
$this->ticketService->calculateRefund($tenant, $ticket) $this->ticketService->calculateRefund($tenant, $ticket, $request->user()->event_id)
); );
} }
@@ -69,7 +69,7 @@ class TicketController extends Controller
return $this->ticketPdfService->download( return $this->ticketPdfService->download(
$tenant, $tenant,
$this->ticketService->ticketsForExport($tenant, $request->validated()), $this->ticketService->ticketsForExport($tenant, $request->validated(), $request->user()->event_id),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -80,7 +80,7 @@ class TicketController extends Controller
return $this->ticketExcelService->download( return $this->ticketExcelService->download(
$tenant, $tenant,
$this->ticketService->ticketsForExport($tenant, $request->validated()), $this->ticketService->ticketsForExport($tenant, $request->validated(), $request->user()->event_id),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }

View File

@@ -41,9 +41,9 @@ class AdminAppTicketService
/** /**
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters
*/ */
public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult public function search(Tenant $tenant, array $filters = [], ?int $eventId = null): AdminAppTicketResult
{ {
$query = $this->baseQuery($tenant, $filters); $query = $this->baseQuery($tenant, $filters, $eventId);
$countQuery = clone $query; $countQuery = clone $query;
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters); $databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
@@ -77,7 +77,7 @@ class AdminAppTicketService
tickets: $tickets, tickets: $tickets,
scannedTickets: $scannedTickets, scannedTickets: $scannedTickets,
totalTickets: $totalTickets, totalTickets: $totalTickets,
refundedTotal: $this->refundSummaryService->totalForTenant($tenant), refundedTotal: $this->refundSummaryService->totalForTenant($tenant, $eventId),
); );
} }
@@ -85,9 +85,9 @@ class AdminAppTicketService
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters
* @return Collection<int, Ticket> * @return Collection<int, Ticket>
*/ */
public function ticketsForExport(Tenant $tenant, array $filters = []): Collection public function ticketsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
{ {
$query = $this->baseQuery($tenant, $filters); $query = $this->baseQuery($tenant, $filters, $eventId);
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters); $databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
$tickets = $query $tickets = $query
->with(self::RELATIONS) ->with(self::RELATIONS)
@@ -97,11 +97,10 @@ class AdminAppTicketService
return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters); return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters);
} }
public function cancel(Tenant $tenant, int $ticketId): Ticket public function cancel(Tenant $tenant, int $ticketId, ?int $eventId = null): Ticket
{ {
return DB::transaction(function () use ($tenant, $ticketId): Ticket { return DB::transaction(function () use ($tenant, $ticketId, $eventId): Ticket {
$ticket = Ticket::query() $ticket = $this->ticketsQuery($tenant, $eventId)
->where('tenant_code', $tenant->codigo)
->lockForUpdate() ->lockForUpdate()
->findOrFail($ticketId); ->findOrFail($ticketId);
@@ -124,10 +123,9 @@ class AdminAppTicketService
* partial: string|null, * partial: string|null,
* } * }
*/ */
public function calculateRefund(Tenant $tenant, int $ticketId): array public function calculateRefund(Tenant $tenant, int $ticketId, ?int $eventId = null): array
{ {
$ticket = Ticket::query() $ticket = $this->ticketsQuery($tenant, $eventId)
->where('tenant_code', $tenant->codigo)
->findOrFail($ticketId); ->findOrFail($ticketId);
if (! $ticket->can_refund()) { if (! $ticket->can_refund()) {
@@ -175,14 +173,13 @@ class AdminAppTicketService
string $refundType, string $refundType,
?User $createdBy = null, ?User $createdBy = null,
): Ticket { ): Ticket {
$this->ensureRefundIsAllowed($tenant, $refundType);
return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket { return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket {
$ticket = Ticket::query() $ticket = $this->ticketsQuery($tenant, $createdBy?->event_id)
->where('tenant_code', $tenant->codigo)
->lockForUpdate() ->lockForUpdate()
->findOrFail($ticketId); ->findOrFail($ticketId);
$this->ensureRefundIsAllowed($tenant, $refundType);
if (! $ticket->can_refund()) { if (! $ticket->can_refund()) {
if ($ticket->status !== Ticket::STATUS_ACTIVE) { if ($ticket->status !== Ticket::STATUS_ACTIVE) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -310,12 +307,11 @@ class AdminAppTicketService
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters
* @return Builder<Ticket> * @return Builder<Ticket>
*/ */
private function baseQuery(Tenant $tenant, array $filters): Builder private function baseQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder
{ {
$search = trim((string) ($filters['q'] ?? '')); $search = trim((string) ($filters['q'] ?? ''));
$query = Ticket::query() $query = $this->ticketsQuery($tenant, $eventId)
->where('tenant_code', $tenant->codigo)
->when($search !== '', function (Builder $query) use ($search): void { ->when($search !== '', function (Builder $query) use ($search): void {
$this->applySearchFilter($query, $search); $this->applySearchFilter($query, $search);
}) })
@@ -355,6 +351,14 @@ class AdminAppTicketService
return $query; return $query;
} }
/** @return Builder<Ticket> */
private function ticketsQuery(Tenant $tenant, ?int $eventId): Builder
{
return Ticket::query()
->where('tenant_code', $tenant->codigo)
->when($eventId !== null, fn (Builder $query): Builder => $query->where('tickets.event_id', $eventId));
}
/** @param Builder<Ticket> $query */ /** @param Builder<Ticket> $query */
private function applySearchFilter(Builder $query, string $search): void private function applySearchFilter(Builder $query, string $search): void
{ {

View File

@@ -82,13 +82,17 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:
- `POST /tickets/pdf`. - `POST /tickets/pdf`.
Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú
`onticket.adminapp.tickets`: `adminapp.tickets`:
- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye - `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye
`scanned_tickets` y `total_tickets` para el tenant autenticado. `scanned_tickets` y `total_tickets` para el tenant autenticado.
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas. `TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
Si el administrador autenticado tiene `event_id`, las consultas de Tickets y sus exportaciones se limitan a `tickets.event_id` dentro del tenant. Los contadores usan el mismo alcance y el total reembolsado se limita a las compras del evento. Sin `event_id`, se conserva el alcance por tenant.
La cancelación, el cálculo de reembolso y el reembolso buscan el ticket dentro de ese alcance antes de validar o ejecutar la operación. Un ticket de otro evento o sin evento devuelve 404 para un administrador con evento asignado. El alcance se obtiene del usuario autenticado, no de los parámetros del cliente.
## Dependencias y reglas ## Dependencias y reglas
Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación. Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación.

View File

@@ -7,24 +7,24 @@ Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void { ->group(function (): void {
Route::get('tickets', [TicketController::class, 'index']) Route::get('tickets', [TicketController::class, 'index'])
->middleware('tenant.menu:onticket.adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.index'); ->name('adminapp.tickets.index');
Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel']) Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel'])
->whereNumber('ticket') ->whereNumber('ticket')
->middleware('tenant.menu:onticket.adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.cancel'); ->name('adminapp.tickets.cancel');
Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund']) Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund'])
->whereNumber('ticket') ->whereNumber('ticket')
->middleware('tenant.menu:onticket.adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.calculate-refund'); ->name('adminapp.tickets.calculate-refund');
Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund']) Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund'])
->whereNumber('ticket') ->whereNumber('ticket')
->middleware('tenant.menu:onticket.adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.refund'); ->name('adminapp.tickets.refund');
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf']) Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
->middleware('tenant.menu:onticket.adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.pdf'); ->name('adminapp.tickets.pdf');
Route::get('tickets/excel', [TicketController::class, 'downloadExcel']) Route::get('tickets/excel', [TicketController::class, 'downloadExcel'])
->middleware('tenant.menu:onticket.adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.excel'); ->name('adminapp.tickets.excel');
}); });

View File

@@ -18,12 +18,12 @@ Route::prefix('v1/adminapp/forms')
Route::get( Route::get(
'desfile/entry-reservation', 'desfile/entry-reservation',
DesfileEntryReservationFormController::class DesfileEntryReservationFormController::class
)->middleware('tenant.menu:onticket.adminapp.desfile.reservas') )->middleware('tenant.menu:adminapp.desfile.reservas')
->name('adminapp.forms.desfile.entry-reservation'); ->name('adminapp.forms.desfile.entry-reservation');
Route::get('sale', SaleFormController::class); Route::get('sale', SaleFormController::class);
Route::get('staff', StaffFormController::class); Route::get('staff', StaffFormController::class);
Route::get('tickets-filter', TicketFilterFormController::class) Route::get('tickets-filter', TicketFilterFormController::class)
->middleware('tenant.menu:onticket.adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.forms.tickets-filter'); ->name('adminapp.forms.tickets-filter');
Route::get( Route::get(
'fiesta-futbol-infantil/ticket', 'fiesta-futbol-infantil/ticket',

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('menues')->where('code', 'help.contact')->update([
'content_type' => 'dynamic',
'static_content_schema' => null,
]);
// Preserve legacy pivot content for rollback; dynamic menus do not expose it.
}
public function down(): void
{
DB::table('menues')->where('code', 'help.contact')->update([
'content_type' => 'static',
'static_content_schema' => json_encode([
'whatsapp' => 'required|array',
'whatsapp.whatsapp_url' => 'required|url',
'whatsapp.whatsapp_label' => 'required|string',
'phone' => 'required|string',
'locations' => 'required|array|min:1',
'locations.*.label' => 'required|string',
'locations.*.addresses' => 'required|array|min:1',
'locations.*.addresses.*' => 'required|array:label,address,coordinates',
'locations.*.addresses.*.label' => 'required|string',
'locations.*.addresses.*.address' => 'required|string',
'locations.*.addresses.*.coordinates' => 'required|array|size:2',
'locations.*.addresses.*.coordinates.0' => 'required|numeric|between:-90,90',
'locations.*.addresses.*.coordinates.1' => 'required|numeric|between:-180,180',
'map_locations' => 'prohibited',
], JSON_THROW_ON_ERROR),
]);
}
};

View File

@@ -0,0 +1,94 @@
<?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('phone_numbers', function (Blueprint $table): void {
$table->string('country_code', 8)->nullable()->default('+54');
$table->string('mobile_prefix', 4)->nullable()->default('9');
$table->string('area_code', 8)->nullable();
$table->string('local_number')->nullable();
});
DB::table('phone_numbers')->orderBy('id')->chunkById(100, function ($numbers): void {
foreach ($numbers as $phone) {
DB::table('phone_numbers')->where('id', $phone->id)
->update($this->components($phone->number));
}
});
Schema::table('phone_numbers', function (Blueprint $table): void {
$table->dropColumn('number');
});
}
public function down(): void
{
Schema::table('phone_numbers', function (Blueprint $table): void {
$table->string('number')->nullable();
});
DB::table('phone_numbers')->orderBy('id')->chunkById(100, function ($numbers): void {
foreach ($numbers as $phone) {
$parts = array_filter([
$phone->country_code,
$phone->mobile_prefix,
$phone->area_code,
$phone->local_number,
], fn ($part): bool => $part !== null && $part !== '');
DB::table('phone_numbers')->where('id', $phone->id)
->update(['number' => implode(' ', $parts)]);
}
});
Schema::table('phone_numbers', function (Blueprint $table): void {
$table->dropColumn(['country_code', 'mobile_prefix', 'area_code', 'local_number']);
});
}
/** @return array{country_code: ?string, mobile_prefix: ?string, area_code: ?string, local_number: string} */
private function components(string $number): array
{
$number = trim($number);
if (preg_match('/^(\+?\d{1,3})\s+(?:(9)\s+)?\(?(\d{2,5})\)?[\s-]+([\d\s-]+)$/', $number, $match)) {
return [
'country_code' => '+'.ltrim($match[1], '+'),
'mobile_prefix' => ($match[2] ?? '') ?: null,
'area_code' => $match[3],
'local_number' => preg_replace('/\D/', '', $match[4]),
];
}
if (preg_match('/^\(?(0\d{2,5})\)?[\s-]+([\d\s-]+)$/', $number, $match)) {
return [
'country_code' => null,
'mobile_prefix' => null,
'area_code' => $match[1],
'local_number' => preg_replace('/\D/', '', $match[2]),
];
}
if (preg_match('/^\+54(9?)(\d+)$/', $number, $match)) {
return [
'country_code' => '+54',
'mobile_prefix' => $match[1] === '9' ? '9' : null,
'area_code' => null,
'local_number' => $match[2],
];
}
return [
'country_code' => null,
'mobile_prefix' => null,
'area_code' => null,
'local_number' => $number,
];
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('categorias', function (Blueprint $table): void {
$table->foreignId('banner_id')
->nullable()
->constrained('attachments')
->nullOnDelete();
$table->boolean('banner_enabled')->default(false);
});
}
public function down(): void
{
Schema::table('categorias', function (Blueprint $table): void {
$table->dropConstrainedForeignId('banner_id');
$table->dropColumn('banner_enabled');
});
}
};

View File

@@ -0,0 +1,28 @@
<?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('users', function (Blueprint $table): void {
$table->foreignId('event_id')
->nullable()
->after('tenant_codigo')
->constrained('events')
->cascadeOnUpdate()
->restrictOnDelete();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropForeign(['event_id']);
$table->dropColumn('event_id');
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('admin_website_types')
->where('codigo', 'shopit')
->update([
'secondary_color' => '#ADADAD',
'danger_color' => '#FF8888',
'success_color' => '#58B341',
'updated_at' => now(),
]);
}
public function down(): void
{
// Presentation settings are operational data; preserve them on rollback.
}
};

View File

@@ -0,0 +1,34 @@
<?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('categorias', function (Blueprint $table): void {
$table->unsignedInteger('sort_order')->default(0);
$table->index(['tenant_code', 'categoria_id', 'sort_order']);
});
// Preserve the alphabetical navigation order until an administrator reorders it.
$orders = [];
foreach (DB::table('categorias')->orderBy('nombre')->orderBy('id')->get() as $category) {
$group = json_encode([$category->tenant_code, $category->categoria_id]);
$order = $orders[$group] ?? 0;
DB::table('categorias')->where('id', $category->id)->update(['sort_order' => $order]);
$orders[$group] = $order + 1;
}
}
public function down(): void
{
Schema::table('categorias', function (Blueprint $table): void {
$table->dropIndex(['tenant_code', 'categoria_id', 'sort_order']);
$table->dropColumn('sort_order');
});
}
};

View File

@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::transaction(function (): void {
$menus = DB::table('menues')->where('code', 'like', 'onticket.%')->get();
// Create every destination before updating foreign keys, including parents.
foreach ($menus as $menu) {
$data = (array) $menu;
unset($data['id']);
$data['code'] = substr($menu->code, strlen('onticket.'));
if (! DB::table('menues')->where('code', $data['code'])->exists()) {
DB::table('menues')->insert($data);
}
}
foreach ($menus as $menu) {
$code = substr($menu->code, strlen('onticket.'));
DB::table('menues')->where('parent_menu_code', $menu->code)
->update(['parent_menu_code' => $code]);
$this->moveAssignments('tenants_menues', 'menu_code', 'tenant_code', $menu->code, $code);
$this->moveAssignments('roles_menues', 'menu_codigo', 'rol_codigo', $menu->code, $code);
}
DB::table('menues')->whereIn('code', $menus->pluck('code'))->delete();
});
}
private function moveAssignments(
string $table,
string $menuColumn,
string $ownerColumn,
string $sourceCode,
string $targetCode,
): void {
foreach (DB::table($table)->where($menuColumn, $sourceCode)->get() as $assignment) {
$data = (array) $assignment;
unset($data['id']);
$data[$menuColumn] = $targetCode;
DB::table($table)->updateOrInsert([
$ownerColumn => $data[$ownerColumn],
$menuColumn => $targetCode,
], $data);
}
DB::table($table)->where($menuColumn, $sourceCode)->delete();
}
public function down(): void
{
// Keep compatible identifiers: merged assignments cannot be split reliably.
}
};

View File

@@ -53,85 +53,85 @@ class MenuSeeder extends Seeder
'route' => '/scanner/detail/:id', 'route' => '/scanner/detail/:id',
], ],
[ [
'code' => 'onticket.adminapp.event', 'code' => 'adminapp.event',
'label' => 'Eventos', 'label' => 'Eventos',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/event', 'route' => '/admin/event',
], ],
[ [
'code' => 'onticket.adminapp.inicio', 'code' => 'adminapp.inicio',
'label' => 'Inicio', 'label' => 'Inicio',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/inicio', 'route' => '/admin/inicio',
], ],
[ [
'code' => 'onticket.adminapp.catalog', 'code' => 'adminapp.catalog',
'label' => 'Catálogo', 'label' => 'Catálogo',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/catalog', 'route' => '/admin/catalog',
], ],
[ [
'code' => 'onticket.adminapp.combos', 'code' => 'adminapp.combos',
'label' => 'Combos', 'label' => 'Combos',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/combos', 'route' => '/admin/combos',
], ],
[ [
'code' => 'onticket.adminapp.categories', 'code' => 'adminapp.categories',
'label' => 'Categorías', 'label' => 'Categorías',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/categories', 'route' => '/admin/categories',
], ],
[ [
'code' => 'onticket.adminapp.ventas', 'code' => 'adminapp.ventas',
'label' => 'Ventas', 'label' => 'Ventas',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/ventas', 'route' => '/admin/ventas',
], ],
[ [
'code' => 'onticket.adminapp.staff', 'code' => 'adminapp.staff',
'label' => 'Usuarios', 'label' => 'Usuarios',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/staff', 'route' => '/admin/staff',
], ],
[ [
'code' => 'onticket.adminapp.tickets', 'code' => 'adminapp.tickets',
'label' => 'Tickets', 'label' => 'Tickets',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/tickets', 'route' => '/admin/tickets',
], ],
[ [
'code' => 'onticket.adminapp.fiesta-futbol-infantil.entradas', 'code' => 'adminapp.fiesta-futbol-infantil.entradas',
'label' => 'Entradas', 'label' => 'Entradas',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/entradas', 'route' => '/admin/entradas',
], ],
[ [
'code' => 'onticket.adminapp.fiesta-futbol-infantil.alojamientos', 'code' => 'adminapp.fiesta-futbol-infantil.alojamientos',
'label' => 'Alojamientos', 'label' => 'Alojamientos',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/alojamientos', 'route' => '/admin/alojamientos',
], ],
[ [
'code' => 'onticket.adminapp.fiesta-futbol-infantil.merchandising', 'code' => 'adminapp.fiesta-futbol-infantil.merchandising',
'label' => 'Merchandising', 'label' => 'Merchandising',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/merchandising', 'route' => '/admin/merchandising',
], ],
[ [
'code' => 'onticket.adminapp.fiesta-futbol-infantil.comida', 'code' => 'adminapp.fiesta-futbol-infantil.comida',
'label' => 'Comida', 'label' => 'Comida',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/comidas', 'route' => '/admin/comidas',
], ],
[ [
'code' => 'onticket.adminapp.desfile.entradas', 'code' => 'adminapp.desfile.entradas',
'label' => 'Entradas', 'label' => 'Entradas',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/desfile/entradas', 'route' => '/admin/desfile/entradas',
], ],
[ [
'code' => 'onticket.adminapp.desfile.reservas', 'code' => 'adminapp.desfile.reservas',
'label' => 'Reserva de Tickets', 'label' => 'Reserva de Tickets',
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/desfile/reservas', 'route' => '/admin/desfile/reservas',
@@ -224,23 +224,8 @@ class MenuSeeder extends Seeder
'code' => 'help.contact', 'code' => 'help.contact',
'label' => 'Contacto', 'label' => 'Contacto',
'parent_menu_code' => 'help', 'parent_menu_code' => 'help',
'content_type' => Menu::CONTENT_TYPE_STATIC, 'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'static_content_schema' => [ 'static_content_schema' => null,
'whatsapp' => 'required|array',
'whatsapp.whatsapp_url' => 'required|url',
'whatsapp.whatsapp_label' => 'required|string',
'phone' => 'required|string',
'locations' => 'required|array|min:1',
'locations.*.label' => 'required|string',
'locations.*.addresses' => 'required|array|min:1',
'locations.*.addresses.*' => 'required|array:label,address,coordinates',
'locations.*.addresses.*.label' => 'required|string',
'locations.*.addresses.*.address' => 'required|string',
'locations.*.addresses.*.coordinates' => 'required|array|size:2',
'locations.*.addresses.*.coordinates.0' => 'required|numeric|between:-90,90',
'locations.*.addresses.*.coordinates.1' => 'required|numeric|between:-180,180',
'map_locations' => 'prohibited',
],
'route' => '/ayuda/contacto', 'route' => '/ayuda/contacto',
], ],
[ [
@@ -301,7 +286,7 @@ class MenuSeeder extends Seeder
->delete(); ->delete();
Menu::query() Menu::query()
->where('code', 'like', 'adminapp.%') ->where('code', 'like', 'onticket.adminapp.%')
->delete(); ->delete();
$allRoleMenuCodes = Menu::query()->pluck('code'); $allRoleMenuCodes = Menu::query()->pluck('code');
@@ -348,30 +333,30 @@ class MenuSeeder extends Seeder
'mutual_smep', 'mutual_smep',
]; ];
$ticketAdminMenuCodes = [ $ticketAdminMenuCodes = [
'onticket.adminapp.tickets', 'adminapp.tickets',
]; ];
$fiestaCategoryMenuCodes = [ $fiestaCategoryMenuCodes = [
'onticket.adminapp.fiesta-futbol-infantil.entradas', 'adminapp.fiesta-futbol-infantil.entradas',
'onticket.adminapp.fiesta-futbol-infantil.alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos',
'onticket.adminapp.fiesta-futbol-infantil.merchandising', 'adminapp.fiesta-futbol-infantil.merchandising',
'onticket.adminapp.fiesta-futbol-infantil.comida', 'adminapp.fiesta-futbol-infantil.comida',
]; ];
$fiestaExcludedAdminMenuCodes = [ $fiestaExcludedAdminMenuCodes = [
'onticket.adminapp.inicio', 'adminapp.inicio',
'onticket.adminapp.catalog', 'adminapp.catalog',
'onticket.adminapp.categories', 'adminapp.categories',
'onticket.adminapp.combos', 'adminapp.combos',
]; ];
$desfileMenuCodes = [ $desfileMenuCodes = [
'onticket.adminapp.desfile.entradas', 'adminapp.desfile.entradas',
'onticket.adminapp.desfile.reservas', 'adminapp.desfile.reservas',
]; ];
$shopItAdminMenuCodes = Menu::query() $shopItAdminMenuCodes = Menu::query()
->where('code', 'like', 'shopit.adminapp.%') ->where('code', 'like', 'shopit.adminapp.%')
->pluck('code') ->pluck('code')
->all(); ->all();
$onTicketAdminMenuCodes = Menu::query() $onTicketAdminMenuCodes = Menu::query()
->where('code', 'like', 'onticket.adminapp.%') ->where('code', 'like', 'adminapp.%')
->pluck('code') ->pluck('code')
->all(); ->all();
$onTicketMenuCodes = [ $onTicketMenuCodes = [
@@ -399,30 +384,6 @@ class MenuSeeder extends Seeder
'is_active' => false, 'is_active' => false,
], ],
]; ];
$contactContent = [
'whatsapp' => [
'whatsapp_url' => 'https://wa.me/543412602222',
'whatsapp_label' => 'Chatea con nosotros',
],
'phone' => '+54 9 (0341) 6658247',
'locations' => [
'rosario' => [
'label' => 'Rosario',
'addresses' => [
[
'label' => 'Gigante de Arroyito',
'address' => 'Av. Génova 640, Rosario',
'coordinates' => [-32.913997, -60.674567],
],
[
'label' => 'Telepagos',
'address' => 'Rioja 1150, piso 12, dpto. 3, Rosario',
'coordinates' => [-32.946820, -60.639320],
],
],
],
],
];
$mutualSmepFrequentlyAskedQuestions = [ $mutualSmepFrequentlyAskedQuestions = [
[ [
'pregunta' => '¿Hay algún límite de compra?', 'pregunta' => '¿Hay algún límite de compra?',
@@ -440,25 +401,6 @@ class MenuSeeder extends Seeder
'is_active' => false, 'is_active' => false,
], ],
]; ];
$mutualSmepContactContent = [
'whatsapp' => [
'whatsapp_url' => 'https://wa.me/5493412474530',
'whatsapp_label' => 'Chateá con Mutual SMEP',
],
'phone' => '+54 9 341 247-4530',
'locations' => [
'rosario' => [
'label' => 'Rosario',
'addresses' => [
[
'label' => 'Mutual SMEP',
'address' => 'San Lorenzo 1543, Rosario, Santa Fe',
'coordinates' => [-32.9431184, -60.6437991],
],
],
],
],
];
foreach ($tenants as $tenant) { foreach ($tenants as $tenant) {
$menuCodes = $allMenus; $menuCodes = $allMenus;
@@ -511,11 +453,6 @@ class MenuSeeder extends Seeder
? $mutualSmepFrequentlyAskedQuestions ? $mutualSmepFrequentlyAskedQuestions
: $frequentlyAskedQuestions, : $frequentlyAskedQuestions,
]); ]);
$tenant->menues()->updateExistingPivot('help.contact', [
'static_content' => $tenant->codigo === 'mutual_smep'
? $mutualSmepContactContent
: $contactContent,
]);
} }
} }
} }

View File

@@ -49,6 +49,9 @@ class WebsiteTypeSeeder extends Seeder
'site_title' => 'ShopIt', 'site_title' => 'ShopIt',
...self::PRESENTATION, ...self::PRESENTATION,
'primary_color' => '#2FD3AC', 'primary_color' => '#2FD3AC',
'secondary_color' => '#ADADAD',
'danger_color' => '#FF8888',
'success_color' => '#58B341',
'site_logo' => $this->shopItLogo(), 'site_logo' => $this->shopItLogo(),
'footer_logo' => $this->shopItFooterLogo(), 'footer_logo' => $this->shopItFooterLogo(),
'favicon' => $this->shopItFavicon(), 'favicon' => $this->shopItFavicon(),

View File

@@ -2,10 +2,13 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Domains\Core\Address\Models\Address;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Core\Authorization\Models\Role; use App\Domains\Core\Authorization\Models\Role;
use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Menu\Models\Menu;
use App\Domains\Core\PhoneNumber\Models\PhoneNumber;
use App\Domains\Core\Tenant\Models\SocialMedia;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
@@ -46,6 +49,21 @@ class AdminAppMeControllerTest extends TestCase
$tenant->menues()->sync([$catalog->code, $staff->code, $storefront->code]); $tenant->menues()->sync([$catalog->code, $staff->code, $storefront->code]);
$otherTenant->menues()->sync([$staff->code]); $otherTenant->menues()->sync([$staff->code]);
$instagram = SocialMedia::query()->create([
'code' => 'instagram', 'name' => 'Instagram', 'icon' => 'fa-brands fa-instagram',
]);
$tenant->socialMedia()->attach($instagram->code, ['url' => 'https://instagram.com/acme', 'orden' => 0]);
$address = Address::query()->create(['label' => 'Principal', 'address_text' => 'San Martín 123']);
$tenant->addresses()->attach($address->id, ['is_main' => true]);
$phone = PhoneNumber::query()->create([
'label' => 'Principal',
'country_code' => '+54',
'mobile_prefix' => '9',
'area_code' => '0341',
'local_number' => '6658247',
]);
$tenant->phoneNumbers()->attach($phone->id, ['is_main' => true]);
$user = User::factory()->create([ $user = User::factory()->create([
'nombre_apellido' => 'Admin Acme', 'nombre_apellido' => 'Admin Acme',
'email' => 'admin@acme.test', 'email' => 'admin@acme.test',
@@ -59,6 +77,11 @@ class AdminAppMeControllerTest extends TestCase
->assertJsonPath('data.user.id', $user->id) ->assertJsonPath('data.user.id', $user->id)
->assertJsonPath('data.user.email', 'admin@acme.test') ->assertJsonPath('data.user.email', 'admin@acme.test')
->assertJsonPath('data.tenant.codigo', 'acme') ->assertJsonPath('data.tenant.codigo', 'acme')
->assertJsonPath('data.tenant.social_media.0.url', 'https://instagram.com/acme')
->assertJsonPath('data.tenant.addresses.0.address_text', 'San Martín 123')
->assertJsonPath('data.tenant.phone_numbers.0.number', '+54 9 (0341) 6658247')
->assertJsonPath('data.tenant.phone_numbers.0.area_code', '0341')
->assertJsonPath('data.tenant.phone_numbers.0.local_number', '6658247')
->assertJsonCount(1, 'data.tenant.menues') ->assertJsonCount(1, 'data.tenant.menues')
->assertJsonPath('data.tenant.menues.0.code', 'admin.catalog') ->assertJsonPath('data.tenant.menues.0.code', 'admin.catalog')
->assertJsonPath('data.tenant.menues.0.label', 'Catálogo') ->assertJsonPath('data.tenant.menues.0.label', 'Catálogo')

View File

@@ -43,7 +43,7 @@ class ScannerMeControllerTest extends TestCase
'route' => '/scanner/scan', 'route' => '/scanner/scan',
]); ]);
$foreign = Menu::query()->create([ $foreign = Menu::query()->create([
'code' => 'onticket.adminapp.inicio', 'code' => 'adminapp.inicio',
'label' => 'Administración', 'label' => 'Administración',
'route' => '/admin/inicio', 'route' => '/admin/inicio',
]); ]);

View File

@@ -17,6 +17,7 @@ class UserAuthorizationRelationsTest extends TestCase
$this->assertTrue(Schema::hasColumns('users', [ $this->assertTrue(Schema::hasColumns('users', [
'rol_codigo', 'rol_codigo',
'tenant_codigo', 'tenant_codigo',
'event_id',
])); ]));
} }
@@ -28,5 +29,7 @@ class UserAuthorizationRelationsTest extends TestCase
$this->assertNull($user->tenant_codigo); $this->assertNull($user->tenant_codigo);
$this->assertSame(RoleCode::User->value, $user->role->codigo); $this->assertSame(RoleCode::User->value, $user->role->codigo);
$this->assertNull($user->tenant); $this->assertNull($user->tenant);
$this->assertNull($user->event_id);
$this->assertNull($user->event);
} }
} }

View File

@@ -0,0 +1,271 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Core\Bootstrap\Services\TenantBootstrapService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Resources\TenantResource;
use App\Domains\Core\Tenant\Services\TenantInformationService;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use App\Shared\Storage\Services\TemporaryUrlService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class CategoryConfigurationTest extends TestCase
{
private const URL = '/api/v1/adminapp/tenant/categories';
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo')->unique();
$table->string('dominio')->nullable();
$table->string('base_path')->default('/');
$table->string('storefront_website_type_code')->default('shopit');
});
Schema::create('attachments', function (Blueprint $table): void {
$table->id();
$table->string('key')->unique();
$table->string('path');
$table->string('filename');
$table->string('type');
$table->string('mime_type')->nullable();
$table->string('extension')->nullable();
$table->integer('size')->default(0);
$table->timestamps();
});
(require database_path('migrations/2026_06_24_000000_create_categorias_table.php'))->up();
(require database_path('migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php'))->up();
(require database_path('migrations/2026_10_01_000000_add_banner_to_categorias_table.php'))->up();
Schema::create('catalog_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('category_id')->nullable()->constrained('categorias')->restrictOnDelete();
$table->softDeletes();
});
Schema::create('featured_groups', function (Blueprint $table): void {
$table->id();
$table->foreignId('category_id')->nullable()->constrained('categorias')->restrictOnDelete();
});
DB::table('tenants')->insert([['codigo' => 'shop'], ['codigo' => 'other']]);
DB::table('categorias')->insert([
['tenant_code' => 'shop', 'nombre' => 'Zeta'],
['tenant_code' => 'shop', 'nombre' => 'Alfa'],
]);
(require database_path('migrations/2026_10_01_000200_add_sort_order_to_categorias_table.php'))->up();
$this->tenant = Tenant::query()->where('codigo', 'shop')->firstOrFail();
$user = new User;
$user->forceFill(['id' => 1, 'tenant_codigo' => 'shop', 'rol_codigo' => RoleCode::AdminApp->value]);
Sanctum::actingAs($user);
$this->mock(TemporaryUrlService::class, function ($mock): void {
$mock->shouldReceive('generate')->andReturnUsing(fn ($path) => [
'temporary_url' => 'https://example.com/'.$path,
'temporary_url_expires_at' => now()->addDay()->toIso8601String(),
]);
});
}
public function test_get_returns_sorted_tree_including_hidden_categories_and_disabled_banners(): void
{
$parent = Category::where('nombre', 'Alfa')->firstOrFail();
$banner = $this->banner();
$parent->update(['is_enabled' => false, 'banner_id' => $banner->id]);
$child = Category::create(['tenant_code' => 'shop', 'categoria_id' => $parent->id, 'nombre' => 'Hija']);
Category::create(['tenant_code' => 'other', 'nombre' => 'Ajena']);
$response = $this->getJson(self::URL)->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.nombre', 'Alfa')
->assertJsonPath('data.0.is_enabled', false)
->assertJsonPath('data.0.banner_enabled', false)
->assertJsonPath('data.0.banner_key', $banner->key)
->assertJsonPath('data.0.banner_url', 'https://example.com/banner.jpg')
->assertJsonPath('data.0.subcategories.0.id', $child->id);
$this->assertArrayNotHasKey('sort_order', $response->json('data.0'));
}
public function test_put_creates_edits_reorders_and_removes_only_explicitly_deleted_nodes(): void
{
$alfa = Category::where('nombre', 'Alfa')->firstOrFail();
$zeta = Category::where('nombre', 'Zeta')->firstOrFail();
$child = Category::create(['tenant_code' => 'shop', 'categoria_id' => $alfa->id, 'nombre' => 'Eliminar']);
$this->putJson(self::URL, [
'categories' => [
$this->node($zeta, ['nombre' => 'Primera', 'is_enabled' => false]),
$this->node($alfa, ['subcategories' => [$this->node(null, ['nombre' => 'Nueva hija'])]]),
$this->node(null, ['nombre' => 'Nueva raíz']),
],
'deleted_ids' => [$child->id],
])->assertOk()->assertJsonPath('data.0.nombre', 'Primera')
->assertJsonPath('data.1.subcategories.0.nombre', 'Nueva hija')
->assertJsonPath('data.2.nombre', 'Nueva raíz');
$this->assertSame(0, $zeta->fresh()->sort_order);
$this->assertSame(1, $alfa->fresh()->sort_order);
$this->assertDatabaseMissing('categorias', ['id' => $child->id]);
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => []])->assertOk();
$this->assertDatabaseCount('categorias', 4);
}
public function test_it_preserves_existing_banners_and_supports_replacement_and_removal(): void
{
$category = Category::where('nombre', 'Alfa')->firstOrFail();
$banner = $this->banner();
$category->update(['banner_id' => $banner->id]);
$this->putJson(self::URL, ['categories' => [$this->node($category, ['banner' => $banner->key, 'banner_enabled' => true])], 'deleted_ids' => []])
->assertOk()->assertJsonPath('data.0.banner_key', $banner->key);
$replacement = $this->banner('replacement.jpg');
$this->mock(AttachmentService::class, function ($mock) use ($replacement): void {
$mock->shouldReceive('store')->once()->withArgs(fn ($image, $path) => str_starts_with($image, 'data:image/png;base64,') && $path === 'tenants/shop/categories')->andReturn($replacement);
});
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2ioAAAAASUVORK5CYII=';
$this->putJson(self::URL, ['categories' => [$this->node($category, ['banner' => $image])], 'deleted_ids' => []])
->assertOk()->assertJsonPath('data.0.banner_key', $replacement->key);
$this->putJson(self::URL, ['categories' => [$this->node($category)], 'deleted_ids' => []])
->assertOk()->assertJsonPath('data.0.banner_key', null);
$this->assertDatabaseHas('attachments', ['id' => $banner->id]);
}
public function test_it_rejects_foreign_ids_duplicate_ids_and_foreign_banners_atomically(): void
{
$local = Category::where('nombre', 'Alfa')->firstOrFail();
$foreign = Category::create(['tenant_code' => 'other', 'nombre' => 'Ajena']);
foreach ([
[$this->node($local, ['nombre' => 'Cambio']), $this->node($foreign)],
[$this->node($local), $this->node($local)],
[$this->node($local, ['banner' => $this->banner()->key])],
] as $nodes) {
$this->putJson(self::URL, ['categories' => $nodes, 'deleted_ids' => []])->assertUnprocessable();
$this->assertSame('Alfa', $local->fresh()->nombre);
}
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$foreign->id]])->assertUnprocessable();
}
public function test_it_rejects_deleting_categories_with_products_or_featured_groups(): void
{
$category = Category::where('nombre', 'Alfa')->firstOrFail();
foreach (['catalog_items', 'featured_groups'] as $table) {
DB::table($table)->insert(['category_id' => $category->id]);
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$category->id]])
->assertUnprocessable()->assertJsonValidationErrors('deleted_ids');
$this->assertDatabaseHas('categorias', ['id' => $category->id]);
DB::table($table)->delete();
}
DB::table('catalog_items')->insert(['category_id' => $category->id, 'deleted_at' => now()]);
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$category->id]])
->assertUnprocessable()->assertJsonValidationErrors('deleted_ids');
}
public function test_it_stores_new_banner_uploads_and_cleans_up_files_when_a_save_rolls_back(): void
{
Storage::fake('s3');
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2ioAAAAASUVORK5CYII=';
$parent = Category::where('nombre', 'Alfa')->firstOrFail();
$this->putJson(self::URL, ['categories' => [$this->node($parent, ['banner' => $image])], 'deleted_ids' => []])->assertOk();
$banner = $parent->fresh()->banner;
Storage::disk('s3')->assertExists($banner->path);
Category::create(['tenant_code' => 'shop', 'categoria_id' => $parent->id, 'nombre' => 'Hija']);
$this->putJson(self::URL, ['categories' => [$this->node(null, ['banner' => $image])], 'deleted_ids' => [$parent->id]])->assertUnprocessable();
$this->assertDatabaseCount('attachments', 1);
$this->assertCount(1, Storage::disk('s3')->allFiles());
$this->assertDatabaseHas('categorias', ['id' => $parent->id]);
}
public function test_it_rejects_orphaning_children_and_cycles_but_can_delete_a_whole_tree(): void
{
$parent = Category::where('nombre', 'Alfa')->firstOrFail();
$child = Category::create(['tenant_code' => 'shop', 'categoria_id' => $parent->id, 'nombre' => 'Hija']);
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$parent->id]])->assertUnprocessable();
$this->putJson(self::URL, ['categories' => [$this->node($child, ['subcategories' => [$this->node($parent)]])], 'deleted_ids' => []])->assertOk();
$this->assertSame($child->id, $parent->fresh()->categoria_id);
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$parent->id, $child->id]])->assertOk();
$this->assertDatabaseMissing('categorias', ['id' => $parent->id]);
}
public function test_it_validates_nested_names_images_and_required_fields(): void
{
foreach ([['nombre' => ' '], ['banner' => 'not-an-image'], ['is_enabled' => 'invalid']] as $patch) {
$this->putJson(self::URL, [
'categories' => [$this->node(null, ['subcategories' => [$this->node(null, $patch)]])],
'deleted_ids' => [],
])->assertUnprocessable();
}
$this->putJson(self::URL, ['categories' => []])->assertUnprocessable()->assertJsonValidationErrors('deleted_ids');
$this->assertDatabaseCount('categorias', 2);
}
public function test_non_adminapp_users_cannot_change_categories(): void
{
$user = new User;
$user->forceFill(['id' => 2, 'tenant_codigo' => 'shop', 'rol_codigo' => RoleCode::User->value]);
Sanctum::actingAs($user);
$this->getJson(self::URL)->assertForbidden();
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => []])->assertForbidden();
}
public function test_sort_order_migration_can_be_rolled_back(): void
{
(require database_path('migrations/2026_10_01_000200_add_sort_order_to_categorias_table.php'))->down();
$this->assertFalse(Schema::hasColumn('categorias', 'sort_order'));
$this->assertDatabaseCount('categorias', 2);
}
public function test_public_bootstrap_uses_saved_order_without_exposing_it_and_hides_disabled_branches(): void
{
DB::table('tenants')->where('codigo', 'shop')->update(['dominio' => 'shop.test']);
$alfa = Category::where('nombre', 'Alfa')->firstOrFail();
$zeta = Category::where('nombre', 'Zeta')->firstOrFail();
$alfa->update(['is_enabled' => false]);
$first = Category::create(['tenant_code' => 'shop', 'nombre' => 'Z hija', 'categoria_id' => $zeta->id, 'sort_order' => 0]);
Category::create(['tenant_code' => 'shop', 'nombre' => 'A hija', 'categoria_id' => $zeta->id, 'sort_order' => 1]);
Category::create(['tenant_code' => 'shop', 'nombre' => 'Oculta', 'categoria_id' => $zeta->id, 'is_enabled' => false]);
Category::create(['tenant_code' => 'shop', 'nombre' => 'Hija de padre oculto', 'categoria_id' => $alfa->id]);
$this->mock(TenantInformationService::class, function ($mock): void {
$mock->shouldReceive('load')->once()->andReturnUsing(function ($tenant, $relations) {
return $tenant->load(['categories' => $relations['categories']]);
});
});
$loaded = app(TenantBootstrapService::class)->get('shop.test');
$this->assertSame($alfa->id, $loaded->categories->first()->id);
$resourceTenant = new Tenant(['codigo' => 'shop', 'storefront_website_type_code' => 'shopit']);
foreach (['favicon', 'headerLogo', 'footerLogo', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) {
$resourceTenant->setRelation($relation, null);
}
$resourceTenant->setRelation('categories', $loaded->categories);
$data = TenantResource::make($resourceTenant)->resolve(request());
$this->assertCount(1, $data['categories']);
$this->assertSame('Zeta', $data['categories'][0]['nombre']);
$this->assertSame(['Z hija', 'A hija'], $data['categories'][0]['subcategories']->pluck('nombre')->all());
$this->assertSame($first->id, $data['categories'][0]['subcategories'][0]['id']);
$this->assertArrayNotHasKey('sort_order', $data['categories'][0]);
}
private function node(?Category $category, array $patch = []): array
{
return array_replace([
'id' => $category?->id,
'nombre' => $category?->nombre ?? 'Nueva',
'is_enabled' => true,
'banner_enabled' => false,
'banner' => null,
'subcategories' => [],
], $patch);
}
private function banner(string $path = 'banner.jpg'): Attachment
{
return Attachment::create(['path' => $path, 'filename' => $path, 'type' => 'image']);
}
}

View File

@@ -8,6 +8,8 @@ use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Storage\Services\TemporaryUrlService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -72,6 +74,39 @@ class CategoryDetailTest extends TestCase
->assertJsonCount(0, 'data'); ->assertJsonCount(0, 'data');
} }
public function test_it_returns_the_banner_url_only_when_a_banner_exists_and_is_enabled(): void
{
$tenant = $this->createTenant('category-banner');
$category = $this->createCategory($tenant, 'Remeras');
$banner = Attachment::query()->create([
'path' => 'categories/banner.jpg',
'filename' => 'banner.jpg',
'type' => 'image',
'mime_type' => 'image/jpeg',
'extension' => 'jpg',
'size' => 100,
]);
$this->mock(TemporaryUrlService::class, function ($mock): void {
$mock->shouldReceive('generate')->once()
->with('categories/banner.jpg', 1440)
->andReturn([
'temporary_url' => 'https://example.com/banner.jpg',
'temporary_url_expires_at' => now()->addDay()->toIso8601String(),
]);
});
$url = "/api/tenants/{$tenant->codigo}/categories/{$category->id}";
$category->update(['banner_enabled' => true]);
$this->getJson($url)->assertOk()->assertJsonPath('category.banner_url', null);
$category->update(['banner_id' => $banner->id, 'banner_enabled' => false]);
$this->getJson($url)->assertOk()->assertJsonPath('category.banner_url', null);
$category->update(['banner_enabled' => true]);
$this->getJson($url)->assertOk()
->assertJsonPath('category.banner_url', 'https://example.com/banner.jpg');
}
public function test_it_includes_products_from_all_descendant_categories(): void public function test_it_includes_products_from_all_descendant_categories(): void
{ {
$tenant = $this->createTenant('category-descendants'); $tenant = $this->createTenant('category-descendants');

View File

@@ -186,7 +186,7 @@ class EntryControllerTest extends TestCase
'admin_website_type_code' => 'onticket', 'admin_website_type_code' => 'onticket',
]); ]);
$menu = Menu::query()->firstOrCreate( $menu = Menu::query()->firstOrCreate(
['code' => 'onticket.adminapp.desfile.entradas'], ['code' => 'adminapp.desfile.entradas'],
['label' => 'Entradas', 'route' => '/admin/desfile/entradas'], ['label' => 'Entradas', 'route' => '/admin/desfile/entradas'],
); );
$tenant->menues()->attach($menu->code); $tenant->menues()->attach($menu->code);

View File

@@ -236,7 +236,7 @@ class AccommodationControllerTest extends TestCase
if ($withMenu) { if ($withMenu) {
$menu = Menu::query()->firstOrCreate( $menu = Menu::query()->firstOrCreate(
['code' => 'onticket.adminapp.fiesta-futbol-infantil.alojamientos'], ['code' => 'adminapp.fiesta-futbol-infantil.alojamientos'],
['label' => 'Alojamientos', 'route' => '/admin/alojamientos'], ['label' => 'Alojamientos', 'route' => '/admin/alojamientos'],
); );
$tenant->menues()->syncWithoutDetaching([$menu->code]); $tenant->menues()->syncWithoutDetaching([$menu->code]);

View File

@@ -43,10 +43,10 @@ class EntryControllerTest extends TestCase
{ {
$tenant = $this->createFiestaTenant(); $tenant = $this->createFiestaTenant();
$categories = [ $categories = [
'entries' => ['Entradas', 'onticket.adminapp.fiesta-futbol-infantil.entradas'], 'entries' => ['Entradas', 'adminapp.fiesta-futbol-infantil.entradas'],
'foods' => ['Comidas', 'onticket.adminapp.fiesta-futbol-infantil.comida'], 'foods' => ['Comidas', 'adminapp.fiesta-futbol-infantil.comida'],
'accommodations' => ['Alojamientos', 'onticket.adminapp.fiesta-futbol-infantil.alojamientos'], 'accommodations' => ['Alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos'],
'merchandise' => ['Merchandising', 'onticket.adminapp.fiesta-futbol-infantil.merchandising'], 'merchandise' => ['Merchandising', 'adminapp.fiesta-futbol-infantil.merchandising'],
]; ];
foreach ($categories as [$label, $menuCode]) { foreach ($categories as [$label, $menuCode]) {
@@ -405,7 +405,7 @@ class EntryControllerTest extends TestCase
{ {
$tenant = $this->createTenant('fiesta_futbol_infantil'); $tenant = $this->createTenant('fiesta_futbol_infantil');
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.fiesta-futbol-infantil.entradas', 'code' => 'adminapp.fiesta-futbol-infantil.entradas',
'label' => 'Entradas', 'label' => 'Entradas',
'route' => '/admin/entradas', 'route' => '/admin/entradas',
]); ]);

View File

@@ -367,7 +367,7 @@ class FoodControllerTest extends TestCase
'time_end' => '23:59', 'time_end' => '23:59',
]); ]);
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.fiesta-futbol-infantil.comida', 'code' => 'adminapp.fiesta-futbol-infantil.comida',
'label' => 'Comida', 'label' => 'Comida',
'route' => '/admin/comidas', 'route' => '/admin/comidas',
]); ]);

View File

@@ -293,7 +293,7 @@ class MerchandiseControllerTest extends TestCase
['value' => 'XL', 'label' => 'XL', 'sort_order' => 4], ['value' => 'XL', 'label' => 'XL', 'sort_order' => 4],
]); ]);
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.fiesta-futbol-infantil.merchandising', 'code' => 'adminapp.fiesta-futbol-infantil.merchandising',
'label' => 'Merchandising', 'label' => 'Merchandising',
'route' => '/admin/merchandising', 'route' => '/admin/merchandising',
]); ]);

View File

@@ -135,7 +135,7 @@ class AdminAppDesfileEntryReservationFormControllerTest extends TestCase
private function grantReservationsMenu(Tenant $tenant): void private function grantReservationsMenu(Tenant $tenant): void
{ {
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.desfile.reservas', 'code' => 'adminapp.desfile.reservas',
'label' => 'Reserva de Tickets', 'label' => 'Reserva de Tickets',
'route' => '/admin/desfile/reservas', 'route' => '/admin/desfile/reservas',
]); ]);

View File

@@ -349,7 +349,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase
private function grantTicketsMenu(Tenant $tenant): void private function grantTicketsMenu(Tenant $tenant): void
{ {
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.tickets', 'code' => 'adminapp.tickets',
'label' => 'Tickets', 'label' => 'Tickets',
'route' => '/admin/tickets', 'route' => '/admin/tickets',
]); ]);

View File

@@ -0,0 +1,77 @@
<?php
namespace Tests\Feature\Menu;
use App\Domains\Core\Auth\Models\User;
use App\Http\Middleware\EnsureTenantHasMenu;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
class TicketMenuAccessTest extends TestCase
{
public static function menuAssignments(): array
{
return [
'compatible code' => ['adminapp.tickets', 'current', true],
'prefixed code' => ['onticket.adminapp.tickets', 'current', false],
'another tenant' => ['adminapp.tickets', 'other', false],
'unrelated menu' => ['adminapp.ventas', 'current', false],
];
}
#[DataProvider('menuAssignments')]
public function test_ticket_menu_requires_an_association_with_the_authenticated_tenant(string $menuCode, string $assignedTenant, bool $allowed): void
{
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo');
});
Schema::create('menues', function (Blueprint $table): void {
$table->id();
$table->string('code');
});
Schema::create('tenants_menues', function (Blueprint $table): void {
$table->string('tenant_code');
$table->string('menu_code');
$table->boolean('is_enabled')->default(true);
});
DB::table('tenants')->insert(['codigo' => 'current']);
DB::table('menues')->insert(['code' => $menuCode]);
DB::table('tenants_menues')->insert(['tenant_code' => $assignedTenant, 'menu_code' => $menuCode]);
$user = new User(['tenant_codigo' => 'current']);
$request = Request::create('/api/v1/adminapp/tenant/tickets');
$request->setUserResolver(fn () => $user);
if (! $allowed) {
$this->expectException(HttpException::class);
$this->expectExceptionCode(0);
}
try {
$response = (new EnsureTenantHasMenu)->handle($request, fn () => response('allowed'), 'adminapp.tickets');
$this->assertSame('allowed', $response->getContent());
} catch (HttpException $exception) {
$this->assertSame(404, $exception->getStatusCode());
throw $exception;
}
}
public function test_all_ticket_routes_and_filter_form_use_the_updated_menu_codes(): void
{
foreach ([
'adminapp.tickets.index', 'adminapp.tickets.cancel',
'adminapp.tickets.calculate-refund', 'adminapp.tickets.refund',
'adminapp.tickets.pdf', 'adminapp.tickets.excel', 'adminapp.forms.tickets-filter',
] as $name) {
$route = Route::getRoutes()->getByName($name);
$this->assertNotNull($route);
$this->assertContains('tenant.menu:adminapp.tickets', $route->gatherMiddleware());
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Tests\Feature\Migrations;
use App\Domains\Commerce\Catalog\Models\Category;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class AddBannerToCategoriesTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Schema::create('attachments', function (Blueprint $table): void {
$table->id();
});
Schema::create('categorias', function (Blueprint $table): void {
$table->id();
$table->string('nombre');
$table->boolean('is_enabled')->default(true);
$table->timestamps();
});
DB::table('categorias')->insert(['nombre' => 'Existente']);
(require database_path('migrations/2026_10_01_000000_add_banner_to_categorias_table.php'))->up();
}
public function test_existing_categories_default_to_no_banner_and_disabled(): void
{
$category = Category::query()->firstOrFail();
$this->assertNull($category->banner_id);
$this->assertFalse($category->banner_enabled);
$this->assertNull($category->banner);
}
public function test_banner_can_be_assigned_and_deleting_it_preserves_the_category(): void
{
$bannerId = DB::table('attachments')->insertGetId([]);
$category = Category::query()->create([
'nombre' => 'Con banner',
'banner_id' => $bannerId,
'banner_enabled' => true,
])->fresh();
$this->assertSame($bannerId, $category->banner_id);
$this->assertTrue($category->banner_enabled);
$this->assertSame($bannerId, $category->banner->id);
DB::table('attachments')->where('id', $bannerId)->delete();
$this->assertNull($category->fresh()->banner_id);
$this->assertDatabaseHas('categorias', ['id' => $category->id]);
}
public function test_banner_must_reference_an_existing_attachment(): void
{
$this->expectException(QueryException::class);
Category::query()->create(['nombre' => 'Inválida', 'banner_id' => 999]);
}
public function test_migration_can_be_rolled_back_without_removing_categories(): void
{
(require database_path('migrations/2026_10_01_000000_add_banner_to_categorias_table.php'))->down();
$this->assertFalse(Schema::hasColumn('categorias', 'banner_id'));
$this->assertFalse(Schema::hasColumn('categorias', 'banner_enabled'));
$this->assertDatabaseHas('categorias', ['nombre' => 'Existente']);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Tests\Feature\Migrations;
use App\Domains\Core\Administrator\Resources\AdministratorResource;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Resources\UserResource;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class AddEventIdToUsersTest extends TestCase
{
public function test_event_assignment_preserves_existing_users_and_restricts_event_deletion(): void
{
Schema::create('events', function (Blueprint $table): void {
$table->id();
});
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('tenant_codigo')->nullable();
$table->softDeletes();
$table->timestamps();
});
DB::table('users')->insert(['id' => 1, 'tenant_codigo' => 'legacy']);
DB::table('events')->insert(['id' => 42]);
$migration = require database_path('migrations/2026_10_01_000000_add_event_id_to_users_table.php');
$migration->up();
$legacy = User::query()->findOrFail(1);
$this->assertNull($legacy->event_id);
$this->assertNull($legacy->event);
$this->assertSame('legacy', $legacy->tenant_codigo);
$legacy->update(['event_id' => '42']);
$user = $legacy->fresh();
$this->assertSame(42, $user->event_id);
$this->assertSame(42, $user->event->id);
$this->assertSame(42, UserResource::make($user)->resolve(new Request)['event_id']);
$this->assertSame(42, AdministratorResource::make($user)->resolve(new Request)['event_id']);
try {
DB::table('events')->where('id', 42)->delete();
$this->fail('An assigned event must not be deleted.');
} catch (QueryException $exception) {
$this->assertStringContainsString('FOREIGN KEY', $exception->getMessage());
}
$migration->down();
$this->assertFalse(Schema::hasColumn('users', 'event_id'));
$this->assertSame('legacy', DB::table('users')->where('id', 1)->value('tenant_codigo'));
}
}

View File

@@ -2,6 +2,7 @@
namespace Tests\Feature\Migrations; namespace Tests\Feature\Migrations;
use App\Domains\Core\PhoneNumber\Models\PhoneNumber;
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource; use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
@@ -96,6 +97,49 @@ class ContactPhoneNumbersTest extends TestCase
]); ]);
} }
public function test_it_splits_mobile_and_landline_numbers_and_builds_dialable_urls(): void
{
DB::table('phone_numbers')->insert([
['label' => 'Móvil', 'number' => '+54 9 (0341) 6658247'],
['label' => 'Fijo', 'number' => '+54 341 111-1111'],
['label' => 'Local', 'number' => '0341 1234567'],
['label' => 'Móvil sin signo', 'number' => '54 9 (0341) 6658247'],
]);
$migration = require database_path('migrations/2026_09_30_000700_split_phone_number_components.php');
$migration->up();
[$mobile, $landline, $local, $mobileWithoutPlus] = PhoneNumber::query()->orderBy('id')->get()->all();
$this->assertSame('+54', $mobile->country_code);
$this->assertSame('9', $mobile->mobile_prefix);
$this->assertSame('0341', $mobile->area_code);
$this->assertSame('6658247', $mobile->local_number);
$this->assertSame('+54 9 (0341) 6658247', $mobile->number);
$this->assertSame('tel:+5493416658247', $mobile->telUrl());
$this->assertSame('+54 341 1111111', $landline->number);
$this->assertSame('tel:+543411111111', $landline->telUrl());
$this->assertSame('0341 1234567', $local->number);
$this->assertSame('tel:03411234567', $local->telUrl());
$this->assertSame('+54 9 (0341) 6658247', $mobileWithoutPlus->number);
$resource = PhoneNumberResource::make($mobile)->resolve();
$this->assertSame('9', $resource['mobile_prefix']);
$this->assertSame('0341', $resource['area_code']);
$this->assertSame('tel:+5493416658247', $resource['tel_url']);
$created = PhoneNumber::query()->create([
'label' => 'Nuevo',
'area_code' => '0341',
'local_number' => '7777777',
]);
$this->assertSame('+54', $created->fresh()->country_code);
$this->assertSame('9', $created->fresh()->mobile_prefix);
$this->assertSame('+54 9 (0341) 7777777', $created->number);
$this->assertSame('tel:+5493417777777', $created->telUrl());
$migration->down();
$this->assertDatabaseHas('phone_numbers', ['id' => $mobile->id, 'number' => '+54 9 0341 6658247']);
}
private function contact(string $tenant, mixed $phone): void private function contact(string $tenant, mixed $phone): void
{ {
DB::table('tenants_menues')->insert([ DB::table('tenants_menues')->insert([

View File

@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature\Migrations;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DynamicContactMenuTest extends TestCase
{
public function test_it_converts_only_contact_and_preserves_legacy_data_for_rollback(): void
{
Schema::create('menues', function (Blueprint $table): void {
$table->string('code')->primary();
$table->string('content_type');
$table->json('static_content_schema')->nullable();
});
Schema::create('tenants_menues', function (Blueprint $table): void {
$table->string('tenant_code');
$table->string('menu_code');
$table->json('static_content')->nullable();
});
DB::table('menues')->insert([
['code' => 'help.contact', 'content_type' => 'static', 'static_content_schema' => '{}'],
['code' => 'help.faq', 'content_type' => 'static', 'static_content_schema' => '{}'],
]);
$legacy = json_encode(['whatsapp' => ['whatsapp_url' => 'https://wa.me/123']], JSON_THROW_ON_ERROR);
DB::table('tenants_menues')->insert([
'tenant_code' => 'test', 'menu_code' => 'help.contact', 'static_content' => $legacy,
]);
$migration = require database_path('migrations/2026_09_30_000600_make_contact_menu_dynamic.php');
$migration->up();
$migration->up();
$this->assertDatabaseHas('menues', [
'code' => 'help.contact', 'content_type' => 'dynamic', 'static_content_schema' => null,
]);
$this->assertDatabaseHas('menues', ['code' => 'help.faq', 'content_type' => 'static', 'static_content_schema' => '{}']);
$this->assertDatabaseHas('tenants_menues', ['menu_code' => 'help.contact', 'static_content' => $legacy]);
$migration->down();
$this->assertDatabaseHas('menues', ['code' => 'help.contact', 'content_type' => 'static']);
$schema = json_decode(DB::table('menues')->where('code', 'help.contact')->value('static_content_schema'), true, flags: JSON_THROW_ON_ERROR);
$this->assertSame('required|url', $schema['whatsapp.whatsapp_url']);
$this->assertDatabaseHas('tenants_menues', ['menu_code' => 'help.contact', 'static_content' => $legacy]);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Tests\Feature\Migrations;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class RemoveOnticketMenuCodePrefixTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Schema::create('menues', function (Blueprint $table): void {
$table->id();
$table->string('code')->unique();
$table->string('label');
$table->string('parent_menu_code')->nullable();
$table->foreign('parent_menu_code')->references('code')->on('menues')->nullOnDelete();
});
Schema::create('tenants_menues', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->string('menu_code');
$table->json('static_content')->nullable();
$table->boolean('is_enabled')->default(true);
$table->timestamps();
$table->foreign('menu_code')->references('code')->on('menues')->cascadeOnDelete();
$table->unique(['tenant_code', 'menu_code']);
});
Schema::create('roles_menues', function (Blueprint $table): void {
$table->id();
$table->string('rol_codigo');
$table->string('menu_codigo');
$table->string('access_level')->default('none');
$table->foreign('menu_codigo')->references('code')->on('menues')->cascadeOnDelete();
$table->unique(['rol_codigo', 'menu_codigo']);
});
}
public function test_it_preserves_hierarchy_assignments_content_and_access_and_can_run_twice(): void
{
DB::table('menues')->insert(['code' => 'onticket.custom', 'label' => 'Custom']);
DB::table('menues')->insert([
['code' => 'onticket.custom.child', 'label' => 'Child', 'parent_menu_code' => 'onticket.custom'],
['code' => 'unrelated', 'label' => 'Unrelated', 'parent_menu_code' => 'onticket.custom'],
['code' => 'shopit.adminapp.marca', 'label' => 'Marca', 'parent_menu_code' => null],
]);
$createdAt = '2026-09-01 12:00:00';
DB::table('tenants_menues')->insert([
'tenant_code' => 'tenant', 'menu_code' => 'onticket.custom.child',
'static_content' => '{"title":"Content"}', 'is_enabled' => false,
'created_at' => $createdAt, 'updated_at' => $createdAt,
]);
DB::table('roles_menues')->insert([
'rol_codigo' => 'adminapp', 'menu_codigo' => 'onticket.custom.child', 'access_level' => 'edit',
]);
$migration = require database_path('migrations/2026_10_02_000000_remove_onticket_menu_code_prefix.php');
$migration->up();
$migration->up();
$this->assertFalse(DB::table('menues')->where('code', 'like', 'onticket.%')->exists());
$this->assertDatabaseHas('menues', ['code' => 'custom.child', 'label' => 'Child', 'parent_menu_code' => 'custom']);
$this->assertDatabaseHas('menues', ['code' => 'unrelated', 'parent_menu_code' => 'custom']);
$this->assertDatabaseHas('menues', ['code' => 'shopit.adminapp.marca']);
$this->assertDatabaseHas('tenants_menues', [
'tenant_code' => 'tenant', 'menu_code' => 'custom.child',
'static_content' => '{"title":"Content"}', 'is_enabled' => false, 'created_at' => $createdAt,
]);
$this->assertDatabaseHas('roles_menues', [
'rol_codigo' => 'adminapp', 'menu_codigo' => 'custom.child', 'access_level' => 'edit',
]);
$this->assertDatabaseCount('tenants_menues', 1);
$this->assertDatabaseCount('roles_menues', 1);
}
public function test_it_merges_existing_codes_without_losing_the_active_assignment_settings(): void
{
DB::table('menues')->insert([
['code' => 'adminapp.tickets', 'label' => 'Tickets'],
['code' => 'onticket.adminapp.tickets', 'label' => 'Tickets prefixed'],
]);
DB::table('tenants_menues')->insert([
['tenant_code' => 'tenant', 'menu_code' => 'adminapp.tickets', 'is_enabled' => true],
['tenant_code' => 'tenant', 'menu_code' => 'onticket.adminapp.tickets', 'is_enabled' => false],
['tenant_code' => 'other', 'menu_code' => 'adminapp.tickets', 'is_enabled' => true],
]);
DB::table('roles_menues')->insert([
['rol_codigo' => 'adminapp', 'menu_codigo' => 'adminapp.tickets', 'access_level' => 'edit'],
['rol_codigo' => 'adminapp', 'menu_codigo' => 'onticket.adminapp.tickets', 'access_level' => 'none'],
['rol_codigo' => 'other', 'menu_codigo' => 'adminapp.tickets', 'access_level' => 'view'],
]);
$migration = require database_path('migrations/2026_10_02_000000_remove_onticket_menu_code_prefix.php');
$migration->up();
$this->assertDatabaseCount('menues', 1);
$this->assertDatabaseCount('tenants_menues', 2);
$this->assertDatabaseCount('roles_menues', 2);
$this->assertDatabaseHas('tenants_menues', ['tenant_code' => 'tenant', 'menu_code' => 'adminapp.tickets', 'is_enabled' => false]);
$this->assertDatabaseHas('tenants_menues', ['tenant_code' => 'other', 'menu_code' => 'adminapp.tickets', 'is_enabled' => true]);
$this->assertDatabaseHas('roles_menues', ['rol_codigo' => 'adminapp', 'menu_codigo' => 'adminapp.tickets', 'access_level' => 'none']);
$this->assertDatabaseHas('roles_menues', ['rol_codigo' => 'other', 'menu_codigo' => 'adminapp.tickets', 'access_level' => 'view']);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Tests\Feature\Migrations;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class UpdateShopitAdminColorsTest extends TestCase
{
public function test_updates_only_shopit_admin_colors_and_preserves_them_on_rollback(): void
{
Schema::create('admin_website_types', function (Blueprint $table): void {
$table->string('codigo')->primary();
$table->string('primary_color');
$table->string('secondary_color');
$table->string('danger_color');
$table->string('success_color');
$table->timestamps();
});
foreach (['shopit', 'onticket'] as $code) {
DB::table('admin_website_types')->insert([
'codigo' => $code,
'primary_color' => '#2FD3AC',
'secondary_color' => '#777777',
'danger_color' => '#E04A4A',
'success_color' => '#81BC73',
]);
}
$migration = require database_path('migrations/2026_10_01_000100_update_shopit_admin_secondary_and_danger_colors.php');
$migration->up();
$shopit = DB::table('admin_website_types')->where('codigo', 'shopit')->first();
$this->assertSame('#ADADAD', $shopit->secondary_color);
$this->assertSame('#FF8888', $shopit->danger_color);
$this->assertSame('#58B341', $shopit->success_color);
$this->assertSame('#2FD3AC', $shopit->primary_color);
$onticket = DB::table('admin_website_types')->where('codigo', 'onticket')->first();
$this->assertSame('#777777', $onticket->secondary_color);
$this->assertSame('#E04A4A', $onticket->danger_color);
$this->assertSame('#81BC73', $onticket->success_color);
$migration->down();
$this->assertSame('#ADADAD', DB::table('admin_website_types')->where('codigo', 'shopit')->value('secondary_color'));
$this->assertSame('#FF8888', DB::table('admin_website_types')->where('codigo', 'shopit')->value('danger_color'));
$this->assertSame('#58B341', DB::table('admin_website_types')->where('codigo', 'shopit')->value('success_color'));
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace Tests\Feature\Sale;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Laravel\Sanctum\Sanctum;
use Mockery\MockInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Tests\TestCase;
class AdminAppSaleEventScopeTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Isolated schema: the full legacy migration chain cannot run on SQLite.
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo');
});
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->softDeletes();
});
Schema::create('compras', function (Blueprint $table): void {
$table->id();
$table->string('tenant_codigo');
$table->unsignedBigInteger('event_id')->nullable();
$table->string('nombre_apellido');
$table->string('status');
$table->decimal('total', 12, 2);
$table->timestamps();
});
Schema::create('compra_items', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('compra_id');
$table->integer('cantidad');
});
Schema::create('tickets', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('source_purchase_item_id');
});
Schema::create('ticket_refunds', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('purchase_item_id');
$table->decimal('amount', 12, 2);
});
Schema::create('value_changes', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->string('trackable_type');
$table->unsignedBigInteger('trackable_id');
$table->string('attribute');
$table->string('old_value');
$table->string('new_value');
$table->timestamp('changed_at');
$table->string('actor_type');
$table->unsignedBigInteger('user_id')->nullable();
});
DB::table('tenants')->insert(['codigo' => 'onticket']);
foreach ([1 => ['onticket', 10], 2 => ['onticket', 20], 3 => ['onticket', null], 4 => ['other', 10]] as $id => [$tenant, $event]) {
DB::table('compras')->insert([
'id' => $id,
'tenant_codigo' => $tenant,
'event_id' => $event,
'nombre_apellido' => 'Cliente',
'status' => Purchase::STATUS_PAID,
'total' => $id * 100,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('compra_items')->insert(['id' => $id, 'compra_id' => $id, 'cantidad' => 1]);
DB::table('ticket_refunds')->insert(['purchase_item_id' => $id, 'amount' => $id * 10]);
DB::table('value_changes')->insert([
'id' => $id,
'tenant_code' => $tenant,
'trackable_type' => (new Purchase)->getMorphClass(),
'trackable_id' => $id,
'attribute' => 'status',
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
'new_value' => Purchase::STATUS_PAID,
'changed_at' => now(),
'actor_type' => 'system',
]);
}
$this->actingAsAdministrator(10);
}
public function test_list_totals_and_filters_cannot_escape_the_authenticated_event(): void
{
$this->getJson('/api/v1/adminapp/tenant/sales?event_id=20&q=Cliente')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', 1)
->assertJsonPath('confirmed_sales_total', '100.00')
->assertJsonPath('refunded_total', '10.00');
$this->getJson('/api/v1/adminapp/tenant/sales?id=2')->assertOk()->assertJsonCount(0, 'data');
$this->getJson('/api/v1/adminapp/tenant/sales/modifications?q=Cliente')
->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.sale_id', 1);
}
public function test_unscoped_administrators_keep_access_to_all_sales_in_their_tenant(): void
{
$this->actingAsAdministrator(null);
$this->getJson('/api/v1/adminapp/tenant/sales')->assertOk()->assertJsonCount(3, 'data')
->assertJsonPath('confirmed_sales_total', '600.00')->assertJsonPath('refunded_total', '60.00');
$this->getJson('/api/v1/adminapp/tenant/sales/modifications')->assertOk()->assertJsonCount(3, 'data');
$this->getJson('/api/v1/adminapp/tenant/sales/2')->assertOk();
$this->getJson('/api/v1/adminapp/tenant/sales/3')->assertOk();
}
public function test_foreign_and_unassigned_sales_are_inaccessible_before_any_checkout_action(): void
{
$this->mock(CheckoutService::class, function (MockInterface $mock): void {
$mock->shouldNotReceive('confirmPaidPurchase');
$mock->shouldNotReceive('cancelPurchaseFromAdmin');
});
foreach ([2, 3, 4] as $id) {
$this->getJson("/api/v1/adminapp/tenant/sales/{$id}")->assertNotFound();
$this->getJson("/api/v1/adminapp/tenant/sales/{$id}/tickets")->assertNotFound();
$this->postJson("/api/v1/adminapp/tenant/sales/{$id}/confirm", ['event_id' => 20])->assertNotFound();
$this->postJson("/api/v1/adminapp/tenant/sales/{$id}/cancel", ['event_id' => 20])->assertNotFound();
}
$this->assertSame(Purchase::STATUS_PAID, DB::table('compras')->where('id', 2)->value('status'));
}
public function test_own_event_allows_detail_tickets_and_checkout_actions(): void
{
// Empty snapshots keep this fixture focused on authorization.
DB::table('compra_items')->where('compra_id', 1)->delete();
$this->mock(CheckoutService::class, function (MockInterface $mock): void {
foreach (['confirmPaidPurchase', 'cancelPurchaseFromAdmin'] as $method) {
$mock->shouldReceive($method)->once()->withArgs(fn (Purchase $sale): bool => $sale->id === 1)
->andReturnUsing(fn (Purchase $sale): Purchase => $sale);
}
});
$this->getJson('/api/v1/adminapp/tenant/sales/1')->assertOk()->assertJsonPath('data.id', 1);
$this->getJson('/api/v1/adminapp/tenant/sales/1/tickets')->assertOk();
$this->postJson('/api/v1/adminapp/tenant/sales/1/confirm')->assertOk()->assertJsonPath('data.id', 1);
$this->postJson('/api/v1/adminapp/tenant/sales/1/cancel')->assertOk()->assertJsonPath('data.id', 1);
}
public function test_pdf_and_excel_exports_only_receive_sales_and_history_for_the_own_event(): void
{
foreach ([AdminAppSalePdfService::class, AdminAppSaleExcelService::class] as $class) {
$this->mock($class, function (MockInterface $mock) use ($class): void {
foreach (['downloadSales', 'downloadModifications'] as $method) {
$mock->shouldReceive($method)->once()->withArgs(
fn ($tenant, Collection $rows, $timezone): bool => $tenant->codigo === 'onticket'
&& $rows->pluck('id')->all() === [1] && $timezone === 'UTC'
)->andReturn($class === AdminAppSalePdfService::class
? response('pdf') : new StreamedResponse(fn () => print ('excel')));
}
});
}
foreach (['pdf', 'excel', 'modifications/pdf', 'modifications/excel'] as $path) {
$this->getJson("/api/v1/adminapp/tenant/sales/{$path}?timezone=UTC&event_id=20")->assertOk();
}
}
private function actingAsAdministrator(?int $eventId): void
{
$user = new User;
$user->setRawAttributes([
'id' => 1,
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => 'onticket',
'event_id' => $eventId,
]);
Sanctum::actingAs($user);
}
}

View File

@@ -135,7 +135,7 @@ class DesfilePuraTendenciaSeederTest extends TestCase
$expectedMenus = DB::table('tenants_menues') $expectedMenus = DB::table('tenants_menues')
->where('tenant_code', 'fiesta_futbol_infantil') ->where('tenant_code', 'fiesta_futbol_infantil')
->where('menu_code', 'not like', 'onticket.adminapp.fiesta-futbol-infantil.%') ->where('menu_code', 'not like', 'adminapp.fiesta-futbol-infantil.%')
->orderBy('menu_code') ->orderBy('menu_code')
->pluck('menu_code') ->pluck('menu_code')
->all(); ->all();
@@ -147,9 +147,9 @@ class DesfilePuraTendenciaSeederTest extends TestCase
$this->assertSame($expectedMenus, $actualMenus); $this->assertSame($expectedMenus, $actualMenus);
$this->assertContains('main.adminapp', $actualMenus); $this->assertContains('main.adminapp', $actualMenus);
$this->assertContains('onticket.adminapp.event', $actualMenus); $this->assertContains('adminapp.event', $actualMenus);
$this->assertContains('scanner.scan', $actualMenus); $this->assertContains('scanner.scan', $actualMenus);
$this->assertNotContains('onticket.adminapp.fiesta-futbol-infantil.entradas', $actualMenus); $this->assertNotContains('adminapp.fiesta-futbol-infantil.entradas', $actualMenus);
$catalogItem = DB::table('catalog_items') $catalogItem = DB::table('catalog_items')
->where('tenant_code', 'desfile_pura_tendencia') ->where('tenant_code', 'desfile_pura_tendencia')

View File

@@ -26,24 +26,24 @@ class MenuSeederTest extends TestCase
$this->seed(MenuSeeder::class); $this->seed(MenuSeeder::class);
$expectedMenus = [ $expectedMenus = [
'onticket.adminapp.inicio' => ['Inicio', '/admin/inicio'], 'adminapp.inicio' => ['Inicio', '/admin/inicio'],
'onticket.adminapp.catalog' => ['Catálogo', '/admin/catalog'], 'adminapp.catalog' => ['Catálogo', '/admin/catalog'],
'onticket.adminapp.categories' => ['Categorías', '/admin/categories'], 'adminapp.categories' => ['Categorías', '/admin/categories'],
'onticket.adminapp.combos' => ['Combos', '/admin/combos'], 'adminapp.combos' => ['Combos', '/admin/combos'],
'onticket.adminapp.event' => ['Eventos', '/admin/event'], 'adminapp.event' => ['Eventos', '/admin/event'],
'onticket.adminapp.staff' => ['Usuarios', '/admin/staff'], 'adminapp.staff' => ['Usuarios', '/admin/staff'],
'onticket.adminapp.ventas' => ['Ventas', '/admin/ventas'], 'adminapp.ventas' => ['Ventas', '/admin/ventas'],
]; ];
$fiestaCategoryMenus = [ $fiestaCategoryMenus = [
'onticket.adminapp.tickets' => ['Tickets', '/admin/tickets'], 'adminapp.tickets' => ['Tickets', '/admin/tickets'],
'onticket.adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'], 'adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'],
'onticket.adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'], 'adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'],
'onticket.adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'], 'adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'],
'onticket.adminapp.fiesta-futbol-infantil.comida' => ['Comida', '/admin/comidas'], 'adminapp.fiesta-futbol-infantil.comida' => ['Comida', '/admin/comidas'],
]; ];
$desfileMenus = [ $desfileMenus = [
'onticket.adminapp.desfile.entradas' => ['Entradas', '/admin/desfile/entradas'], 'adminapp.desfile.entradas' => ['Entradas', '/admin/desfile/entradas'],
'onticket.adminapp.desfile.reservas' => ['Reserva de Tickets', '/admin/desfile/reservas'], 'adminapp.desfile.reservas' => ['Reserva de Tickets', '/admin/desfile/reservas'],
]; ];
$shopItMenus = $this->shopItAdminMenus(); $shopItMenus = $this->shopItAdminMenus();
@@ -95,10 +95,10 @@ class MenuSeederTest extends TestCase
} }
foreach ([ foreach ([
'onticket.adminapp.inicio', 'adminapp.inicio',
'onticket.adminapp.catalog', 'adminapp.catalog',
'onticket.adminapp.categories', 'adminapp.categories',
'onticket.adminapp.combos', 'adminapp.combos',
] as $code) { ] as $code) {
$this->assertFalse( $this->assertFalse(
$fiestaTenant->menues()->where('menues.code', $code)->exists() $fiestaTenant->menues()->where('menues.code', $code)->exists()
@@ -163,7 +163,7 @@ class MenuSeederTest extends TestCase
} }
$this->assertFalse( $this->assertFalse(
Menu::query()->where('code', 'like', 'adminapp.%')->exists() Menu::query()->where('code', 'like', 'onticket.%')->exists()
); );
} }
@@ -213,7 +213,7 @@ class MenuSeederTest extends TestCase
$this->assertSame($userMenuCodes, $userRoleMenuCodes); $this->assertSame($userMenuCodes, $userRoleMenuCodes);
$this->assertNotContains('main.adminapp', $userRoleMenuCodes); $this->assertNotContains('main.adminapp', $userRoleMenuCodes);
$this->assertNotContains('onticket.adminapp.catalog', $userRoleMenuCodes); $this->assertNotContains('adminapp.catalog', $userRoleMenuCodes);
$scannerRoleMenuCodes = Role::query() $scannerRoleMenuCodes = Role::query()
->where('codigo', RoleCode::Scanner->value) ->where('codigo', RoleCode::Scanner->value)
@@ -338,26 +338,8 @@ class MenuSeederTest extends TestCase
], $faq->static_content_schema); ], $faq->static_content_schema);
$contact = Menu::query()->where('code', 'help.contact')->firstOrFail(); $contact = Menu::query()->where('code', 'help.contact')->firstOrFail();
$this->assertSame(Menu::CONTENT_TYPE_STATIC, $contact->content_type); $this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $contact->content_type);
$this->assertSame('required|url', $contact->static_content_schema['whatsapp.whatsapp_url']); $this->assertNull($contact->static_content_schema);
$this->assertSame('required|string', $contact->static_content_schema['phone']);
$this->assertSame(
'required|array|min:1',
$contact->static_content_schema['locations.*.addresses']
);
$this->assertSame(
'required|array|size:2',
$contact->static_content_schema['locations.*.addresses.*.coordinates']
);
$this->assertSame(
'required|string',
$contact->static_content_schema['locations.*.addresses.*.address']
);
$this->assertSame(
'required|numeric|between:-90,90',
$contact->static_content_schema['locations.*.addresses.*.coordinates.0']
);
$this->assertSame('prohibited', $contact->static_content_schema['map_locations']);
} }
public function test_it_assigns_help_to_the_supported_tenants_with_faq_content(): void public function test_it_assigns_help_to_the_supported_tenants_with_faq_content(): void
@@ -385,30 +367,6 @@ class MenuSeederTest extends TestCase
'is_active' => false, 'is_active' => false,
], ],
]; ];
$expectedContact = [
'whatsapp' => [
'whatsapp_url' => 'https://wa.me/543412602222',
'whatsapp_label' => 'Chatea con nosotros',
],
'phone' => '+54 9 (0341) 6658247',
'locations' => [
'rosario' => [
'label' => 'Rosario',
'addresses' => [
[
'label' => 'Gigante de Arroyito',
'address' => 'Av. Génova 640, Rosario',
'coordinates' => [-32.913997, -60.674567],
],
[
'label' => 'Telepagos',
'address' => 'Rioja 1150, piso 12, dpto. 3, Rosario',
'coordinates' => [-32.946820, -60.639320],
],
],
],
],
];
foreach ([$sonder, $fiesta] as $tenant) { foreach ([$sonder, $fiesta] as $tenant) {
$helpMenus = $tenant->menues() $helpMenus = $tenant->menues()
@@ -422,10 +380,7 @@ class MenuSeederTest extends TestCase
$expectedQuestions, $expectedQuestions,
$helpMenus->firstWhere('code', 'help.faq')->pivot->static_content $helpMenus->firstWhere('code', 'help.faq')->pivot->static_content
); );
$this->assertEquals( $this->assertNull($helpMenus->firstWhere('code', 'help.contact')->pivot->static_content);
$expectedContact,
$helpMenus->firstWhere('code', 'help.contact')->pivot->static_content
);
} }
$this->assertFalse( $this->assertFalse(

View File

@@ -0,0 +1,158 @@
<?php
namespace Tests\Feature\Staff;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\ResetPasswordAttemptService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Laravel\Sanctum\Sanctum;
use Mockery\MockInterface;
use Tests\TestCase;
class StaffEventScopeTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Exercise HTTP authorization on SQLite without the incompatible legacy migrations.
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo');
$table->boolean('scanner_category_validation_enabled')->default(false);
});
Schema::create('roles', function (Blueprint $table): void {
$table->id();
$table->string('codigo');
$table->string('nombre');
});
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('rol_codigo');
$table->string('tenant_codigo');
$table->unsignedBigInteger('event_id')->nullable();
$table->string('nombre_apellido');
$table->string('dni');
$table->string('email');
$table->string('active_email')->nullable();
$table->string('password')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('categorias', function (Blueprint $table): void {
$table->id();
$table->string('nombre');
$table->string('tenant_code')->nullable();
$table->unsignedBigInteger('categoria_id')->nullable();
});
Schema::create('catalog_items', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('category_id');
$table->string('tenant_code');
$table->softDeletes();
});
Schema::create('category_scanners', function (Blueprint $table): void {
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('categoria_id');
$table->timestamps();
});
Schema::create('personal_access_tokens', function (Blueprint $table): void {
$table->id();
$table->string('tokenable_type');
$table->unsignedBigInteger('tokenable_id');
});
DB::table('tenants')->insert(['codigo' => 'onticket']);
foreach (['adminapp', 'scanner'] as $role) {
DB::table('roles')->insert(['codigo' => $role, 'nombre' => $role]);
foreach ([10, 20, null] as $eventId) {
$this->insertUser($role, 'onticket', $eventId);
}
$this->insertUser($role, 'other', 10);
}
Sanctum::actingAs(User::query()->findOrFail(1));
}
public function test_lists_and_searches_are_restricted_to_the_authenticated_event(): void
{
foreach (['administrators' => 1, 'staff' => 5] as $path => $id) {
foreach (['', '?search=Persona&event_id=20'] as $query) {
$this->getJson("/api/v1/adminapp/tenant/{$path}{$query}")
->assertOk()->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $id)->assertJsonPath('data.0.event_id', 10);
}
}
}
public function test_creation_inherits_the_actor_event_even_if_the_client_supplies_another(): void
{
$this->mock(ResetPasswordAttemptService::class, function (MockInterface $mock): void {
$mock->shouldReceive('createForAdminAppEmail')->once();
$mock->shouldReceive('createForScannerEmail')->once();
});
foreach (['administrators' => 'adminapp', 'staff' => 'scanner'] as $path => $role) {
$this->postJson("/api/v1/adminapp/tenant/{$path}", [
...$this->payload("new-{$role}@example.com"), 'event_id' => 20,
])->assertSuccessful()->assertJsonPath('data.event_id', 10);
$this->assertDatabaseHas('users', ['email' => "new-{$role}@example.com", 'event_id' => 10, 'rol_codigo' => $role]);
}
}
public function test_foreign_and_unassigned_staff_cannot_be_edited_deleted_or_inspected(): void
{
foreach (['administrators' => [2, 3, 4], 'staff' => [6, 7, 8]] as $path => $ids) {
foreach ($ids as $id) {
$this->putJson("/api/v1/adminapp/tenant/{$path}/{$id}", $this->payload("update-{$id}@example.com"))->assertNotFound();
$this->deleteJson("/api/v1/adminapp/tenant/{$path}/{$id}")->assertNotFound();
$this->assertDatabaseHas('users', ['id' => $id, 'deleted_at' => null, 'email' => "person-{$id}@example.com"]);
if ($path === 'staff') {
$this->getJson("/api/v1/adminapp/tenant/staff/{$id}/scan-attempts")->assertNotFound();
}
}
}
}
public function test_own_staff_can_be_edited_and_deleted_without_changing_its_event(): void
{
$adminId = $this->insertUser('adminapp', 'onticket', 10);
foreach (['administrators' => $adminId, 'staff' => 5] as $path => $id) {
$this->putJson("/api/v1/adminapp/tenant/{$path}/{$id}", [
...$this->payload("updated-{$id}@example.com"), 'event_id' => 20,
])->assertOk()->assertJsonPath('data.event_id', 10);
$this->deleteJson("/api/v1/adminapp/tenant/{$path}/{$id}")->assertNoContent();
$this->assertSoftDeleted('users', ['id' => $id]);
}
}
public function test_unscoped_actor_keeps_tenant_lists_and_creates_without_an_event(): void
{
Sanctum::actingAs(User::query()->findOrFail(3));
$this->mock(ResetPasswordAttemptService::class, function (MockInterface $mock): void {
$mock->shouldReceive('createForAdminAppEmail')->once();
$mock->shouldReceive('createForScannerEmail')->once();
});
foreach (['administrators', 'staff'] as $path) {
$this->getJson("/api/v1/adminapp/tenant/{$path}")->assertOk()->assertJsonCount(3, 'data');
$this->postJson("/api/v1/adminapp/tenant/{$path}", $this->payload("legacy-{$path}@example.com"))
->assertSuccessful()->assertJsonPath('data.event_id', null);
}
}
private function insertUser(string $role, string $tenant, ?int $eventId): int
{
$id = DB::table('users')->count() + 1;
return DB::table('users')->insertGetId([
'id' => $id, 'rol_codigo' => $role, 'tenant_codigo' => $tenant,
'event_id' => $eventId, 'nombre_apellido' => 'Persona', 'dni' => '12345678',
'email' => "person-{$id}@example.com", 'active_email' => "person-{$id}@example.com",
]);
}
private function payload(string $email): array
{
return ['nombre_apellido' => 'Persona editada', 'dni' => '87654321', 'email' => $email];
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\Core\Address\Models\Address;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Core\Tenant\Models\SocialMedia;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Resources\TenantResource;
use App\Domains\Core\Tenant\Services\ContactService;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppContactControllerTest extends TestCase
{
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
// Use the real contact migrations in the guarded in-memory database. The
// unrelated historical migration chain cannot currently run on SQLite.
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo')->unique();
$table->string('nombre');
$table->string('dominio');
foreach (array_keys((new Tenant)->getAttributes()) as $column) {
$table->string($column)->nullable();
}
$table->unsignedBigInteger('active_event_id')->nullable();
$table->timestamps();
});
foreach ([
'2026_07_23_000200_create_social_media_tables.php',
'2026_07_23_000300_add_orden_to_tenant_social_media_table.php',
'2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php',
'2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php',
'2026_09_30_000700_split_phone_number_components.php',
] as $migration) {
(require database_path('migrations/'.$migration))->up();
}
Schema::create('events', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->string('slug');
$table->string('title');
$table->unsignedBigInteger('address_id')->nullable();
$table->timestamps();
});
Schema::create('event_social_media', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('event_id');
$table->string('social_media_code');
$table->string('url');
$table->unsignedInteger('orden');
$table->timestamps();
});
$this->tenant = Tenant::create(['codigo' => 'contact-test', 'nombre' => 'Contact', 'dominio' => 'contact.test']);
SocialMedia::firstOrCreate(['code' => 'instagram'], ['name' => 'Instagram', 'icon' => 'instagram']);
}
private function login(): void
{
Sanctum::actingAs(new User([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $this->tenant->codigo,
]));
}
private function payload(): array
{
return [
'social_media' => [['code' => 'instagram', 'url' => 'https://instagram.com/contact']],
'addresses' => [['address_text' => 'San Martín 123']],
'phone_numbers' => [[
'country_code' => '+54', 'mobile_prefix' => null, 'area_code' => '341',
'local_number' => '5551234',
]],
];
}
public function test_authentication_and_admin_role_are_required(): void
{
$this->getJson('/api/v1/adminapp/tenant/contact')->assertUnauthorized();
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertUnauthorized();
Sanctum::actingAs(new User(['tenant_codigo' => $this->tenant->codigo]));
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertForbidden();
}
public function test_contact_is_persisted_and_exposed_in_storefront_resource(): void
{
$this->login();
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk()
->assertJsonPath('data.phone_numbers.0.mobile_prefix', null)
->assertJsonPath('data.addresses.0.is_main', true);
$this->getJson('/api/v1/adminapp/tenant/contact')->assertOk()
->assertJsonPath('data.addresses.0.address_text', 'San Martín 123');
$storefront = $this->storefrontData();
$this->assertSame('https://instagram.com/contact', $storefront['tenant_social_media'][0]['url']);
$this->assertSame('San Martín 123', $storefront['main_address']->resolve()['address_text']);
$this->assertSame('tel:+543415551234', $storefront['main_phone_number']->resolve()['tel_url']);
}
public function test_existing_contacts_can_be_updated_replaced_and_cleared(): void
{
$this->login();
$first = $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk()->json('data');
$payload = $this->payload();
$payload['addresses'] = [
['id' => $first['addresses'][0]['id'], 'address_text' => 'Updated', 'is_main' => false],
['address_text' => 'New principal', 'is_main' => true],
];
$payload['phone_numbers'][0]['id'] = $first['phone_numbers'][0]['id'];
$payload['phone_numbers'][0]['local_number'] = '7771234';
$payload['phone_numbers'][0]['is_main'] = false;
$payload['phone_numbers'][] = [...$this->payload()['phone_numbers'][0], 'local_number' => '8881234', 'is_main' => true];
$updated = $this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()->assertJsonCount(2, 'data.addresses')->json('data');
// Position wins, even if a client sends conflicting is_main flags.
$this->assertSame('Updated', $this->tenant->mainAddress()->firstOrFail()->address_text);
$this->assertSame('7771234', $this->tenant->mainPhoneNumber()->firstOrFail()->local_number);
$payload['addresses'] = array_reverse($updated['addresses']);
$payload['addresses'] = array_map(fn ($item) => array_intersect_key($item, array_flip(['id', 'address_text'])), $payload['addresses']);
$payload['phone_numbers'] = array_reverse($payload['phone_numbers']);
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()
->assertJsonPath('data.addresses.0.address_text', 'New principal')
->assertJsonPath('data.addresses.0.is_main', true)
->assertJsonPath('data.addresses.1.is_main', false);
$this->getJson('/api/v1/adminapp/tenant/contact')->assertOk()
->assertJsonPath('data.addresses.0.address_text', 'New principal');
$this->assertSame('New principal', $this->tenant->mainAddress()->firstOrFail()->address_text);
$this->assertSame('8881234', $this->tenant->mainPhoneNumber()->firstOrFail()->local_number);
$this->putJson('/api/v1/adminapp/tenant/contact', [
'social_media' => [], 'addresses' => [], 'phone_numbers' => [],
])->assertOk()->assertJsonCount(0, 'data.phone_numbers');
$this->assertNull($this->tenant->mainAddress()->first());
$this->assertNull($this->tenant->mainPhoneNumber()->first());
}
public function test_foreign_ids_and_invalid_payloads_do_not_change_contact(): void
{
$this->login();
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk();
$foreign = Address::create(['label' => 'Foreign', 'address_text' => 'Private']);
$payload = $this->payload();
$payload['social_media'][0]['url'] = 'https://instagram.com/changed';
$payload['addresses'][0]['id'] = $foreign->id;
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertUnprocessable()->assertJsonValidationErrors('addresses.0.id');
$payload = $this->payload();
$payload['phone_numbers'][0]['id'] = 999999;
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertUnprocessable()->assertJsonValidationErrors('phone_numbers.0.id');
$payload = $this->payload();
$payload['social_media'][0]['url'] = 'javascript:alert(1)';
$payload['addresses'][] = $payload['addresses'][0];
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertUnprocessable()
->assertJsonValidationErrors(['social_media.0.url']);
$this->getJson('/api/v1/adminapp/tenant/contact')->assertJsonPath('data.social_media.0.url', 'https://instagram.com/contact');
}
public function test_service_rolls_back_all_changes_if_contact_membership_changes(): void
{
$this->login();
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk();
$payload = $this->payload();
$payload['social_media'] = [];
$payload['phone_numbers'][0]['id'] = 999999;
try {
app(ContactService::class)->update($this->tenant, $payload);
$this->fail('An invalid contact ID must reject the whole transaction.');
} catch (ValidationException) {
$this->assertSame(1, $this->tenant->socialMedia()->count());
$this->assertSame(1, $this->tenant->addresses()->count());
}
}
public function test_shared_records_are_not_modified_for_other_tenants(): void
{
$this->login();
$first = $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk()->json('data');
$other = Tenant::create(['codigo' => 'other-contact', 'nombre' => 'Other', 'dominio' => 'other-contact.test']);
$other->addresses()->attach($first['addresses'][0]['id'], ['is_main' => true]);
$other->phoneNumbers()->attach($first['phone_numbers'][0]['id'], ['is_main' => true]);
$payload = $this->payload();
$payload['addresses'][0] = [...$payload['addresses'][0], 'id' => $first['addresses'][0]['id'], 'address_text' => 'Changed'];
$payload['phone_numbers'][0] = [...$payload['phone_numbers'][0], 'id' => $first['phone_numbers'][0]['id'], 'local_number' => '9991234'];
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk();
$this->assertSame('San Martín 123', $other->addresses()->firstOrFail()->address_text);
$this->assertSame('5551234', $other->phoneNumbers()->firstOrFail()->local_number);
}
public function test_contact_networks_are_independent_of_active_event_networks(): void
{
$this->login();
$event = Event::create(['tenant_code' => $this->tenant->codigo, 'slug' => 'event', 'title' => 'Event']);
$event->socialMedia()->attach('instagram', ['url' => 'https://instagram.com/event', 'orden' => 0]);
$this->tenant->update(['active_event_id' => $event->id]);
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk();
$this->getJson('/api/v1/adminapp/tenant/contact')->assertOk()
->assertJsonPath('data.social_media.0.url', 'https://instagram.com/contact');
$this->assertSame('https://instagram.com/event', $event->socialMedia()->firstOrFail()->pivot->url);
$tenant = $this->tenant->fresh()->load(['socialMedia', 'activeEvent.socialMedia']);
$this->assertSame('https://instagram.com/contact', $tenant->socialMedia->first()->pivot->url);
}
private function storefrontData(): array
{
$tenant = $this->tenant->fresh()->load(['socialMedia', 'addresses', 'mainAddress', 'phoneNumbers', 'mainPhoneNumber']);
foreach (['favicon', 'headerLogo', 'footerLogo', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) {
$tenant->setRelation($relation, null);
}
return TenantResource::make($tenant)->resolve(request());
}
public function test_address_coordinates_are_preserved_until_the_address_changes(): void
{
$this->login();
$address = Address::create(['label' => 'Sucursal', 'address_text' => 'Original', 'latitude' => -32.9, 'longitude' => -60.6]);
$this->tenant->addresses()->attach($address->id, ['is_main' => true]);
$payload = $this->payload();
$payload['addresses'] = [['id' => $address->id, 'address_text' => 'Original', 'is_main' => true]];
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()->assertJsonPath('data.addresses.0.latitude', -32.9);
$payload['addresses'][0]['address_text'] = 'New address';
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()->assertJsonPath('data.addresses.0.latitude', null);
$this->assertSame('Sucursal', $address->fresh()->label);
}
}

View File

@@ -510,7 +510,7 @@ class BootstrapTenantControllerTest extends TestCase
$tenant->menues()->sync([ $tenant->menues()->sync([
$parent->code, $parent->code,
$staticChild->code => ['static_content' => $staticContent], $staticChild->code => ['static_content' => $staticContent],
$dynamicChild->code, $dynamicChild->code => ['static_content' => ['legacy' => 'must not be returned']],
]); ]);
$userRole->menus()->sync([ $userRole->menus()->sync([
$parent->code, $parent->code,

View File

@@ -1359,7 +1359,7 @@ class AdminAppTicketControllerTest extends TestCase
private function grantTicketsMenu(Tenant $tenant): void private function grantTicketsMenu(Tenant $tenant): void
{ {
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.tickets', 'code' => 'adminapp.tickets',
'label' => 'Tickets', 'label' => 'Tickets',
'route' => '/admin/tickets', 'route' => '/admin/tickets',
]); ]);

View File

@@ -0,0 +1,178 @@
<?php
namespace Tests\Feature\Ticket;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Ticketing\Ticket\Services\AdminAppTicketExcelService;
use App\Domains\Ticketing\Ticket\Services\AdminAppTicketPdfService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Laravel\Sanctum\Sanctum;
use Mockery\MockInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Tests\TestCase;
class AdminAppTicketEventScopeTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Keep HTTP tests isolated from legacy migrations incompatible with SQLite.
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo');
$table->string('timezone')->default('UTC');
$table->boolean('allow_ticket_refund')->default(false);
$table->boolean('allow_ticket_total_refund')->default(false);
$table->boolean('allow_ticket_partial_refund')->default(false);
});
Schema::create('menues', function (Blueprint $table): void {
$table->id();
$table->string('code');
});
Schema::create('tenants_menues', function (Blueprint $table): void {
$table->string('tenant_code');
$table->string('menu_code');
});
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('nombre_apellido');
$table->softDeletes();
});
Schema::create('tickets', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->unsignedBigInteger('event_id')->nullable();
$table->string('ticket');
foreach (['source_variant_id', 'source_catalog_item_id', 'source_purchase_item_id', 'scanner_user_id', 'user_id'] as $column) {
$table->unsignedBigInteger($column)->nullable();
}
foreach (['used_at', 'disabled_at', 'cancelled_at', 'refunded_at'] as $column) {
$table->timestamp($column)->nullable();
}
$table->timestamps();
});
Schema::create('compras', function (Blueprint $table): void {
$table->id();
$table->string('tenant_codigo');
$table->unsignedBigInteger('event_id')->nullable();
$table->string('nombre_apellido');
});
Schema::create('compra_items', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('compra_id');
$table->decimal('precio_unitario', 12, 2);
});
Schema::create('ticket_refunds', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('ticket_id')->nullable();
$table->unsignedBigInteger('purchase_item_id');
$table->decimal('amount', 12, 2);
});
Schema::create('desfile_entry_reservations', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('ticket_id');
$table->softDeletes();
});
Schema::create('value_changes', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->string('trackable_type');
$table->unsignedBigInteger('trackable_id');
$table->string('attribute');
$table->string('old_value')->nullable();
$table->string('new_value')->nullable();
$table->timestamp('changed_at');
$table->string('actor_type');
$table->unsignedBigInteger('user_id')->nullable();
});
DB::table('tenants')->insert(['codigo' => 'onticket']);
DB::table('menues')->insert(['code' => 'adminapp.tickets']);
DB::table('tenants_menues')->insert(['tenant_code' => 'onticket', 'menu_code' => 'adminapp.tickets']);
foreach ([1 => ['onticket', 10], 2 => ['onticket', 20], 3 => ['onticket', null], 4 => ['other', 10]] as $id => [$tenant, $event]) {
DB::table('tickets')->insert([
'id' => $id, 'tenant_code' => $tenant, 'event_id' => $event,
'ticket' => "ticket-{$id}", 'used_at' => now(),
]);
DB::table('compras')->insert(['id' => $id, 'tenant_codigo' => $tenant, 'event_id' => $event, 'nombre_apellido' => 'Cliente']);
DB::table('compra_items')->insert(['id' => $id, 'compra_id' => $id, 'precio_unitario' => 100]);
DB::table('ticket_refunds')->insert(['purchase_item_id' => $id, 'amount' => $id * 10]);
}
$this->actingAsAdmin(10);
}
public function test_list_counts_refunded_total_and_search_cannot_escape_the_user_event(): void
{
$this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=id&event_id=20')
->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', 1)
->assertJsonPath('scanned_tickets', 1)->assertJsonPath('total_tickets', 1)
->assertJsonPath('refunded_total', '10.00');
$this->getJson('/api/v1/adminapp/tenant/tickets?q=2')->assertOk()->assertJsonCount(0, 'data');
// Status uses sorting in memory rather than the database.
$this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=status')
->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', 1);
DB::table('tickets')->where('id', 1)->update(['used_at' => null]);
$this->getJson('/api/v1/adminapp/tenant/tickets?status=active')
->assertOk()->assertJsonCount(1, 'data')
->assertJsonPath('scanned_tickets', 0)->assertJsonPath('total_tickets', 1);
}
public function test_foreign_unassigned_and_other_tenant_tickets_cannot_be_modified_or_refunded(): void
{
foreach ([2, 3, 4] as $id) {
$this->postJson("/api/v1/adminapp/tenant/tickets/{$id}/cancel", ['event_id' => 20])->assertNotFound();
$this->getJson("/api/v1/adminapp/tenant/tickets/{$id}/refund")->assertNotFound();
$this->postJson("/api/v1/adminapp/tenant/tickets/{$id}/refund", ['refund_type' => 'total', 'event_id' => 20])->assertNotFound();
$this->assertDatabaseHas('tickets', ['id' => $id, 'cancelled_at' => null, 'refunded_at' => null]);
}
$this->assertDatabaseCount('value_changes', 0);
$this->assertDatabaseCount('ticket_refunds', 4);
}
public function test_own_ticket_can_be_cancelled_and_refund_requests_reach_business_validation(): void
{
DB::table('tickets')->where('id', 1)->update(['used_at' => null]);
$this->getJson('/api/v1/adminapp/tenant/tickets/1/refund')->assertUnprocessable();
$this->postJson('/api/v1/adminapp/tenant/tickets/1/refund', ['refund_type' => 'total'])->assertUnprocessable();
$this->postJson('/api/v1/adminapp/tenant/tickets/1/cancel')->assertOk();
$this->assertNotNull(DB::table('tickets')->where('id', 1)->value('cancelled_at'));
}
public function test_unscoped_admin_keeps_the_tenant_scope(): void
{
$this->actingAsAdmin(null);
$this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=id')
->assertOk()->assertJsonCount(3, 'data')->assertJsonPath('total_tickets', 3)
->assertJsonPath('refunded_total', '60.00');
DB::table('tickets')->where('id', 3)->update(['used_at' => null]);
$this->postJson('/api/v1/adminapp/tenant/tickets/3/cancel')->assertOk();
$this->postJson('/api/v1/adminapp/tenant/tickets/4/cancel')->assertNotFound();
}
public function test_pdf_and_excel_receive_only_the_tickets_of_the_user_event(): void
{
foreach ([AdminAppTicketPdfService::class, AdminAppTicketExcelService::class] as $class) {
$this->mock($class, function (MockInterface $mock) use ($class): void {
$mock->shouldReceive('download')->once()->withArgs(
fn ($tenant, Collection $tickets, $timezone): bool => $tenant->codigo === 'onticket'
&& $tickets->pluck('id')->all() === [1] && $timezone === 'UTC'
)->andReturn($class === AdminAppTicketPdfService::class
? response('pdf') : new StreamedResponse(fn () => print ('excel')));
});
}
foreach (['pdf', 'excel'] as $format) {
$this->getJson("/api/v1/adminapp/tenant/tickets/{$format}?timezone=UTC&event_id=20")->assertOk();
}
}
private function actingAsAdmin(?int $eventId): void
{
$user = new User;
$user->setRawAttributes(['id' => 1, 'rol_codigo' => 'adminapp', 'tenant_codigo' => 'onticket', 'event_id' => $eventId]);
Sanctum::actingAs($user);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit\Bootstrap;
use App\Domains\Core\Menu\Models\Menu;
use App\Domains\Core\Menu\Models\TenantMenu;
use App\Domains\Core\Tenant\Models\SocialMedia;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Resources\TenantResource;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Tests\TestCase;
class TenantContactResourceTest extends TestCase
{
public function test_dynamic_contact_omits_legacy_content_and_prioritizes_active_event_social_media(): void
{
$tenant = new Tenant(['codigo' => 'test']);
foreach (['favicon', 'headerLogo', 'footerLogo', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) {
$tenant->setRelation($relation, null);
}
$contact = new Menu([
'code' => 'help.contact', 'label' => 'Contacto', 'content_type' => 'dynamic', 'route' => '/ayuda/contacto',
]);
$contact->setRelation('pivot', new TenantMenu(['static_content' => ['whatsapp' => 'legacy']]));
$faq = new Menu(['code' => 'help.faq', 'label' => 'FAQ', 'content_type' => 'static']);
$faq->setRelation('pivot', new TenantMenu(['static_content' => ['question' => 'Example']]));
$tenant->setRelation('menues', collect([$contact, $faq]));
$tenantSocial = new SocialMedia(['code' => 'whatsapp', 'name' => 'WhatsApp', 'icon' => 'fa-brands fa-whatsapp']);
$tenantSocial->setRelation('pivot', new Pivot(['url' => 'https://wa.me/111']));
$eventSocial = new SocialMedia(['code' => 'whatsapp', 'name' => 'WhatsApp', 'icon' => 'fa-brands fa-whatsapp']);
$eventSocial->setRelation('pivot', new Pivot(['url' => 'https://wa.me/222']));
$tenant->setRelation('socialMedia', collect([$tenantSocial]));
$event = new Event;
$event->setRelation('socialMedia', collect([$eventSocial]));
$event->setRelation('address', null);
$event->setRelation('dates', collect());
$tenant->setRelation('activeEvent', $event);
$data = TenantResource::make($tenant)->resolve(request());
$this->assertSame('dynamic', $data['menues'][0]['content_type']);
$this->assertArrayNotHasKey('static_content', $data['menues'][0]);
$this->assertSame(['question' => 'Example'], $data['menues'][1]['static_content']);
$this->assertSame('https://wa.me/222', $data['social_media'][0]['url']);
$this->assertSame('https://wa.me/111', $data['tenant_social_media'][0]['url']);
$event->setRelation('socialMedia', collect());
$data = TenantResource::make($tenant)->resolve(request());
$this->assertCount(0, $data['social_media']);
$tenant->setRelation('activeEvent', null);
$data = TenantResource::make($tenant)->resolve(request());
$this->assertSame('https://wa.me/111', $data['social_media'][0]['url']);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Tests\Unit\Catalog;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Resources\CategoryResource;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Storage\Services\TemporaryUrlService;
use Tests\TestCase;
class CategoryResourceTest extends TestCase
{
public function test_it_returns_the_banner_url_only_when_the_banner_exists_and_is_enabled(): void
{
$category = new Category(['nombre' => 'Remeras', 'banner_enabled' => true]);
$category->setRelation('banner', null);
$this->assertNull(CategoryResource::make($category)->resolve()['banner_url']);
$category->setRelation('banner', new Attachment(['path' => 'categories/banner.jpg']));
$category->banner_enabled = false;
$this->mock(TemporaryUrlService::class, function ($mock): void {
$mock->shouldReceive('generate')->once()
->with('categories/banner.jpg', 1440)
->andReturn([
'temporary_url' => 'https://example.com/banner.jpg',
'temporary_url_expires_at' => now()->addDay()->toIso8601String(),
]);
});
$this->assertNull(CategoryResource::make($category)->resolve()['banner_url']);
$category->banner_enabled = true;
$this->assertSame(
'https://example.com/banner.jpg',
CategoryResource::make($category)->resolve()['banner_url'],
);
}
}