Compare commits
42 Commits
feature/sc
...
adaptacion
| Author | SHA1 | Date | |
|---|---|---|---|
| 5197862a62 | |||
| 2115d2384d | |||
| a458c564ca | |||
| 68dd373d61 | |||
| 8d8e49fcfb | |||
| 19b506a465 | |||
| 6f63a87af5 | |||
| a59b96dd22 | |||
| 6354e26620 | |||
| 69320c54d4 | |||
| 5bfc8b9f70 | |||
| 17f4cb6502 | |||
| 6844ab7af9 | |||
| 8542c03466 | |||
| e7c8807a67 | |||
| c6351d706f | |||
| 2693df2cbf | |||
| 4aaa66dc38 | |||
| 1cd60f7021 | |||
| ca605127d0 | |||
| 00489c14d3 | |||
| 63f98b5da2 | |||
| b05314b1dd | |||
| feed3205eb | |||
| 11776f0734 | |||
| e1ad27ecf0 | |||
| 6a6d3690cc | |||
| ed4502425a | |||
| 6318a99328 | |||
| 21b70777f2 | |||
| 91af233941 | |||
| 717ee5d194 | |||
| 14d53d1e0b | |||
| 7b7efeb4cc | |||
| 60b8415c59 | |||
| f578c6e24a | |||
| 5ad61eb217 | |||
| 20dd246cd8 | |||
| 7561ba756c | |||
| b224dd8650 | |||
| 448ffb4102 | |||
| 7f01adab73 |
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Requests\ScannerLoginRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\PasswordLoginService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ScannerLoginController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PasswordLoginService $passwordLoginService,
|
||||
) {}
|
||||
|
||||
public function __invoke(ScannerLoginRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->validated();
|
||||
$user = $this->passwordLoginService->authenticateScanner(
|
||||
$credentials['email'],
|
||||
$credentials['password'],
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
);
|
||||
|
||||
$expirationMinutes = (int) config('sanctum.expiration');
|
||||
$token = $user->createToken(
|
||||
'scanner-token',
|
||||
['scanner'],
|
||||
now()->addMinutes($expirationMinutes),
|
||||
)->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'code' => 'auth.login_success',
|
||||
'message' => __('api.auth.login_success'),
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Auth\Resources\ScannerMeResource;
|
||||
use App\Domains\Auth\Services\ScannerContextService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ScannerMeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ScannerContextService $scannerContextService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): ScannerMeResource
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return ScannerMeResource::make($this->scannerContextService->load($user));
|
||||
}
|
||||
}
|
||||
@@ -53,16 +53,6 @@ class User extends Authenticatable
|
||||
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
|
||||
}
|
||||
|
||||
public function hasPermission(string $permissionCode): bool
|
||||
{
|
||||
return $this->role()
|
||||
->whereHas(
|
||||
'permissions',
|
||||
fn ($query) => $query->where('permisos.codigo', $permissionCode)
|
||||
)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
class ScannerLoginRequest extends AdminAppLoginRequest {}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Resources;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Tenant\Resources\TenantResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin User */
|
||||
class ScannerMeResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'user' => UserResource::make($this->resource),
|
||||
'tenant' => TenantResource::make($this->tenant),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ namespace App\Domains\Auth\Services;
|
||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||
use App\Domains\Auth\Models\LoginAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\PermissionCode;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -63,39 +62,14 @@ class PasswordLoginService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a tenant-bound user authorized to scan tickets.
|
||||
*
|
||||
* @throws AccountLockedException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticateScanner(
|
||||
string $email,
|
||||
string $password,
|
||||
?string $ipAddress,
|
||||
?string $userAgent,
|
||||
): User {
|
||||
return $this->authenticateUser(
|
||||
$email,
|
||||
$password,
|
||||
null,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
null,
|
||||
true,
|
||||
PermissionCode::ScanTickets->value,
|
||||
);
|
||||
}
|
||||
|
||||
private function authenticateUser(
|
||||
string $email,
|
||||
string $password,
|
||||
?string $tenantCode,
|
||||
?string $ipAddress,
|
||||
?string $userAgent,
|
||||
?RoleCode $requiredRole = RoleCode::User,
|
||||
RoleCode $requiredRole = RoleCode::User,
|
||||
bool $requiresTenant = false,
|
||||
?string $requiredPermission = null,
|
||||
): User {
|
||||
$normalizedEmail = mb_strtolower(trim($email));
|
||||
$now = CarbonImmutable::now();
|
||||
@@ -110,21 +84,10 @@ class PasswordLoginService
|
||||
$now,
|
||||
$requiredRole,
|
||||
$requiresTenant,
|
||||
$requiredPermission,
|
||||
): array {
|
||||
$user = User::query()
|
||||
->where('email', $normalizedEmail)
|
||||
->when(
|
||||
$requiredRole !== null,
|
||||
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
|
||||
)
|
||||
->when(
|
||||
$requiredPermission !== null,
|
||||
fn ($query) => $query->whereHas(
|
||||
'role.permissions',
|
||||
fn ($query) => $query->where('permisos.codigo', $requiredPermission)
|
||||
),
|
||||
)
|
||||
->where('rol_codigo', $requiredRole->value)
|
||||
->when(
|
||||
$requiresTenant,
|
||||
fn ($query) => $query->whereNotNull('tenant_codigo'),
|
||||
@@ -159,8 +122,8 @@ class PasswordLoginService
|
||||
}
|
||||
|
||||
if ($user === null || ! Hash::check($password, $user->password)) {
|
||||
if ($user !== null && $attemptTenantCode !== null) {
|
||||
$this->registerFailure($user, $now, $attemptTenantCode);
|
||||
if ($user !== null) {
|
||||
$this->registerFailure($user, $now, $tenantCode);
|
||||
}
|
||||
|
||||
$outcome = $user?->locked_until?->isFuture()
|
||||
@@ -247,7 +210,7 @@ class PasswordLoginService
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to trigger reset password on account lock', [
|
||||
'user_id' => $user->id,
|
||||
'exception' => $e,
|
||||
'exception' => $e
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
|
||||
class ScannerContextService
|
||||
{
|
||||
public function load(User $user): User
|
||||
{
|
||||
$tenant = $user->tenant()
|
||||
->with([
|
||||
'menues' => fn ($query) => $query->whereHas(
|
||||
'roles',
|
||||
fn ($query) => $query->where('codigo', $user->rol_codigo)
|
||||
),
|
||||
])
|
||||
->firstOrFail();
|
||||
|
||||
$user->setRelation('tenant', $tenant);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,3 @@ Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
||||
|
||||
require __DIR__.'/adminapp.php';
|
||||
require __DIR__.'/scanner.php';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Auth\Controllers\ScannerLoginController;
|
||||
use App\Domains\Auth\Controllers\ScannerMeController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/scanner')->group(function (): void {
|
||||
Route::post('login', ScannerLoginController::class)->middleware('throttle:login');
|
||||
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
||||
->get('me', ScannerMeController::class);
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Authorization\Enums;
|
||||
|
||||
enum PermissionCode: string
|
||||
{
|
||||
case ScanTickets = 'tickets.escanear';
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Bootstrap\Controllers;
|
||||
|
||||
use App\Domains\Bootstrap\Requests\ScannerBootstrapRequest;
|
||||
use App\Domains\Bootstrap\Resources\ScannerBootstrapResource;
|
||||
use App\Domains\Bootstrap\Services\ScannerBootstrapService;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class ScannerBootstrapController extends Controller
|
||||
{
|
||||
public function __construct(protected ScannerBootstrapService $bootstrapService) {}
|
||||
|
||||
public function __invoke(ScannerBootstrapRequest $request): ScannerBootstrapResource
|
||||
{
|
||||
return ScannerBootstrapResource::make(
|
||||
$this->bootstrapService->get((string) $request->validated('dominio'))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Bootstrap\Requests;
|
||||
|
||||
class ScannerBootstrapRequest extends TenantBootstrapRequest {}
|
||||
@@ -1,5 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Bootstrap\Resources;
|
||||
|
||||
class ScannerBootstrapResource extends AdminAppBootstrapResource {}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Bootstrap\Services;
|
||||
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
|
||||
class ScannerBootstrapService
|
||||
{
|
||||
/** @return array{website_type: WebsiteType} */
|
||||
public function get(string $domain): array
|
||||
{
|
||||
return [
|
||||
'website_type' => WebsiteType::query()
|
||||
->with(['siteLogo', 'footerLogo'])
|
||||
->where('scanner_domain', $domain)
|
||||
->firstOrFail(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,3 @@ Route::get('tenants/bootstrap/{dominio}', TenantBootstrapController::class)
|
||||
->where('dominio', '.*');
|
||||
|
||||
require __DIR__.'/adminapp.php';
|
||||
require __DIR__.'/scanner.php';
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Bootstrap\Controllers\ScannerBootstrapController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get(
|
||||
'v1/scanner/bootstrap/{dominio}',
|
||||
ScannerBootstrapController::class
|
||||
);
|
||||
@@ -13,7 +13,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'codigo',
|
||||
'nombre',
|
||||
'dominio',
|
||||
'scanner_domain',
|
||||
'primary_color',
|
||||
'secondary_color',
|
||||
'danger_color',
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Controllers\Scanner;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Ticket\Requests\ScannerTicketIndexRequest;
|
||||
use App\Domains\Ticket\Resources\Scanner\ScannedTicketResource;
|
||||
use App\Domains\Ticket\Resources\TicketResource;
|
||||
use App\Domains\Ticket\Services\ScannerTicketService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ScannerTicketService $ticketService) {}
|
||||
|
||||
public function index(ScannerTicketIndexRequest $request): AnonymousResourceCollection
|
||||
{
|
||||
/** @var User $scanner */
|
||||
$scanner = $request->user();
|
||||
|
||||
return ScannedTicketResource::collection(
|
||||
$this->ticketService->scannedBy($scanner, $request->validated())
|
||||
);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $ticketUuid): TicketResource
|
||||
{
|
||||
/** @var User $scanner */
|
||||
$scanner = $request->user();
|
||||
|
||||
return TicketResource::make(
|
||||
$this->ticketService->detail($scanner, $ticketUuid)
|
||||
);
|
||||
}
|
||||
|
||||
public function scan(Request $request, string $ticketUuid): TicketResource
|
||||
{
|
||||
/** @var User $scanner */
|
||||
$scanner = $request->user();
|
||||
|
||||
return TicketResource::make(
|
||||
$this->ticketService->scan($scanner, $ticketUuid)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ScannerTicketIndexRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Resources\Scanner;
|
||||
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin Ticket */
|
||||
class ScannedTicketResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'product' => $this->name,
|
||||
'id' => $this->id,
|
||||
'ticket' => $this->ticket,
|
||||
'used_at' => $this->used_at,
|
||||
'expires_at' => $this->getEffectiveExpiresAt(),
|
||||
'status' => $this->status,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,6 @@ class TicketResource extends JsonResource
|
||||
'ticket' => $this->ticket,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'client' => $this->user?->nombre_apellido,
|
||||
'category' => $this->sourceCatalogItem?->category?->nombre,
|
||||
'source_catalog_item_id' => $this->source_catalog_item_id,
|
||||
'source_variant_id' => $this->source_variant_id,
|
||||
'validity_times' => ValidityTimeResource::collection($this->allValidityTimes()),
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ScannerTicketService
|
||||
{
|
||||
/**
|
||||
* @param array{q?: string|null, page?: int, per_page?: int} $filters
|
||||
* @return LengthAwarePaginator<Ticket>
|
||||
*/
|
||||
public function scannedBy(User $scanner, array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$search = trim((string) ($filters['q'] ?? ''));
|
||||
|
||||
return $this->baseQuery()
|
||||
->where('tenant_code', $scanner->tenant_codigo)
|
||||
->where('scanner_user_id', $scanner->getKey())
|
||||
->when($search !== '', function (Builder $query) use ($search): void {
|
||||
$usedAtDate = $this->parseSearchDate($search);
|
||||
|
||||
$query->where(function (Builder $searchQuery) use ($search, $usedAtDate): void {
|
||||
$searchQuery->where('ticket', 'like', "%{$search}%");
|
||||
|
||||
if (ctype_digit($search)) {
|
||||
$searchQuery->orWhere('id', (int) $search);
|
||||
}
|
||||
|
||||
if ($usedAtDate !== null) {
|
||||
$searchQuery->orWhereDate('used_at', $usedAtDate);
|
||||
}
|
||||
});
|
||||
})
|
||||
->orderByDesc('used_at')
|
||||
->orderByDesc('id')
|
||||
->paginateFromRequest()
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
private function parseSearchDate(string $search): ?string
|
||||
{
|
||||
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) {
|
||||
[$year, $month, $day] = array_map('intval', array_slice($matches, 1));
|
||||
|
||||
if (checkdate($month, $day, $year)) {
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{2}|\d{4})$/', $search, $matches) === 1) {
|
||||
$day = (int) $matches[1];
|
||||
$month = (int) $matches[2];
|
||||
$year = (int) $matches[3];
|
||||
$year = strlen($matches[3]) === 2 ? 2000 + $year : $year;
|
||||
|
||||
if (checkdate($month, $day, $year)) {
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function detail(User $scanner, string $ticketUuid): Ticket
|
||||
{
|
||||
$categoryIds = $this->scannerCategoryIds($scanner);
|
||||
|
||||
return $this->baseQuery()
|
||||
->where('tenant_code', $scanner->tenant_codigo)
|
||||
->where('ticket', $ticketUuid)
|
||||
->where(function (Builder $query) use ($scanner, $categoryIds): void {
|
||||
$query
|
||||
->where('scanner_user_id', $scanner->getKey())
|
||||
->orWhereHas(
|
||||
'sourceCatalogItem',
|
||||
fn (Builder $catalogItemQuery): Builder => $catalogItemQuery
|
||||
->whereIn('category_id', $categoryIds)
|
||||
);
|
||||
})
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
public function scan(User $scanner, string $ticketUuid): Ticket
|
||||
{
|
||||
return DB::transaction(function () use ($scanner, $ticketUuid): Ticket {
|
||||
$ticket = $this->baseQuery()
|
||||
->where('tenant_code', $scanner->tenant_codigo)
|
||||
->where('ticket', $ticketUuid)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if (! $this->scannerCanScan($scanner, $ticket)) {
|
||||
throw ValidationException::withMessages([
|
||||
'ticket' => __('api.ticket.scanner_category_forbidden'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($ticket->is_used) {
|
||||
throw ValidationException::withMessages([
|
||||
'ticket' => __('api.ticket.already_scanned'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $ticket->is_valid) {
|
||||
throw ValidationException::withMessages([
|
||||
'ticket' => $ticket->is_expired
|
||||
? __('api.ticket.expired_for_scan')
|
||||
: __('api.ticket.not_valid_for_scan'),
|
||||
]);
|
||||
}
|
||||
|
||||
$ticket->forceFill([
|
||||
'used_at' => now(),
|
||||
'scanner_user_id' => $scanner->getKey(),
|
||||
])->save();
|
||||
|
||||
return $ticket->refresh()->load($this->relations());
|
||||
});
|
||||
}
|
||||
|
||||
/** @return Builder<Ticket> */
|
||||
private function baseQuery(): Builder
|
||||
{
|
||||
return Ticket::query()->with($this->relations());
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
private function relations(): array
|
||||
{
|
||||
return [
|
||||
'validityGroups.validityTimes',
|
||||
'sourceCatalogItem.category',
|
||||
'sourceVariant.eventDate',
|
||||
'sourceVariant.catalogItem',
|
||||
'user',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, int> */
|
||||
private function scannerCategoryIds(User $scanner): array
|
||||
{
|
||||
return $scanner->scanCategories()
|
||||
->pluck('categorias.id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function scannerCanScan(User $scanner, Ticket $ticket): bool
|
||||
{
|
||||
$categoryId = $ticket->sourceCatalogItem?->category_id;
|
||||
|
||||
return $categoryId !== null
|
||||
&& $scanner->scanCategories()
|
||||
->where('categorias.id', $categoryId)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,3 @@ Route::prefix('tenants/{tenant:codigo}')
|
||||
Route::get('tickets', [TicketController::class, 'index']);
|
||||
Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']);
|
||||
});
|
||||
|
||||
require __DIR__.'/scanner.php';
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Ticket\Controllers\Scanner\TicketController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/scanner/tickets')
|
||||
->middleware(['auth:sanctum', 'scanner.tenant'])
|
||||
->group(function (): void {
|
||||
Route::get('/', [TicketController::class, 'index']);
|
||||
Route::get('{ticketUuid}', [TicketController::class, 'show'])
|
||||
->whereUuid('ticketUuid');
|
||||
Route::post('{ticketUuid}/scan', [TicketController::class, 'scan'])
|
||||
->whereUuid('ticketUuid');
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Domains\Authorization\Enums\PermissionCode;
|
||||
use Closure;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureScannerTenant
|
||||
{
|
||||
/**
|
||||
* Ensure the authenticated user can scan tickets for a tenant.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (
|
||||
! $user
|
||||
|| ! $user->tenant_codigo
|
||||
|| ! $user->hasPermission(PermissionCode::ScanTickets->value)
|
||||
) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||
use App\Http\Middleware\EnsureAdminAppTenant;
|
||||
use App\Http\Middleware\EnsureScannerTenant;
|
||||
use App\Http\Middleware\EnsureTenantHasMenu;
|
||||
use App\Http\Middleware\SetApiLocale;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -26,7 +25,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'adminapp.tenant' => EnsureAdminAppTenant::class,
|
||||
'scanner.tenant' => EnsureScannerTenant::class,
|
||||
'tenant.menu' => EnsureTenantHasMenu::class,
|
||||
]);
|
||||
$middleware->encryptCookies(except: [
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('website_type', function (Blueprint $table): void {
|
||||
$table->string('scanner_domain')->nullable()->unique()->after('dominio');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('website_type', function (Blueprint $table): void {
|
||||
$table->dropUnique(['scanner_domain']);
|
||||
$table->dropColumn('scanner_domain');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Authorization\Enums\PermissionCode;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Authorization\Models\Permission;
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
@@ -82,7 +81,7 @@ class AuthorizationSeeder extends Seeder
|
||||
'nombre' => 'Gestionar tickets',
|
||||
'descripcion' => 'Permite emitir, invalidar o regenerar tickets.',
|
||||
],
|
||||
PermissionCode::ScanTickets->value => [
|
||||
'tickets.escanear' => [
|
||||
'nombre' => 'Escanear tickets',
|
||||
'descripcion' => 'Permite validar y consumir tickets de las categorías asignadas al usuario.',
|
||||
],
|
||||
@@ -120,12 +119,12 @@ class AuthorizationSeeder extends Seeder
|
||||
RoleCode::AdminApp->value => [
|
||||
'nombre' => 'Administrador de la aplicación',
|
||||
'descripcion' => 'Accede a los menús administrativos de la aplicación.',
|
||||
'permisos' => [PermissionCode::ScanTickets->value],
|
||||
'permisos' => [],
|
||||
],
|
||||
RoleCode::Scanner->value => [
|
||||
'nombre' => 'Scanner',
|
||||
'descripcion' => 'Valida y consume tickets de las categorías que tiene asignadas.',
|
||||
'permisos' => [PermissionCode::ScanTickets->value],
|
||||
'permisos' => ['tickets.escanear'],
|
||||
],
|
||||
RoleCode::User->value => [
|
||||
'nombre' => 'Usuario',
|
||||
|
||||
@@ -10,11 +10,8 @@ use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
|
||||
use App\Domains\Ticket\Enums\ValidityTimeType;
|
||||
use App\Domains\Ticket\Models\ValidityTime;
|
||||
use Illuminate\Database\Seeder;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -50,30 +47,13 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
'event_location' => 'Sunchales, Santa Fe',
|
||||
]);
|
||||
|
||||
$existingValidityTimeIds = $tenant->eventDates()->pluck('validity_time_id');
|
||||
$tenant->eventDates()->delete();
|
||||
ValidityTime::query()->whereKey($existingValidityTimeIds)->delete();
|
||||
|
||||
$eventDates = collect(['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'])
|
||||
->map(function (string $date) use ($tenant): EventDate {
|
||||
$validityTime = ValidityTime::query()->create([
|
||||
'type' => ValidityTimeType::FixedWindow,
|
||||
'start_time' => null,
|
||||
'end_time' => null,
|
||||
'fixed_starts_at' => $date.' 00:00:00',
|
||||
'fixed_expires_at' => $date.' 23:59:59',
|
||||
]);
|
||||
|
||||
$eventDate = new EventDate;
|
||||
$eventDate->forceFill([
|
||||
'date' => $date,
|
||||
'time_start' => '00:00:00',
|
||||
'time_end' => '23:59:59',
|
||||
'validity_time_id' => $validityTime->id,
|
||||
]);
|
||||
|
||||
return $tenant->eventDates()->save($eventDate);
|
||||
});
|
||||
->map(fn (string $date) => $tenant->eventDates()->create([
|
||||
'date' => $date,
|
||||
'time_start' => '00:00:00',
|
||||
'time_end' => '23:59:59',
|
||||
]));
|
||||
$dateIds = $eventDates->pluck('id')->map(fn ($id): int => (int) $id)->values();
|
||||
|
||||
$this->createProduct($tenant, [
|
||||
|
||||
@@ -29,13 +29,6 @@ class MenuSeeder extends Seeder
|
||||
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
|
||||
'route' => '/',
|
||||
],
|
||||
['code' => 'scanner.inicio', 'label' => 'Inicio', 'route' => '/scanner/inicio'],
|
||||
['code' => 'scanner.scan', 'label' => 'Escanear', 'route' => '/scanner/scan'],
|
||||
[
|
||||
'code' => 'scanner.detail',
|
||||
'label' => 'Detalle',
|
||||
'route' => '/scanner/detail/:id',
|
||||
],
|
||||
[
|
||||
'code' => 'adminapp.event',
|
||||
'label' => 'Eventos',
|
||||
@@ -226,11 +219,8 @@ class MenuSeeder extends Seeder
|
||||
->where('code', 'main.adminapp')
|
||||
->orWhere('parent_menu_code', 'main.adminapp')
|
||||
->pluck('code');
|
||||
$scannerMenuCodes = Menu::query()
|
||||
->where('code', 'like', 'scanner.%')
|
||||
->pluck('code');
|
||||
$userMenuCodes = Menu::query()
|
||||
->whereNotIn('code', $adminAppMenuCodes->merge($scannerMenuCodes))
|
||||
->whereNotIn('code', $adminAppMenuCodes)
|
||||
->pluck('code');
|
||||
|
||||
Role::query()
|
||||
@@ -241,10 +231,6 @@ class MenuSeeder extends Seeder
|
||||
->where('codigo', RoleCode::User->value)
|
||||
->each(fn (Role $role) => $role->menus()->sync($userMenuCodes));
|
||||
|
||||
Role::query()
|
||||
->where('codigo', RoleCode::Scanner->value)
|
||||
->each(fn (Role $role) => $role->menus()->sync($scannerMenuCodes));
|
||||
|
||||
$tenants = Tenant::all();
|
||||
|
||||
$allMenus = Menu::pluck('code')->toArray();
|
||||
|
||||
@@ -128,8 +128,8 @@ class TenantSeeder extends Seeder
|
||||
'button_text' => 'Comprar entradas',
|
||||
'button_href' => '/tickets',
|
||||
'background_image_id' => $this->uploadedImage(
|
||||
'images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.png',
|
||||
'futbol_infantil_hero.png',
|
||||
'images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.jpg',
|
||||
'futbol_infantil_hero.jpg',
|
||||
),
|
||||
],
|
||||
'eventConfig' => [
|
||||
|
||||
@@ -31,7 +31,6 @@ class WebsiteTypeSeeder extends Seeder
|
||||
[
|
||||
'nombre' => 'ShopIt',
|
||||
'dominio' => 'localhost',
|
||||
'scanner_domain' => 'scanner.localhost',
|
||||
...self::PRESENTATION,
|
||||
'site_logo' => $this->onTicketLogo(),
|
||||
'footer_logo' => $this->onTicketFooterLogo(),
|
||||
@@ -64,7 +63,6 @@ class WebsiteTypeSeeder extends Seeder
|
||||
[
|
||||
'nombre' => 'OnTicket',
|
||||
'dominio' => 'onticket.localhost',
|
||||
'scanner_domain' => 'scanner.onticket.localhost',
|
||||
...self::PRESENTATION,
|
||||
'site_logo' => $this->onTicketLogo(),
|
||||
'footer_logo' => $this->onTicketFooterLogo(),
|
||||
|
||||
@@ -62,10 +62,6 @@ return [
|
||||
'invalid_variant' => 'Variant :variant does not belong to product :product.',
|
||||
'purchase_without_user' => 'Purchase :purchase does not have an associated user.',
|
||||
'product_not_found' => 'The product for purchase item :purchase_item was not found.',
|
||||
'already_scanned' => 'The ticket has already been scanned.',
|
||||
'expired_for_scan' => 'The ticket has expired.',
|
||||
'not_valid_for_scan' => 'The ticket is not currently valid.',
|
||||
'scanner_category_forbidden' => 'The scanner is not assigned to the ticket category.',
|
||||
],
|
||||
'integration' => [
|
||||
'not_configured' => 'The integration is not configured for this tenant.',
|
||||
|
||||
@@ -62,10 +62,6 @@ return [
|
||||
'invalid_variant' => 'La variante :variant no pertenece al producto :product.',
|
||||
'purchase_without_user' => 'La compra :purchase no tiene un usuario asociado.',
|
||||
'product_not_found' => 'No se encontró el producto de la línea de compra :purchase_item.',
|
||||
'already_scanned' => 'El ticket ya fue escaneado.',
|
||||
'expired_for_scan' => 'El ticket está vencido.',
|
||||
'not_valid_for_scan' => 'El ticket no es válido en este momento.',
|
||||
'scanner_category_forbidden' => 'El scanner no está asignado a la categoría del ticket.',
|
||||
],
|
||||
'integration' => [
|
||||
'not_configured' => 'La integración no está configurada para este tenant.',
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Auth\Models\LoginAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\PermissionCode;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Authorization\Models\Permission;
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScannerLoginControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_logs_in_a_tenant_bound_user_with_scan_permission(): void
|
||||
{
|
||||
$role = Role::query()->create([
|
||||
'codigo' => RoleCode::AdminApp->value,
|
||||
'nombre' => 'Operador',
|
||||
]);
|
||||
$permission = Permission::query()->create([
|
||||
'codigo' => PermissionCode::ScanTickets->value,
|
||||
'nombre' => 'Escanear tickets',
|
||||
]);
|
||||
$role->permissions()->attach($permission->codigo);
|
||||
$tenant = Tenant::query()->create([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.test',
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'email' => 'scanner@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
'rol_codigo' => $role->codigo,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/scanner/login', [
|
||||
'email' => ' SCANNER@EXAMPLE.COM ',
|
||||
'password' => 'secret123',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('user.id', $user->id)
|
||||
->assertJsonPath('user.rol_codigo', RoleCode::AdminApp->value)
|
||||
->assertJsonPath('token_type', 'Bearer');
|
||||
|
||||
$this->assertSame(['scanner'], $user->tokens()->sole()->abilities);
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_user_without_scan_permission(): void
|
||||
{
|
||||
$role = Role::query()->create([
|
||||
'codigo' => RoleCode::Scanner->value,
|
||||
'nombre' => 'Scanner sin permiso',
|
||||
]);
|
||||
$tenant = Tenant::query()->create([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.test',
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'email' => 'customer@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
'rol_codigo' => $role->codigo,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/v1/scanner/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
}
|
||||
|
||||
public function test_an_invalid_password_is_recorded_with_the_users_tenant(): void
|
||||
{
|
||||
$role = Role::query()->create([
|
||||
'codigo' => RoleCode::Scanner->value,
|
||||
'nombre' => 'Scanner',
|
||||
]);
|
||||
$permission = Permission::query()->create([
|
||||
'codigo' => PermissionCode::ScanTickets->value,
|
||||
'nombre' => 'Escanear tickets',
|
||||
]);
|
||||
$role->permissions()->attach($permission->codigo);
|
||||
$tenant = Tenant::query()->create([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.test',
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'email' => 'scanner@example.com',
|
||||
'password' => Hash::make('correct-password'),
|
||||
'rol_codigo' => $role->codigo,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/v1/scanner/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
|
||||
$this->assertSame(1, $user->refresh()->failed_login_attempts);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\PermissionCode;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Authorization\Models\Permission;
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScannerMeControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_only_scanner_menus_assigned_to_the_tenant(): void
|
||||
{
|
||||
$scannerRole = Role::query()->create([
|
||||
'codigo' => RoleCode::Scanner->value,
|
||||
'nombre' => 'Scanner',
|
||||
]);
|
||||
$permission = Permission::query()->create([
|
||||
'codigo' => PermissionCode::ScanTickets->value,
|
||||
'nombre' => 'Escanear tickets',
|
||||
]);
|
||||
$scannerRole->permissions()->attach($permission->codigo);
|
||||
$tenant = Tenant::query()->create([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.test',
|
||||
]);
|
||||
$home = Menu::query()->create([
|
||||
'code' => 'scanner.inicio',
|
||||
'label' => 'Inicio',
|
||||
'route' => '/scanner/inicio',
|
||||
]);
|
||||
$scan = Menu::query()->create([
|
||||
'code' => 'scanner.scan',
|
||||
'label' => 'Escanear',
|
||||
'route' => '/scanner/scan',
|
||||
]);
|
||||
$foreign = Menu::query()->create([
|
||||
'code' => 'adminapp.inicio',
|
||||
'label' => 'Administración',
|
||||
'route' => '/admin/inicio',
|
||||
]);
|
||||
|
||||
$scannerRole->menus()->sync([$home->code, $scan->code]);
|
||||
$tenant->menues()->sync([$home->code, $scan->code, $foreign->code]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'rol_codigo' => $scannerRole->codigo,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->getJson('/api/v1/scanner/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.user.id', $user->id)
|
||||
->assertJsonPath('data.tenant.codigo', $tenant->codigo)
|
||||
->assertJsonCount(2, 'data.tenant.menues')
|
||||
->assertJsonMissing(['code' => $foreign->code]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Tests\Feature\Seeders;
|
||||
|
||||
use App\Domains\Authorization\Enums\PermissionCode;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Authorization\Models\Permission;
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
@@ -22,7 +21,6 @@ class AuthorizationSeederTest extends TestCase
|
||||
[
|
||||
RoleCode::Admin->value,
|
||||
RoleCode::AdminApp->value,
|
||||
RoleCode::Scanner->value,
|
||||
RoleCode::User->value,
|
||||
],
|
||||
Role::query()->orderBy('codigo')->pluck('codigo')->all()
|
||||
@@ -36,15 +34,10 @@ class AuthorizationSeederTest extends TestCase
|
||||
|
||||
$admin = Role::query()->where('codigo', RoleCode::Admin->value)->firstOrFail();
|
||||
$appAdmin = Role::query()->where('codigo', RoleCode::AdminApp->value)->firstOrFail();
|
||||
$scanner = Role::query()->where('codigo', RoleCode::Scanner->value)->firstOrFail();
|
||||
$user = Role::query()->where('codigo', RoleCode::User->value)->firstOrFail();
|
||||
|
||||
$this->assertCount(22, $admin->permissions);
|
||||
$this->assertCount(0, $appAdmin->permissions);
|
||||
$this->assertSame(
|
||||
[PermissionCode::ScanTickets->value],
|
||||
$scanner->permissions->pluck('codigo')->all()
|
||||
);
|
||||
$this->assertCount(0, $user->permissions);
|
||||
}
|
||||
|
||||
@@ -53,8 +46,8 @@ class AuthorizationSeederTest extends TestCase
|
||||
$this->seed(AuthorizationSeeder::class);
|
||||
$this->seed(AuthorizationSeeder::class);
|
||||
|
||||
$this->assertCount(4, Role::query()->get());
|
||||
$this->assertCount(3, Role::query()->get());
|
||||
$this->assertCount(22, Permission::query()->get());
|
||||
$this->assertDatabaseCount('roles_permisos', 23);
|
||||
$this->assertDatabaseCount('roles_permisos', 22);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
|
||||
use App\Domains\Ticket\Enums\ValidityTimeType;
|
||||
use App\Domains\Ticket\Models\ValidityTime;
|
||||
use Database\Seeders\AttributeSeeder;
|
||||
use Database\Seeders\FiestaFutbolInfantilProductSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -46,14 +44,6 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
||||
$this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]);
|
||||
|
||||
$this->assertSame('9, 10, 11 y 12 de Octubre 2026', $tenant->fresh()->event_date_text);
|
||||
$this->assertCount(4, $tenant->eventDates);
|
||||
$this->assertCount(4, ValidityTime::query()->whereHas('eventDate', fn ($query) => $query
|
||||
->where('tenant_code', $tenant->codigo))->get());
|
||||
$this->assertTrue($tenant->eventDates()->with('validityTime')->get()->every(
|
||||
fn ($eventDate): bool => $eventDate->validityTime->type === ValidityTimeType::FixedWindow
|
||||
&& $eventDate->validityTime->fixed_starts_at->format('Y-m-d H:i:s') === $eventDate->date->format('Y-m-d').' 00:00:00'
|
||||
&& $eventDate->validityTime->fixed_expires_at->format('Y-m-d H:i:s') === $eventDate->date->format('Y-m-d').' 23:59:59'
|
||||
));
|
||||
|
||||
$this->assertSame(
|
||||
['color', 'event_date', 'horario', 'servicio', 'talle', 'tipo_alojamiento'],
|
||||
|
||||
@@ -124,13 +124,8 @@ class MenuSeederTest extends TestCase
|
||||
->where('code', 'main.adminapp')
|
||||
->orWhere('parent_menu_code', 'main.adminapp')
|
||||
->pluck('code');
|
||||
$scannerMenuCodes = Menu::query()
|
||||
->where('code', 'like', 'scanner.%')
|
||||
->orderBy('code')
|
||||
->pluck('code')
|
||||
->all();
|
||||
$userMenuCodes = Menu::query()
|
||||
->whereNotIn('code', [...$adminAppMenuCodes, ...$scannerMenuCodes])
|
||||
->whereNotIn('code', $adminAppMenuCodes)
|
||||
->orderBy('code')
|
||||
->pluck('code')
|
||||
->all();
|
||||
@@ -158,23 +153,9 @@ class MenuSeederTest extends TestCase
|
||||
$this->assertSame($userMenuCodes, $userRoleMenuCodes);
|
||||
$this->assertNotContains('main.adminapp', $userRoleMenuCodes);
|
||||
$this->assertNotContains('adminapp.catalog', $userRoleMenuCodes);
|
||||
|
||||
$scannerRoleMenuCodes = Role::query()
|
||||
->where('codigo', RoleCode::Scanner->value)
|
||||
->firstOrFail()
|
||||
->menus()
|
||||
->orderBy('menues.code')
|
||||
->pluck('menues.code')
|
||||
->all();
|
||||
|
||||
$this->assertSame([
|
||||
'scanner.detail',
|
||||
'scanner.inicio',
|
||||
'scanner.scan',
|
||||
], $scannerRoleMenuCodes);
|
||||
$this->assertDatabaseCount(
|
||||
'roles_menues',
|
||||
(count($allMenuCodes) * 2) + count($userMenuCodes) + count($scannerMenuCodes)
|
||||
(count($allMenuCodes) * 2) + count($userMenuCodes)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ class WebsiteTypeSeederTest extends TestCase
|
||||
|
||||
$this->assertSame('ShopIt', $shopIt->nombre);
|
||||
$this->assertSame('localhost', $shopIt->dominio);
|
||||
$this->assertSame('scanner.localhost', $shopIt->scanner_domain);
|
||||
$this->assertSame($expectedPresentation, $shopIt->only(array_keys($expectedPresentation)));
|
||||
$this->assertSame('onticket_logo.png', $shopIt->siteLogo->filename);
|
||||
Storage::disk('s3')->assertExists($shopIt->siteLogo->path);
|
||||
@@ -71,7 +70,6 @@ class WebsiteTypeSeederTest extends TestCase
|
||||
|
||||
$this->assertSame('OnTicket', $onTicket->nombre);
|
||||
$this->assertSame('onticket.localhost', $onTicket->dominio);
|
||||
$this->assertSame('scanner.onticket.localhost', $onTicket->scanner_domain);
|
||||
$this->assertSame($expectedPresentation, $onTicket->only(array_keys($expectedPresentation)));
|
||||
$this->assertSame('onticket_logo.png', $onTicket->siteLogo->filename);
|
||||
Storage::disk('s3')->assertExists($onTicket->siteLogo->path);
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BootstrapScannerControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void
|
||||
{
|
||||
$siteLogo = Attachment::factory()->create();
|
||||
|
||||
WebsiteType::query()->create([
|
||||
'codigo' => 'shopit',
|
||||
'nombre' => 'ShopIt',
|
||||
'dominio' => 'admin.shopit.test',
|
||||
'scanner_domain' => 'scanner.shopit.test',
|
||||
'primary_color' => '#112233',
|
||||
'secondary_color' => '#445566',
|
||||
'danger_color' => '#aa0000',
|
||||
'success_color' => '#00aa00',
|
||||
'warning_color' => '#ffaa00',
|
||||
'body_color' => '#666666',
|
||||
'darker_body_color' => '#333333',
|
||||
'surface_color' => '#ffffff',
|
||||
'background_color' => '#f8f8f8',
|
||||
'border_color' => '#eaeaea',
|
||||
'login_header_footer_color' => '#313131',
|
||||
'site_logo' => $siteLogo->id,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.website_type_code', 'shopit')
|
||||
->assertJsonPath('data.primary_color', '#112233')
|
||||
->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440))
|
||||
->assertJsonPath('data.footer_logo', null)
|
||||
->assertJsonMissingPath('data.codigo')
|
||||
->assertJsonMissingPath('data.nombre')
|
||||
->assertJsonMissingPath('data.dominio')
|
||||
->assertJsonMissingPath('data.scanner_domain');
|
||||
}
|
||||
|
||||
public function test_it_does_not_match_the_admin_app_domain(): void
|
||||
{
|
||||
WebsiteType::query()->create([
|
||||
'codigo' => 'shopit',
|
||||
'nombre' => 'ShopIt',
|
||||
'dominio' => 'admin.shopit.test',
|
||||
'scanner_domain' => 'scanner.shopit.test',
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/scanner/bootstrap/admin.shopit.test')
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_it_returns_not_found_for_an_unknown_scanner_domain(): void
|
||||
{
|
||||
$this->getJson('/api/v1/scanner/bootstrap/unknown.test')
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_it_rejects_an_invalid_domain(): void
|
||||
{
|
||||
$invalidDomain = str_repeat('a', 256);
|
||||
|
||||
$this->getJson("/api/v1/scanner/bootstrap/{$invalidDomain}")
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['dominio']);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ class WebsiteTypeServiceTest extends TestCase
|
||||
'codigo' => 'marketplace',
|
||||
'nombre' => 'Marketplace',
|
||||
'dominio' => 'marketplace.test',
|
||||
'scanner_domain' => 'scanner.marketplace.test',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#ff0000',
|
||||
@@ -49,7 +48,6 @@ class WebsiteTypeServiceTest extends TestCase
|
||||
$this->assertDatabaseHas('website_type', [
|
||||
'codigo' => 'marketplace',
|
||||
'dominio' => 'marketplace.test',
|
||||
'scanner_domain' => 'scanner.marketplace.test',
|
||||
'warning_color' => '#ffaa00',
|
||||
'body_color' => '#666666',
|
||||
'darker_body_color' => '#333333',
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Ticket;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Database\Seeders\AuthorizationSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScannerTicketControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
private User $scanner;
|
||||
|
||||
private User $ticketOwner;
|
||||
|
||||
private Category $category;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(AuthorizationSeeder::class);
|
||||
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
|
||||
$this->tenant = $this->createTenant('acme');
|
||||
$this->scanner = User::factory()->create([
|
||||
'rol_codigo' => RoleCode::Scanner->value,
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
]);
|
||||
$this->ticketOwner = User::factory()->create();
|
||||
$this->category = Category::query()->create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
'nombre' => 'Entradas',
|
||||
]);
|
||||
$this->scanner->scanCategories()->attach($this->category);
|
||||
}
|
||||
|
||||
public function test_scanner_routes_require_authentication_and_scan_permission(): void
|
||||
{
|
||||
$this->getJson('/api/v1/scanner/tickets')->assertUnauthorized();
|
||||
|
||||
Sanctum::actingAs(User::factory()->create([
|
||||
'rol_codigo' => RoleCode::AdminApp->value,
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
]));
|
||||
|
||||
$this->getJson('/api/v1/scanner/tickets')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_scanner_can_list_only_its_scanned_tickets_using_adminapp_format(): void
|
||||
{
|
||||
$older = $this->createTicket('11111111-1111-4111-8111-111111111111', [
|
||||
'used_at' => now()->subMinutes(2),
|
||||
'scanner_user_id' => $this->scanner->id,
|
||||
]);
|
||||
$newer = $this->createTicket('22222222-2222-4222-8222-222222222222', [
|
||||
'used_at' => now()->subMinute(),
|
||||
'scanner_user_id' => $this->scanner->id,
|
||||
]);
|
||||
$otherScanner = User::factory()->create([
|
||||
'rol_codigo' => RoleCode::Scanner->value,
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
]);
|
||||
$this->createTicket('33333333-3333-4333-8333-333333333333', [
|
||||
'used_at' => now(),
|
||||
'scanner_user_id' => $otherScanner->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->scanner);
|
||||
|
||||
$this->getJson('/api/v1/scanner/tickets')
|
||||
->assertOk()
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.id', $newer->id)
|
||||
->assertJsonPath('data.0.ticket', $newer->ticket)
|
||||
->assertJsonPath('data.0.status', Ticket::STATUS_USED)
|
||||
->assertJsonPath('data.1.id', $older->id)
|
||||
->assertJsonPath('meta.current_page', 1)
|
||||
->assertJsonPath('meta.total', 2);
|
||||
}
|
||||
|
||||
public function test_scanner_ticket_history_supports_id_search_and_pagination(): void
|
||||
{
|
||||
$matching = $this->createTicket('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', [
|
||||
'used_at' => now(),
|
||||
'scanner_user_id' => $this->scanner->id,
|
||||
]);
|
||||
$this->createTicket('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', [
|
||||
'used_at' => now()->subMinute(),
|
||||
'scanner_user_id' => $this->scanner->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->scanner);
|
||||
|
||||
$this->getJson('/api/v1/scanner/tickets?q=aaaaaaaa&per_page=1')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.ticket', $matching->ticket)
|
||||
->assertJsonPath('meta.current_page', 1)
|
||||
->assertJsonPath('meta.per_page', 1)
|
||||
->assertJsonPath('meta.total', 1);
|
||||
}
|
||||
|
||||
public function test_scanner_ticket_history_can_be_searched_by_database_id(): void
|
||||
{
|
||||
$matching = $this->createTicket('cccccccc-cccc-4ccc-8ccc-cccccccccccc', [
|
||||
'used_at' => now(),
|
||||
'scanner_user_id' => $this->scanner->id,
|
||||
]);
|
||||
$this->createTicket('dddddddd-dddd-4ddd-8ddd-dddddddddddd', [
|
||||
'used_at' => now()->subMinute(),
|
||||
'scanner_user_id' => $this->scanner->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->scanner);
|
||||
|
||||
$this->getJson("/api/v1/scanner/tickets?q={$matching->id}")
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $matching->id);
|
||||
}
|
||||
|
||||
public function test_scanner_ticket_history_can_be_searched_by_used_date(): void
|
||||
{
|
||||
$matching = $this->createTicket('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', [
|
||||
'used_at' => '2026-08-11 14:30:00',
|
||||
'scanner_user_id' => $this->scanner->id,
|
||||
]);
|
||||
$this->createTicket('ffffffff-ffff-4fff-8fff-ffffffffffff', [
|
||||
'used_at' => '2026-08-10 14:30:00',
|
||||
'scanner_user_id' => $this->scanner->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->scanner);
|
||||
|
||||
$this->getJson('/api/v1/scanner/tickets?q=11%2F08%2F26')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $matching->id);
|
||||
}
|
||||
|
||||
public function test_scanner_can_read_an_authorized_ticket_detail_by_uuid(): void
|
||||
{
|
||||
$ticket = $this->createTicket('44444444-4444-4444-8444-444444444444');
|
||||
Sanctum::actingAs($this->scanner);
|
||||
|
||||
$this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $ticket->id)
|
||||
->assertJsonPath('data.ticket', $ticket->ticket)
|
||||
->assertJsonPath('data.name', $ticket->name)
|
||||
->assertJsonPath('data.client', $this->ticketOwner->nombre_apellido)
|
||||
->assertJsonPath('data.category', $this->category->nombre)
|
||||
->assertJsonPath('data.is_valid', true)
|
||||
->assertJsonPath('data.is_used', false);
|
||||
}
|
||||
|
||||
public function test_scanner_cannot_read_a_ticket_from_an_unassigned_category_or_tenant(): void
|
||||
{
|
||||
$otherCategory = Category::query()->create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
'nombre' => 'Comidas',
|
||||
]);
|
||||
$unassignedTicket = $this->createTicket(
|
||||
'55555555-5555-4555-8555-555555555555',
|
||||
[],
|
||||
$otherCategory,
|
||||
);
|
||||
$otherTenant = $this->createTenant('other');
|
||||
$foreignCategory = Category::query()->create([
|
||||
'tenant_code' => $otherTenant->codigo,
|
||||
'nombre' => 'Externas',
|
||||
]);
|
||||
$foreignTicket = $this->createTicket(
|
||||
'66666666-6666-4666-8666-666666666666',
|
||||
[],
|
||||
$foreignCategory,
|
||||
$otherTenant,
|
||||
);
|
||||
Sanctum::actingAs($this->scanner);
|
||||
|
||||
$this->getJson("/api/v1/scanner/tickets/{$unassignedTicket->ticket}")
|
||||
->assertNotFound();
|
||||
$this->getJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}")
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_scanner_can_scan_an_authorized_ticket_by_uuid(): void
|
||||
{
|
||||
$ticket = $this->createTicket('77777777-7777-4777-8777-777777777777');
|
||||
Sanctum::actingAs($this->scanner);
|
||||
|
||||
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.ticket', $ticket->ticket)
|
||||
->assertJsonPath('data.scanner_user_id', $this->scanner->id)
|
||||
->assertJsonPath('data.is_valid', false)
|
||||
->assertJsonPath('data.is_used', true);
|
||||
|
||||
$this->assertDatabaseHas('tickets', [
|
||||
'id' => $ticket->id,
|
||||
'scanner_user_id' => $this->scanner->id,
|
||||
]);
|
||||
$this->assertNotNull($ticket->fresh()->used_at);
|
||||
}
|
||||
|
||||
public function test_ticket_cannot_be_scanned_twice(): void
|
||||
{
|
||||
$ticket = $this->createTicket('88888888-8888-4888-8888-888888888888');
|
||||
Sanctum::actingAs($this->scanner);
|
||||
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")->assertOk();
|
||||
|
||||
$otherScanner = User::factory()->create([
|
||||
'rol_codigo' => RoleCode::Scanner->value,
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
]);
|
||||
$otherScanner->scanCategories()->attach($this->category);
|
||||
Sanctum::actingAs($otherScanner);
|
||||
|
||||
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('ticket');
|
||||
$this->assertSame($this->scanner->id, $ticket->fresh()->scanner_user_id);
|
||||
}
|
||||
|
||||
public function test_scanner_cannot_scan_a_ticket_from_an_unassigned_category(): void
|
||||
{
|
||||
$otherCategory = Category::query()->create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
'nombre' => 'Comidas',
|
||||
]);
|
||||
$ticket = $this->createTicket(
|
||||
'99999999-9999-4999-8999-999999999999',
|
||||
[],
|
||||
$otherCategory,
|
||||
);
|
||||
Sanctum::actingAs($this->scanner);
|
||||
|
||||
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('ticket');
|
||||
$this->assertNull($ticket->fresh()->used_at);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $attributes */
|
||||
private function createTicket(
|
||||
string $uuid,
|
||||
array $attributes = [],
|
||||
?Category $category = null,
|
||||
?Tenant $tenant = null,
|
||||
): Ticket {
|
||||
$tenant ??= $this->tenant;
|
||||
$category ??= $this->category;
|
||||
$catalogItem = CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'category_id' => $category->id,
|
||||
'slug' => "ticket-{$uuid}",
|
||||
'nombre' => 'Entrada general',
|
||||
'descripcion' => 'Acceso general',
|
||||
'precio' => 100,
|
||||
'has_tickets' => true,
|
||||
]);
|
||||
|
||||
return Ticket::query()->create(array_merge([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'ticket' => $uuid,
|
||||
'name' => 'Entrada general',
|
||||
'description' => 'Acceso general',
|
||||
'source_catalog_item_id' => $catalogItem->id,
|
||||
'user_id' => $this->ticketOwner->id,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
private function createTenant(string $code): Tenant
|
||||
{
|
||||
$logo = Attachment::query()->create([
|
||||
'key' => (string) Str::uuid(),
|
||||
'path' => "tests/{$code}-logo.png",
|
||||
'filename' => "{$code}-logo.png",
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/png',
|
||||
'extension' => 'png',
|
||||
'size' => 1,
|
||||
]);
|
||||
|
||||
return Tenant::query()->create([
|
||||
'codigo' => $code,
|
||||
'nombre' => ucfirst($code),
|
||||
'dominio' => "{$code}.test",
|
||||
'primary_color' => '#ff7006',
|
||||
'secondary_color' => '#777777',
|
||||
'danger_color' => '#e04a4a',
|
||||
'success_color' => '#81bc73',
|
||||
'header_bg_color' => '#313131',
|
||||
'footer_bg_color' => '#313131',
|
||||
'header_logo_id' => $logo->id,
|
||||
'footer_logo_id' => $logo->id,
|
||||
'website_type_code' => 'onticket',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user