Compare commits

...

10 Commits

Author SHA1 Message Date
09faa22920 feat(event-admin): remove menu restrictions for non-admin users and add test for shared menus among event admins 2026-09-30 16:14:47 -03:00
bebf6a7ed5 feat(scanner): scope login tickets scanning and history by event 2026-09-30 15:31:56 -03:00
86ca57a3ad chore(menus): remove deprecated combos and categories admin menus 2026-09-30 15:31:56 -03:00
e64393812c feat(staff): scope scanner management and category assignments by event 2026-09-30 15:31:56 -03:00
655364bfec feat(sales): scope sales actions totals history and exports by event 2026-09-30 15:31:55 -03:00
55c28f33ef feat(events): authorize event editing and date changes by assigned event 2026-09-30 15:31:54 -03:00
5a0ce08adb feat(refunds): persist and apply refund configuration per event 2026-09-30 15:31:54 -03:00
a770d435eb feat(auth): enable event-scoped admin access and backfill staff assignments 2026-09-30 15:31:53 -03:00
44bf67bd12 feat(admin): add admin scope management for users and events
- Introduced AdminScope enum for tenant and event scopes.
- Updated User model to include admin_scope and event_id attributes.
- Enhanced AdminAppAccessService to validate user scopes.
- Modified AdminAppMeResource and UserResource to include event data.
- Implemented middleware to ensure valid tenant access.
- Created migration to add admin_scope and event_id to users table.
- Added tests for event admin functionality and scope validation.
2026-09-30 15:02:10 -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
49 changed files with 1233 additions and 127 deletions

View File

@@ -34,3 +34,8 @@ Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenan
## 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.
## Menús deprecados
Los menús `adminapp.combos` y `adminapp.categories` están retirados; las
categorías del catálogo y sus datos comerciales no se eliminan.

View File

@@ -8,12 +8,13 @@ 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 $purchase) => $purchase->where('event_id', $eventId))
) )
->sum('amount'); ->sum('amount');

View File

@@ -13,6 +13,7 @@ use App\Domains\Commerce\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService; use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService; use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService;
use App\Domains\Commerce\Sale\Services\AdminAppSaleService; use App\Domains\Commerce\Sale\Services\AdminAppSaleService;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -27,15 +28,20 @@ class SaleController extends Controller
protected AdminAppSaleExcelService $saleExcelService, protected AdminAppSaleExcelService $saleExcelService,
) {} ) {}
private function eventId(Request $request): ?int
{
return app(EventScopeService::class)->eventId($request->user());
}
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
{ {
$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(), $this->eventId($request))
)->additional([ )->additional([
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant), 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant, $this->eventId($request)),
'refunded_total' => $this->saleService->refundedTotal($tenant), 'refunded_total' => $this->saleService->refundedTotal($tenant, $this->eventId($request)),
]); ]);
} }
@@ -43,7 +49,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, $this->eventId($request)));
} }
public function tickets(Request $request, int $sale): AnonymousResourceCollection public function tickets(Request $request, int $sale): AnonymousResourceCollection
@@ -51,7 +57,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, $this->eventId($request))
); );
} }
@@ -59,14 +65,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, $this->eventId($request)));
} }
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, $this->eventId($request)));
} }
public function modifications( public function modifications(
@@ -76,6 +82,7 @@ class SaleController extends Controller
$this->saleService->modifications( $this->saleService->modifications(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
$this->eventId($request),
) )
); );
} }
@@ -86,7 +93,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(), $this->eventId($request)),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -97,7 +104,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(), $this->eventId($request)),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -108,7 +115,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(), $this->eventId($request)),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -120,7 +127,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(), $this->eventId($request)),
$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,20 @@ 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 = Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->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 +48,44 @@ 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 Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->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 +96,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 +116,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',
@@ -139,6 +141,7 @@ class AdminAppSaleService
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->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);
@@ -176,11 +179,14 @@ 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) => $query->whereHasMorph(
'trackable', [Purchase::class],
fn (Builder $sales) => $sales->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,10 +227,11 @@ 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 Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->findOrFail($saleId); ->findOrFail($saleId);
} }

View File

@@ -30,3 +30,10 @@ 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.
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.
## Alcance por evento
Las rutas usan `adminapp.tenant:event`. Para admins de evento, listados, totales,
detalles, tickets de compras, confirmación, cancelación, historial y exportaciones
se filtran por `user.event_id` además del tenant. Las compras pertenecen a un
único evento. Un admin de tenant conserva acceso a sus compras de todos los eventos.

View File

@@ -4,7 +4,7 @@ use App\Domains\Commerce\Sale\Controllers\AdminApp\SaleController;
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:event'])
->group(function (): void { ->group(function (): void {
Route::get('sales', [SaleController::class, 'index']); Route::get('sales', [SaleController::class, 'index']);
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']); Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Core\Auth\Enums;
enum AdminScope: string
{
case Tenant = 'tenant';
case Event = 'event';
}

View File

@@ -2,11 +2,13 @@
namespace App\Domains\Core\Auth\Models; namespace App\Domains\Core\Auth\Models;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Auth\Enums\AdminScope;
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 +22,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', 'admin_scope', '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
{ {
@@ -29,6 +31,7 @@ class User extends Authenticatable
protected $attributes = [ protected $attributes = [
'rol_codigo' => RoleCode::User->value, 'rol_codigo' => RoleCode::User->value,
'admin_scope' => AdminScope::Tenant->value,
]; ];
protected static function newFactory(): UserFactory protected static function newFactory(): UserFactory
@@ -86,6 +89,19 @@ 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);
}
public function isTenantAdministrator(): bool
{
return $this->rol_codigo === RoleCode::AdminApp->value
&& $this->admin_scope === AdminScope::Tenant->value
&& $this->event_id === null;
}
/** @return BelongsToMany<Category, $this> */ /** @return BelongsToMany<Category, $this> */
public function scanCategories(): BelongsToMany public function scanCategories(): BelongsToMany
{ {
@@ -103,6 +119,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

@@ -20,6 +20,11 @@ class AdminAppMeResource extends JsonResource
return [ return [
'user' => UserResource::make($this->resource), 'user' => UserResource::make($this->resource),
'tenant' => TenantResource::make($this->tenant), 'tenant' => TenantResource::make($this->tenant),
'event' => $this->whenLoaded('event', fn () => $this->event === null ? null : [
'id' => $this->event->id,
'title' => $this->event->title,
'tenant_code' => $this->event->tenant_code,
]),
]; ];
} }
} }

View File

@@ -16,6 +16,9 @@ class ScannerMeResource extends JsonResource
return [ return [
'user' => UserResource::make($this->resource), 'user' => UserResource::make($this->resource),
'tenant' => TenantResource::make($this->tenant), 'tenant' => TenantResource::make($this->tenant),
'event' => $this->whenLoaded('event', fn () => $this->event === null ? null : [
'id' => $this->event->id, 'title' => $this->event->title,
]),
]; ];
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Core\Auth\Resources; namespace App\Domains\Core\Auth\Resources;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -24,6 +25,8 @@ 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,
'admin_scope' => $this->when($this->rol_codigo === RoleCode::AdminApp->value, $this->admin_scope),
'event_id' => $this->when(in_array($this->rol_codigo, [RoleCode::AdminApp->value, RoleCode::Scanner->value], true), $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

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Core\Auth\Services;
use App\Domains\Core\Auth\Enums\AdminScope;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode;
class AdminAppAccessService
{
public function hasValidScope(User $user): bool
{
if ($user->rol_codigo !== RoleCode::AdminApp->value
|| ! $user->tenant_codigo
|| ! $user->tenant()->exists()) {
return false;
}
if ($user->isTenantAdministrator()) {
return true;
}
return $user->admin_scope === AdminScope::Event->value
&& $user->event_id !== null
&& $user->event()->where('tenant_code', $user->tenant_codigo)->exists();
}
}

View File

@@ -20,6 +20,7 @@ class AdminAppContextService
->firstOrFail(); ->firstOrFail();
$user->setRelation('tenant', $tenant); $user->setRelation('tenant', $tenant);
$user->load('event');
return $user; return $user;
} }

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Core\Auth\Services;
use App\Domains\Core\Auth\Enums\AdminScope;
use App\Domains\Core\Auth\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
class EventScopeService
{
public function eventId(User $user): ?int
{
if ($user->admin_scope === AdminScope::Event->value || $user->event_id !== null) {
if ($user->event_id === null
|| ! $user->event()->where('tenant_code', $user->tenant_codigo)->exists()) {
throw new AuthorizationException;
}
return $user->event_id;
}
return null;
}
}

View File

@@ -10,6 +10,7 @@ use App\Domains\Core\Authorization\Enums\PermissionCode;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Shared\Notification\Events\PasswordResetRequested; use App\Shared\Notification\Events\PasswordResetRequested;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -19,6 +20,7 @@ class PasswordLoginService
{ {
public function __construct( public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService, private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
private readonly AdminAppAccessService $adminAppAccessService,
) {} ) {}
/** /**
@@ -196,6 +198,26 @@ class PasswordLoginService
]; ];
} }
if ($requiredRole === RoleCode::AdminApp && ! $this->adminAppAccessService->hasValidScope($user)) {
$this->recordAttempt(
$user, $normalizedEmail, $attemptTenantCode,
LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $ipAddress, $userAgent,
);
return ['outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, 'user' => $user, 'locked_until' => null];
}
if ($requiredRole === RoleCode::Scanner) {
try {
app(EventScopeService::class)->eventId($user);
} catch (AuthorizationException) {
$this->recordAttempt($user, $normalizedEmail, $attemptTenantCode,
LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $ipAddress, $userAgent);
return ['outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, 'user' => $user, 'locked_until' => null];
}
}
$user->forceFill([ $user->forceFill([
'failed_login_attempts' => 0, 'failed_login_attempts' => 0,
'last_failed_login_at' => null, 'last_failed_login_at' => null,

View File

@@ -18,6 +18,7 @@ class ScannerContextService
->firstOrFail(); ->firstOrFail();
$user->setRelation('tenant', $tenant); $user->setRelation('tenant', $tenant);
$user->load('event');
if ($tenant->requiresScannerCategoryValidation()) { if ($tenant->requiresScannerCategoryValidation()) {
$categories = $user->scanCategories() $categories = $user->scanCategories()

View File

@@ -17,6 +17,6 @@ Route::prefix('v1/adminapp')->group(function (): void {
Route::post('password/reset', ResetPasswordController::class) Route::post('password/reset', ResetPasswordController::class)
->defaults('reset_role', 'adminapp') ->defaults('reset_role', 'adminapp')
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::middleware(['auth:sanctum', 'adminapp.tenant']) Route::middleware(['auth:sanctum', 'adminapp.tenant:context'])
->get('me', AdminAppMeController::class); ->get('me', AdminAppMeController::class);
}); });

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Core\Staff\Controllers; namespace App\Domains\Core\Staff\Controllers;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Core\Staff\Requests\StoreStaffRequest; use App\Domains\Core\Staff\Requests\StoreStaffRequest;
use App\Domains\Core\Staff\Requests\UpdateStaffRequest; use App\Domains\Core\Staff\Requests\UpdateStaffRequest;
use App\Domains\Core\Staff\Resources\StaffResource; use App\Domains\Core\Staff\Resources\StaffResource;
@@ -21,11 +22,17 @@ class AdminAppStaffController extends Controller
private readonly ScannerTicketService $scannerTicketService, private readonly ScannerTicketService $scannerTicketService,
) {} ) {}
private function eventId(Request $request): ?int
{
return app(EventScopeService::class)->eventId($request->user());
}
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
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,
$this->eventId($request),
)); ));
} }
@@ -34,6 +41,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(),
$this->eventId($request),
)); ));
} }
@@ -43,12 +51,13 @@ class AdminAppStaffController extends Controller
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$staff, $staff,
$request->validated(), $request->validated(),
$this->eventId($request),
)); ));
} }
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, $this->eventId($request));
return response()->noContent(); return response()->noContent();
} }
@@ -60,6 +69,7 @@ class AdminAppStaffController extends Controller
$scanner = $this->staffService->find( $scanner = $this->staffService->find(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$staff, $staff,
$this->eventId($request),
); );
return ScanAttemptResource::collection( return ScanAttemptResource::collection(

View File

@@ -14,6 +14,7 @@ class StaffResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'event_id' => $this->event_id,
'nombre_apellido' => $this->nombre_apellido, 'nombre_apellido' => $this->nombre_apellido,
'dni' => $this->dni, 'dni' => $this->dni,
'email' => $this->email, 'email' => $this->email,

View File

@@ -2,12 +2,13 @@
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 App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -22,9 +23,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 {
@@ -38,7 +39,7 @@ class StaffService
} }
/** @return Collection<int, Category> */ /** @return Collection<int, Category> */
private function assignableCategories(Tenant $tenant): Collection private function assignableCategories(Tenant $tenant, ?int $eventId = null): Collection
{ {
return Category::query() return Category::query()
->whereNull('categoria_id') ->whereNull('categoria_id')
@@ -47,23 +48,30 @@ class StaffService
->orWhereHas('catalogItems', fn (Builder $items) => $items ->orWhereHas('catalogItems', fn (Builder $items) => $items
->where('tenant_code', $tenant->codigo)); ->where('tenant_code', $tenant->codigo));
}) })
->when($eventId !== null, fn (Builder $query) => $query->whereHas(
'catalogItems', fn (Builder $items) => $items->where('tenant_code', $tenant->codigo)->where('event_id', $eventId)))
->orderBy('nombre') ->orderBy('nombre')
->get(); ->get();
} }
/** @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
{ {
$eventId ??= $tenant->active_event_id;
Event::query()
->where('tenant_code', $tenant->codigo)->findOrFail($eventId);
$categoryIds = $this->categoryIdsFor($tenant, $data); $categoryIds = $this->categoryIdsFor($tenant, $data);
$this->assertCategoriesBelongToTenant($tenant, $categoryIds); $this->assertCategoriesBelongToTenant($tenant, $categoryIds, $eventId);
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,
'admin_scope' => $eventId === null ? 'tenant' : 'event',
]); ]);
$staff->scanCategories()->sync($categoryIds); $staff->scanCategories()->sync($categoryIds);
$this->resetPasswordAttemptService->createForScannerEmail( $this->resetPasswordAttemptService->createForScannerEmail(
@@ -76,11 +84,11 @@ 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, $eventId);
return DB::transaction(function () use ($staff, $data, $categoryIds): User { return DB::transaction(function () use ($staff, $data, $categoryIds): User {
$attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']); $attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']);
@@ -92,9 +100,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,23 +110,24 @@ 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) => $query->where('event_id', $eventId));
} }
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
* @return array<int, int> * @return array<int, int>
*/ */
private function categoryIdsFor(Tenant $tenant, array $data): array private function categoryIdsFor(Tenant $tenant, array $data, ?int $eventId = null): array
{ {
if (! $tenant->requiresScannerCategoryValidation()) { if (! $tenant->requiresScannerCategoryValidation()) {
return []; return [];
@@ -128,9 +137,9 @@ class StaffService
} }
/** @param array<int, int> $categoryIds */ /** @param array<int, int> $categoryIds */
private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds): void private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds, ?int $eventId = null): void
{ {
$validIds = $this->assignableCategories($tenant) $validIds = $this->assignableCategories($tenant, $eventId)
->whereIn('id', $categoryIds) ->whereIn('id', $categoryIds)
->pluck('id'); ->pluck('id');

View File

@@ -18,3 +18,13 @@ 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.
## Alcance por evento
Los endpoints de Staff y su formulario aceptan `adminapp.tenant:event`. Un admin
de evento lista, edita, elimina y consulta el historial solo de scanners de su
evento. El backend asigna el evento al crear un scanner y no acepta cambios de
asignación desde el formulario. Las categorías autorizables se limitan a las
usadas por productos del evento. Los scanners aplican además su evento en
lectura de tickets, escaneo e historial. Los intentos registran `event_id` para
conservar el aislamiento aunque cambie la asignación del scanner.

View File

@@ -4,7 +4,7 @@ use App\Domains\Core\Staff\Controllers\AdminAppStaffController;
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:event'])
->group(function (): void { ->group(function (): void {
Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']); Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']);
Route::apiResource('staff', AdminAppStaffController::class)->except('show'); Route::apiResource('staff', AdminAppStaffController::class)->except('show');

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Ticketing\Event\Controllers\AdminApp; namespace App\Domains\Ticketing\Event\Controllers\AdminApp;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Event\Requests\RescheduleEventDateRequest; use App\Domains\Ticketing\Event\Requests\RescheduleEventDateRequest;
use App\Domains\Ticketing\Event\Requests\StoreEventDateRequest; use App\Domains\Ticketing\Event\Requests\StoreEventDateRequest;
@@ -19,7 +20,8 @@ class EventController extends Controller
public function show(Request $request): EventResource public function show(Request $request): EventResource
{ {
return EventResource::make( return EventResource::make(
$this->eventService->forTenant($request->user()->tenant()->firstOrFail()) $this->eventService->forTenant($request->user()->tenant()->firstOrFail(),
app(EventScopeService::class)->eventId($request->user()))
); );
} }
@@ -28,7 +30,8 @@ class EventController extends Controller
return EventResource::make( return EventResource::make(
$this->eventService->updateForTenant( $this->eventService->updateForTenant(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated() $request->validated(),
app(EventScopeService::class)->eventId($request->user())
) )
); );
} }
@@ -39,6 +42,7 @@ class EventController extends Controller
$this->eventService->createDateForTenant( $this->eventService->createDateForTenant(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
app(EventScopeService::class)->eventId($request->user()),
) )
); );
} }

View File

@@ -15,14 +15,27 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['client_id', 'tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] #[Fillable(['client_id', 'tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id', 'allow_ticket_refund', 'allow_ticket_total_refund', 'allow_ticket_partial_refund', 'ticket_partial_refund_percentage'])]
class Event extends Model class Event extends Model
{ {
use HasFactory; use HasFactory;
protected function casts(): array protected function casts(): array
{ {
return ['client_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array']; return ['allow_ticket_refund' => 'boolean', 'allow_ticket_total_refund' => 'boolean',
'allow_ticket_partial_refund' => 'boolean', 'ticket_partial_refund_percentage' => 'decimal:2', 'client_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array'];
}
public function allow_refund(): bool
{
return (bool) $this->allow_ticket_refund
&& ((bool) $this->allow_ticket_total_refund || $this->allow_partial_refund());
}
public function allow_partial_refund(): bool
{
return (bool) $this->allow_ticket_refund && (bool) $this->allow_ticket_partial_refund
&& (float) $this->ticket_partial_refund_percentage > 0;
} }
/** @return BelongsTo<Tenant, $this> */ /** @return BelongsTo<Tenant, $this> */

View File

@@ -26,10 +26,10 @@ class EventResource extends JsonResource
'date_text' => $this->date_text, 'date_text' => $this->date_text,
'published_at' => $this->published_at?->toIso8601String(), 'published_at' => $this->published_at?->toIso8601String(),
'attachment_id' => $this->attachment_id, 'attachment_id' => $this->attachment_id,
'allow_ticket_refund' => $this->tenant->allow_ticket_refund, 'allow_ticket_refund' => $this->allow_ticket_refund,
'allow_ticket_total_refund' => $this->tenant->allow_ticket_total_refund, 'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
'allow_ticket_partial_refund' => $this->tenant->allow_ticket_partial_refund, 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
'ticket_partial_refund_percentage' => $this->tenant->ticket_partial_refund_percentage, 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
'dates' => EventDateResource::collection( 'dates' => EventDateResource::collection(
app(EventDateGroupingService::class)->group($this->dates) app(EventDateGroupingService::class)->group($this->dates)
), ),

View File

@@ -2,18 +2,19 @@
namespace App\Domains\Ticketing\Event\Services; namespace App\Domains\Ticketing\Event\Services;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Services\InvalidateEventDateCartsService; use App\Domains\Commerce\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\StockReservationService; use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Catalog\Services\VariantReplacementService; use App\Domains\Commerce\Catalog\Services\VariantReplacementService;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Enums\EventDateChangeType; use App\Domains\Ticketing\Event\Enums\EventDateChangeType;
use App\Domains\Ticketing\Event\Events\EventDateRescheduled; use App\Domains\Ticketing\Event\Events\EventDateRescheduled;
use App\Domains\Ticketing\Event\Events\EventDateSuspended; use App\Domains\Ticketing\Event\Events\EventDateSuspended;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Event\Models\EventDateChange; use App\Domains\Ticketing\Event\Models\EventDateChange;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -34,22 +35,22 @@ class EventService
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts, private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
) {} ) {}
public function forTenant(Tenant $tenant): Event public function forTenant(Tenant $tenant, ?int $eventId = null): Event
{ {
return $tenant->activeEvent->load(['dates.validityTime', 'socialMedia']); return $this->resolveEvent($tenant, $eventId)->load(['dates.validityTime', 'socialMedia']);
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function updateForTenant(Tenant $tenant, array $data): Event public function updateForTenant(Tenant $tenant, array $data, ?int $eventId = null): Event
{ {
return DB::transaction(function () use ($tenant, $data): Event { return DB::transaction(function () use ($tenant, $data, $eventId): Event {
$event = $tenant->activeEvent; $event = $this->resolveEvent($tenant, $eventId);
$event->update([ $event->update([
'title' => $data['title'], 'title' => $data['title'],
'location' => $data['location'], 'location' => $data['location'],
...array_intersect_key($data, ['attachment_id' => true, 'exact_location' => true]), ...array_intersect_key($data, ['attachment_id' => true, 'exact_location' => true]),
]); ]);
$tenant->update([ $event->update([
...array_intersect_key($data, array_flip([ ...array_intersect_key($data, array_flip([
'allow_ticket_refund', 'allow_ticket_refund',
'allow_ticket_total_refund', 'allow_ticket_total_refund',
@@ -69,10 +70,10 @@ class EventService
} }
/** @param array{date: string, start_time: string, end_time: string} $data */ /** @param array{date: string, start_time: string, end_time: string} $data */
public function createDateForTenant(Tenant $tenant, array $data): EventDate public function createDateForTenant(Tenant $tenant, array $data, ?int $eventId = null): EventDate
{ {
return DB::transaction(function () use ($tenant, $data): EventDate { return DB::transaction(function () use ($tenant, $data, $eventId): EventDate {
$event = $tenant->activeEvent; $event = $this->resolveEvent($tenant, $eventId);
$attributes = $this->dateAttributes($data); $attributes = $this->dateAttributes($data);
if ($event->dates()->where($attributes)->exists()) { if ($event->dates()->where($attributes)->exists()) {
@@ -93,7 +94,7 @@ class EventService
?User $createdBy = null, ?User $createdBy = null,
): EventDate { ): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate { return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
$source = $this->lockedDateForTenant($tenant, $eventDate); $source = $this->lockedDateForTenant($tenant, $eventDate, $createdBy);
if ($source->suspended_at !== null) { if ($source->suspended_at !== null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -172,7 +173,7 @@ class EventService
?User $createdBy = null, ?User $createdBy = null,
): EventDate { ): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate { return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
$date = $this->lockedDateForTenant($tenant, $eventDate); $date = $this->lockedDateForTenant($tenant, $eventDate, $createdBy);
if ($date->rescheduled_to_event_date_id !== null) { if ($date->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -216,10 +217,18 @@ class EventService
}); });
} }
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate private function resolveEvent(Tenant $tenant, ?int $eventId): Event
{
return Event::query()->where('tenant_code', $tenant->codigo)
->findOrFail($eventId ?? $tenant->active_event_id);
}
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate, ?User $actor = null): EventDate
{ {
return $tenant->eventDates() return $tenant->eventDates()
->whereKey($eventDate->getKey()) ->whereKey($eventDate->getKey())
->when($actor !== null && ! $actor->isTenantAdministrator(),
fn ($query) => $query->where('event_id', app(EventScopeService::class)->eventId($actor)))
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
} }

View File

@@ -37,10 +37,10 @@ se guardan en el evento. Sin evento activo se conservan las redes propias del te
## API ## API
Los endpoints de AdminApp usan `auth:sanctum` y `adminapp.tenant`: Los endpoints de AdminApp usan `auth:sanctum` y `adminapp.tenant:event`:
- `GET/PUT /v1/adminapp/tenant/event`: acceso al evento seleccionado para el - `GET/PUT /v1/adminapp/tenant/event`: acceso al evento asociado al usuario con scope de evento;
storefront de evento único. para admins de tenant se conserva el evento activo.
- `POST /v1/adminapp/tenant/event-dates`: crea una fecha para ese evento. - `POST /v1/adminapp/tenant/event-dates`: crea una fecha para ese evento.
- `POST /v1/adminapp/tenant/event-dates/{eventDate}/reschedule` y - `POST /v1/adminapp/tenant/event-dates/{eventDate}/reschedule` y
`POST /v1/adminapp/tenant/event-dates/{eventDate}/suspend`: cambios de fecha. `POST /v1/adminapp/tenant/event-dates/{eventDate}/suspend`: cambios de fecha.
@@ -53,3 +53,8 @@ una lista de eventos ni existe todavía una pantalla para gestionarlos.
Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar
tickets. Los avisos por suspensión y reprogramación se construyen dinámicamente tickets. Los avisos por suspensión y reprogramación se construyen dinámicamente
después de excluir los cambios que el usuario ya vio tres veces. después de excluir los cambios que el usuario ya vio tres veces.
La configuración de devoluciones se persiste en `events`, se entrega en
`EventResource` y se aplica al evento de cada ticket. La migración inicial copia
los valores anteriores del tenant a sus eventos. Las fechas se autorizan por
tenant y por evento antes de cualquier suspensión o reprogramación.

View File

@@ -4,7 +4,7 @@ use App\Domains\Ticketing\Event\Controllers\AdminApp\EventController;
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:event'])
->group(function (): void { ->group(function (): void {
Route::get('event', [EventController::class, 'show']); Route::get('event', [EventController::class, 'show']);
Route::put('event', [EventController::class, 'update']); Route::put('event', [EventController::class, 'update']);

View File

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable([
'tenant_code', 'tenant_code',
'event_id',
'scanner_user_id', 'scanner_user_id',
'ticket_id', 'ticket_id',
'data', 'data',
@@ -43,6 +44,7 @@ class ScanAttempt extends Model
{ {
return [ return [
'scanner_user_id' => 'integer', 'scanner_user_id' => 'integer',
'event_id' => 'integer',
'ticket_id' => 'integer', 'ticket_id' => 'integer',
'result' => ScanAttemptResult::class, 'result' => ScanAttemptResult::class,
'created_at' => 'datetime', 'created_at' => 'datetime',

View File

@@ -148,7 +148,9 @@ class Ticket extends Model
public function allow_refund(): bool public function allow_refund(): bool
{ {
return $this->tenant?->allow_refund() ?? false; return $this->event_id === null
? ($this->tenant?->allow_refund() ?? false)
: ($this->event?->allow_refund() ?? false);
} }
public function allowRefund(): bool public function allowRefund(): bool

View File

@@ -32,7 +32,7 @@ class ScanAttemptResource extends JsonResource
ScanAttemptResult::Processing => 'Error', ScanAttemptResult::Processing => 'Error',
ScanAttemptResult::Accepted => 'Verificado', ScanAttemptResult::Accepted => 'Verificado',
ScanAttemptResult::InvalidQr => 'QR no pertenece al evento', ScanAttemptResult::InvalidQr => 'QR no pertenece al evento',
ScanAttemptResult::TicketNotFound => 'Error', ScanAttemptResult::TicketNotFound => 'QR no pertenece al evento',
ScanAttemptResult::CategoryForbidden => 'Error', ScanAttemptResult::CategoryForbidden => 'Error',
ScanAttemptResult::AlreadyScanned => 'Usado', ScanAttemptResult::AlreadyScanned => 'Usado',
ScanAttemptResult::Expired => 'Vencido', ScanAttemptResult::Expired => 'Vencido',

View File

@@ -9,6 +9,7 @@ use App\Domains\Commerce\Purchase\Models\PurchaseItem;
use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService;
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\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Models\TicketRefund; use App\Domains\Ticketing\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -23,6 +24,7 @@ class AdminAppTicketService
...TicketValidityResolver::RELATIONS, ...TicketValidityResolver::RELATIONS,
...TicketPresentationResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS,
'tenant', 'tenant',
'event',
'user', 'user',
'scannerUser', 'scannerUser',
'sourceCatalogItem.category', 'sourceCatalogItem.category',
@@ -145,19 +147,20 @@ class AdminAppTicketService
]); ]);
} }
$configuration = $ticket->event_id === null ? $tenant : $ticket->event;
$unitPrice = (float) $purchaseItem->precio_unitario; $unitPrice = (float) $purchaseItem->precio_unitario;
$itemTotal = (float) $purchaseItem->total; $itemTotal = (float) $purchaseItem->total;
$itemRefundedAmount = $this->refundedAmountForPurchaseItem($purchaseItem); $itemRefundedAmount = $this->refundedAmountForPurchaseItem($purchaseItem);
$remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2)); $remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2));
$total = null; $total = null;
if ($tenant->allow_refund() && $tenant->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) { if ($configuration->allow_refund() && $configuration->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) {
$total = number_format($unitPrice, 2, '.', ''); $total = number_format($unitPrice, 2, '.', '');
} }
$partial = null; $partial = null;
if ($tenant->allow_refund() && $tenant->allow_partial_refund()) { if ($configuration->allow_refund() && $configuration->allow_partial_refund()) {
$partialAmount = $this->refundAmount($purchaseItem, $tenant, 'partial'); $partialAmount = $this->refundAmount($purchaseItem, $configuration, 'partial');
if ($partialAmount <= $remainingItemAmount) { if ($partialAmount <= $remainingItemAmount) {
$partial = number_format($partialAmount, 2, '.', ''); $partial = number_format($partialAmount, 2, '.', '');
} }
@@ -175,14 +178,15 @@ 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 = Ticket::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->lockForUpdate() ->lockForUpdate()
->findOrFail($ticketId); ->findOrFail($ticketId);
$configuration = $ticket->event_id === null ? $tenant : $ticket->event;
$this->ensureRefundIsAllowed($configuration, $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([
@@ -205,7 +209,7 @@ class AdminAppTicketService
]); ]);
} }
$refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType); $refundAmount = $this->refundAmount($purchaseItem, $configuration, $refundType);
$refundedAmount = round( $refundedAmount = round(
$this->refundedAmountForPurchaseItem($purchaseItem) + $refundAmount, $this->refundedAmountForPurchaseItem($purchaseItem) + $refundAmount,
2, 2,
@@ -286,7 +290,7 @@ class AdminAppTicketService
->sum('amount'), 2); ->sum('amount'), 2);
} }
private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void private function ensureRefundIsAllowed(Tenant|Event $tenant, string $refundType): void
{ {
$isAllowed = match ($refundType) { $isAllowed = match ($refundType) {
TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(), TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(),
@@ -300,7 +304,7 @@ class AdminAppTicketService
} }
} }
private function refundAmount(PurchaseItem $purchaseItem, Tenant $tenant, string $refundType): float private function refundAmount(PurchaseItem $purchaseItem, Tenant|Event $tenant, string $refundType): float
{ {
$ticketAmount = (float) $purchaseItem->precio_unitario; $ticketAmount = (float) $purchaseItem->precio_unitario;

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Ticketing\Ticket\Services; namespace App\Domains\Ticketing\Ticket\Services;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Ticketing\Ticket\Enums\ScanAttemptResult; use App\Domains\Ticketing\Ticket\Enums\ScanAttemptResult;
use App\Domains\Ticketing\Ticket\Models\ScanAttempt; use App\Domains\Ticketing\Ticket\Models\ScanAttempt;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
@@ -27,6 +28,7 @@ class ScannerTicketService
->with('ticket.sourceCatalogItem.category') ->with('ticket.sourceCatalogItem.category')
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey()) ->where('scanner_user_id', $scanner->getKey())
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->when($search !== '', function (Builder $query) use ($search): void { ->when($search !== '', function (Builder $query) use ($search): void {
$attemptedAtDate = $this->parseSearchDate($search); $attemptedAtDate = $this->parseSearchDate($search);
@@ -60,6 +62,7 @@ class ScannerTicketService
->with('ticket.sourceCatalogItem.category') ->with('ticket.sourceCatalogItem.category')
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey()) ->where('scanner_user_id', $scanner->getKey())
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->when($search !== '', function (Builder $query) use ($search): void { ->when($search !== '', function (Builder $query) use ($search): void {
$attemptedAtDate = $this->parseSearchDate($search); $attemptedAtDate = $this->parseSearchDate($search);
$attemptedAtDayMonth = $this->parseSearchDayMonth($search); $attemptedAtDayMonth = $this->parseSearchDayMonth($search);
@@ -106,6 +109,7 @@ class ScannerTicketService
->with('ticket') ->with('ticket')
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey()) ->where('scanner_user_id', $scanner->getKey())
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->findOrFail($scanAttemptId); ->findOrFail($scanAttemptId);
$scanAttempt->ticket?->loadMissing($this->relations()); $scanAttempt->ticket?->loadMissing($this->relations());
@@ -113,6 +117,11 @@ class ScannerTicketService
return $scanAttempt; return $scanAttempt;
} }
private function eventId(User $scanner): ?int
{
return app(EventScopeService::class)->eventId($scanner);
}
private function parseSearchDate(string $search): ?string private function parseSearchDate(string $search): ?string
{ {
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) { if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) {
@@ -154,7 +163,8 @@ class ScannerTicketService
{ {
$query = $this->baseQuery() $query = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $ticketUuid); ->where('ticket', $ticketUuid)
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)));
if ($this->requiresCategoryValidation($scanner)) { if ($this->requiresCategoryValidation($scanner)) {
$categoryIds = $this->scannerCategoryIds($scanner); $categoryIds = $this->scannerCategoryIds($scanner);
@@ -178,6 +188,7 @@ class ScannerTicketService
$scanAttempt = ScanAttempt::query()->create([ $scanAttempt = ScanAttempt::query()->create([
'tenant_code' => $scanner->tenant_codigo, 'tenant_code' => $scanner->tenant_codigo,
'scanner_user_id' => $scanner->getKey(), 'scanner_user_id' => $scanner->getKey(),
'event_id' => $this->eventId($scanner),
'data' => $this->serializeScannedData($scannedData), 'data' => $this->serializeScannedData($scannedData),
'result' => ScanAttemptResult::Processing, 'result' => ScanAttemptResult::Processing,
]); ]);
@@ -200,6 +211,7 @@ class ScannerTicketService
$ticket = $this->baseQuery() $ticket = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $scannedData) ->where('ticket', $scannedData)
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
$ticketId = (int) $ticket->getKey(); $ticketId = (int) $ticket->getKey();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Domains\Core\Auth\Services\AdminAppAccessService;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use Closure; use Closure;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -10,10 +11,12 @@ use Symfony\Component\HttpFoundation\Response;
class EnsureAdminAppTenant class EnsureAdminAppTenant
{ {
public function __construct(private readonly AdminAppAccessService $accessService) {}
/** /**
* Ensure the authenticated user is an AdminApp user bound to a tenant. * Ensure the authenticated user is an AdminApp user bound to a tenant.
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next, string $access = 'tenant'): Response
{ {
$user = $request->user(); $user = $request->user();
@@ -21,6 +24,9 @@ class EnsureAdminAppTenant
! $user ! $user
|| $user->rol_codigo !== RoleCode::AdminApp->value || $user->rol_codigo !== RoleCode::AdminApp->value
|| ! $user->tenant_codigo || ! $user->tenant_codigo
|| ! $this->accessService->hasValidScope($user)
// Only routes implementing event authorization may accept event administrators.
|| (! in_array($access, ['context', 'event'], true) && ! $user->isTenantAdministrator())
) { ) {
throw new AuthorizationException; throw new AuthorizationException;
} }

View File

@@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Core\Authorization\Enums\PermissionCode; use App\Domains\Core\Authorization\Enums\PermissionCode;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use Closure; use Closure;
@@ -27,6 +28,8 @@ class EnsureScannerTenant
throw new AuthorizationException; throw new AuthorizationException;
} }
app(EventScopeService::class)->eventId($user);
return $next($request); return $next($request);
} }
} }

View File

@@ -2,9 +2,10 @@
namespace App\Shared\Forms\Controllers\AdminApp; namespace App\Shared\Forms\Controllers\AdminApp;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Http\Controllers\Controller;
use App\Shared\Forms\Resources\EventFormResource; use App\Shared\Forms\Resources\EventFormResource;
use App\Shared\Forms\Services\EventFormService; use App\Shared\Forms\Services\EventFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class EventFormController extends Controller class EventFormController extends Controller
@@ -15,7 +16,8 @@ class EventFormController extends Controller
{ {
return EventFormResource::make( return EventFormResource::make(
$this->eventFormService->get( $this->eventFormService->get(
$request->user('sanctum')->tenant()->firstOrFail() $request->user('sanctum')->tenant()->firstOrFail(),
app(EventScopeService::class)->eventId($request->user())
) )
); );
} }

View File

@@ -2,9 +2,10 @@
namespace App\Shared\Forms\Controllers\AdminApp; namespace App\Shared\Forms\Controllers\AdminApp;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Http\Controllers\Controller;
use App\Shared\Forms\Resources\StaffFormResource; use App\Shared\Forms\Resources\StaffFormResource;
use App\Shared\Forms\Services\StaffFormService; use App\Shared\Forms\Services\StaffFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class StaffFormController extends Controller class StaffFormController extends Controller
@@ -15,7 +16,8 @@ class StaffFormController extends Controller
{ {
return StaffFormResource::make( return StaffFormResource::make(
$this->staffFormService->get( $this->staffFormService->get(
$request->user('sanctum')->tenant()->firstOrFail() $request->user('sanctum')->tenant()->firstOrFail(),
app(EventScopeService::class)->eventId($request->user())
) )
); );
} }

View File

@@ -4,14 +4,17 @@ namespace App\Shared\Forms\Services;
use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\SocialMedia;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
class EventFormService class EventFormService
{ {
/** @return array{social_media: Collection<int, SocialMedia>} */ /** @return array{social_media: Collection<int, SocialMedia>} */
public function get(Tenant $tenant): array public function get(Tenant $tenant, ?int $eventId = null): array
{ {
$event = $tenant->activeEvent; $event = $eventId === null ? $tenant->activeEvent
: Event::query()
->where('tenant_code', $tenant->codigo)->findOrFail($eventId);
$urls = $event?->socialMedia() $urls = $event?->socialMedia()
->pluck('event_social_media.url', 'social_media.code') ?? collect(); ->pluck('event_social_media.url', 'social_media.code') ?? collect();

View File

@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Collection;
class StaffFormService class StaffFormService
{ {
/** @return array{categories: Collection<int, Category>} */ /** @return array{categories: Collection<int, Category>} */
public function get(Tenant $tenant): array public function get(Tenant $tenant, ?int $eventId = null): array
{ {
return [ return [
'categories' => Category::query() 'categories' => Category::query()
@@ -20,6 +20,8 @@ class StaffFormService
->orWhereHas('catalogItems', fn (Builder $items) => $items ->orWhereHas('catalogItems', fn (Builder $items) => $items
->where('tenant_code', $tenant->codigo)); ->where('tenant_code', $tenant->codigo));
}) })
->when($eventId !== null, fn (Builder $query) => $query->whereHas(
'catalogItems', fn (Builder $items) => $items->where('tenant_code', $tenant->codigo)->where('event_id', $eventId)))
->orderBy('nombre') ->orderBy('nombre')
->get(), ->get(),
]; ];

View File

@@ -12,33 +12,33 @@ use App\Shared\Forms\Controllers\AdminApp\TicketFormController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/forms') Route::prefix('v1/adminapp/forms')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant:event'])
->group(function (): void { ->group(function (): void {
Route::get('event', EventFormController::class); Route::get('event', EventFormController::class);
Route::get( Route::get(
'desfile/entry-reservation', 'desfile/entry-reservation',
DesfileEntryReservationFormController::class DesfileEntryReservationFormController::class
)->middleware('tenant.menu:adminapp.desfile.reservas') )->middleware('adminapp.tenant')->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('adminapp.tenant')
->middleware('tenant.menu: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',
TicketFormController::class TicketFormController::class
); )->middleware('adminapp.tenant');
Route::get( Route::get(
'fiesta-futbol-infantil/entry', 'fiesta-futbol-infantil/entry',
EntryFormController::class EntryFormController::class
); )->middleware('adminapp.tenant');
Route::get( Route::get(
'fiesta-futbol-infantil/merchandise', 'fiesta-futbol-infantil/merchandise',
MerchandiseFormController::class MerchandiseFormController::class
); )->middleware('adminapp.tenant');
Route::get( Route::get(
'fiesta-futbol-infantil/food', 'fiesta-futbol-infantil/food',
FoodFormController::class FoodFormController::class
); )->middleware('adminapp.tenant');
}); });

View File

@@ -0,0 +1,25 @@
<?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 {
// Existing administrators keep their tenant-wide access.
$table->string('admin_scope', 20)->default('tenant');
$table->foreignId('event_id')->nullable()->constrained('events')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
$table->dropColumn('admin_scope');
});
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::transaction(function (): void {
DB::table('tenants')
->join('events', 'events.id', '=', 'tenants.active_event_id')
->whereColumn('events.tenant_code', 'tenants.codigo')
->select('tenants.codigo', 'tenants.active_event_id')
->get()
->each(function (object $tenant): void {
DB::table('users')
->whereIn('rol_codigo', ['adminapp', 'scanner'])
->where('tenant_codigo', $tenant->codigo)
->where('admin_scope', 'tenant')
->whereNull('event_id')
->whereNull('deleted_at')
->update([
'admin_scope' => 'event',
'event_id' => $tenant->active_event_id,
'updated_at' => now(),
]);
});
// Missing or mismatched active events must not leave legacy staff with tenant-wide access.
DB::table('users')
->whereIn('rol_codigo', ['adminapp', 'scanner'])
->where('admin_scope', 'tenant')
->whereNull('event_id')
->whereNull('deleted_at')
->update(['admin_scope' => 'event', 'updated_at' => now()]);
});
}
public function down(): void
{
// Do not broaden permissions or overwrite subsequent assignments on rollback.
}
};

View File

@@ -0,0 +1,38 @@
<?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('events', function (Blueprint $table): void {
$table->boolean('allow_ticket_refund')->default(false);
$table->boolean('allow_ticket_total_refund')->default(false);
$table->boolean('allow_ticket_partial_refund')->default(false);
$table->decimal('ticket_partial_refund_percentage', 5, 2)->default(0);
});
DB::table('tenants')->select([
'codigo', 'allow_ticket_refund', 'allow_ticket_total_refund',
'allow_ticket_partial_refund', 'ticket_partial_refund_percentage',
])->get()->each(function (object $tenant): void {
DB::table('events')->where('tenant_code', $tenant->codigo)->update([
'allow_ticket_refund' => $tenant->allow_ticket_refund,
'allow_ticket_total_refund' => $tenant->allow_ticket_total_refund,
'allow_ticket_partial_refund' => $tenant->allow_ticket_partial_refund,
'ticket_partial_refund_percentage' => $tenant->ticket_partial_refund_percentage ?? 0,
]);
});
}
public function down(): void
{
Schema::table('events', fn (Blueprint $table) => $table->dropColumn([
'allow_ticket_refund', 'allow_ticket_total_refund',
'allow_ticket_partial_refund', 'ticket_partial_refund_percentage',
]));
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::transaction(function (): void {
$codes = ['adminapp.combos', 'adminapp.categories'];
DB::table('roles_menues')->whereIn('menu_codigo', $codes)->delete();
DB::table('tenants_menues')->whereIn('menu_code', $codes)->delete();
DB::table('menues')->whereIn('code', $codes)->delete();
});
}
public function down(): void
{
// Deprecated menus must not be restored by rollback.
}
};

View File

@@ -0,0 +1,28 @@
<?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('scan_attempts', function (Blueprint $table): void {
$table->foreignId('event_id')->nullable()->constrained('events')->nullOnDelete();
$table->index(['scanner_user_id', 'event_id']);
});
DB::table('scan_attempts')->update([
'event_id' => DB::raw('(SELECT tickets.event_id FROM tickets WHERE tickets.id = scan_attempts.ticket_id)'),
]);
}
public function down(): void
{
Schema::table('scan_attempts', function (Blueprint $table): void {
$table->dropIndex(['scanner_user_id', 'event_id']);
$table->dropConstrainedForeignId('event_id');
});
}
};

View File

@@ -70,18 +70,6 @@ class MenuSeeder extends Seeder
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/catalog', 'route' => '/admin/catalog',
], ],
[
'code' => 'adminapp.combos',
'label' => 'Combos',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/combos',
],
[
'code' => 'adminapp.categories',
'label' => 'Categorías',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/categories',
],
[ [
'code' => 'adminapp.ventas', 'code' => 'adminapp.ventas',
'label' => 'Ventas', 'label' => 'Ventas',
@@ -242,6 +230,8 @@ class MenuSeeder extends Seeder
->whereIn('code', [ ->whereIn('code', [
'admin.event', 'admin.event',
'admin.catalog', 'admin.catalog',
'adminapp.combos',
'adminapp.categories',
'admin.combos', 'admin.combos',
'admin.categories', 'admin.categories',
'admin.ventas', 'admin.ventas',

View File

@@ -0,0 +1,236 @@
<?php
namespace Tests\Feature\Auth;
use App\Domains\Core\Auth\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
class AdminAppEventScopeTest extends TestCase
{
private User $user;
protected function setUp(): void
{
parent::setUp();
// Focused pre-migration schema: do not replay unrelated provisioning migrations.
// Tests\TestCase and tests/bootstrap.php enforce SQLite :memory:.
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo')->unique();
$table->string('nombre');
$table->string('dominio');
$table->string('search_product_layout')->default('column_with_image');
$table->string('search_group_layout')->default('paginated');
});
Schema::create('events', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->string('title');
$table->timestamps();
});
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('nombre_apellido');
$table->string('email');
$table->string('active_email')->virtualAs('CASE WHEN deleted_at IS NULL THEN lower(email) ELSE NULL END');
$table->string('password');
$table->string('rol_codigo')->default('user');
$table->string('tenant_codigo')->nullable();
$table->softDeletes();
$table->timestamps();
$table->unique(['active_email', 'rol_codigo']);
});
Schema::create('menues', function (Blueprint $table): void {
$table->id();
$table->string('code')->unique();
$table->string('label');
$table->string('route');
$table->string('parent_menu_code')->nullable();
$table->string('content_type')->default('dynamic');
});
Schema::create('roles_menues', function (Blueprint $table): void {
$table->string('rol_codigo');
$table->string('menu_codigo');
});
Schema::create('tenants_menues', function (Blueprint $table): void {
$table->string('tenant_code');
$table->string('menu_code');
$table->json('static_content')->nullable();
$table->timestamps();
});
foreach ([
'2026_06_18_130006_create_personal_access_tokens_table.php',
'2026_07_28_000000_create_roles_and_permissions_tables.php',
'2026_07_29_000000_add_login_security_fields_to_users_table.php',
'2026_07_29_000100_create_login_attempts_table.php',
] as $file) {
(require database_path('migrations/'.$file))->up();
}
DB::table('roles')->insert(['codigo' => 'adminapp', 'nombre' => 'AdminApp']);
DB::table('tenants')->insert([
['codigo' => 'onticket', 'nombre' => 'OnTicket', 'dominio' => 'onticket.test'],
['codigo' => 'other', 'nombre' => 'Other', 'dominio' => 'other.test'],
]);
DB::table('events')->insert([
['id' => 1, 'tenant_code' => 'onticket', 'title' => 'Evento A'],
['id' => 2, 'tenant_code' => 'onticket', 'title' => 'Evento B'],
['id' => 3, 'tenant_code' => 'other', 'title' => 'Evento ajeno'],
]);
// An existing administrator must remain general after applying the new migration.
DB::table('users')->insert([
'id' => 1, 'nombre_apellido' => 'Admin', 'email' => 'admin@example.test',
'password' => Hash::make('secret123'), 'rol_codigo' => 'adminapp', 'tenant_codigo' => 'onticket',
]);
$this->scopeMigration()->up();
$this->user = User::query()->findOrFail(1);
DB::table('menues')->insert([
['code' => 'main.adminapp', 'label' => 'Administración', 'route' => '/', 'parent_menu_code' => null],
['code' => 'adminapp.ventas', 'label' => 'Ventas', 'route' => '/admin/ventas', 'parent_menu_code' => 'main.adminapp'],
]);
foreach (['main.adminapp', 'adminapp.ventas'] as $code) {
DB::table('roles_menues')->insert(['rol_codigo' => 'adminapp', 'menu_codigo' => $code]);
DB::table('tenants_menues')->insert(['tenant_code' => 'onticket', 'menu_code' => $code]);
}
}
private function scopeMigration(): Migration
{
return require database_path('migrations/2026_09_30_000000_add_admin_scope_to_users.php');
}
private function login(array $extra = []): TestResponse
{
return $this->postJson('/api/v1/adminapp/login', [
'email' => ' ADMIN@EXAMPLE.TEST ', 'password' => 'secret123', ...$extra,
]);
}
public function test_existing_admin_keeps_general_access_after_migration(): void
{
$this->assertTrue($this->user->isTenantAdministrator());
$token = $this->login()->assertOk()->assertJsonPath('user.admin_scope', 'tenant')
->assertJsonPath('user.event_id', null)->json('token');
$this->withToken($token)->getJson('/api/v1/adminapp/me')->assertOk()
->assertJsonPath('data.event', null)->assertJsonCount(1, 'data.tenant.menues.0.submenues');
}
public function test_event_admin_logs_in_and_restores_only_its_assigned_context(): void
{
$this->user->update(['admin_scope' => 'event', 'event_id' => 1]);
$this->assertSame(1, $this->user->event->id);
// Scope cannot be chosen by the caller during login.
$token = $this->login(['admin_scope' => 'tenant', 'event_id' => 2])->assertOk()
->assertJsonPath('user.admin_scope', 'event')->assertJsonPath('user.event_id', 1)->json('token');
$this->withToken($token)->getJson('/api/v1/adminapp/me')->assertOk()
->assertJsonPath('data.event.id', 1)->assertJsonPath('data.event.title', 'Evento A')
->assertJsonPath('data.tenant.codigo', 'onticket')
->assertJsonCount(1, 'data.tenant.menues.0.submenues');
$this->assertSame(['adminapp'], $this->user->tokens()->sole()->abilities);
}
public function test_event_admins_of_the_same_tenant_receive_the_same_assigned_menus(): void
{
DB::table('menues')->insert([
'code' => 'onticket.adminapp.event',
'label' => 'Eventos',
'route' => '/admin/event',
'parent_menu_code' => 'main.adminapp',
]);
DB::table('roles_menues')->insert([
'rol_codigo' => 'adminapp',
'menu_codigo' => 'onticket.adminapp.event',
]);
DB::table('tenants_menues')->insert([
'tenant_code' => 'onticket',
'menu_code' => 'onticket.adminapp.event',
]);
$this->user->update(['admin_scope' => 'event', 'event_id' => 1]);
$token = $this->login()->assertOk()->json('token');
$firstMenus = $this->withToken($token)->getJson('/api/v1/adminapp/me')
->assertOk()->json('data.tenant.menues');
$this->user->update(['event_id' => 2]);
$secondMenus = $this->withToken($token)->getJson('/api/v1/adminapp/me')
->assertOk()->json('data.tenant.menues');
$this->assertSame($firstMenus, $secondMenus);
$this->assertSame(
['adminapp.ventas', 'onticket.adminapp.event'],
collect($firstMenus[0]['submenues'])->pluck('code')->sort()->values()->all(),
);
}
public function test_invalid_scopes_never_issue_tokens_or_increment_password_failure_counts(): void
{
foreach ([
['admin_scope' => 'event', 'event_id' => null],
['admin_scope' => 'event', 'event_id' => 3],
['admin_scope' => 'tenant', 'event_id' => 1],
['admin_scope' => 'unknown', 'event_id' => null],
['admin_scope' => 'event', 'event_id' => 1, 'tenant_codigo' => null],
] as $attributes) {
$this->user->update($attributes);
$this->login()->assertUnprocessable()->assertJsonValidationErrors('email');
}
$this->assertDatabaseCount('personal_access_tokens', 0);
$this->assertSame(0, $this->user->refresh()->failed_login_attempts);
}
public function test_event_admin_cannot_access_tenant_operations_but_can_logout(): void
{
$this->user->update(['admin_scope' => 'event', 'event_id' => 1]);
$token = $this->login()->assertOk()->json('token');
foreach (['tenant/tickets', 'tenant/administrators', 'tenant/website-extras', 'forms/tickets-filter'] as $path) {
$this->withToken($token)->getJson('/api/v1/adminapp/'.$path)->assertForbidden();
}
$this->withToken($token)->postJson('/api/v1/adminapp/tenant/administrators', [])->assertForbidden();
$this->withToken($token)->postJson('/api/logout')->assertOk();
$this->assertDatabaseCount('personal_access_tokens', 0);
}
public function test_deleting_the_event_invalidates_an_existing_token_without_promoting_the_user(): void
{
$this->user->update(['admin_scope' => 'event', 'event_id' => 1]);
$token = $this->login()->assertOk()->json('token');
DB::table('events')->where('id', 1)->delete();
$this->assertNull($this->user->refresh()->event_id);
$this->assertSame('event', $this->user->admin_scope);
$this->withToken($token)->getJson('/api/v1/adminapp/me')->assertForbidden();
$this->withToken($token)->getJson('/api/v1/adminapp/tenant/administrators')->assertForbidden();
}
public function test_event_reassignment_and_tenant_changes_apply_to_existing_tokens(): void
{
$this->user->update(['admin_scope' => 'event', 'event_id' => 1]);
$token = $this->login()->assertOk()->json('token');
$this->user->update(['event_id' => 2]);
$this->withToken($token)->getJson('/api/v1/adminapp/me')->assertOk()->assertJsonPath('data.event.id', 2);
DB::table('events')->where('id', 2)->update(['tenant_code' => 'other']);
$this->withToken($token)->getJson('/api/v1/adminapp/me')->assertForbidden();
}
public function test_wrong_password_still_counts_as_a_failed_attempt(): void
{
$this->user->update(['admin_scope' => 'event', 'event_id' => 1]);
$this->login(['password' => 'wrong'])->assertUnprocessable()->assertJsonValidationErrors('email');
$this->assertSame(1, $this->user->refresh()->failed_login_attempts);
$this->assertDatabaseCount('personal_access_tokens', 0);
}
public function test_scope_migration_can_be_rolled_back_without_removing_users(): void
{
$this->scopeMigration()->down();
$this->assertFalse(Schema::hasColumn('users', 'admin_scope'));
$this->assertFalse(Schema::hasColumn('users', 'event_id'));
$this->assertDatabaseHas('users', ['id' => 1, 'email' => 'admin@example.test']);
}
}

View File

@@ -0,0 +1,485 @@
<?php
namespace Tests\Feature\Auth;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Sale\Services\AdminAppSaleService;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Services\AdminAppTicketService;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Testing\TestResponse;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class EventScopedOperationsTest extends TestCase
{
private User $user;
protected function setUp(): void
{
parent::setUp();
// Focused pre-migration schema: do not replay unrelated provisioning migrations.
// Tests\TestCase and tests/bootstrap.php enforce SQLite :memory:.
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo')->unique();
$table->string('nombre');
$table->string('dominio');
$table->string('search_product_layout')->default('column_with_image');
$table->string('search_group_layout')->default('paginated');
});
Schema::create('events', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->string('title');
$table->timestamps();
});
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('nombre_apellido');
$table->string('email');
$table->string('active_email')->virtualAs('CASE WHEN deleted_at IS NULL THEN lower(email) ELSE NULL END');
$table->string('password');
$table->string('rol_codigo')->default('user');
$table->string('tenant_codigo')->nullable();
$table->softDeletes();
$table->timestamps();
$table->unique(['active_email', 'rol_codigo']);
});
Schema::create('menues', function (Blueprint $table): void {
$table->id();
$table->string('code')->unique();
$table->string('label');
$table->string('route');
$table->string('parent_menu_code')->nullable();
$table->string('content_type')->default('dynamic');
});
Schema::create('roles_menues', function (Blueprint $table): void {
$table->string('rol_codigo');
$table->string('menu_codigo');
});
Schema::create('tenants_menues', function (Blueprint $table): void {
$table->string('tenant_code');
$table->string('menu_code');
$table->json('static_content')->nullable();
$table->timestamps();
});
foreach ([
'2026_06_18_130006_create_personal_access_tokens_table.php',
'2026_07_28_000000_create_roles_and_permissions_tables.php',
'2026_07_29_000000_add_login_security_fields_to_users_table.php',
'2026_07_29_000100_create_login_attempts_table.php',
] as $file) {
(require database_path('migrations/'.$file))->up();
}
DB::table('roles')->insert(['codigo' => 'adminapp', 'nombre' => 'AdminApp']);
DB::table('tenants')->insert([
['codigo' => 'onticket', 'nombre' => 'OnTicket', 'dominio' => 'onticket.test'],
['codigo' => 'other', 'nombre' => 'Other', 'dominio' => 'other.test'],
]);
DB::table('events')->insert([
['id' => 1, 'tenant_code' => 'onticket', 'title' => 'Evento A'],
['id' => 2, 'tenant_code' => 'onticket', 'title' => 'Evento B'],
['id' => 3, 'tenant_code' => 'other', 'title' => 'Evento ajeno'],
]);
// An existing administrator must remain general after applying the new migration.
DB::table('users')->insert([
'id' => 1, 'nombre_apellido' => 'Admin', 'email' => 'admin@example.test',
'password' => Hash::make('secret123'), 'rol_codigo' => 'adminapp', 'tenant_codigo' => 'onticket',
]);
$this->scopeMigration()->up();
$this->user = User::query()->findOrFail(1);
$this->createOperationsSchema();
DB::table('menues')->insert([
['code' => 'main.adminapp', 'label' => 'Administración', 'route' => '/', 'parent_menu_code' => null],
['code' => 'adminapp.ventas', 'label' => 'Ventas', 'route' => '/admin/ventas', 'parent_menu_code' => 'main.adminapp'],
]);
foreach (['main.adminapp', 'adminapp.ventas'] as $code) {
DB::table('roles_menues')->insert(['rol_codigo' => 'adminapp', 'menu_codigo' => $code]);
DB::table('tenants_menues')->insert(['tenant_code' => 'onticket', 'menu_code' => $code]);
}
}
private function scopeMigration(): Migration
{
return require database_path('migrations/2026_09_30_000000_add_admin_scope_to_users.php');
}
private function login(array $extra = []): TestResponse
{
return $this->postJson('/api/v1/adminapp/login', [
'email' => ' ADMIN@EXAMPLE.TEST ', 'password' => 'secret123', ...$extra,
]);
}
private function createOperationsSchema(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->unsignedBigInteger('active_event_id')->nullable();
$table->boolean('scanner_category_validation_enabled')->default(false);
$table->boolean('allow_ticket_refund')->default(true);
$table->boolean('allow_ticket_total_refund')->default(true);
$table->boolean('allow_ticket_partial_refund')->default(true);
$table->decimal('ticket_partial_refund_percentage')->default(25);
});
Schema::table('users', fn (Blueprint $table) => $table->string('dni')->nullable());
Schema::table('events', function (Blueprint $table): void {
$table->string('location')->nullable();
$table->string('date_text')->nullable();
});
(require database_path('migrations/2026_09_30_000200_add_refund_configuration_to_events.php'))->up();
DB::table('tenants')->where('codigo', 'onticket')->update(['active_event_id' => 2]);
Schema::create('social_media', function (Blueprint $table): void {
$table->id();
$table->string('code')->unique();
$table->string('nombre');
});
Schema::create('event_social_media', function (Blueprint $table): void {
$table->unsignedBigInteger('event_id');
$table->string('social_media_code');
$table->string('url');
$table->integer('orden');
$table->timestamps();
});
Schema::create('event_dates', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->unsignedBigInteger('event_id');
$table->date('date');
$table->time('time_start');
$table->time('time_end');
$table->unsignedBigInteger('validity_time_id')->nullable();
$table->unsignedBigInteger('rescheduled_to_event_date_id')->nullable();
$table->timestamp('suspended_at')->nullable();
});
Schema::create('validity_times', function (Blueprint $table): void {
$table->id();
$table->string('type');
$table->time('start_time')->nullable();
$table->time('end_time')->nullable();
$table->timestamp('fixed_starts_at')->nullable();
$table->timestamp('fixed_expires_at')->nullable();
$table->timestamps();
});
Schema::create('categorias', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code')->nullable();
$table->unsignedBigInteger('categoria_id')->nullable();
$table->string('nombre');
});
Schema::create('category_scanners', function (Blueprint $table): void {
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('categoria_id');
$table->timestamps();
});
Schema::create('catalog_items', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->unsignedBigInteger('event_id');
$table->unsignedBigInteger('category_id')->nullable();
$table->string('nombre');
$table->string('slug');
$table->text('descripcion')->nullable();
$table->decimal('precio')->default(0);
$table->string('type')->default('standard');
$table->string('inventory_policy')->default('tracked');
$table->string('inventory_subject')->default('product');
$table->integer('group_order')->default(0);
$table->boolean('has_tickets')->default(false);
$table->softDeletes();
});
Schema::create('compras', function (Blueprint $table): void {
$table->id();
$table->string('tenant_codigo');
$table->unsignedBigInteger('event_id');
$table->string('status');
$table->decimal('total');
$table->string('nombre_apellido');
$table->timestamps();
});
Schema::create('compra_items', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('compra_id');
$table->integer('cantidad');
$table->string('item_nombre')->nullable();
$table->decimal('precio_unitario')->default(10);
$table->decimal('total')->default(10);
});
Schema::create('tickets', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->unsignedBigInteger('event_id')->nullable();
$table->uuid('ticket');
foreach (['source_purchase_item_id', 'source_catalog_item_id', 'source_variant_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();
}
});
Schema::create('ticket_refunds', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('purchase_item_id');
$table->decimal('amount');
});
Schema::create('scan_attempts', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->unsignedBigInteger('scanner_user_id');
$table->unsignedBigInteger('ticket_id')->nullable();
$table->text('data')->nullable();
$table->string('result');
$table->timestamp('created_at')->nullable();
$table->timestamp('resolved_at')->nullable();
});
(require database_path('migrations/2026_09_30_000400_add_event_id_to_scan_attempts.php'))->up();
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->string('actor_type')->default('user');
$table->unsignedBigInteger('user_id')->nullable();
$table->timestamp('changed_at')->nullable();
});
DB::table('roles')->insert(['codigo' => 'scanner', 'nombre' => 'Scanner']);
DB::table('permisos')->insert(['codigo' => 'tickets.escanear', 'nombre' => 'Escanear']);
DB::table('roles_permisos')->insert(['rol_codigo' => 'scanner', 'codigo_permiso' => 'tickets.escanear']);
foreach (['adminapp.catalog', 'adminapp.event', 'adminapp.staff', 'adminapp.inicio'] as $code) {
DB::table('menues')->insert(['code' => $code, 'label' => $code, 'route' => '/admin/test', 'parent_menu_code' => 'main.adminapp']);
DB::table('roles_menues')->insert(['rol_codigo' => 'adminapp', 'menu_codigo' => $code]);
DB::table('tenants_menues')->insert(['tenant_code' => 'onticket', 'menu_code' => $code]);
}
}
private function actingEventAdmin(): void
{
$this->user->update(['admin_scope' => 'event', 'event_id' => 1]);
Sanctum::actingAs($this->user);
}
public function test_event_and_refund_settings_use_user_event_even_when_tenant_active_event_changes(): void
{
$this->actingEventAdmin();
$this->getJson('/api/v1/adminapp/tenant/event')->assertOk()->assertJsonPath('data.id', 1)
->assertJsonPath('data.allow_ticket_refund', true);
$this->putJson('/api/v1/adminapp/tenant/event', [
'title' => 'Solo A', 'location' => 'Predio A', 'social_media' => [], 'allow_ticket_refund' => false,
'allow_ticket_total_refund' => true, 'allow_ticket_partial_refund' => true,
'ticket_partial_refund_percentage' => 30,
])->assertOk()->assertJsonPath('data.id', 1)->assertJsonPath('data.allow_ticket_refund', false);
$this->assertDatabaseHas('events', ['id' => 1, 'title' => 'Solo A', 'allow_ticket_refund' => false]);
$this->assertDatabaseHas('events', ['id' => 2, 'title' => 'Evento B', 'allow_ticket_refund' => true]);
$this->assertDatabaseHas('tenants', ['codigo' => 'onticket', 'allow_ticket_refund' => true]);
$this->getJson('/api/v1/adminapp/forms/event')->assertOk();
$this->getJson('/api/v1/adminapp/tenant/website-extras')->assertForbidden();
}
public function test_dates_are_created_on_user_event_and_other_event_dates_cannot_be_changed(): void
{
$this->actingEventAdmin();
$this->postJson('/api/v1/adminapp/tenant/event-dates', [
'date' => '2027-01-20', 'start_time' => '10:00', 'end_time' => '12:00',
])->assertSuccessful();
$this->assertDatabaseHas('event_dates', ['event_id' => 1, 'date' => '2027-01-20']);
DB::table('event_dates')->insert(['id' => 20, 'event_id' => 2, 'tenant_code' => 'onticket',
'date' => '2027-01-20', 'time_start' => '10:00', 'time_end' => '12:00']);
$this->postJson('/api/v1/adminapp/tenant/event-dates/20/suspend')->assertNotFound();
$this->postJson('/api/v1/adminapp/tenant/event-dates/20/reschedule', ['date' => '2027-01-21'])->assertNotFound();
$this->assertDatabaseHas('event_dates', ['id' => 20, 'suspended_at' => null]);
}
public function test_sales_totals_details_exports_and_history_are_scoped_to_user_event(): void
{
$this->actingEventAdmin();
foreach ([1, 2] as $id) {
DB::table('compras')->insert(['id' => $id, 'tenant_codigo' => 'onticket', 'event_id' => $id,
'status' => 'paid', 'total' => $id * 100, 'nombre_apellido' => 'Cliente', 'created_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(['tenant_code' => 'onticket',
'trackable_type' => (new Purchase)->getMorphClass(),
'trackable_id' => $id, 'attribute' => 'status', 'new_value' => 'paid', 'changed_at' => now()]);
}
$this->getJson('/api/v1/adminapp/tenant/sales')->assertOk()->assertJsonCount(1, 'data')
->assertJsonPath('confirmed_sales_total', '100.00')->assertJsonPath('refunded_total', '10.00');
$this->getJson('/api/v1/adminapp/tenant/sales/1')->assertOk();
foreach (['', '/tickets'] as $suffix) {
$this->getJson('/api/v1/adminapp/tenant/sales/2'.$suffix)->assertNotFound();
}
foreach (['confirm', 'cancel'] as $action) {
$this->postJson('/api/v1/adminapp/tenant/sales/2/'.$action)->assertNotFound();
}
$this->getJson('/api/v1/adminapp/tenant/sales/modifications')->assertOk()->assertJsonCount(1, 'data');
$service = app(AdminAppSaleService::class);
$tenant = $this->user->tenant;
$this->assertSame([1], $service->salesForExport($tenant, [], 1)->pluck('id')->all());
$this->assertSame([1], $service->modificationsForExport($tenant, [], 1)->pluck('trackable_id')->all());
}
private function scanner(int $eventId, int $id = 10): User
{
return User::query()->create(['id' => $id, 'nombre_apellido' => 'Scanner',
'email' => "scanner{$id}@example.test", 'password' => 'password', 'dni' => '123',
'tenant_codigo' => 'onticket', 'rol_codigo' => 'scanner', 'admin_scope' => 'event', 'event_id' => $eventId]);
}
public function test_staff_is_created_on_admin_event_and_other_staff_cannot_be_managed(): void
{
$this->actingEventAdmin();
$scanner = $this->scanner(2);
$this->getJson('/api/v1/adminapp/tenant/staff')->assertOk()->assertJsonCount(0, 'data');
$payload = ['nombre_apellido' => 'Nuevo', 'email' => 'nuevo@example.test', 'dni' => '123'];
$this->putJson('/api/v1/adminapp/tenant/staff/'.$scanner->id, $payload)->assertNotFound();
$this->deleteJson('/api/v1/adminapp/tenant/staff/'.$scanner->id)->assertNotFound();
$this->getJson('/api/v1/adminapp/tenant/staff/'.$scanner->id.'/scan-attempts')->assertNotFound();
$this->mock(ResetPasswordAttemptService::class,
fn ($mock) => $mock->shouldReceive('createForScannerEmail')->once());
$this->postJson('/api/v1/adminapp/tenant/staff', [...$payload, 'event_id' => 2])
->assertSuccessful()->assertJsonPath('data.event_id', 1);
$this->assertDatabaseHas('users', ['email' => 'nuevo@example.test', 'event_id' => 1, 'admin_scope' => 'event']);
}
public function test_scanner_rejects_foreign_event_qr_without_consuming_or_disclosing_ticket(): void
{
$scanner = $this->scanner(1);
Sanctum::actingAs($scanner);
$uuid = '11111111-1111-4111-8111-111111111111';
DB::table('tickets')->insert(['id' => 20, 'tenant_code' => 'onticket', 'event_id' => 2, 'ticket' => $uuid]);
$this->getJson('/api/v1/scanner/tickets/'.$uuid)->assertNotFound();
$this->postJson('/api/v1/scanner/tickets/scan', ['data' => $uuid])->assertOk()
->assertJsonPath('data.scan_attempt.result', 'ticket_not_found')->assertJsonPath('data.ticket', null);
$this->assertDatabaseHas('tickets', ['id' => 20, 'used_at' => null, 'scanner_user_id' => null]);
$this->assertDatabaseHas('scan_attempts', ['scanner_user_id' => $scanner->id, 'event_id' => 1, 'ticket_id' => null]);
}
public function test_scanner_history_and_detail_follow_assignment_changes_and_invalid_event_is_denied(): void
{
$scanner = $this->scanner(1);
Sanctum::actingAs($scanner);
$response = $this->postJson('/api/v1/scanner/tickets/scan', ['data' => 'invalid'])->assertOk();
$id = $response->json('data.scan_attempt.id');
$this->getJson('/api/v1/scanner/attempts')->assertOk()->assertJsonCount(1, 'data');
$scanner->update(['event_id' => 2]);
$this->getJson('/api/v1/scanner/attempts')->assertOk()->assertJsonCount(0, 'data');
$this->getJson('/api/v1/scanner/attempts/'.$id)->assertNotFound();
$scanner->update(['event_id' => null]);
$this->getJson('/api/v1/scanner/attempts')->assertForbidden();
}
public function test_initial_assignment_migration_preserves_existing_scopes_and_ignores_missing_events(): void
{
$this->user->update(['admin_scope' => 'event', 'event_id' => 1]);
$scanner = $this->scanner(1);
$scanner->update(['event_id' => null, 'admin_scope' => 'tenant']);
$migration = require database_path('migrations/2026_09_30_000100_assign_active_event_to_staff_users.php');
$migration->up();
$migration->up();
$this->assertDatabaseHas('users', ['id' => 1, 'event_id' => 1]);
$this->assertDatabaseHas('users', ['id' => $scanner->id, 'event_id' => 2, 'admin_scope' => 'event']);
DB::table('tenants')->where('codigo', 'onticket')->update(['active_event_id' => 3]);
$scanner->refresh()->update(['event_id' => null, 'admin_scope' => 'tenant']);
$migration->up();
$this->assertDatabaseHas('users', ['id' => $scanner->id, 'event_id' => null, 'admin_scope' => 'event']);
}
public function test_deprecated_menus_are_removed_with_their_role_and_tenant_assignments(): void
{
foreach (['adminapp.categories', 'adminapp.combos'] as $code) {
DB::table('menues')->insert(['code' => $code, 'label' => $code, 'route' => '/admin/old']);
DB::table('roles_menues')->insert(['rol_codigo' => 'adminapp', 'menu_codigo' => $code]);
DB::table('tenants_menues')->insert(['tenant_code' => 'onticket', 'menu_code' => $code]);
}
(require database_path('migrations/2026_09_30_000300_remove_deprecated_admin_menus.php'))->up();
foreach (['adminapp.categories', 'adminapp.combos'] as $code) {
$this->assertDatabaseMissing('menues', ['code' => $code]);
$this->assertDatabaseMissing('roles_menues', ['menu_codigo' => $code]);
$this->assertDatabaseMissing('tenants_menues', ['menu_code' => $code]);
}
}
public function test_scanner_accepts_ticket_from_its_event_and_cannot_consume_it_twice(): void
{
$scanner = $this->scanner(1);
Sanctum::actingAs($scanner);
$uuid = '11111111-1111-4111-8111-111111111111';
DB::table('tickets')->insert(['id' => 20, 'tenant_code' => 'onticket', 'event_id' => 1, 'ticket' => $uuid]);
$this->postJson('/api/v1/scanner/tickets/scan', ['data' => $uuid])->assertOk()
->assertJsonPath('data.scan_attempt.result', 'accepted')->assertJsonPath('data.ticket.id', 20);
$this->assertNotNull(DB::table('tickets')->where('id', 20)->value('used_at'));
$this->postJson('/api/v1/scanner/tickets/scan', ['data' => $uuid])->assertOk()
->assertJsonPath('data.scan_attempt.result', 'already_scanned');
$this->getJson('/api/v1/scanner/tickets/'.$uuid)->assertOk();
}
public function test_refund_calculation_uses_ticket_event_configuration_instead_of_tenant_defaults(): void
{
Schema::create('desfile_entry_reservations', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('ticket_id');
$table->softDeletes();
});
DB::table('ticket_refunds')->delete();
DB::table('compra_items')->insert(['id' => 1, 'compra_id' => 1, 'cantidad' => 1,
'precio_unitario' => 100, 'total' => 100]);
DB::table('tickets')->insert(['id' => 20, 'tenant_code' => 'onticket', 'event_id' => 1,
'ticket' => '11111111-1111-4111-8111-111111111111', 'source_purchase_item_id' => 1]);
DB::table('events')->where('id', 1)->update(['ticket_partial_refund_percentage' => 75]);
$calculation = app(AdminAppTicketService::class)
->calculateRefund($this->user->tenant, 20);
$this->assertSame(['total' => '100.00', 'partial' => '75.00'], $calculation);
DB::table('events')->where('id', 1)->update(['allow_ticket_refund' => false]);
$this->assertFalse(Ticket::query()->findOrFail(20)->allow_refund());
}
public function test_staff_category_options_and_assignments_exclude_other_event_products(): void
{
$this->actingEventAdmin();
DB::table('tenants')->where('codigo', 'onticket')->update(['scanner_category_validation_enabled' => true]);
foreach ([1, 2] as $id) {
DB::table('categorias')->insert(['id' => $id, 'nombre' => "Categoria {$id}", 'tenant_code' => 'onticket']);
DB::table('catalog_items')->insert(['id' => $id, 'tenant_code' => 'onticket', 'event_id' => $id,
'nombre' => "Producto {$id}", 'slug' => "producto-{$id}", 'category_id' => $id]);
}
$this->getJson('/api/v1/adminapp/forms/staff')->assertOk()->assertJsonCount(1, 'data.categories')
->assertJsonPath('data.categories.0.id', 1);
$this->postJson('/api/v1/adminapp/tenant/staff', ['nombre_apellido' => 'Nuevo', 'dni' => '123',
'email' => 'nuevo@example.test', 'category_ids' => [2]])->assertUnprocessable()->assertJsonValidationErrors('category_ids');
$this->assertDatabaseMissing('users', ['email' => 'nuevo@example.test']);
}
public function test_initial_migration_assigns_existing_tenant_admin_and_blocks_staff_without_active_event(): void
{
$unassigned = $this->scanner(1);
$unassigned->update(['tenant_codigo' => 'other', 'admin_scope' => 'tenant', 'event_id' => null]);
(require database_path('migrations/2026_09_30_000100_assign_active_event_to_staff_users.php'))->up();
$this->assertDatabaseHas('users', ['id' => 1, 'admin_scope' => 'event', 'event_id' => 2]);
$this->assertDatabaseHas('users', ['id' => $unassigned->id, 'admin_scope' => 'event', 'event_id' => null]);
$this->user->refresh();
$this->assertFalse($this->user->isTenantAdministrator());
$this->login()->assertOk()->assertJsonPath('user.event_id', 2);
}
public function test_scanner_login_validates_event_before_issuing_a_token(): void
{
$scanner = $this->scanner(1);
$this->postJson('/api/v1/scanner/login', ['email' => $scanner->email, 'password' => 'password'])
->assertOk()->assertJsonPath('user.event_id', 1);
$scanner->update(['event_id' => null]);
$this->postJson('/api/v1/scanner/login', ['email' => $scanner->email, 'password' => 'password'])
->assertUnprocessable()->assertJsonValidationErrors('email');
$this->assertDatabaseCount('personal_access_tokens', 1);
$this->assertSame(0, $scanner->refresh()->failed_login_attempts);
}
}

View File

@@ -27,8 +27,6 @@ class MenuSeederTest extends TestCase
$expectedMenus = [ $expectedMenus = [
'adminapp.inicio' => ['Inicio', '/admin/inicio'], 'adminapp.inicio' => ['Inicio', '/admin/inicio'],
'adminapp.catalog' => ['Catálogo', '/admin/catalog'], 'adminapp.catalog' => ['Catálogo', '/admin/catalog'],
'adminapp.categories' => ['Categorías', '/admin/categories'],
'adminapp.combos' => ['Combos', '/admin/combos'],
'adminapp.event' => ['Eventos', '/admin/event'], 'adminapp.event' => ['Eventos', '/admin/event'],
'adminapp.staff' => ['Staff', '/admin/staff'], 'adminapp.staff' => ['Staff', '/admin/staff'],
'adminapp.ventas' => ['Ventas', '/admin/ventas'], 'adminapp.ventas' => ['Ventas', '/admin/ventas'],
@@ -100,6 +98,8 @@ class MenuSeederTest extends TestCase
$this->assertFalse( $this->assertFalse(
Menu::query()->whereIn('code', [ Menu::query()->whereIn('code', [
'adminapp.categories',
'adminapp.combos',
'admin.event', 'admin.event',
'admin.catalog', 'admin.catalog',
'admin.combos', 'admin.combos',