Compare commits
40 Commits
feature/ti
...
cac4fcf2b2
| Author | SHA1 | Date | |
|---|---|---|---|
| cac4fcf2b2 | |||
| 64e965aff7 | |||
| 002f08a8fa | |||
| 531dbe52b9 | |||
| a885a2fc0e | |||
| 0c49bae752 | |||
| 67bcb420e4 | |||
| 34058e6a81 | |||
| 8a65d358a4 | |||
| 193e10dc48 | |||
| b607c1b673 | |||
| e9521e65d0 | |||
| d0625f25e5 | |||
| 1fdcfc1547 | |||
| ca4ea0aa6c | |||
| bb1f9c8e91 | |||
| 99fe94fa6a | |||
| a4b5c2eb19 | |||
| 2feca2bed5 | |||
| a35ff69140 | |||
| 75152b53a4 | |||
| 99594b17e7 | |||
| 85edea0661 | |||
| ab5ea7dd33 | |||
| c0057c237a | |||
| e4146288f1 | |||
| f11ba5a470 | |||
| e4db36e650 | |||
| ffa3f10b18 | |||
| 7acef66ee7 | |||
| 18f1217daa | |||
| ac44e82454 | |||
| 1e7a9b6876 | |||
| deea4fd4af | |||
| c4ff695f2e | |||
| 703158bd06 | |||
| eda2343380 | |||
| 0baad641d5 | |||
| ddb8c3743f | |||
| 900ef6cf98 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Administrator\Requests\StoreAdministratorRequest;
|
||||||
|
use App\Domains\Administrator\Requests\UpdateAdministratorRequest;
|
||||||
|
use App\Domains\Administrator\Resources\AdministratorResource;
|
||||||
|
use App\Domains\Administrator\Services\AdministratorService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AdminAppAdministratorController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AdministratorService $administratorService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return AdministratorResource::collection($this->administratorService->list(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->string('search')->trim()->toString() ?: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreAdministratorRequest $request): AdministratorResource
|
||||||
|
{
|
||||||
|
return AdministratorResource::make($this->administratorService->create(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->validated(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateAdministratorRequest $request, int $administrator): AdministratorResource
|
||||||
|
{
|
||||||
|
return AdministratorResource::make($this->administratorService->update(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$administrator,
|
||||||
|
$request->validated(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $administrator): Response
|
||||||
|
{
|
||||||
|
$this->administratorService->delete($request->user()->tenant()->firstOrFail(), $administrator, $request->user());
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreAdministratorRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->rol_codigo === RoleCode::AdminApp->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateAdministratorRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->rol_codigo === RoleCode::AdminApp->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$administratorId = (int) $this->route('administrator');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($administratorId),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin User */
|
||||||
|
class AdministratorResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'nombre_apellido' => $this->nombre_apellido,
|
||||||
|
'dni' => $this->dni,
|
||||||
|
'email' => $this->email,
|
||||||
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'role' => $this->whenLoaded('role', fn () => [
|
||||||
|
'codigo' => $this->role?->codigo,
|
||||||
|
'nombre' => $this->role?->nombre,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Domains/Administrator/Services/AdministratorService.php
Normal file
99
app/Domains/Administrator/Services/AdministratorService.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AdministratorService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
|
||||||
|
|
||||||
|
/** @return Collection<int, User> */
|
||||||
|
public function list(Tenant $tenant, ?string $search = null): Collection
|
||||||
|
{
|
||||||
|
return $this->query($tenant)->with('role')
|
||||||
|
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
|
||||||
|
$query->where('nombre_apellido', 'like', "%{$search}%")
|
||||||
|
->orWhere('dni', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
|
}))
|
||||||
|
->orderBy('nombre_apellido')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function create(Tenant $tenant, array $data): User
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): User {
|
||||||
|
$administrator = User::query()->create([
|
||||||
|
...$this->attributes($data),
|
||||||
|
'password' => Str::random(64),
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
$this->resetPasswordAttemptService->createForAdminAppEmail(
|
||||||
|
$administrator->email,
|
||||||
|
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $administrator->load('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function update(Tenant $tenant, int $administratorId, array $data): User
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $administratorId, $data): User {
|
||||||
|
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
||||||
|
$administrator->update($this->attributes($data));
|
||||||
|
|
||||||
|
return $administrator->load('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Tenant $tenant, int $administratorId, User $actor): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($tenant, $administratorId, $actor): void {
|
||||||
|
// Serialize deletions for this tenant, including requests already authenticated
|
||||||
|
// when another administrator removes their account.
|
||||||
|
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
||||||
|
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
||||||
|
if ($administrator->is($actor)) {
|
||||||
|
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
|
||||||
|
}
|
||||||
|
$activeAdministrators = $this->query($tenant)->lockForUpdate()->get();
|
||||||
|
if ($activeAdministrators->count() <= 1) {
|
||||||
|
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
|
||||||
|
}
|
||||||
|
abort_unless($activeAdministrators->contains('id', $actor->id), 403);
|
||||||
|
$administrator->tokens()->delete();
|
||||||
|
$administrator->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function query(Tenant $tenant): Builder
|
||||||
|
{
|
||||||
|
return User::query()->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('rol_codigo', RoleCode::AdminApp->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function attributes(array $data): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...Arr::only($data, ['nombre_apellido', 'dni']),
|
||||||
|
'email' => mb_strtolower(trim((string) $data['email'])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Domains/Administrator/documentacion/README.md
Normal file
63
app/Domains/Administrator/documentacion/README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Administradores de AdminApp
|
||||||
|
|
||||||
|
CRUD de usuarios con rol `adminapp`, limitado al tenant del usuario autenticado.
|
||||||
|
Todos los administradores del tenant pueden gestionar esta sección.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Base: `/api/v1/adminapp/tenant/administrators`.
|
||||||
|
Requieren `auth:sanctum` y `adminapp.tenant`.
|
||||||
|
|
||||||
|
- `GET /`: listado ordenado por nombre; acepta `search` por nombre, DNI o email.
|
||||||
|
- `POST /`: alta; responde `201` con `data`.
|
||||||
|
- `PUT /{administrator}`: actualización de los tres campos; responde `200` con `data`.
|
||||||
|
- `DELETE /{administrator}`: baja lógica; responde `204`.
|
||||||
|
|
||||||
|
Alta y actualización reciben:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nombre_apellido": "Ada Lovelace",
|
||||||
|
"dni": "12345678",
|
||||||
|
"email": "ada@example.test"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nombre (hasta 255 caracteres), DNI (hasta 50) y email (hasta 255) son obligatorios.
|
||||||
|
El email se normaliza a minúsculas antes de validar y debe ser único entre
|
||||||
|
usuarios activos, sin importar su tenant o rol. Se permite reutilizar el email
|
||||||
|
de un usuario eliminado. Rol y tenant no son editables desde esta API.
|
||||||
|
|
||||||
|
Las respuestas incluyen `id`, `nombre_apellido`, `dni`, `email`, `rol_codigo`
|
||||||
|
y `role` (`codigo`, `nombre`). Nunca incluyen contraseña ni datos de escaneo.
|
||||||
|
|
||||||
|
## Alta y acceso
|
||||||
|
|
||||||
|
Se genera una contraseña aleatoria y un intento de establecimiento de contraseña
|
||||||
|
con motivo `administrator_created`, reutilizando `createForAdminAppEmail`.
|
||||||
|
El evento usa el canal `adminapp`; el listener existente envía el email después
|
||||||
|
del commit mediante la cola `emails`. Requiere la configuración de correo,
|
||||||
|
dominio AdminApp y worker existentes. No se envían contraseñas en texto plano.
|
||||||
|
|
||||||
|
## Eliminación y aislamiento
|
||||||
|
|
||||||
|
Las consultas de usuarios se limitan por tenant y rol `adminapp`. IDs ajenos,
|
||||||
|
usuarios eliminados y usuarios de otros roles devuelven `404`.
|
||||||
|
La validación de campos devuelve `422`; falta de autenticación, `401`, y rol
|
||||||
|
no autorizado, `403`.
|
||||||
|
|
||||||
|
No se permite eliminar al propio usuario ni dejar al tenant sin administradores
|
||||||
|
(`422`, error `administrator`). La eliminación bloquea la fila del tenant dentro
|
||||||
|
de una transacción para serializar bajas concurrentes. También verifica que el
|
||||||
|
actor siga activo, revoca tokens y aplica el borrado lógico existente en `users`.
|
||||||
|
|
||||||
|
No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el frontend.
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
|
||||||
|
|
||||||
|
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de
|
||||||
|
contraseña, restricciones de rol y tenant, baja lógica, tokens y protecciones de
|
||||||
|
eliminación. El caso de petición autenticada antes de la baja del actor se simula;
|
||||||
|
no es una prueba con conexiones concurrentes reales.
|
||||||
10
app/Domains/Administrator/routes/api.php
Normal file
10
app/Domains/Administrator/routes/api.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Administrator\Controllers\AdminAppAdministratorController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::apiResource('administrators', AdminAppAdministratorController::class)->except('show');
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
|
|||||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
||||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -26,6 +27,7 @@ class ResetPasswordController extends Controller
|
|||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
$data['password'],
|
$data['password'],
|
||||||
|
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
|
||||||
)) {
|
)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'codigo' => __('api.auth.password_reset_invalid'),
|
'codigo' => __('api.auth.password_reset_invalid'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
|
|||||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
||||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -25,6 +26,7 @@ class ValidateResetPasswordAttemptController extends Controller
|
|||||||
$result = $this->resetPasswordAttemptService->validateCode(
|
$result = $this->resetPasswordAttemptService->validateCode(
|
||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
|
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class ResetPasswordAttempt extends Model
|
|||||||
|
|
||||||
public const REASON_STAFF_CREATED = 'staff_created';
|
public const REASON_STAFF_CREATED = 'staff_created';
|
||||||
|
|
||||||
|
public const REASON_ADMINISTRATOR_CREATED = 'administrator_created';
|
||||||
|
|
||||||
public const STATUS_PENDING = 'pending';
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
public const STATUS_VALIDATED = 'validated';
|
public const STATUS_VALIDATED = 'validated';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Domains\Authorization\Enums\RoleCode;
|
|||||||
use App\Domains\Authorization\Models\Role;
|
use App\Domains\Authorization\Models\Role;
|
||||||
use App\Domains\Catalog\Models\Category;
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\ScanAttempt;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
@@ -13,16 +14,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
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;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
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'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'rol_codigo' => RoleCode::User->value,
|
'rol_codigo' => RoleCode::User->value,
|
||||||
@@ -45,6 +47,12 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(LoginAttempt::class);
|
return $this->hasMany(LoginAttempt::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ScanAttempt, $this> */
|
||||||
|
public function scanAttempts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ScanAttempt::class, 'scanner_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return BelongsTo<Role, $this>
|
* @return BelongsTo<Role, $this>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Requests;
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
@@ -13,15 +14,26 @@ class RegisterUserRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function prepareForValidation(): void
|
||||||
* @return array<string, mixed>
|
{
|
||||||
*/
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
||||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::User->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
|
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
|
||||||
'dni' => ['nullable', 'string', 'max:255'],
|
'dni' => ['nullable', 'string', 'max:255'],
|
||||||
'telefono' => ['nullable', 'string', 'max:255'],
|
'telefono' => ['nullable', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Auth\Requests;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
class UpdateProfileRequest extends FormRequest
|
class UpdateProfileRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -12,6 +13,13 @@ class UpdateProfileRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -19,11 +27,13 @@ class UpdateProfileRequest extends FormRequest
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
Rule::unique('users', 'email')->ignore($this->user()->id),
|
Rule::unique('users', 'active_email')->where('rol_codigo', $this->user()->rol_codigo)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($this->user()->id),
|
||||||
],
|
],
|
||||||
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
||||||
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
||||||
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
|
'password' => ['nullable', 'string', Password::min(8)->mixedCase()->symbols()],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ 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,
|
||||||
|
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
|
||||||
|
->map(fn ($category) => [
|
||||||
|
'id' => $category->id,
|
||||||
|
'nombre' => $category->nombre,
|
||||||
|
])->values()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AdminCredentialVerifier
|
|||||||
public function verify(string $email, string $password): bool
|
public function verify(string $email, string $password): bool
|
||||||
{
|
{
|
||||||
$admin = User::query()
|
$admin = User::query()
|
||||||
->where('email', mb_strtolower(trim($email)))
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::Admin->value)
|
->where('rol_codigo', RoleCode::Admin->value)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Auth\Services;
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
@@ -124,12 +125,12 @@ class GoogleAuthService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()->where('google_id', $googleId)->first();
|
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('google_id', $googleId)->first();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()->where('email', $email)->first();
|
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('active_email', mb_strtolower(trim($email)))->first();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$user->forceFill(['google_id' => $googleId])->save();
|
$user->forceFill(['google_id' => $googleId])->save();
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class PasswordLoginService
|
|||||||
null,
|
null,
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
$userAgent,
|
$userAgent,
|
||||||
null,
|
RoleCode::Scanner,
|
||||||
true,
|
true,
|
||||||
PermissionCode::ScanTickets->value,
|
PermissionCode::ScanTickets->value,
|
||||||
PasswordResetRequested::CHANNEL_SCANNER,
|
PasswordResetRequested::CHANNEL_SCANNER,
|
||||||
@@ -120,7 +120,7 @@ class PasswordLoginService
|
|||||||
$passwordResetChannel,
|
$passwordResetChannel,
|
||||||
): array {
|
): array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $normalizedEmail)
|
->where('active_email', $normalizedEmail)
|
||||||
->when(
|
->when(
|
||||||
$requiredRole !== null,
|
$requiredRole !== null,
|
||||||
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
|
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', RoleCode::User->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::AdminApp->value)
|
->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
->whereNotNull('tenant_codigo')
|
->whereNotNull('tenant_codigo')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -113,7 +114,7 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::Scanner->value)
|
->where('rol_codigo', RoleCode::Scanner->value)
|
||||||
->whereNotNull('tenant_codigo')
|
->whereNotNull('tenant_codigo')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -152,14 +153,15 @@ class ResetPasswordAttemptService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validateCode(string $email, string $code): string
|
public function validateCode(string $email, string $code, RoleCode $role = RoleCode::User): string
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
|
return DB::transaction(function () use ($email, $code, $emailFingerprint, $role): string {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', $role->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -207,14 +209,15 @@ class ResetPasswordAttemptService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetPassword(string $email, string $code, string $password): bool
|
public function resetPassword(string $email, string $code, string $password, RoleCode $role = RoleCode::User): bool
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
|
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint, $role): bool {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', $role->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ class ScannerContextService
|
|||||||
|
|
||||||
$user->setRelation('tenant', $tenant);
|
$user->setRelation('tenant', $tenant);
|
||||||
|
|
||||||
|
if ($tenant->requiresScannerCategoryValidation()) {
|
||||||
|
$categories = $user->scanCategories()
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($categories->isNotEmpty()) {
|
||||||
|
$user->setRelation('scanCategories', $categories);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ Route::prefix('v1/adminapp')->group(function (): void {
|
|||||||
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
->defaults('reset_role', 'adminapp')
|
||||||
->middleware('throttle:10,1');
|
->middleware('throttle:10,1');
|
||||||
Route::post('password/reset', ResetPasswordController::class)
|
Route::post('password/reset', ResetPasswordController::class)
|
||||||
|
->defaults('reset_role', 'adminapp')
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->get('me', AdminAppMeController::class);
|
->get('me', AdminAppMeController::class);
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ Route::prefix('v1/scanner')->group(function (): void {
|
|||||||
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
->defaults('reset_role', 'scanner')
|
||||||
->middleware('throttle:10,1');
|
->middleware('throttle:10,1');
|
||||||
Route::post('password/reset', ResetPasswordController::class)
|
Route::post('password/reset', ResetPasswordController::class)
|
||||||
|
->defaults('reset_role', 'scanner')
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
||||||
->get('me', ScannerMeController::class);
|
->get('me', ScannerMeController::class);
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ class InvitationPurchaseProvisioner
|
|||||||
|
|
||||||
private function userId(DateTimeInterface $now): int
|
private function userId(DateTimeInterface $now): int
|
||||||
{
|
{
|
||||||
$user = DB::table('users')->where('email', self::USER_EMAIL)->first();
|
$user = DB::table('users')
|
||||||
|
->where('active_email', self::USER_EMAIL)
|
||||||
|
->where('rol_codigo', 'user')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($user !== null) {
|
if ($user !== null) {
|
||||||
if ($user->tenant_codigo !== self::TENANT_CODE) {
|
if ($user->tenant_codigo !== self::TENANT_CODE) {
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ namespace App\Domains\Integration\Controllers;
|
|||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Integration\Models\Integration;
|
use App\Domains\Integration\Models\Integration;
|
||||||
use App\Domains\Integration\Requests\StoreClientIntegrationRequest;
|
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
|
||||||
use App\Domains\Integration\Services\ClientIntegrationService;
|
use App\Domains\Integration\Services\ClientIntegrationService;
|
||||||
|
use App\Domains\Integration\Services\IntegrationAssociationService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ class ClientIntegrationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function store(
|
public function store(
|
||||||
StoreClientIntegrationRequest $request,
|
ConfigureIntegrationRequest $request,
|
||||||
Client $client,
|
Client $client,
|
||||||
string $integrationCode,
|
string $integrationCode,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
@@ -61,4 +62,11 @@ class ClientIntegrationController extends Controller
|
|||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
|
||||||
|
{
|
||||||
|
$service->detach($client, $integrationCode);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class IntegrationController extends Controller
|
|||||||
|
|
||||||
public function destroy(Integration $integration)
|
public function destroy(Integration $integration)
|
||||||
{
|
{
|
||||||
|
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
|
||||||
$integration->delete();
|
$integration->delete();
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
|
||||||
|
use App\Domains\Integration\Resources\IntegrationAssociationResource;
|
||||||
|
use App\Domains\Integration\Services\IntegrationAssociationService;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class WebsiteTypeIntegrationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly IntegrationAssociationService $service) {}
|
||||||
|
|
||||||
|
public function index(WebsiteType $websiteType)
|
||||||
|
{
|
||||||
|
return IntegrationAssociationResource::collection($websiteType->integrations()->with('integrationInstance')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ConfigureIntegrationRequest $request, WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
$integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail();
|
||||||
|
|
||||||
|
return new IntegrationAssociationResource($this->service->configure(
|
||||||
|
$websiteType,
|
||||||
|
$integration,
|
||||||
|
$request->validated('integration_data'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
$this->service->detach($websiteType, $integrationCode);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,15 @@
|
|||||||
namespace App\Domains\Integration\Models;
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class ClientIntegration extends Model
|
class ClientIntegration extends Model
|
||||||
{
|
{
|
||||||
protected $hidden = ['integration_data'];
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'client_id',
|
'client_id',
|
||||||
'integration_code',
|
'integration_code',
|
||||||
'integration_data',
|
'integration_instance_id',
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'integration_data' => EncryptedIntegrationData::class,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @return BelongsTo<Client, $this> */
|
/** @return BelongsTo<Client, $this> */
|
||||||
@@ -32,4 +25,10 @@ class ClientIntegration extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<IntegrationInstance, $this> */
|
||||||
|
public function integrationInstance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(IntegrationInstance::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Integration\Models;
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Integration extends Model
|
class Integration extends Model
|
||||||
{
|
{
|
||||||
@@ -13,16 +14,29 @@ class Integration extends Model
|
|||||||
'name',
|
'name',
|
||||||
'url',
|
'url',
|
||||||
'integration_data_schema',
|
'integration_data_schema',
|
||||||
'requires_client_configuration',
|
'requires_configuration',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'integration_data_schema' => 'array',
|
'integration_data_schema' => 'array',
|
||||||
'requires_client_configuration' => 'boolean',
|
'requires_configuration' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function clientIntegrations()
|
/** @return HasMany<IntegrationInstance, $this> */
|
||||||
|
public function instances(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ClientIntegration, $this> */
|
||||||
|
public function clientIntegrations(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
|
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WebsiteTypeIntegration, $this> */
|
||||||
|
public function websiteTypeIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebsiteTypeIntegration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Domains/Integration/Models/IntegrationInstance.php
Normal file
41
app/Domains/Integration/Models/IntegrationInstance.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class IntegrationInstance extends Model
|
||||||
|
{
|
||||||
|
// The ciphertext changes whenever credentials are saved, so old tokens cannot be reused.
|
||||||
|
public function tokenCacheKey(): string
|
||||||
|
{
|
||||||
|
return 'integration_token:instance:'.$this->id.':'.hash('sha256', (string) $this->getRawOriginal('integration_data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $fillable = ['integration_code', 'name', 'integration_data'];
|
||||||
|
|
||||||
|
protected $hidden = ['integration_data'];
|
||||||
|
|
||||||
|
protected $casts = ['integration_data' => EncryptedIntegrationData::class];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Integration, $this> */
|
||||||
|
public function integration(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ClientIntegration, $this> */
|
||||||
|
public function clientIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ClientIntegration::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WebsiteTypeIntegration, $this> */
|
||||||
|
public function websiteTypeIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebsiteTypeIntegration::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Domains/Integration/Models/WebsiteTypeIntegration.php
Normal file
30
app/Domains/Integration/Models/WebsiteTypeIntegration.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WebsiteTypeIntegration extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['website_type_code', 'integration_code', 'integration_instance_id'];
|
||||||
|
|
||||||
|
/** @return BelongsTo<WebsiteType, $this> */
|
||||||
|
public function websiteType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Integration, $this> */
|
||||||
|
public function integration(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<IntegrationInstance, $this> */
|
||||||
|
public function integrationInstance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(IntegrationInstance::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Domains/Integration/Policies/IntegrationPolicy.php
Normal file
14
app/Domains/Integration/Policies/IntegrationPolicy.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Policies;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
|
||||||
|
class IntegrationPolicy
|
||||||
|
{
|
||||||
|
public function manage(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->rol_codigo === RoleCode::Admin->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,20 +6,19 @@ use App\Domains\Integration\Models\Integration;
|
|||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class StoreClientIntegrationRequest extends FormRequest
|
class ConfigureIntegrationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
protected ?Integration $integrationModel = null;
|
protected ?Integration $integrationModel = null;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return $this->user()?->can('manage', Integration::class) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$integrationCode = $this->route('integration_code');
|
|
||||||
$this->integrationModel = Integration::query()
|
$this->integrationModel = Integration::query()
|
||||||
->where('integration_code', $integrationCode)
|
->where('integration_code', $this->route('integration_code'))
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $this->integrationModel) {
|
if (! $this->integrationModel) {
|
||||||
@@ -31,7 +30,7 @@ class StoreClientIntegrationRequest extends FormRequest
|
|||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
$rules = [];
|
$rules = ['integration_data' => ['present', 'array']];
|
||||||
|
|
||||||
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
|
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
|
||||||
$rules['integration_data.'.$field] = $rule;
|
$rules['integration_data.'.$field] = $rule;
|
||||||
@@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'url' => ['nullable', 'url', 'max:255'],
|
'url' => ['nullable', 'url', 'max:255'],
|
||||||
'integration_data_schema' => ['nullable', 'array'],
|
'integration_data_schema' => ['nullable', 'array'],
|
||||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
'requires_configuration' => ['sometimes', 'boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Integration\Requests;
|
namespace App\Domains\Integration\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UpdateIntegrationRequest extends FormRequest
|
class UpdateIntegrationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -19,9 +20,8 @@ class UpdateIntegrationRequest extends FormRequest
|
|||||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||||
'url' => ['nullable', 'url', 'max:255'],
|
'url' => ['nullable', 'url', 'max:255'],
|
||||||
'integration_data_schema' => ['nullable', 'array'],
|
'integration_data_schema' => ['nullable', 'array'],
|
||||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
'requires_configuration' => ['sometimes', 'boolean'],
|
||||||
// the code shouldn't ideally be updatable, but if it is:
|
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
|
||||||
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class IntegrationAssociationResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'client_id' => $this->when(isset($this->client_id), $this->client_id),
|
||||||
|
'website_type_code' => $this->when(isset($this->website_type_code), $this->website_type_code),
|
||||||
|
'integration_code' => $this->integration_code,
|
||||||
|
'integration_instance_id' => $this->integration_instance_id,
|
||||||
|
'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class IntegrationInstanceResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'integration_code' => $this->integration_code,
|
||||||
|
'name' => $this->name,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ namespace App\Domains\Integration\Services;
|
|||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Integration\Models\ClientIntegration;
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
use App\Domains\Integration\Models\Integration;
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use App\Domains\Integration\Models\WebsiteTypeIntegration;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Http\Client\PendingRequest;
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
@@ -32,9 +34,9 @@ abstract class BaseIntegrationService
|
|||||||
protected ?Integration $integration = null;
|
protected ?Integration $integration = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The client-owned integration configuration.
|
* The effective integration instance.
|
||||||
*/
|
*/
|
||||||
protected ?ClientIntegration $clientIntegration = null;
|
protected ?IntegrationInstance $integrationInstance = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the integration code.
|
* Set the integration code.
|
||||||
@@ -85,7 +87,7 @@ abstract class BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the integration definition and its client-owned configuration.
|
* Load the integration definition and its effective instance configuration.
|
||||||
*
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -95,6 +97,7 @@ abstract class BaseIntegrationService
|
|||||||
throw new Exception('Integration code is not set.');
|
throw new Exception('Integration code is not set.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->integrationInstance = null;
|
||||||
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
||||||
if (! $this->integration) {
|
if (! $this->integration) {
|
||||||
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
||||||
@@ -104,11 +107,18 @@ abstract class BaseIntegrationService
|
|||||||
throw new Exception('Client context is not set.');
|
throw new Exception('Client context is not set.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id)
|
$this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id)
|
||||||
->where('integration_code', $this->integrationCode)
|
->where('integration_code', $this->integrationCode)
|
||||||
->first();
|
->first()?->integrationInstance;
|
||||||
|
|
||||||
if (! $this->clientIntegration && $this->integration->requires_client_configuration) {
|
if (! $this->integrationInstance && $this->tenant?->website_type_code) {
|
||||||
|
$this->integrationInstance = WebsiteTypeIntegration::with('integrationInstance')
|
||||||
|
->where('website_type_code', $this->tenant->website_type_code)
|
||||||
|
->where('integration_code', $this->integrationCode)
|
||||||
|
->first()?->integrationInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->integrationInstance && $this->integration->requires_configuration) {
|
||||||
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
|
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,15 +141,15 @@ abstract class BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an integration setting from the client-owned configuration.
|
* Get an integration setting from the effective instance configuration.
|
||||||
*/
|
*/
|
||||||
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) {
|
if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) {
|
||||||
return $default;
|
return $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->clientIntegration->integration_data[$key] ?? $default;
|
return $this->integrationInstance->integration_data[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ClientIntegrationService
|
|||||||
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
|
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
|
||||||
{
|
{
|
||||||
return $client->integrations()
|
return $client->integrations()
|
||||||
|
->with(['integration', 'integrationInstance'])
|
||||||
->where('integration_code', $integrationCode)
|
->where('integration_code', $integrationCode)
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
@@ -20,7 +21,7 @@ class ClientIntegrationService
|
|||||||
/** @return Collection<int, ClientIntegration> */
|
/** @return Collection<int, ClientIntegration> */
|
||||||
public function getAllForClient(Client $client): Collection
|
public function getAllForClient(Client $client): Collection
|
||||||
{
|
{
|
||||||
return $client->integrations()->with('integration')->get();
|
return $client->integrations()->with(['integration', 'integrationInstance'])->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateOrCreateIntegration(
|
public function updateOrCreateIntegration(
|
||||||
@@ -29,13 +30,7 @@ class ClientIntegrationService
|
|||||||
array $data,
|
array $data,
|
||||||
): ClientIntegration {
|
): ClientIntegration {
|
||||||
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
|
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
|
||||||
$clientIntegration = ClientIntegration::query()->updateOrCreate(
|
$clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
|
||||||
[
|
|
||||||
'client_id' => $client->id,
|
|
||||||
'integration_code' => $integration->integration_code,
|
|
||||||
],
|
|
||||||
['integration_data' => $data],
|
|
||||||
);
|
|
||||||
|
|
||||||
$service = $this->resolveService($integration->integration_code);
|
$service = $this->resolveService($integration->integration_code);
|
||||||
$service?->forClient($client)->onSetup();
|
$service?->forClient($client)->onSetup();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use App\Domains\Integration\Models\WebsiteTypeIntegration;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class IntegrationAssociationService
|
||||||
|
{
|
||||||
|
public function configure(Client|WebsiteType $owner, Integration $integration, array $integrationData): ClientIntegration|WebsiteTypeIntegration
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($owner, $integration, $integrationData): ClientIntegration|WebsiteTypeIntegration {
|
||||||
|
$instance = IntegrationInstance::create([
|
||||||
|
'integration_code' => $integration->integration_code,
|
||||||
|
'name' => $integration->name.' / '.$this->ownerName($owner),
|
||||||
|
'integration_data' => $integrationData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->associate($owner, $integration->integration_code, $instance);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function associate(Client|WebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|WebsiteTypeIntegration
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($owner, $code, $instance): ClientIntegration|WebsiteTypeIntegration {
|
||||||
|
$association = $owner->integrations()
|
||||||
|
->where('integration_code', $code)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
$previousInstanceId = $association?->integration_instance_id;
|
||||||
|
|
||||||
|
$instanceIds = array_values(array_unique(array_filter([
|
||||||
|
$previousInstanceId,
|
||||||
|
$instance->id,
|
||||||
|
])));
|
||||||
|
sort($instanceIds);
|
||||||
|
|
||||||
|
$instances = IntegrationInstance::query()
|
||||||
|
->whereKey($instanceIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
$instance = $instances->get($instance->id) ?? IntegrationInstance::query()->findOrFail($instance->id);
|
||||||
|
abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.');
|
||||||
|
|
||||||
|
$association = $owner->integrations()->updateOrCreate(
|
||||||
|
['integration_code' => $code],
|
||||||
|
['integration_instance_id' => $instance->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($previousInstanceId && $previousInstanceId !== $instance->id) {
|
||||||
|
$this->deleteIfUnused($previousInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $association->load('integrationInstance');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detach(Client|WebsiteType $owner, string $code): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($owner, $code): void {
|
||||||
|
$association = $owner->integrations()
|
||||||
|
->where('integration_code', $code)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $association) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instanceId = $association->integration_instance_id;
|
||||||
|
$association->delete();
|
||||||
|
$this->deleteIfUnused($instanceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteIfUnused(int $instanceId): void
|
||||||
|
{
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->find($instanceId);
|
||||||
|
|
||||||
|
if ($instance
|
||||||
|
&& ! $instance->clientIntegrations()->exists()
|
||||||
|
&& ! $instance->websiteTypeIntegrations()->exists()) {
|
||||||
|
$instance->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ownerName(Client|WebsiteType $owner): string
|
||||||
|
{
|
||||||
|
return $owner instanceof Client ? $owner->name : $owner->nombre;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class IntegrationInstanceService
|
||||||
|
{
|
||||||
|
public function create(array $data): IntegrationInstance
|
||||||
|
{
|
||||||
|
return IntegrationInstance::create($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(IntegrationInstance $instance, array $data): IntegrationInstance
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($instance, $data): IntegrationInstance {
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
|
||||||
|
$cacheKey = $instance->tokenCacheKey();
|
||||||
|
$instance->update($data);
|
||||||
|
if (array_key_exists('integration_data', $data)) {
|
||||||
|
DB::afterCommit(fn () => Cache::forget($cacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(IntegrationInstance $instance): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($instance): void {
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
|
||||||
|
abort_if($instance->clientIntegrations()->exists() || $instance->websiteTypeIntegrations()->exists(), 409, 'Unlink the instance before deleting it.');
|
||||||
|
$cacheKey = $instance->tokenCacheKey();
|
||||||
|
$instance->delete();
|
||||||
|
DB::afterCommit(fn () => Cache::forget($cacheKey));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ class MailService extends BaseIntegrationService
|
|||||||
|
|
||||||
private ?Mailer $mailer = null;
|
private ?Mailer $mailer = null;
|
||||||
|
|
||||||
private bool $usesClientMailer = false;
|
private bool $usesInstanceMailer = false;
|
||||||
|
|
||||||
public function __construct(?MailFactory $mailFactory = null)
|
public function __construct(?MailFactory $mailFactory = null)
|
||||||
{
|
{
|
||||||
@@ -40,12 +40,12 @@ class MailService extends BaseIntegrationService
|
|||||||
{
|
{
|
||||||
parent::forTenant($tenantCode);
|
parent::forTenant($tenantCode);
|
||||||
|
|
||||||
if ($this->clientIntegration) {
|
if ($this->integrationInstance) {
|
||||||
$this->mailer = $this->resolveMailer();
|
$this->mailer = $this->resolveMailer();
|
||||||
$this->usesClientMailer = true;
|
$this->usesInstanceMailer = true;
|
||||||
} else {
|
} else {
|
||||||
$this->mailer = $this->mailFactory->mailer();
|
$this->mailer = $this->mailFactory->mailer();
|
||||||
$this->usesClientMailer = false;
|
$this->usesInstanceMailer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -56,12 +56,12 @@ class MailService extends BaseIntegrationService
|
|||||||
parent::forClient($client);
|
parent::forClient($client);
|
||||||
$this->tenant = $this->clientContext?->tenants()->first();
|
$this->tenant = $this->clientContext?->tenants()->first();
|
||||||
|
|
||||||
if ($this->clientIntegration) {
|
if ($this->integrationInstance) {
|
||||||
$this->mailer = $this->resolveMailer();
|
$this->mailer = $this->resolveMailer();
|
||||||
$this->usesClientMailer = true;
|
$this->usesInstanceMailer = true;
|
||||||
} else {
|
} else {
|
||||||
$this->mailer = $this->mailFactory->mailer();
|
$this->mailer = $this->mailFactory->mailer();
|
||||||
$this->usesClientMailer = false;
|
$this->usesInstanceMailer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -122,8 +122,8 @@ class MailService extends BaseIntegrationService
|
|||||||
|
|
||||||
public function mailerName(): string
|
public function mailerName(): string
|
||||||
{
|
{
|
||||||
return $this->usesClientMailer
|
return $this->usesInstanceMailer
|
||||||
? 'client-smtp'
|
? 'integration-smtp'
|
||||||
: (string) config('mail.default');
|
: (string) config('mail.default');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ class MailService extends BaseIntegrationService
|
|||||||
|
|
||||||
private function resolveMailer(): Mailer
|
private function resolveMailer(): Mailer
|
||||||
{
|
{
|
||||||
$data = $this->clientIntegration?->integration_data;
|
$data = $this->integrationInstance?->integration_data;
|
||||||
|
|
||||||
if (! is_array($data)) {
|
if (! is_array($data)) {
|
||||||
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
|
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
|
||||||
@@ -205,7 +205,7 @@ class MailService extends BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$mailer = $this->mailFactory->build([
|
$mailer = $this->mailFactory->build([
|
||||||
'name' => 'client-smtp-'.$this->clientContext?->id,
|
'name' => 'integration-smtp-'.$this->integrationInstance?->id,
|
||||||
'transport' => 'smtp',
|
'transport' => 'smtp',
|
||||||
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
||||||
'host' => $data['MAIL_HOST'],
|
'host' => $data['MAIL_HOST'],
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
*/
|
*/
|
||||||
public function getToken(): string
|
public function getToken(): string
|
||||||
{
|
{
|
||||||
if (! $this->clientIntegration || ! $this->clientContext) {
|
if (! $this->integrationInstance) {
|
||||||
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
|
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
|
|
||||||
$token = Cache::get($cacheKey);
|
$token = Cache::get($cacheKey);
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
// Calculate TTL and subtract a buffer of 60 seconds
|
// Calculate TTL and subtract a buffer of 60 seconds
|
||||||
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
Cache::put($cacheKey, $token, $ttlSeconds);
|
Cache::put($cacheKey, $token, $ttlSeconds);
|
||||||
|
|
||||||
return $token;
|
return $token;
|
||||||
@@ -220,11 +220,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
*/
|
*/
|
||||||
public function clearToken(): void
|
public function clearToken(): void
|
||||||
{
|
{
|
||||||
if (! $this->clientContext) {
|
if (! $this->integrationInstance) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
Cache::forget($cacheKey);
|
Cache::forget($cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,48 @@
|
|||||||
# Dominio Integration
|
# Dominio Integration
|
||||||
|
|
||||||
## Propósito
|
## Modelo
|
||||||
|
|
||||||
Gestiona integraciones externas disponibles y su configuración por cliente. Un cliente puede agrupar múltiples tenants que comparten las mismas credenciales. Incluye correo y pagos mediante Telepagos.
|
- `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`.
|
||||||
|
- `IntegrationInstance`: configuración interna concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API.
|
||||||
|
- `ClientIntegration` y `WebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario.
|
||||||
|
|
||||||
## Modelo y seguridad
|
Las instancias no se administran directamente por HTTP. Cada configuración enviada desde un cliente o tipo de sitio crea una instancia interna nueva y reemplaza únicamente la asociación de ese propietario. Al reemplazar o desvincular una instancia, esta se elimina si ya no tiene asociaciones con ningún cliente ni tipo de sitio; las instancias compartidas se conservan mientras tengan al menos una asociación.
|
||||||
|
|
||||||
- `Integration`: definición global de una integración.
|
## Resolución
|
||||||
- `ClientIntegration`: configuración y credenciales de una integración para un cliente.
|
|
||||||
- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos.
|
|
||||||
- `ClientIntegrationService`: consulta y configura integraciones del cliente.
|
|
||||||
|
|
||||||
## Servicios externos
|
`BaseIntegrationService::forTenant()` busca primero la asociación del cliente y después la del tipo de sitio del tenant. Selecciona una configuración completa, sin mezclar credenciales entre niveles. Si una configuración está presente pero es inválida, produce un error en vez de recurrir a otra instancia.
|
||||||
|
|
||||||
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
|
`forClient()` usa únicamente la asociación del cliente: sin un tenant concreto no se elige un tipo de sitio. Si no existe una instancia y `requires_configuration` es verdadero, se genera un error. Para correo opcional, `MailService` usa el mailer global si no encuentra una instancia; cuando la encuentra, construye un transporte SMTP aislado identificado como `integration-smtp`.
|
||||||
- `MailService`: envío de correo usando la integración configurada.
|
|
||||||
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
|
|
||||||
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
|
|
||||||
|
|
||||||
## Endpoints
|
Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual.
|
||||||
|
|
||||||
- CRUD global bajo `/integrations`.
|
## Administración
|
||||||
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
|
|
||||||
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
|
|
||||||
|
|
||||||
## Logging de Telepagos
|
Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran configuraciones.
|
||||||
|
|
||||||
Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
|
| Método | Ruta | Operación |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/clients/{client}/integrations[/{integration_code}]` | Consultar asociaciones directas. |
|
||||||
|
| PUT | `/clients/{client}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. |
|
||||||
|
| DELETE | `/clients/{client}/integrations/{integration_code}` | Desvincular. |
|
||||||
|
| GET | `/website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de sitio. |
|
||||||
|
| PUT | `/website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de sitio. |
|
||||||
|
| DELETE | `/website-types/{codigo}/integrations/{integration_code}` | Desvincular. |
|
||||||
|
|
||||||
## Dependencias y reglas
|
Los dos `PUT` reciben `integration_data`, un objeto completo validado según el esquema de la integración. La integración debe existir previamente en el catálogo interno. No hay endpoints públicos para administrar el catálogo ni las instancias directamente.
|
||||||
|
|
||||||
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra.
|
Las respuestas y consultas incluyen metadatos de `integration_instance`, pero nunca sus credenciales. La configuración del cliente conserva su mensaje de respuesta histórico; la del tipo de sitio usa un resource con envoltorio `data`.
|
||||||
|
|
||||||
|
## Webhooks y contexto operativo
|
||||||
|
|
||||||
|
`POST /webhooks/telepagos/{client}` conserva su contrato público con el proveedor y la validación de pertenencia de las compras al cliente. Como no recibe un tenant, requiere una asociación directa al cliente. Para usar una instancia compartida en ese flujo, asociarla también al cliente; la herencia por tipo de sitio no se aplica a esa URL.
|
||||||
|
|
||||||
|
`Notification` consume `MailService`; `Purchase` consume Telepagos. Los tenants mantienen el contexto operativo y el branding.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
Ejecutar `php artisan migrate` junto con este código. La migración `2026_09_04_000003` renombra `requires_client_configuration` a `requires_configuration` conservando sus valores. Los payloads del catálogo deben usar el nuevo nombre. Las migraciones previas trasladan el texto cifrado sin descifrarlo y no comparten instancias automáticamente.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Telepagos registra eventos en el canal diario `telepagos`. El nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Se eliminan tokens y credenciales de las estructuras registradas.
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
||||||
use App\Domains\Integration\Controllers\IntegrationController;
|
|
||||||
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
||||||
|
use App\Domains\Integration\Controllers\WebsiteTypeIntegrationController;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::group(['prefix' => 'integrations'], function () {
|
Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
|
||||||
Route::get('/', [IntegrationController::class, 'index']);
|
Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void {
|
||||||
Route::post('/', [IntegrationController::class, 'store']);
|
Route::get('/', [WebsiteTypeIntegrationController::class, 'index']);
|
||||||
Route::get('/{integration}', [IntegrationController::class, 'show']);
|
Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']);
|
||||||
Route::put('/{integration}', [IntegrationController::class, 'update']);
|
Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']);
|
||||||
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
|
Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
||||||
Route::get('/', [ClientIntegrationController::class, 'index']);
|
Route::get('/', [ClientIntegrationController::class, 'index']);
|
||||||
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
||||||
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
||||||
|
Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
||||||
|
|||||||
@@ -32,13 +32,14 @@ class NotificationMailService
|
|||||||
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
||||||
$user = User::query()->findOrFail($userId);
|
$user = User::query()->findOrFail($userId);
|
||||||
$brand = $tenant->websiteType ?? $tenant;
|
$brand = $tenant->websiteType ?? $tenant;
|
||||||
|
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($tenantCode)
|
->forTenant($tenantCode)
|
||||||
->send(
|
->send(
|
||||||
$user->email,
|
$user->email,
|
||||||
"Bienvenido a {$brand->nombre}",
|
"Bienvenido a {$brand->nombre}",
|
||||||
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
|
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
|
||||||
$brand,
|
$brand,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -99,12 +100,25 @@ class NotificationMailService
|
|||||||
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||||
$brand = $tenant->websiteType ?? $tenant;
|
$brand = $tenant->websiteType ?? $tenant;
|
||||||
|
|
||||||
|
[$subject, $template] = match ($attempt->reason) {
|
||||||
|
ResetPasswordAttempt::REASON_STAFF_CREATED => [
|
||||||
|
'Tu cuenta de escáner está lista', 'scanner-created',
|
||||||
|
],
|
||||||
|
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [
|
||||||
|
'Tu cuenta de administrador está lista', 'administrator-created',
|
||||||
|
],
|
||||||
|
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [
|
||||||
|
'Desbloqueá tu cuenta', 'account-locked',
|
||||||
|
],
|
||||||
|
default => ['Código para recuperar tu contraseña', 'password-reset'],
|
||||||
|
};
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($tenantCode)
|
->forTenant($tenantCode)
|
||||||
->send(
|
->send(
|
||||||
$attempt->user->email,
|
$attempt->user->email,
|
||||||
"Código para recuperar tu contraseña - {$brand->nombre}",
|
"{$subject} - {$brand->nombre}",
|
||||||
view('mail.notifications.password-reset', [
|
view("mail.notifications.{$template}", [
|
||||||
'attempt' => $attempt,
|
'attempt' => $attempt,
|
||||||
'recoveryUrl' => $recoveryUrl,
|
'recoveryUrl' => $recoveryUrl,
|
||||||
'brand' => $brand,
|
'brand' => $brand,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ use App\Domains\Staff\Requests\StoreStaffRequest;
|
|||||||
use App\Domains\Staff\Requests\UpdateStaffRequest;
|
use App\Domains\Staff\Requests\UpdateStaffRequest;
|
||||||
use App\Domains\Staff\Resources\StaffResource;
|
use App\Domains\Staff\Resources\StaffResource;
|
||||||
use App\Domains\Staff\Services\StaffService;
|
use App\Domains\Staff\Services\StaffService;
|
||||||
|
use App\Domains\Ticket\Requests\ScanAttemptIndexRequest;
|
||||||
|
use App\Domains\Ticket\Resources\Scanner\ScanAttemptResource;
|
||||||
|
use App\Domains\Ticket\Services\ScannerTicketService;
|
||||||
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;
|
||||||
@@ -13,7 +16,10 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
|
|
||||||
class AdminAppStaffController extends Controller
|
class AdminAppStaffController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly StaffService $staffService) {}
|
public function __construct(
|
||||||
|
private readonly StaffService $staffService,
|
||||||
|
private readonly ScannerTicketService $scannerTicketService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): AnonymousResourceCollection
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
@@ -46,4 +52,18 @@ class AdminAppStaffController extends Controller
|
|||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scanAttempts(
|
||||||
|
ScanAttemptIndexRequest $request,
|
||||||
|
int $staff,
|
||||||
|
): AnonymousResourceCollection {
|
||||||
|
$scanner = $this->staffService->find(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$staff,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ScanAttemptResource::collection(
|
||||||
|
$this->scannerTicketService->attemptsByStaff($scanner, $request->validated())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Staff\Requests;
|
namespace App\Domains\Staff\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -12,6 +13,13 @@ class StoreStaffRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -23,7 +31,12 @@ class StoreStaffRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
'dni' => ['required', 'string', 'max:50'],
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::Scanner->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
'category_ids' => $categoryRules,
|
'category_ids' => $categoryRules,
|
||||||
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Staff\Requests;
|
namespace App\Domains\Staff\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -12,6 +13,13 @@ class UpdateStaffRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -28,7 +36,9 @@ class UpdateStaffRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('users', 'email')->ignore($staffId),
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::Scanner->value)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($staffId),
|
||||||
],
|
],
|
||||||
'category_ids' => $categoryRules,
|
'category_ids' => $categoryRules,
|
||||||
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
||||||
|
|||||||
@@ -94,7 +94,12 @@ class StaffService
|
|||||||
|
|
||||||
public function delete(Tenant $tenant, int $staffId): void
|
public function delete(Tenant $tenant, int $staffId): void
|
||||||
{
|
{
|
||||||
$this->find($tenant, $staffId)->delete();
|
$staff = $this->find($tenant, $staffId);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($staff): void {
|
||||||
|
$staff->tokens()->delete();
|
||||||
|
$staff->delete();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function find(Tenant $tenant, int $staffId): User
|
public function find(Tenant $tenant, int $staffId): User
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ use Illuminate\Support\Facades\Route;
|
|||||||
Route::prefix('v1/adminapp/tenant')
|
Route::prefix('v1/adminapp/tenant')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
|
Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']);
|
||||||
Route::apiResource('staff', AdminAppStaffController::class)->except('show');
|
Route::apiResource('staff', AdminAppStaffController::class)->except('show');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Domains\Event\Models\EventDate;
|
|||||||
use App\Domains\Menu\Models\Menu;
|
use App\Domains\Menu\Models\Menu;
|
||||||
use App\Domains\Menu\Models\TenantMenu;
|
use App\Domains\Menu\Models\TenantMenu;
|
||||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
||||||
|
use App\Domains\Ticket\Models\ScanAttempt;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -195,6 +196,12 @@ class Tenant extends Model
|
|||||||
return $this->hasMany(Category::class, 'tenant_code', 'codigo');
|
return $this->hasMany(Category::class, 'tenant_code', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ScanAttempt, $this> */
|
||||||
|
public function scanAttempts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ScanAttempt::class, 'tenant_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return BelongsToMany<SocialMedia, $this>
|
* @return BelongsToMany<SocialMedia, $this>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Tenant\Models;
|
namespace App\Domains\Tenant\Models;
|
||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Integration\Models\WebsiteTypeIntegration;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -36,6 +37,12 @@ class WebsiteType extends Model
|
|||||||
|
|
||||||
protected $table = 'website_type';
|
protected $table = 'website_type';
|
||||||
|
|
||||||
|
/** @return HasMany<WebsiteTypeIntegration, $this> */
|
||||||
|
public function integrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebsiteTypeIntegration::class, 'website_type_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return BelongsTo<Attachment, $this>
|
* @return BelongsTo<Attachment, $this>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class TenantResource extends JsonResource
|
|||||||
'site_title' => $this->site_title
|
'site_title' => $this->site_title
|
||||||
?? $this->websiteType?->site_title
|
?? $this->websiteType?->site_title
|
||||||
?? 'ShopitFront',
|
?? 'ShopitFront',
|
||||||
|
'asset_url' => config('filesystems.disks.s3.url'),
|
||||||
'address' => $this->address,
|
'address' => $this->address,
|
||||||
'phone' => $this->phone,
|
'phone' => $this->phone,
|
||||||
'favicon' => ($this->favicon ?? $this->websiteType?->favicon)
|
'favicon' => ($this->favicon ?? $this->websiteType?->favicon)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Controllers\Scanner;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Ticket\Requests\ScanAttemptIndexRequest;
|
||||||
|
use App\Domains\Ticket\Resources\Scanner\ScanAttemptResource;
|
||||||
|
use App\Domains\Ticket\Resources\Scanner\ScannerScanResultResource;
|
||||||
|
use App\Domains\Ticket\Services\ScannerTicketService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class ScanAttemptController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ScannerTicketService $ticketService) {}
|
||||||
|
|
||||||
|
public function __invoke(ScanAttemptIndexRequest $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
/** @var User $scanner */
|
||||||
|
$scanner = $request->user();
|
||||||
|
|
||||||
|
return ScanAttemptResource::collection(
|
||||||
|
$this->ticketService->attemptsBy($scanner, $request->validated())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, int $scanAttempt): ScannerScanResultResource
|
||||||
|
{
|
||||||
|
/** @var User $scanner */
|
||||||
|
$scanner = $request->user();
|
||||||
|
|
||||||
|
return ScannerScanResultResource::make(
|
||||||
|
$this->ticketService->scanAttemptDetail($scanner, $scanAttempt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,28 +3,18 @@
|
|||||||
namespace App\Domains\Ticket\Controllers\Scanner;
|
namespace App\Domains\Ticket\Controllers\Scanner;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Ticket\Requests\ScannerTicketIndexRequest;
|
use App\Domains\Ticket\Resources\Scanner\ScannerScanResultResource;
|
||||||
use App\Domains\Ticket\Resources\Scanner\ScannedTicketResource;
|
|
||||||
use App\Domains\Ticket\Resources\TicketResource;
|
use App\Domains\Ticket\Resources\TicketResource;
|
||||||
use App\Domains\Ticket\Services\ScannerTicketService;
|
use App\Domains\Ticket\Services\ScannerTicketService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class TicketController extends Controller
|
class TicketController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ScannerTicketService $ticketService) {}
|
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
|
public function show(Request $request, string $ticketUuid): TicketResource
|
||||||
{
|
{
|
||||||
/** @var User $scanner */
|
/** @var User $scanner */
|
||||||
@@ -35,13 +25,13 @@ class TicketController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scan(Request $request, string $ticketUuid): TicketResource
|
public function scan(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var User $scanner */
|
/** @var User $scanner */
|
||||||
$scanner = $request->user();
|
$scanner = $request->user();
|
||||||
|
|
||||||
return TicketResource::make(
|
return ScannerScanResultResource::make(
|
||||||
$this->ticketService->scan($scanner, $ticketUuid)
|
$this->ticketService->scan($scanner, $request->input('data'))
|
||||||
);
|
)->response()->setStatusCode(Response::HTTP_OK);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
app/Domains/Ticket/Enums/ScanAttemptResult.php
Normal file
16
app/Domains/Ticket/Enums/ScanAttemptResult.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Enums;
|
||||||
|
|
||||||
|
enum ScanAttemptResult: string
|
||||||
|
{
|
||||||
|
case Processing = 'processing';
|
||||||
|
case Accepted = 'accepted';
|
||||||
|
case InvalidQr = 'invalid_qr';
|
||||||
|
case TicketNotFound = 'ticket_not_found';
|
||||||
|
case CategoryForbidden = 'category_forbidden';
|
||||||
|
case AlreadyScanned = 'already_scanned';
|
||||||
|
case Expired = 'expired';
|
||||||
|
case NotValid = 'not_valid';
|
||||||
|
case UnexpectedError = 'unexpected_error';
|
||||||
|
}
|
||||||
52
app/Domains/Ticket/Models/ScanAttempt.php
Normal file
52
app/Domains/Ticket/Models/ScanAttempt.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Models;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Enums\ScanAttemptResult;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'tenant_code',
|
||||||
|
'scanner_user_id',
|
||||||
|
'ticket_id',
|
||||||
|
'data',
|
||||||
|
'result',
|
||||||
|
'resolved_at',
|
||||||
|
])]
|
||||||
|
class ScanAttempt extends Model
|
||||||
|
{
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function scanner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Ticket, $this> */
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Tenant, $this> */
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'scanner_user_id' => 'integer',
|
||||||
|
'ticket_id' => 'integer',
|
||||||
|
'result' => ScanAttemptResult::class,
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'resolved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -78,7 +79,13 @@ class Ticket extends Model
|
|||||||
/** @return BelongsTo<User, $this> */
|
/** @return BelongsTo<User, $this> */
|
||||||
public function scannerUser(): BelongsTo
|
public function scannerUser(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'scanner_user_id');
|
return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ScanAttempt, $this> */
|
||||||
|
public function scanAttempts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ScanAttempt::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<PurchaseItem, $this> */
|
/** @return BelongsTo<PurchaseItem, $this> */
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace App\Domains\Ticket\Requests;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class ScannerTicketIndexRequest extends FormRequest
|
class ScanAttemptIndexRequest extends FormRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
46
app/Domains/Ticket/Resources/Scanner/ScanAttemptResource.php
Normal file
46
app/Domains/Ticket/Resources/Scanner/ScanAttemptResource.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Resources\Scanner;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Enums\ScanAttemptResult;
|
||||||
|
use App\Domains\Ticket\Models\ScanAttempt;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin ScanAttempt */
|
||||||
|
class ScanAttemptResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'data' => $this->data,
|
||||||
|
'ticket_id' => $this->ticket_id,
|
||||||
|
'ticket' => $this->ticket?->ticket,
|
||||||
|
'category' => $this->ticket?->sourceCatalogItem?->category?->nombre,
|
||||||
|
'attempted_at' => $this->created_at,
|
||||||
|
'resolved_at' => $this->resolved_at,
|
||||||
|
'result' => $this->result->value,
|
||||||
|
'result_label' => match ($this->result) {
|
||||||
|
ScanAttemptResult::Accepted => 'Verificado',
|
||||||
|
ScanAttemptResult::AlreadyScanned => 'Usado',
|
||||||
|
ScanAttemptResult::Expired => 'Vencido',
|
||||||
|
default => 'Error',
|
||||||
|
},
|
||||||
|
'result_detail_label' => match ($this->result) {
|
||||||
|
ScanAttemptResult::Processing => 'Error',
|
||||||
|
ScanAttemptResult::Accepted => 'Verificado',
|
||||||
|
ScanAttemptResult::InvalidQr => 'QR no pertenece al evento',
|
||||||
|
ScanAttemptResult::TicketNotFound => 'Error',
|
||||||
|
ScanAttemptResult::CategoryForbidden => 'Error',
|
||||||
|
ScanAttemptResult::AlreadyScanned => 'Usado',
|
||||||
|
ScanAttemptResult::Expired => 'Vencido',
|
||||||
|
ScanAttemptResult::NotValid => 'No válido',
|
||||||
|
ScanAttemptResult::UnexpectedError => 'Error',
|
||||||
|
},
|
||||||
|
'can_view_ticket' => $this->ticket !== null
|
||||||
|
&& $this->result !== ScanAttemptResult::CategoryForbidden,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Resources\Scanner;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Models\ScanAttempt;
|
||||||
|
use App\Domains\Ticket\Resources\TicketResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin ScanAttempt */
|
||||||
|
class ScannerScanResultResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$ticket = $this->ticket;
|
||||||
|
$client = $ticket?->user;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'scan_attempt' => ScanAttemptResource::make($this->resource),
|
||||||
|
'ticket' => $ticket === null ? null : TicketResource::make($ticket),
|
||||||
|
'client' => $client === null ? null : [
|
||||||
|
'id' => $client->id,
|
||||||
|
'nombre_apellido' => $client->nombre_apellido,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -293,6 +293,7 @@ class AdminAppTicketService
|
|||||||
'id' => 'tickets.id',
|
'id' => 'tickets.id',
|
||||||
'amount' => $this->purchaseItemColumnQuery('precio_unitario'),
|
'amount' => $this->purchaseItemColumnQuery('precio_unitario'),
|
||||||
'scanned_by' => User::query()
|
'scanned_by' => User::query()
|
||||||
|
->withTrashed()
|
||||||
->select('nombre_apellido')
|
->select('nombre_apellido')
|
||||||
->whereColumn('users.id', 'tickets.scanner_user_id'),
|
->whereColumn('users.id', 'tickets.scanner_user_id'),
|
||||||
'product' => $tenant->codigo === 'fiesta_futbol_infantil'
|
'product' => $tenant->codigo === 'fiesta_futbol_infantil'
|
||||||
|
|||||||
229
app/Domains/Ticket/Services/LoadTestTicketDatasetService.php
Normal file
229
app/Domains/Ticket/Services/LoadTestTicketDatasetService.php
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class LoadTestTicketDatasetService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TicketGeneratorService $ticketGenerator,
|
||||||
|
private readonly TicketValidityResolver $validityResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* run_id: string,
|
||||||
|
* tenant_code: string,
|
||||||
|
* catalog_item_id: int,
|
||||||
|
* variant_id: int|null,
|
||||||
|
* tickets: int,
|
||||||
|
* scanners: int,
|
||||||
|
* owners: int,
|
||||||
|
* rows: array<int, array{scanner_token: string, ticket_uuid: string, expected_status: int}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function prepare(
|
||||||
|
string $tenantCode,
|
||||||
|
int $ticketCount,
|
||||||
|
int $scannerCount,
|
||||||
|
int $ownerCount,
|
||||||
|
?int $catalogItemId = null,
|
||||||
|
?int $variantId = null,
|
||||||
|
?string $runId = null,
|
||||||
|
): array {
|
||||||
|
$this->validateInput($tenantCode, $ticketCount, $scannerCount, $ownerCount);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||||
|
$catalogItem = $this->resolveCatalogItem($tenant, $catalogItemId);
|
||||||
|
$variant = $this->resolveVariant($catalogItem, $variantId);
|
||||||
|
$runId ??= now()->format('Ymd-His').'-'.Str::lower(Str::random(6));
|
||||||
|
|
||||||
|
if (preg_match('/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/', $runId) !== 1) {
|
||||||
|
throw new InvalidArgumentException('run_id contiene caracteres inválidos o es demasiado largo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variant !== null && ! $this->validityResolver->resolveVariant($variant)->isValid()) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'La variante seleccionada no tiene una vigencia activa y resoluble.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scanners = $this->scanners($tenant, $catalogItem, $scannerCount);
|
||||||
|
$owners = $this->owners($tenant, $ownerCount);
|
||||||
|
$tokens = $scanners->map(function (User $scanner): string {
|
||||||
|
$scanner->tokens()->where('name', 'load-test-scanner')->delete();
|
||||||
|
|
||||||
|
return $scanner->createToken(
|
||||||
|
'load-test-scanner',
|
||||||
|
['scanner'],
|
||||||
|
now()->addMinutes((int) config('sanctum.expiration', 720)),
|
||||||
|
)->plainTextToken;
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$remaining = $ticketCount;
|
||||||
|
$ownerIndex = 0;
|
||||||
|
$scannerIndex = 0;
|
||||||
|
$batchSize = min(500, max(1, (int) ceil($ticketCount / $ownerCount)));
|
||||||
|
|
||||||
|
while ($remaining > 0) {
|
||||||
|
$quantity = min($batchSize, $remaining);
|
||||||
|
$owner = $owners[$ownerIndex % $owners->count()];
|
||||||
|
$tickets = $this->ticketGenerator->generate(
|
||||||
|
$catalogItem,
|
||||||
|
$owner,
|
||||||
|
$quantity,
|
||||||
|
$variant?->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($tickets as $ticket) {
|
||||||
|
if (! $ticket->is_valid) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'La configuración seleccionada genera tickets que no están vigentes.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'scanner_token' => $tokens[$scannerIndex % $tokens->count()],
|
||||||
|
'ticket_uuid' => $ticket->ticket,
|
||||||
|
'expected_status' => 200,
|
||||||
|
];
|
||||||
|
$scannerIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining -= $quantity;
|
||||||
|
$ownerIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'run_id' => $runId,
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'catalog_item_id' => $catalogItem->getKey(),
|
||||||
|
'variant_id' => $variant?->getKey(),
|
||||||
|
'tickets' => count($rows),
|
||||||
|
'scanners' => $scanners->count(),
|
||||||
|
'owners' => $owners->count(),
|
||||||
|
'rows' => $rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateInput(
|
||||||
|
string $tenantCode,
|
||||||
|
int $ticketCount,
|
||||||
|
int $scannerCount,
|
||||||
|
int $ownerCount,
|
||||||
|
): void {
|
||||||
|
foreach ([
|
||||||
|
'tickets' => [$ticketCount, 100_000],
|
||||||
|
'scanners' => [$scannerCount, 10_000],
|
||||||
|
'owners' => [$ownerCount, 100_000],
|
||||||
|
] as $name => [$value, $maximum]) {
|
||||||
|
if ($value < 1 || $value > $maximum) {
|
||||||
|
throw new InvalidArgumentException("{$name} debe estar entre 1 y {$maximum}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scannerCount > $ticketCount || $ownerCount > $ticketCount) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'La cantidad de scanners y propietarios no puede superar la cantidad de tickets.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCatalogItem(Tenant $tenant, ?int $catalogItemId): CatalogItem
|
||||||
|
{
|
||||||
|
$query = $tenant->catalogItems()
|
||||||
|
->where('has_tickets', true)
|
||||||
|
->where('type', CatalogItemType::Standard->value);
|
||||||
|
|
||||||
|
if ($catalogItemId !== null) {
|
||||||
|
$query->whereKey($catalogItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalogItem = $query->first();
|
||||||
|
|
||||||
|
if ($catalogItem === null) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'No se encontró un producto estándar con tickets habilitados para el tenant.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant->requiresScannerCategoryValidation() && $catalogItem->category_id === null) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'El producto debe tener una categoría para autorizar a los scanners.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $catalogItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveVariant(CatalogItem $catalogItem, ?int $variantId): ?Variant
|
||||||
|
{
|
||||||
|
if ($variantId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant = $catalogItem->variants()->whereKey($variantId)->first();
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
throw new InvalidArgumentException('La variante no pertenece al producto seleccionado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, User> */
|
||||||
|
private function scanners(Tenant $tenant, CatalogItem $catalogItem, int $count): Collection
|
||||||
|
{
|
||||||
|
return Collection::times($count, function (int $number) use ($tenant, $catalogItem): User {
|
||||||
|
$scanner = User::query()->updateOrCreate(
|
||||||
|
['email' => $this->email($tenant, 'scanner', $number)],
|
||||||
|
[
|
||||||
|
'nombre_apellido' => "Load test scanner {$number}",
|
||||||
|
'password' => Str::password(32),
|
||||||
|
'rol_codigo' => RoleCode::Scanner->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if ($tenant->requiresScannerCategoryValidation()) {
|
||||||
|
$scanner->scanCategories()->syncWithoutDetaching([$catalogItem->category_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scanner;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, User> */
|
||||||
|
private function owners(Tenant $tenant, int $count): Collection
|
||||||
|
{
|
||||||
|
return Collection::times($count, function (int $number) use ($tenant): User {
|
||||||
|
$owner = User::query()->updateOrCreate(
|
||||||
|
['email' => $this->email($tenant, 'owner', $number)],
|
||||||
|
[
|
||||||
|
'nombre_apellido' => "Load test owner {$number}",
|
||||||
|
'password' => Str::password(32),
|
||||||
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $owner;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function email(Tenant $tenant, string $kind, int $number): string
|
||||||
|
{
|
||||||
|
$tenantSlug = Str::lower(preg_replace('/[^a-z0-9]+/i', '-', $tenant->codigo));
|
||||||
|
|
||||||
|
return "loadtest+{$tenantSlug}.{$kind}.{$number}@shopit.test";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,46 +3,116 @@
|
|||||||
namespace App\Domains\Ticket\Services;
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Ticket\Enums\ScanAttemptResult;
|
||||||
|
use App\Domains\Ticket\Models\ScanAttempt;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class ScannerTicketService
|
class ScannerTicketService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array{q?: string|null, page?: int, per_page?: int} $filters
|
* @param array{q?: string|null, page?: int, per_page?: int} $filters
|
||||||
* @return LengthAwarePaginator<Ticket>
|
* @return LengthAwarePaginator<ScanAttempt>
|
||||||
*/
|
*/
|
||||||
public function scannedBy(User $scanner, array $filters = []): LengthAwarePaginator
|
public function attemptsBy(User $scanner, array $filters = []): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$search = trim((string) ($filters['q'] ?? ''));
|
$search = trim((string) ($filters['q'] ?? ''));
|
||||||
|
|
||||||
return $this->baseQuery()
|
return ScanAttempt::query()
|
||||||
|
->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($search !== '', function (Builder $query) use ($search): void {
|
->when($search !== '', function (Builder $query) use ($search): void {
|
||||||
$usedAtDate = $this->parseSearchDate($search);
|
$attemptedAtDate = $this->parseSearchDate($search);
|
||||||
|
|
||||||
$query->where(function (Builder $searchQuery) use ($search, $usedAtDate): void {
|
$query->where(function (Builder $searchQuery) use ($search, $attemptedAtDate): void {
|
||||||
$searchQuery->where('ticket', 'like', "%{$search}%");
|
$searchQuery->where('data', 'like', "%{$search}%");
|
||||||
|
|
||||||
if (ctype_digit($search)) {
|
if (ctype_digit($search)) {
|
||||||
$searchQuery->orWhere('id', (int) $search);
|
$searchQuery->orWhere('id', (int) $search);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($usedAtDate !== null) {
|
if ($attemptedAtDate !== null) {
|
||||||
$searchQuery->orWhereDate('used_at', $usedAtDate);
|
$searchQuery->orWhereDate('created_at', $attemptedAtDate);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
->orderByDesc('used_at')
|
->orderByDesc('created_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{q?: string|null, page?: int, per_page?: int} $filters
|
||||||
|
* @return LengthAwarePaginator<ScanAttempt>
|
||||||
|
*/
|
||||||
|
public function attemptsByStaff(User $scanner, array $filters = []): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$search = trim((string) ($filters['q'] ?? ''));
|
||||||
|
|
||||||
|
return ScanAttempt::query()
|
||||||
|
->with('ticket.sourceCatalogItem.category')
|
||||||
|
->where('tenant_code', $scanner->tenant_codigo)
|
||||||
|
->where('scanner_user_id', $scanner->getKey())
|
||||||
|
->when($search !== '', function (Builder $query) use ($search): void {
|
||||||
|
$attemptedAtDate = $this->parseSearchDate($search);
|
||||||
|
$attemptedAtDayMonth = $this->parseSearchDayMonth($search);
|
||||||
|
|
||||||
|
$query->where(function (Builder $searchQuery) use (
|
||||||
|
$search,
|
||||||
|
$attemptedAtDate,
|
||||||
|
$attemptedAtDayMonth,
|
||||||
|
): void {
|
||||||
|
$searchQuery
|
||||||
|
->whereHas(
|
||||||
|
'ticket.sourceCatalogItem.category',
|
||||||
|
fn (Builder $categoryQuery): Builder => $categoryQuery
|
||||||
|
->where('nombre', 'like', "%{$search}%")
|
||||||
|
)
|
||||||
|
->orWhere('created_at', 'like', "%{$search}%");
|
||||||
|
|
||||||
|
if (ctype_digit($search)) {
|
||||||
|
$searchQuery->orWhere('ticket_id', (int) $search);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attemptedAtDate !== null) {
|
||||||
|
$searchQuery->orWhereDate('created_at', $attemptedAtDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attemptedAtDayMonth !== null) {
|
||||||
|
$searchQuery->orWhere(function (Builder $dateQuery) use ($attemptedAtDayMonth): void {
|
||||||
|
$dateQuery
|
||||||
|
->whereDay('created_at', $attemptedAtDayMonth['day'])
|
||||||
|
->whereMonth('created_at', $attemptedAtDayMonth['month']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->paginateFromRequest()
|
||||||
|
->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scanAttemptDetail(User $scanner, int $scanAttemptId): ScanAttempt
|
||||||
|
{
|
||||||
|
$scanAttempt = ScanAttempt::query()
|
||||||
|
->with('ticket')
|
||||||
|
->where('tenant_code', $scanner->tenant_codigo)
|
||||||
|
->where('scanner_user_id', $scanner->getKey())
|
||||||
|
->findOrFail($scanAttemptId);
|
||||||
|
|
||||||
|
$scanAttempt->ticket?->loadMissing($this->relations());
|
||||||
|
|
||||||
|
return $scanAttempt;
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -67,13 +137,26 @@ class ScannerTicketService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array{day: int, month: int}|null */
|
||||||
|
private function parseSearchDayMonth(string $search): ?array
|
||||||
|
{
|
||||||
|
if (preg_match('/^(\d{1,2})\/(\d{1,2})$/', $search, $matches) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$day = (int) $matches[1];
|
||||||
|
$month = (int) $matches[2];
|
||||||
|
|
||||||
|
return checkdate($month, $day, 2000) ? compact('day', 'month') : null;
|
||||||
|
}
|
||||||
|
|
||||||
public function detail(User $scanner, string $ticketUuid): Ticket
|
public function detail(User $scanner, string $ticketUuid): Ticket
|
||||||
{
|
{
|
||||||
$query = $this->baseQuery()
|
$query = $this->baseQuery()
|
||||||
->where('tenant_code', $scanner->tenant_codigo)
|
->where('tenant_code', $scanner->tenant_codigo)
|
||||||
->where('ticket', $ticketUuid);
|
->where('ticket', $ticketUuid);
|
||||||
|
|
||||||
if ($scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation()) {
|
if ($this->requiresCategoryValidation($scanner)) {
|
||||||
$categoryIds = $this->scannerCategoryIds($scanner);
|
$categoryIds = $this->scannerCategoryIds($scanner);
|
||||||
|
|
||||||
$query->where(function (Builder $query) use ($scanner, $categoryIds): void {
|
$query->where(function (Builder $query) use ($scanner, $categoryIds): void {
|
||||||
@@ -90,42 +173,117 @@ class ScannerTicketService
|
|||||||
return $query->firstOrFail();
|
return $query->firstOrFail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scan(User $scanner, string $ticketUuid): Ticket
|
public function scan(User $scanner, mixed $scannedData): ScanAttempt
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($scanner, $ticketUuid): Ticket {
|
$scanAttempt = ScanAttempt::query()->create([
|
||||||
$ticket = $this->baseQuery()
|
'tenant_code' => $scanner->tenant_codigo,
|
||||||
->where('tenant_code', $scanner->tenant_codigo)
|
'scanner_user_id' => $scanner->getKey(),
|
||||||
->where('ticket', $ticketUuid)
|
'data' => $this->serializeScannedData($scannedData),
|
||||||
->lockForUpdate()
|
'result' => ScanAttemptResult::Processing,
|
||||||
->firstOrFail();
|
]);
|
||||||
|
|
||||||
if (! $this->scannerCanScan($scanner, $ticket)) {
|
if (! is_string($scannedData) || ! Str::isUuid($scannedData)) {
|
||||||
throw ValidationException::withMessages([
|
$this->resolveScanAttempt($scanAttempt, ScanAttemptResult::InvalidQr);
|
||||||
'ticket' => __('api.ticket.scanner_category_forbidden'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ticket->is_used) {
|
return $scanAttempt->refresh();
|
||||||
throw ValidationException::withMessages([
|
}
|
||||||
'ticket' => __('api.ticket.already_scanned'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $ticket->is_valid) {
|
$ticketId = null;
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'ticket' => $ticket->is_expired
|
|
||||||
? __('api.ticket.expired_for_scan')
|
|
||||||
: __('api.ticket.not_valid_for_scan'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$ticket->forceFill([
|
try {
|
||||||
'used_at' => now(),
|
return DB::transaction(function () use (
|
||||||
'scanner_user_id' => $scanner->getKey(),
|
$scanner,
|
||||||
])->save();
|
$scannedData,
|
||||||
|
$scanAttempt,
|
||||||
|
&$ticketId,
|
||||||
|
): ScanAttempt {
|
||||||
|
$ticket = $this->baseQuery()
|
||||||
|
->where('tenant_code', $scanner->tenant_codigo)
|
||||||
|
->where('ticket', $scannedData)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
$ticketId = (int) $ticket->getKey();
|
||||||
|
|
||||||
return $ticket->refresh()->load($this->relations());
|
if (! $this->scannerCanScan($scanner, $ticket)) {
|
||||||
});
|
$this->resolveScanAttempt(
|
||||||
|
$scanAttempt,
|
||||||
|
ScanAttemptResult::CategoryForbidden,
|
||||||
|
$ticketId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticket->is_used) {
|
||||||
|
$this->resolveScanAttempt(
|
||||||
|
$scanAttempt,
|
||||||
|
ScanAttemptResult::AlreadyScanned,
|
||||||
|
$ticketId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $ticket->is_valid) {
|
||||||
|
$result = $ticket->is_expired
|
||||||
|
? ScanAttemptResult::Expired
|
||||||
|
: ScanAttemptResult::NotValid;
|
||||||
|
$this->resolveScanAttempt($scanAttempt, $result, $ticketId);
|
||||||
|
|
||||||
|
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->forceFill([
|
||||||
|
'used_at' => now(),
|
||||||
|
'scanner_user_id' => $scanner->getKey(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->resolveScanAttempt(
|
||||||
|
$scanAttempt,
|
||||||
|
ScanAttemptResult::Accepted,
|
||||||
|
$ticketId,
|
||||||
|
);
|
||||||
|
|
||||||
|
$ticket = $ticket->refresh()->load($this->relations());
|
||||||
|
|
||||||
|
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
|
||||||
|
});
|
||||||
|
} catch (ModelNotFoundException) {
|
||||||
|
$this->resolveScanAttempt($scanAttempt, ScanAttemptResult::TicketNotFound);
|
||||||
|
|
||||||
|
return $scanAttempt->refresh();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
$this->resolveScanAttempt($scanAttempt, ScanAttemptResult::UnexpectedError, $ticketId);
|
||||||
|
|
||||||
|
return $scanAttempt->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveScanAttempt(
|
||||||
|
ScanAttempt $scanAttempt,
|
||||||
|
ScanAttemptResult $result,
|
||||||
|
?int $ticketId = null,
|
||||||
|
): void {
|
||||||
|
$scanAttempt->forceFill([
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'result' => $result,
|
||||||
|
'resolved_at' => now(),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeScannedData(mixed $scannedData): ?string
|
||||||
|
{
|
||||||
|
if ($scannedData === null || is_string($scannedData)) {
|
||||||
|
return $scannedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode(
|
||||||
|
$scannedData,
|
||||||
|
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $encoded === false ? get_debug_type($scannedData) : $encoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Builder<Ticket> */
|
/** @return Builder<Ticket> */
|
||||||
@@ -158,7 +316,7 @@ class ScannerTicketService
|
|||||||
|
|
||||||
private function scannerCanScan(User $scanner, Ticket $ticket): bool
|
private function scannerCanScan(User $scanner, Ticket $ticket): bool
|
||||||
{
|
{
|
||||||
if (! $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation()) {
|
if (! $this->requiresCategoryValidation($scanner)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,4 +327,9 @@ class ScannerTicketService
|
|||||||
->where('categorias.id', $categoryId)
|
->where('categorias.id', $categoryId)
|
||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function requiresCategoryValidation(User $scanner): bool
|
||||||
|
{
|
||||||
|
return $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,33 @@ vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin pe
|
|||||||
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
|
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
|
||||||
4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
|
4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
|
||||||
|
|
||||||
|
## Datos descartables para pruebas de carga
|
||||||
|
|
||||||
|
En ambientes `local`, `testing`, `staging`, `homo` u `homologation`, el comando siguiente crea tickets
|
||||||
|
válidos, identidades scanner con tokens Sanctum y un dataset JSON importable por Postman:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan load-test:tickets:prepare loadtest-evento \
|
||||||
|
--tickets=40000 \
|
||||||
|
--scanners=100 \
|
||||||
|
--owners=1000 \
|
||||||
|
--catalog-item=123 \
|
||||||
|
--run=evento-001
|
||||||
|
```
|
||||||
|
|
||||||
|
El tenant debe existir y se recomienda que sea exclusivo para carga. Si no se indica `--catalog-item`,
|
||||||
|
se usa el primer producto estándar del tenant con tickets habilitados. `--variant`
|
||||||
|
es opcional; al indicarlo, su configuración de vigencia debe estar activa y ser resoluble. Sin variante,
|
||||||
|
los tickets tienen vigencia irrestricta.
|
||||||
|
|
||||||
|
El archivo se escribe por defecto en `storage/app/private/load-tests/` y contiene tokens secretos, por
|
||||||
|
lo que no debe versionarse. Para limpiar el tenant después de la ejecución:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan tenants:reset-transactions loadtest-evento --dry-run
|
||||||
|
php artisan tenants:reset-transactions loadtest-evento
|
||||||
|
```
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:
|
Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Controllers\Scanner\ScanAttemptController;
|
||||||
use App\Domains\Ticket\Controllers\Scanner\TicketController;
|
use App\Domains\Ticket\Controllers\Scanner\TicketController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1/scanner/tickets')
|
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
||||||
->middleware(['auth:sanctum', 'scanner.tenant'])
|
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('/', [TicketController::class, 'index']);
|
Route::get('v1/scanner/attempts', ScanAttemptController::class);
|
||||||
Route::get('{ticketUuid}', [TicketController::class, 'show'])
|
Route::get('v1/scanner/attempts/{scanAttempt}', [ScanAttemptController::class, 'show'])
|
||||||
->whereUuid('ticketUuid');
|
->whereNumber('scanAttempt');
|
||||||
Route::post('{ticketUuid}/scan', [TicketController::class, 'scan'])
|
|
||||||
->whereUuid('ticketUuid');
|
Route::prefix('v1/scanner/tickets')->group(function (): void {
|
||||||
|
Route::post('scan', [TicketController::class, 'scan']);
|
||||||
|
Route::get('{ticketUuid}', [TicketController::class, 'show'])
|
||||||
|
->whereUuid('ticketUuid');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Domains\Authorization\Enums\PermissionCode;
|
use App\Domains\Authorization\Enums\PermissionCode;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -19,6 +20,7 @@ class EnsureScannerTenant
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
! $user
|
! $user
|
||||||
|
|| $user->rol_codigo !== RoleCode::Scanner->value
|
||||||
|| ! $user->tenant_codigo
|
|| ! $user->tenant_codigo
|
||||||
|| ! $user->hasPermission(PermissionCode::ScanTickets->value)
|
|| ! $user->hasPermission(PermissionCode::ScanTickets->value)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Policies\IntegrationPolicy;
|
||||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||||
@@ -13,6 +15,7 @@ use Illuminate\Cache\RateLimiting\Limit;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
@@ -31,6 +34,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
Gate::policy(
|
||||||
|
Integration::class,
|
||||||
|
IntegrationPolicy::class,
|
||||||
|
);
|
||||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||||
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
|
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
|
||||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique(['email']);
|
||||||
|
$table->string('active_email')
|
||||||
|
->nullable()
|
||||||
|
->storedAs('CASE WHEN `deleted_at` IS NULL THEN LOWER(`email`) ELSE NULL END');
|
||||||
|
$table->unique('active_email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique(['active_email']);
|
||||||
|
$table->dropColumn('active_email');
|
||||||
|
$table->unique('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique(['google_id']);
|
||||||
|
$table->string('active_google_id')
|
||||||
|
->nullable()
|
||||||
|
->storedAs('CASE WHEN `deleted_at` IS NULL THEN `google_id` ELSE NULL END');
|
||||||
|
$table->unique('active_google_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique(['active_google_id']);
|
||||||
|
$table->dropColumn('active_google_id');
|
||||||
|
$table->unique('google_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('integration_instances', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('integration_code');
|
||||||
|
$table->string('name');
|
||||||
|
$table->longText('integration_data')->nullable(); // Encrypted JSON.
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('integration_code')->references('integration_code')->on('integrations')->restrictOnDelete();
|
||||||
|
// Allows associations to enforce one instance per integration and owner.
|
||||||
|
$table->unique(['id', 'integration_code'], 'integration_instances_id_code_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('integration_instances');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
private const MENU_CODE = 'adminapp.staff';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('menues')
|
||||||
|
->where('code', self::MENU_CODE)
|
||||||
|
->update([
|
||||||
|
'label' => 'Usuarios',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('menues')
|
||||||
|
->where('code', self::MENU_CODE)
|
||||||
|
->update([
|
||||||
|
'label' => 'Staff',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->unique(['active_email', 'rol_codigo']);
|
||||||
|
$table->dropUnique(['active_email']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->unique('active_email');
|
||||||
|
$table->dropUnique(['active_email', 'rol_codigo']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
// MySQL commits DDL separately; allow retrying after a partially applied migration.
|
||||||
|
if (! Schema::hasColumn('client_integrations', 'integration_instance_id')) {
|
||||||
|
Schema::table('client_integrations', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('integration_instance_id')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('client_integrations')->whereNull('integration_instance_id')->orderBy('id')->chunkById(100, function ($associations): void {
|
||||||
|
foreach ($associations as $association) {
|
||||||
|
DB::transaction(function () use ($association): void {
|
||||||
|
$instanceId = DB::table('integration_instances')->insertGetId([
|
||||||
|
'integration_code' => $association->integration_code,
|
||||||
|
'name' => $association->integration_code.' / client '.$association->client_id,
|
||||||
|
// Copy ciphertext verbatim: no decryption or re-encryption during migration.
|
||||||
|
'integration_data' => $association->integration_data,
|
||||||
|
'created_at' => $association->created_at,
|
||||||
|
'updated_at' => $association->updated_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('client_integrations')->where('id', $association->id)->update([
|
||||||
|
'integration_instance_id' => $instanceId,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('client_integrations', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('integration_instance_id')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
$hasInstanceForeignKey = collect(Schema::getForeignKeys('client_integrations'))
|
||||||
|
->contains(fn (array $key): bool => $key['columns'] === ['integration_instance_id', 'integration_code']);
|
||||||
|
|
||||||
|
if (! $hasInstanceForeignKey) {
|
||||||
|
Schema::table('client_integrations', function (Blueprint $table): void {
|
||||||
|
$table->foreign(['integration_instance_id', 'integration_code'], 'client_integrations_instance_code_fk')
|
||||||
|
->references(['id', 'integration_code'])->on('integration_instances')->restrictOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('client_integrations', 'integration_data')) {
|
||||||
|
Schema::table('client_integrations', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('integration_data');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('client_integrations', function (Blueprint $table): void {
|
||||||
|
$table->longText('integration_data')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('client_integrations')->orderBy('id')->chunkById(100, function ($associations): void {
|
||||||
|
foreach ($associations as $association) {
|
||||||
|
DB::table('client_integrations')->where('id', $association->id)->update([
|
||||||
|
'integration_data' => DB::table('integration_instances')
|
||||||
|
->where('id', $association->integration_instance_id)->value('integration_data'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('client_integrations', function (Blueprint $table): void {
|
||||||
|
// MySQL uses the short name; SQLite needs the columns to rebuild the table.
|
||||||
|
$table->dropForeign('client_integrations_instance_code_fk')
|
||||||
|
->columns(['integration_instance_id', 'integration_code']);
|
||||||
|
$table->dropColumn('integration_instance_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('website_type_integrations', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('website_type_code');
|
||||||
|
$table->string('integration_code');
|
||||||
|
$table->unsignedBigInteger('integration_instance_id');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('website_type_code')->references('codigo')->on('website_type')->cascadeOnDelete();
|
||||||
|
$table->foreign(['integration_instance_id', 'integration_code'], 'website_type_integrations_instance_code_fk')
|
||||||
|
->references(['id', 'integration_code'])->on('integration_instances')->restrictOnDelete();
|
||||||
|
$table->unique(['website_type_code', 'integration_code'], 'website_type_integrations_owner_code_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('website_type_integrations');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('integrations', function (Blueprint $table): void {
|
||||||
|
$table->renameColumn('requires_client_configuration', 'requires_configuration');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('integrations', function (Blueprint $table): void {
|
||||||
|
$table->renameColumn('requires_configuration', 'requires_client_configuration');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\PermissionCode;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('roles_permisos')
|
||||||
|
->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
|
->where('codigo_permiso', PermissionCode::ScanTickets->value)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$roleExists = DB::table('roles')
|
||||||
|
->where('codigo', RoleCode::AdminApp->value)
|
||||||
|
->exists();
|
||||||
|
$permissionExists = DB::table('permisos')
|
||||||
|
->where('codigo', PermissionCode::ScanTickets->value)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $roleExists || ! $permissionExists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('roles_permisos')->updateOrInsert(
|
||||||
|
[
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'codigo_permiso' => PermissionCode::ScanTickets->value,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('scan_attempts', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->foreignId('scanner_user_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('users')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->foreignId('ticket_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('tickets')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->text('data')->nullable();
|
||||||
|
$table->string('result', 32);
|
||||||
|
$table->timestamp('resolved_at')->nullable();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->foreign('tenant_code')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
|
||||||
|
$table->index(['scanner_user_id', 'created_at']);
|
||||||
|
$table->index(['tenant_code', 'created_at']);
|
||||||
|
$table->index(['ticket_id', 'created_at']);
|
||||||
|
$table->index(['tenant_code', 'result', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('scan_attempts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -120,7 +120,7 @@ class AuthorizationSeeder extends Seeder
|
|||||||
RoleCode::AdminApp->value => [
|
RoleCode::AdminApp->value => [
|
||||||
'nombre' => 'Administrador de la aplicación',
|
'nombre' => 'Administrador de la aplicación',
|
||||||
'descripcion' => 'Accede a los menús administrativos de la aplicación.',
|
'descripcion' => 'Accede a los menús administrativos de la aplicación.',
|
||||||
'permisos' => [PermissionCode::ScanTickets->value],
|
'permisos' => [],
|
||||||
],
|
],
|
||||||
RoleCode::Scanner->value => [
|
RoleCode::Scanner->value => [
|
||||||
'nombre' => 'Scanner',
|
'nombre' => 'Scanner',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class EmailIntegrationSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'name' => 'Email',
|
'name' => 'Email',
|
||||||
'url' => null,
|
'url' => null,
|
||||||
'requires_client_configuration' => false,
|
'requires_configuration' => false,
|
||||||
'integration_data_schema' => [
|
'integration_data_schema' => [
|
||||||
'MAIL_MAILER' => 'required|string|in:smtp',
|
'MAIL_MAILER' => 'required|string|in:smtp',
|
||||||
'MAIL_SCHEME' => 'required|string|in:smtp',
|
'MAIL_SCHEME' => 'required|string|in:smtp',
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class MenuSeeder extends Seeder
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'adminapp.staff',
|
'code' => 'adminapp.staff',
|
||||||
'label' => 'Staff',
|
'label' => 'Usuarios',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/staff',
|
'route' => '/admin/staff',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class TelepagosIntegrationSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'name' => 'Telepagos',
|
'name' => 'Telepagos',
|
||||||
'url' => 'https://api.telepagos.com.ar',
|
'url' => 'https://api.telepagos.com.ar',
|
||||||
'requires_client_configuration' => true,
|
'requires_configuration' => true,
|
||||||
'integration_data_schema' => [
|
'integration_data_schema' => [
|
||||||
'username' => 'required|string',
|
'username' => 'required|string',
|
||||||
'password' => 'required|string',
|
'password' => 'required|string',
|
||||||
@@ -30,7 +30,7 @@ class TelepagosIntegrationSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'name' => 'Telepagos Homologación',
|
'name' => 'Telepagos Homologación',
|
||||||
'url' => 'https://api.homo.telepagos.com.ar',
|
'url' => 'https://api.homo.telepagos.com.ar',
|
||||||
'requires_client_configuration' => true,
|
'requires_configuration' => true,
|
||||||
'integration_data_schema' => [
|
'integration_data_schema' => [
|
||||||
'username' => 'required|string',
|
'username' => 'required|string',
|
||||||
'password' => 'required|string',
|
'password' => 'required|string',
|
||||||
|
|||||||
31
docs/email-identity.md
Normal file
31
docs/email-identity.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Identidad por email y rol
|
||||||
|
|
||||||
|
Cada cuenta activa se identifica por `LOWER(email)` + `rol_codigo`, globalmente,
|
||||||
|
sin incluir el tenant. Un mismo correo puede tener una cuenta `user`, otra
|
||||||
|
`adminapp`, otra `scanner` y otra `admin`. Dos cuentas activas del mismo rol
|
||||||
|
no pueden compartir correo, incluso si pertenecen a distintos tenants.
|
||||||
|
|
||||||
|
La base lo garantiza con el índice único `(active_email, rol_codigo)`.
|
||||||
|
`active_email` es una columna generada: vale `LOWER(email)` cuando `deleted_at`
|
||||||
|
es NULL y NULL para cuentas eliminadas. El soft delete libera el correo para
|
||||||
|
ese rol; registrarlo nuevamente crea una cuenta independiente.
|
||||||
|
|
||||||
|
Registro, perfil, administradores y staff validan el correo normalizado contra
|
||||||
|
el rol de destino. Cambiar de rol o restaurar una cuenta también queda sujeto
|
||||||
|
al índice único de la base.
|
||||||
|
|
||||||
|
Login y recuperación seleccionan la identidad de la aplicación:
|
||||||
|
|
||||||
|
- Tienda y Google: `user`.
|
||||||
|
- AdminApp: `adminapp`.
|
||||||
|
- Scanner: `scanner`. Sólo las identidades con ese rol pueden autenticarse y
|
||||||
|
consumir los endpoints del scanner.
|
||||||
|
- Verificación administrativa de plataforma: `admin`.
|
||||||
|
|
||||||
|
Los endpoints de validación de código y cambio de contraseña toman el rol de
|
||||||
|
la ruta, nunca del cuerpo enviado por el cliente. Los intentos y cambios de
|
||||||
|
contraseña pertenecen a una cuenta concreta, aunque otra comparta su email.
|
||||||
|
|
||||||
|
Aplicar `php artisan migrate` antes de habilitar correos compartidos por rol.
|
||||||
|
Para revertir esta migración hay que resolver primero los correos compartidos
|
||||||
|
entre cuentas activas: el índice global anterior no los admite.
|
||||||
@@ -11,7 +11,7 @@ declare(strict_types=1);
|
|||||||
$root = dirname(__DIR__);
|
$root = dirname(__DIR__);
|
||||||
chdir($root);
|
chdir($root);
|
||||||
|
|
||||||
$command = escapeshellarg(PHP_BINARY).' artisan route:list --path=api --json';
|
$command = escapeshellarg(PHP_BINARY).' artisan route:list --json';
|
||||||
$routeJson = shell_exec($command);
|
$routeJson = shell_exec($command);
|
||||||
|
|
||||||
if (! is_string($routeJson) || trim($routeJson) === '') {
|
if (! is_string($routeJson) || trim($routeJson) === '') {
|
||||||
@@ -20,6 +20,11 @@ if (! is_string($routeJson) || trim($routeJson) === '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$routes = json_decode($routeJson, true, flags: JSON_THROW_ON_ERROR);
|
$routes = json_decode($routeJson, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
$routes = array_values(array_filter(
|
||||||
|
$routes,
|
||||||
|
fn (array $route): bool => str_starts_with($route['uri'], 'api/')
|
||||||
|
|| $route['uri'] === 'auth/google/redirect',
|
||||||
|
));
|
||||||
|
|
||||||
const TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
const TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
@@ -70,9 +75,8 @@ function bodyFor(string $method, string $uri): ?array
|
|||||||
'POST api/clients' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo'],
|
'POST api/clients' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo'],
|
||||||
'PUT api/clients/{client}' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo Actualizado'],
|
'PUT api/clients/{client}' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo Actualizado'],
|
||||||
'PATCH api/clients/{client}' => ['name' => 'Cliente Demo Actualizado'],
|
'PATCH api/clients/{client}' => ['name' => 'Cliente Demo Actualizado'],
|
||||||
'POST api/integrations' => ['integration_code' => 'telepagos', 'name' => 'Telepagos', 'url' => 'https://api.example.com', 'integration_data_schema' => ['api_key' => ['required', 'string']], 'requires_client_configuration' => true],
|
|
||||||
'PUT api/integrations/{integration}' => ['name' => 'Telepagos', 'url' => 'https://api.example.com', 'requires_client_configuration' => true],
|
|
||||||
'PUT api/clients/{client}/integrations/{integration_code}' => ['integration_data' => ['api_key' => 'replace-me']],
|
'PUT api/clients/{client}/integrations/{integration_code}' => ['integration_data' => ['api_key' => 'replace-me']],
|
||||||
|
'PUT api/website-types/{websiteType:codigo}/integrations/{integration_code}' => ['integration_data' => ['api_key' => 'replace-me']],
|
||||||
'POST api/menues' => ['code' => 'demo', 'label' => 'Demo', 'parent_menu_code' => null, 'content_type' => 'static', 'static_content_schema' => ['title' => ['required', 'string']], 'route' => '/demo'],
|
'POST api/menues' => ['code' => 'demo', 'label' => 'Demo', 'parent_menu_code' => null, 'content_type' => 'static', 'static_content_schema' => ['title' => ['required', 'string']], 'route' => '/demo'],
|
||||||
'PUT api/menues/{menue}' => ['label' => 'Demo actualizado', 'route' => '/demo'],
|
'PUT api/menues/{menue}' => ['label' => 'Demo actualizado', 'route' => '/demo'],
|
||||||
'PATCH api/menues/{menue}' => ['label' => 'Demo actualizado'],
|
'PATCH api/menues/{menue}' => ['label' => 'Demo actualizado'],
|
||||||
@@ -109,6 +113,7 @@ function bodyFor(string $method, string $uri): ?array
|
|||||||
'POST api/v1/scanner/password/reset-attempts' => ['email' => '{{scanner_email}}'],
|
'POST api/v1/scanner/password/reset-attempts' => ['email' => '{{scanner_email}}'],
|
||||||
'POST api/v1/scanner/password/reset-attempts/validate' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}'],
|
'POST api/v1/scanner/password/reset-attempts/validate' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}'],
|
||||||
'POST api/v1/scanner/password/reset' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{scanner_password}}', 'password_confirmation' => '{{scanner_password}}'],
|
'POST api/v1/scanner/password/reset' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{scanner_password}}', 'password_confirmation' => '{{scanner_password}}'],
|
||||||
|
'POST api/v1/scanner/tickets/scan' => ['data' => '{{ticket_uuid}}'],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isset($exact[$key])) {
|
if (isset($exact[$key])) {
|
||||||
@@ -177,6 +182,10 @@ function bodyFor(string $method, string $uri): ?array
|
|||||||
function queryFor(string $uri): array
|
function queryFor(string $uri): array
|
||||||
{
|
{
|
||||||
return match ($uri) {
|
return match ($uri) {
|
||||||
|
'auth/google/redirect' => [
|
||||||
|
['key' => 'tenant', 'value' => '{{tenant_code}}'],
|
||||||
|
['key' => 'return_url', 'value' => '{{storefront_url}}'],
|
||||||
|
],
|
||||||
'api/tenants/bootstrap' => [['key' => 'dominio', 'value' => '{{tenant_domain}}'], ['key' => 'path', 'value' => '/']],
|
'api/tenants/bootstrap' => [['key' => 'dominio', 'value' => '{{tenant_domain}}'], ['key' => 'path', 'value' => '/']],
|
||||||
'api/v1/adminapp/bootstrap/{dominio}', 'api/v1/scanner/bootstrap/{dominio}' => [['key' => 'path', 'value' => '/']],
|
'api/v1/adminapp/bootstrap/{dominio}', 'api/v1/scanner/bootstrap/{dominio}' => [['key' => 'path', 'value' => '/']],
|
||||||
'api/tenants/{tenant:codigo}/catalog-items' => [['key' => 'q', 'value' => 'demo'], ['key' => 'page', 'value' => '1']],
|
'api/tenants/{tenant:codigo}/catalog-items' => [['key' => 'q', 'value' => 'demo'], ['key' => 'page', 'value' => '1']],
|
||||||
@@ -193,7 +202,7 @@ function queryFor(string $uri): array
|
|||||||
['key' => 'page', 'value' => '1'],
|
['key' => 'page', 'value' => '1'],
|
||||||
['key' => 'per_page', 'value' => '20'],
|
['key' => 'per_page', 'value' => '20'],
|
||||||
],
|
],
|
||||||
'api/v1/scanner/tickets' => [['key' => 'q', 'value' => '', 'disabled' => true], ['key' => 'page', 'value' => '1'], ['key' => 'per_page', 'value' => '20']],
|
'api/v1/scanner/attempts' => [['key' => 'q', 'value' => '', 'disabled' => true], ['key' => 'page', 'value' => '1'], ['key' => 'per_page', 'value' => '20']],
|
||||||
'api/storage-test/s3/temporary-url' => [['key' => 'path', 'value' => '{{s3_path}}'], ['key' => 'expires_in_minutes', 'value' => '60']],
|
'api/storage-test/s3/temporary-url' => [['key' => 'path', 'value' => '{{s3_path}}'], ['key' => 'expires_in_minutes', 'value' => '60']],
|
||||||
default => [],
|
default => [],
|
||||||
};
|
};
|
||||||
@@ -267,7 +276,8 @@ function requestName(string $method, string $action, bool $multiMethod): string
|
|||||||
function pathFor(string $uri): string
|
function pathFor(string $uri): string
|
||||||
{
|
{
|
||||||
$variables = [
|
$variables = [
|
||||||
'tenant:codigo' => 'tenant_code', 'tenant' => 'tenant_id', 'client' => 'client_id',
|
'tenant:codigo' => 'tenant_code', 'tenant' => 'tenant_id', 'client' => 'client_code',
|
||||||
|
'websiteType:codigo' => 'website_type_code',
|
||||||
'integration_code' => 'integration_code', 'integration' => 'integration_id', 'menue' => 'menu_id',
|
'integration_code' => 'integration_code', 'integration' => 'integration_id', 'menue' => 'menu_id',
|
||||||
'menu_code' => 'menu_code', 'tenant_code' => 'tenant_code', 'catalogItem' => 'catalog_item_id',
|
'menu_code' => 'menu_code', 'tenant_code' => 'tenant_code', 'catalogItem' => 'catalog_item_id',
|
||||||
'featuredGroup' => 'featured_group_id', 'category' => 'category_id', 'cartItem' => 'cart_item_id',
|
'featuredGroup' => 'featured_group_id', 'category' => 'category_id', 'cartItem' => 'cart_item_id',
|
||||||
@@ -322,6 +332,111 @@ function tokenCaptureEvent(string $uri): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{name: string, description: string, integration_data: array<string, mixed>}> */
|
||||||
|
function integrationConfigurationPresets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => [
|
||||||
|
'name' => 'Email (SMTP)',
|
||||||
|
'description' => 'Configura el transporte SMTP usado para el envío de correos.',
|
||||||
|
'integration_data' => [
|
||||||
|
'MAIL_MAILER' => 'smtp',
|
||||||
|
'MAIL_SCHEME' => 'smtp',
|
||||||
|
'MAIL_HOST' => 'smtp.example.com',
|
||||||
|
'MAIL_PORT' => 587,
|
||||||
|
'MAIL_USERNAME' => 'usuario@example.com',
|
||||||
|
'MAIL_PASSWORD' => 'replace-me',
|
||||||
|
'MAIL_FROM_ADDRESS' => 'no-reply@example.com',
|
||||||
|
'MAIL_FROM_NAME' => 'ShopIt',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'telepagos' => [
|
||||||
|
'name' => 'Telepagos Producción',
|
||||||
|
'description' => 'Configura las credenciales productivas de Telepagos.',
|
||||||
|
'integration_data' => [
|
||||||
|
'username' => 'replace-me',
|
||||||
|
'password' => 'replace-me',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'telepagos_homo' => [
|
||||||
|
'name' => 'Telepagos Homologación',
|
||||||
|
'description' => 'Configura las credenciales del entorno de homologación de Telepagos.',
|
||||||
|
'integration_data' => [
|
||||||
|
'username' => 'replace-me',
|
||||||
|
'password' => 'replace-me',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, array<string, mixed>> $requests
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
function integrationOwnerFolders(array $requests): array
|
||||||
|
{
|
||||||
|
$owners = [
|
||||||
|
'Client' => '/api/clients/',
|
||||||
|
'Website Type' => '/api/website-types/',
|
||||||
|
];
|
||||||
|
$folders = [];
|
||||||
|
|
||||||
|
foreach ($owners as $ownerName => $pathFragment) {
|
||||||
|
$ownerRequests = array_values(array_filter(
|
||||||
|
$requests,
|
||||||
|
fn (array $item): bool => str_contains($item['request']['url']['raw'], $pathFragment),
|
||||||
|
));
|
||||||
|
$putTemplate = null;
|
||||||
|
foreach ($ownerRequests as $ownerRequest) {
|
||||||
|
if ($ownerRequest['request']['method'] === 'PUT') {
|
||||||
|
$putTemplate = $ownerRequest;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($putTemplate === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($ownerRequests as &$request) {
|
||||||
|
if ($request['request']['method'] === 'PUT') {
|
||||||
|
$request['name'] = 'Configure Integration (generic)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($request);
|
||||||
|
|
||||||
|
$ownerItems = [[
|
||||||
|
'name' => 'Management',
|
||||||
|
'description' => 'Consulta, configura o desvincula cualquier integración usando `{{integration_code}}`.',
|
||||||
|
'item' => $ownerRequests,
|
||||||
|
]];
|
||||||
|
|
||||||
|
foreach (integrationConfigurationPresets() as $integrationCode => $preset) {
|
||||||
|
$request = $putTemplate;
|
||||||
|
$request['name'] = 'Configure '.$preset['name'];
|
||||||
|
$request['request']['description'] .= "\n\nPreset: `{$integrationCode}`. {$preset['description']}";
|
||||||
|
$request['request']['url']['raw'] = str_replace('{{integration_code}}', $integrationCode, $request['request']['url']['raw']);
|
||||||
|
$request['request']['url']['path'] = array_map(
|
||||||
|
fn (string $segment): string => $segment === '{{integration_code}}' ? $integrationCode : $segment,
|
||||||
|
$request['request']['url']['path'],
|
||||||
|
);
|
||||||
|
$request['request']['body'] = jsonBody(['integration_data' => $preset['integration_data']]);
|
||||||
|
|
||||||
|
$ownerItems[] = [
|
||||||
|
'name' => $preset['name'],
|
||||||
|
'description' => $preset['description'],
|
||||||
|
'item' => [$request],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$folders[] = [
|
||||||
|
'name' => $ownerName,
|
||||||
|
'item' => $ownerItems,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $folders;
|
||||||
|
}
|
||||||
|
|
||||||
$tree = [];
|
$tree = [];
|
||||||
$registeredOperations = 0;
|
$registeredOperations = 0;
|
||||||
|
|
||||||
@@ -403,7 +518,9 @@ $items = [];
|
|||||||
foreach ($tree as $section => $folders) {
|
foreach ($tree as $section => $folders) {
|
||||||
$children = [];
|
$children = [];
|
||||||
foreach ($folders as $folder => $requests) {
|
foreach ($folders as $folder => $requests) {
|
||||||
$children[] = ['name' => humanize($folder), 'item' => $requests];
|
$children[] = $section === 'Platform Management' && $folder === 'Integration'
|
||||||
|
? ['name' => 'Integration', 'item' => integrationOwnerFolders($requests)]
|
||||||
|
: ['name' => humanize($folder), 'item' => $requests];
|
||||||
}
|
}
|
||||||
|
|
||||||
$items[] = [
|
$items[] = [
|
||||||
@@ -415,12 +532,14 @@ foreach ($tree as $section => $folders) {
|
|||||||
|
|
||||||
$variables = [
|
$variables = [
|
||||||
'base_url' => 'http://localhost:8000',
|
'base_url' => 'http://localhost:8000',
|
||||||
|
'storefront_url' => 'http://localhost:4200',
|
||||||
'token' => '', 'admin_token' => '', 'scanner_token' => '',
|
'token' => '', 'admin_token' => '', 'scanner_token' => '',
|
||||||
'user_email' => 'usuario@example.com', 'user_password' => 'Password!123',
|
'user_email' => 'usuario@example.com', 'user_password' => 'Password!123',
|
||||||
'admin_email' => 'admin@example.com', 'admin_password' => 'Password!123',
|
'admin_email' => 'admin@example.com', 'admin_password' => 'Password!123',
|
||||||
'scanner_email' => 'scanner@example.com', 'scanner_password' => 'Password!123',
|
'scanner_email' => 'scanner@example.com', 'scanner_password' => 'Password!123',
|
||||||
'tenant_code' => 'demo', 'tenant_id' => '1', 'tenant_domain' => 'demo.test',
|
'tenant_code' => 'demo', 'tenant_id' => '1', 'tenant_domain' => 'demo.test',
|
||||||
'client_id' => '1', 'integration_id' => '1', 'integration_code' => 'telepagos',
|
'client_id' => '1', 'client_code' => 'cliente-demo', 'website_type_code' => 'shopit',
|
||||||
|
'integration_code' => 'telepagos',
|
||||||
'menu_id' => '1', 'menu_code' => 'demo', 'catalog_item_id' => '1', 'variant_id' => '1',
|
'menu_id' => '1', 'menu_code' => 'demo', 'catalog_item_id' => '1', 'variant_id' => '1',
|
||||||
'category_id' => '1', 'featured_group_id' => '1', 'cart_id' => '1', 'cart_item_id' => '1',
|
'category_id' => '1', 'featured_group_id' => '1', 'cart_id' => '1', 'cart_item_id' => '1',
|
||||||
'purchase_id' => '1', 'purchase_item_id' => '1', 'sale_id' => '1', 'staff_id' => '1',
|
'purchase_id' => '1', 'purchase_item_id' => '1', 'sale_id' => '1', 'staff_id' => '1',
|
||||||
@@ -434,7 +553,7 @@ $collection = [
|
|||||||
'info' => [
|
'info' => [
|
||||||
'_postman_id' => '76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2',
|
'_postman_id' => '76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2',
|
||||||
'name' => 'ShopIt API — Complete',
|
'name' => 'ShopIt API — Complete',
|
||||||
'description' => "Colección canónica generada desde las rutas reales de Laravel. Incluye {$registeredOperations} operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
|
'description' => "Colección canónica generada desde las rutas reales de Laravel. Incluye {$registeredOperations} operaciones HTTP, presets de configuración para cada integración, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
|
||||||
'schema' => 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
|
'schema' => 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
|
||||||
],
|
],
|
||||||
'item' => $items,
|
'item' => $items,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@include('mail.notifications.partials.password-action', [
|
||||||
|
'title' => 'Desbloqueá tu cuenta',
|
||||||
|
'description' => 'registramos varios intentos fallidos de inicio de sesión en tu cuenta. Por seguridad, bloqueamos el acceso temporalmente. Utilizá este código para cambiar tu contraseña y desbloquearla.',
|
||||||
|
'buttonLabel' => 'Ingresar código ahora',
|
||||||
|
'footer' => 'Si no fuiste vos, por favor desestimá y borrá este correo. Tu cuenta seguirá protegida.',
|
||||||
|
])
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@include('mail.notifications.partials.password-action', [
|
||||||
|
'title' => 'Tu cuenta de administrador está lista',
|
||||||
|
'description' => 'creamos tu cuenta de administrador en '.$brand->nombre.'. Creá tu contraseña con este código para ingresar al panel de administración.',
|
||||||
|
'buttonLabel' => 'Crear mi contraseña',
|
||||||
|
'footer' => 'Si no esperabas recibir una cuenta de administrador, podés ignorar este mensaje.',
|
||||||
|
])
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<h1 style="margin: 0 0 20px; color: {{ $brand->primary_color }};">
|
||||||
|
{{ $title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p>Hola {{ $attempt->user->nombre_apellido }}, {{ $description }}</p>
|
||||||
|
|
||||||
|
<p>Ingresá este código en {{ $brand->nombre }}:</p>
|
||||||
|
|
||||||
|
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $brand->primary_color }}; border-radius: 8px; text-align: center;">
|
||||||
|
<span style="color: {{ $brand->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
|
||||||
|
{{ $attempt->codigo }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($recoveryUrl)
|
||||||
|
<div style="text-align: center; margin-bottom: 28px;">
|
||||||
|
<a href="{{ $recoveryUrl }}"
|
||||||
|
style="display: inline-block; padding: 12px 24px; background-color: {{ $brand->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||||
|
{{ $buttonLabel }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p style="color: #64748b; font-size: 14px;">{{ $footer }}</p>
|
||||||
@@ -1,45 +1,6 @@
|
|||||||
<h1 style="margin: 0 0 20px; color: {{ $brand->primary_color }};">
|
@include('mail.notifications.partials.password-action', [
|
||||||
Recuperá tu contraseña
|
'title' => 'Recuperá tu contraseña',
|
||||||
</h1>
|
'description' => 'recibimos una solicitud para restablecer la contraseña de tu cuenta.',
|
||||||
|
'buttonLabel' => 'Ingresar código ahora',
|
||||||
@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)
|
'footer' => 'Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.',
|
||||||
<p>
|
])
|
||||||
Hola {{ $attempt->user->nombre_apellido }}, creamos tu cuenta de scanner en {{ $brand->nombre }}. Utilizá este código para crear tu contraseña y comenzar a usarla.
|
|
||||||
</p>
|
|
||||||
@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)
|
|
||||||
<p>
|
|
||||||
Hola {{ $attempt->user->nombre_apellido }}, registramos varios intentos fallidos de inicio de sesión en tu cuenta. Por seguridad, hemos bloqueado el acceso temporalmente. Puedes utilizar este código para cambiar tu contraseña y desbloquearla inmediatamente.
|
|
||||||
</p>
|
|
||||||
@else
|
|
||||||
<p>
|
|
||||||
Hola {{ $attempt->user->nombre_apellido }}, recibimos una solicitud para restablecer
|
|
||||||
la contraseña de tu cuenta.
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<p>Ingresá este código en {{ $brand->nombre }}:</p>
|
|
||||||
|
|
||||||
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $brand->primary_color }}; border-radius: 8px; text-align: center;">
|
|
||||||
<span style="color: {{ $brand->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
|
|
||||||
{{ $attempt->codigo }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($recoveryUrl)
|
|
||||||
<div style="text-align: center; margin-bottom: 28px;">
|
|
||||||
<a href="{{ $recoveryUrl }}"
|
|
||||||
style="display: inline-block; padding: 12px 24px; background-color: {{ $brand->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
|
||||||
{{ $attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED ? 'Crear mi contraseña' : 'Ingresar código ahora' }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<p style="color: #64748b; font-size: 14px;">
|
|
||||||
@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)
|
|
||||||
Si no fuiste vos, por favor desestimá y borrá este correo. Tu cuenta seguirá protegida.
|
|
||||||
@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)
|
|
||||||
Si no esperabas recibir una cuenta de scanner, podés ignorar este mensaje.
|
|
||||||
@else
|
|
||||||
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
|
|
||||||
@endif
|
|
||||||
</p>
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@include('mail.notifications.partials.password-action', [
|
||||||
|
'title' => 'Tu cuenta de escáner está lista',
|
||||||
|
'description' => 'creamos tu cuenta de escáner en '.$brand->nombre.'. Creá tu contraseña con este código para ingresar y comenzar a escanear entradas.',
|
||||||
|
'buttonLabel' => 'Crear mi contraseña',
|
||||||
|
'footer' => 'Si no esperabas recibir una cuenta de escáner, podés ignorar este mensaje.',
|
||||||
|
])
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $brand->nombre }}!</h1>
|
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $brand->nombre }}!</h1>
|
||||||
<p>Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.</p>
|
<p>Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.</p>
|
||||||
<p>Ya podés ingresar y comenzar a comprar.</p>
|
<div style="text-align: center; margin-bottom: 28px;">
|
||||||
|
<a href="{{ $tenantUrl }}"
|
||||||
|
style="display: inline-block; padding: 12px 24px; background-color: {{ $brand->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||||
|
Encendé tu experiencia
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ require __DIR__.'/../app/Domains/Ticket/routes/api.php';
|
|||||||
require __DIR__.'/../app/Domains/Event/routes/api.php';
|
require __DIR__.'/../app/Domains/Event/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Forms/routes/api.php';
|
require __DIR__.'/../app/Domains/Forms/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Staff/routes/api.php';
|
require __DIR__.'/../app/Domains/Staff/routes/api.php';
|
||||||
|
require __DIR__.'/../app/Domains/Administrator/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/FiestaFutbolInfantil/routes/api.php';
|
require __DIR__.'/../app/Domains/FiestaFutbolInfantil/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Desfile/routes/api.php';
|
require __DIR__.'/../app/Domains/Desfile/routes/api.php';
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
use App\Domains\Auth\Services\AdminCredentialVerifier;
|
use App\Domains\Auth\Services\AdminCredentialVerifier;
|
||||||
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||||
use App\Domains\Purchase\Services\TenantTransactionResetService;
|
use App\Domains\Purchase\Services\TenantTransactionResetService;
|
||||||
|
use App\Domains\Ticket\Services\LoadTestTicketDatasetService;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
@@ -24,6 +26,79 @@ Schedule::command('reservations:expire')
|
|||||||
->everyMinute()
|
->everyMinute()
|
||||||
->withoutOverlapping();
|
->withoutOverlapping();
|
||||||
|
|
||||||
|
Artisan::command(
|
||||||
|
'load-test:tickets:prepare
|
||||||
|
{tenant : Código del tenant que recibirá los datos de carga}
|
||||||
|
{--tickets=1000 : Cantidad de tickets válidos}
|
||||||
|
{--scanners=10 : Cantidad de identidades scanner}
|
||||||
|
{--owners=100 : Cantidad de propietarios de tickets}
|
||||||
|
{--catalog-item= : Producto estándar con tickets habilitados}
|
||||||
|
{--variant= : Variante opcional que define la vigencia}
|
||||||
|
{--run= : Identificador opcional de la ejecución}
|
||||||
|
{--output= : Ruta relativa dentro del disco local}',
|
||||||
|
function (LoadTestTicketDatasetService $datasetService): int {
|
||||||
|
if (! app()->environment(['local', 'testing', 'staging', 'homo', 'homologation'])) {
|
||||||
|
$this->error('Este comando sólo puede ejecutarse en local u homologación.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestedOutput = filled($this->option('output'))
|
||||||
|
? ltrim((string) $this->option('output'), '/\\')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($requestedOutput !== null
|
||||||
|
&& ($requestedOutput === '' || str_contains(str_replace('\\', '/', $requestedOutput), '../'))) {
|
||||||
|
$this->error('La ruta de salida debe permanecer dentro del disco local.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dataset = $datasetService->prepare(
|
||||||
|
(string) $this->argument('tenant'),
|
||||||
|
(int) $this->option('tickets'),
|
||||||
|
(int) $this->option('scanners'),
|
||||||
|
(int) $this->option('owners'),
|
||||||
|
filled($this->option('catalog-item')) ? (int) $this->option('catalog-item') : null,
|
||||||
|
filled($this->option('variant')) ? (int) $this->option('variant') : null,
|
||||||
|
filled($this->option('run')) ? (string) $this->option('run') : null,
|
||||||
|
);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->error($exception->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativePath = $requestedOutput !== null
|
||||||
|
? $requestedOutput
|
||||||
|
: "load-tests/{$dataset['run_id']}.postman.json";
|
||||||
|
$payload = json_encode($dataset['rows'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if (! is_string($payload) || ! Storage::disk('local')->put($relativePath, $payload.PHP_EOL)) {
|
||||||
|
$this->error('No se pudo escribir el dataset.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Dataset de carga generado.');
|
||||||
|
$this->table(['Dato', 'Valor'], [
|
||||||
|
['run_id', $dataset['run_id']],
|
||||||
|
['tenant', $dataset['tenant_code']],
|
||||||
|
['catalog_item_id', $dataset['catalog_item_id']],
|
||||||
|
['variant_id', $dataset['variant_id'] ?? 'sin variante (vigencia irrestricta)'],
|
||||||
|
['tickets', $dataset['tickets']],
|
||||||
|
['scanners', $dataset['scanners']],
|
||||||
|
['owners', $dataset['owners']],
|
||||||
|
['archivo', Storage::disk('local')->path($relativePath)],
|
||||||
|
]);
|
||||||
|
$this->warn('El archivo contiene tokens secretos. No lo subas al repositorio.');
|
||||||
|
$this->comment("Limpieza: php artisan tenants:reset-transactions {$dataset['tenant_code']}");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
},
|
||||||
|
)->purpose('Prepare disposable scanner tickets and a Postman performance dataset');
|
||||||
|
|
||||||
Artisan::command(
|
Artisan::command(
|
||||||
'tenants:reset-transactions
|
'tenants:reset-transactions
|
||||||
{tenant : Código del tenant que se limpiará}
|
{tenant : Código del tenant que se limpiará}
|
||||||
|
|||||||
21
tests/Concerns/CreatesIntegrationInstances.php
Normal file
21
tests/Concerns/CreatesIntegrationInstances.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Concerns;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
|
||||||
|
trait CreatesIntegrationInstances
|
||||||
|
{
|
||||||
|
private function createClientIntegration(array $attributes): ClientIntegration
|
||||||
|
{
|
||||||
|
$instance = IntegrationInstance::create([
|
||||||
|
'integration_code' => $attributes['integration_code'],
|
||||||
|
'name' => 'Test instance',
|
||||||
|
'integration_data' => $attributes['integration_data'],
|
||||||
|
]);
|
||||||
|
unset($attributes['integration_data']);
|
||||||
|
|
||||||
|
return ClientIntegration::create($attributes + ['integration_instance_id' => $instance->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
159
tests/Feature/Administrator/AdministratorControllerTest.php
Normal file
159
tests/Feature/Administrator/AdministratorControllerTest.php
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Administrator;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdministratorControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Tenant $tenant;
|
||||||
|
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Event::fake([PasswordResetRequested::class]);
|
||||||
|
$this->seed(AuthorizationSeeder::class);
|
||||||
|
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
|
||||||
|
$headerLogo = $this->createAttachment('header.png');
|
||||||
|
$footerLogo = $this->createAttachment('footer.png');
|
||||||
|
$this->tenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.test',
|
||||||
|
'website_type_code' => 'onticket',
|
||||||
|
'primary_color' => '#111111',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#cc0000',
|
||||||
|
'success_color' => '#008800',
|
||||||
|
'header_bg_color' => '#ffffff',
|
||||||
|
'footer_bg_color' => '#ffffff',
|
||||||
|
'header_logo_id' => $headerLogo->id,
|
||||||
|
'footer_logo_id' => $footerLogo->id,
|
||||||
|
]);
|
||||||
|
$this->admin = User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const URL = '/api/v1/adminapp/tenant/administrators';
|
||||||
|
|
||||||
|
private function payload(): array
|
||||||
|
{
|
||||||
|
return ['nombre_apellido' => 'Ada Lovelace', 'dni' => '12345678', 'email' => 'ada@example.test'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_email_can_be_shared_with_customers_and_scanners(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
foreach (['user', 'scanner'] as $role) {
|
||||||
|
User::factory()->create(['email' => 'ada@example.test', 'rol_codigo' => $role]);
|
||||||
|
}
|
||||||
|
$response = $this->postJson(self::URL, $this->payload())->assertCreated();
|
||||||
|
$this->putJson(self::URL.'/'.$response->json('data.id'), $this->payload())->assertOk();
|
||||||
|
$this->postJson(self::URL, $this->payload())->assertUnprocessable()->assertJsonValidationErrors('email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_crud_and_password_setup_and_token_revocation(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
$response = $this->postJson(self::URL, [...$this->payload(), 'email' => ' ADA@example.test ', 'rol_codigo' => 'admin', 'tenant_codigo' => 'other'])
|
||||||
|
->assertCreated()->assertJsonPath('data.email', 'ada@example.test')
|
||||||
|
->assertJsonPath('data.rol_codigo', 'adminapp')->assertJsonMissingPath('data.password');
|
||||||
|
$id = $response->json('data.id');
|
||||||
|
$this->assertDatabaseHas('users', ['id' => $id, 'tenant_codigo' => $this->tenant->codigo, 'rol_codigo' => 'adminapp']);
|
||||||
|
$this->assertDatabaseHas('reset_password_attempts', ['user_id' => $id, 'reason' => ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED, 'status' => ResetPasswordAttempt::STATUS_PENDING]);
|
||||||
|
Event::assertDispatched(PasswordResetRequested::class, fn ($event) => $event->channel === PasswordResetRequested::CHANNEL_ADMINAPP && $event->tenantCode === $this->tenant->codigo);
|
||||||
|
$this->getJson(self::URL.'?search=Ada')->assertOk()->assertJsonCount(1, 'data');
|
||||||
|
$this->putJson(self::URL."/{$id}", [...$this->payload(), 'nombre_apellido' => 'Ada Byron', 'rol_codigo' => 'scanner'])
|
||||||
|
->assertOk()->assertJsonPath('data.nombre_apellido', 'Ada Byron')->assertJsonPath('data.rol_codigo', 'adminapp');
|
||||||
|
$token = User::findOrFail($id)->createToken('adminapp')->accessToken;
|
||||||
|
$this->deleteJson(self::URL."/{$id}")->assertNoContent();
|
||||||
|
$this->assertSoftDeleted('users', ['id' => $id]);
|
||||||
|
$this->assertDatabaseMissing('personal_access_tokens', ['id' => $token->id]);
|
||||||
|
$this->getJson(self::URL.'?search=Ada')->assertOk()->assertJsonCount(0, 'data');
|
||||||
|
$this->postJson(self::URL, $this->payload())->assertCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_validation_and_case_insensitive_active_email_uniqueness(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
$this->postJson(self::URL, [])->assertUnprocessable()->assertJsonValidationErrors(['nombre_apellido', 'dni', 'email']);
|
||||||
|
$this->postJson(self::URL, [...$this->payload(), 'email' => 'invalid'])->assertUnprocessable()->assertJsonValidationErrors('email');
|
||||||
|
$this->postJson(self::URL, [...$this->payload(), 'email' => strtoupper($this->admin->email)])->assertUnprocessable()->assertJsonValidationErrors('email');
|
||||||
|
$target = User::factory()->create(['rol_codigo' => 'adminapp', 'tenant_codigo' => $this->tenant->codigo]);
|
||||||
|
$this->putJson(self::URL."/{$target->id}", [...$this->payload(), 'email' => strtoupper($this->admin->email)])->assertUnprocessable()->assertJsonValidationErrors('email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_other_tenants_and_roles_are_excluded(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
$otherTenant = $this->tenant->replicate();
|
||||||
|
$otherTenant->codigo = 'other';
|
||||||
|
$otherTenant->dominio = 'other.test';
|
||||||
|
$otherTenant->save();
|
||||||
|
$targets = [
|
||||||
|
User::factory()->create(['rol_codigo' => 'adminapp', 'tenant_codigo' => $otherTenant->codigo]),
|
||||||
|
User::factory()->create(['rol_codigo' => 'scanner', 'tenant_codigo' => $this->tenant->codigo]),
|
||||||
|
User::factory()->create(['rol_codigo' => 'admin', 'tenant_codigo' => $this->tenant->codigo]),
|
||||||
|
];
|
||||||
|
$this->getJson(self::URL)->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $this->admin->id);
|
||||||
|
foreach ($targets as $target) {
|
||||||
|
$this->putJson(self::URL."/{$target->id}", $this->payload())->assertNotFound();
|
||||||
|
$this->deleteJson(self::URL."/{$target->id}")->assertNotFound();
|
||||||
|
$this->assertNotSoftDeleted($target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_delete_self_even_with_another_administrator(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
$this->deleteJson(self::URL."/{$this->admin->id}")->assertUnprocessable()->assertJsonValidationErrors('administrator');
|
||||||
|
User::factory()->create(['rol_codigo' => 'adminapp', 'tenant_codigo' => $this->tenant->codigo]);
|
||||||
|
$this->deleteJson(self::URL."/{$this->admin->id}")->assertUnprocessable();
|
||||||
|
$this->assertNotSoftDeleted($this->admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_in_flight_request_from_deleted_actor_cannot_remove_last_administrator(): void
|
||||||
|
{
|
||||||
|
$remaining = User::factory()->create(['rol_codigo' => 'adminapp', 'tenant_codigo' => $this->tenant->codigo]);
|
||||||
|
$this->admin->delete();
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
$this->deleteJson(self::URL."/{$remaining->id}")->assertUnprocessable()->assertJsonValidationErrors('administrator');
|
||||||
|
$this->assertNotSoftDeleted($remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authentication_and_role_are_required_for_all_operations(): void
|
||||||
|
{
|
||||||
|
$this->getJson(self::URL)->assertUnauthorized();
|
||||||
|
foreach (['user', 'scanner', 'admin'] as $role) {
|
||||||
|
Sanctum::actingAs(User::factory()->create(['rol_codigo' => $role, 'tenant_codigo' => $this->tenant->codigo]));
|
||||||
|
$this->getJson(self::URL)->assertForbidden();
|
||||||
|
$this->postJson(self::URL, $this->payload())->assertForbidden();
|
||||||
|
$this->putJson(self::URL."/{$this->admin->id}", $this->payload())->assertForbidden();
|
||||||
|
$this->deleteJson(self::URL."/{$this->admin->id}")->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAttachment(string $filename): Attachment
|
||||||
|
{
|
||||||
|
return Attachment::query()->create(['path' => "test/{$filename}", 'filename' => $filename, 'type' => AttachmentType::Image, 'mime_type' => 'image/png']);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
tests/Feature/Auth/EmailUniquenessPerRoleTest.php
Normal file
133
tests/Feature/Auth/EmailUniquenessPerRoleTest.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EmailUniquenessPerRoleTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(ThrottleRequests::class);
|
||||||
|
$this->seed(AuthorizationSeeder::class);
|
||||||
|
Event::fake([PasswordResetRequested::class]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_database_allows_different_roles_and_reuse_after_soft_delete(): void
|
||||||
|
{
|
||||||
|
foreach (RoleCode::cases() as $role) {
|
||||||
|
User::factory()->create(['email' => 'Shared@example.com', 'rol_codigo' => $role->value]);
|
||||||
|
}
|
||||||
|
$user = User::where('rol_codigo', 'user')->sole();
|
||||||
|
$user->delete();
|
||||||
|
$replacement = User::factory()->create(['email' => 'shared@example.com']);
|
||||||
|
$this->assertNotSame($user->id, $replacement->id);
|
||||||
|
$this->assertSame(4, User::where('active_email', 'shared@example.com')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_database_rejects_same_role_case_insensitively_across_tenants(): void
|
||||||
|
{
|
||||||
|
foreach (['one', 'two'] as $code) {
|
||||||
|
$this->createTenant($code);
|
||||||
|
}
|
||||||
|
User::factory()->create(['email' => 'Shared@example.com', 'tenant_codigo' => 'one']);
|
||||||
|
$this->expectException(UniqueConstraintViolationException::class);
|
||||||
|
User::factory()->create(['email' => 'shared@example.com', 'tenant_codigo' => 'two']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_role_change_cannot_create_a_duplicate_active_identity(): void
|
||||||
|
{
|
||||||
|
User::factory()->create(['email' => 'shared@example.com']);
|
||||||
|
$admin = User::factory()->create(['email' => 'shared@example.com', 'rol_codigo' => 'adminapp']);
|
||||||
|
$this->expectException(UniqueConstraintViolationException::class);
|
||||||
|
$admin->update(['rol_codigo' => 'user']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_registration_accepts_another_role_but_rejects_same_role(): void
|
||||||
|
{
|
||||||
|
User::factory()->create(['email' => 'SHARED@example.com', 'rol_codigo' => 'adminapp']);
|
||||||
|
$payload = ['nombre_apellido' => 'Shared', 'email' => ' Shared@Example.com ',
|
||||||
|
'password' => 'Secret!123', 'password_confirmation' => 'Secret!123'];
|
||||||
|
$this->postJson('/api/register', $payload)->assertCreated()->assertJsonPath('data.email', 'shared@example.com');
|
||||||
|
$this->postJson('/api/register', $payload)->assertUnprocessable()->assertJsonValidationErrors('email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_login_and_password_reset_select_the_role_from_each_application(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$users = [];
|
||||||
|
// Create staff first so an email-only lookup would select the wrong account.
|
||||||
|
foreach (['adminapp', 'scanner', 'user'] as $role) {
|
||||||
|
$users[$role] = User::factory()->create([
|
||||||
|
'email' => 'Shared@example.com', 'rol_codigo' => $role,
|
||||||
|
'tenant_codigo' => $tenant->codigo, 'password' => 'Old!'.$role,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
foreach (['user' => '/api', 'adminapp' => '/api/v1/adminapp', 'scanner' => '/api/v1/scanner'] as $role => $base) {
|
||||||
|
$this->postJson($base.'/login', [
|
||||||
|
'email' => 'SHARED@example.com', 'password' => 'Old!'.$role, 'tenant_codigo' => 'acme',
|
||||||
|
])->assertOk()->assertJsonPath('user.id', $users[$role]->id);
|
||||||
|
$this->postJson($base.'/password/reset-attempts', [
|
||||||
|
'email' => 'shared@example.com', 'tenant_codigo' => 'acme',
|
||||||
|
])->assertAccepted();
|
||||||
|
}
|
||||||
|
// Identical codes across roles must still only change the intended account.
|
||||||
|
ResetPasswordAttempt::query()->update(['codigo' => '1234']);
|
||||||
|
foreach (['user' => '/api', 'adminapp' => '/api/v1/adminapp', 'scanner' => '/api/v1/scanner'] as $role => $base) {
|
||||||
|
$this->postJson($base.'/password/reset-attempts/validate', [
|
||||||
|
'email' => 'shared@example.com', 'codigo' => '1234',
|
||||||
|
])->assertOk();
|
||||||
|
$this->postJson($base.'/password/reset', [
|
||||||
|
'email' => 'shared@example.com', 'codigo' => '1234',
|
||||||
|
'password' => 'New!'.$role, 'password_confirmation' => 'New!'.$role,
|
||||||
|
])->assertOk();
|
||||||
|
$this->assertTrue(Hash::check('New!'.$role, $users[$role]->fresh()->password));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTenant(string $code): Tenant
|
||||||
|
{
|
||||||
|
$headerLogo = $this->createAttachment("{$code}-header");
|
||||||
|
$footerLogo = $this->createAttachment("{$code}-footer");
|
||||||
|
|
||||||
|
return Tenant::query()->create([
|
||||||
|
'codigo' => $code,
|
||||||
|
'nombre' => ucfirst($code),
|
||||||
|
'dominio' => "{$code}.local",
|
||||||
|
'primary_color' => '#000000',
|
||||||
|
'secondary_color' => '#000000',
|
||||||
|
'danger_color' => '#000000',
|
||||||
|
'success_color' => '#000000',
|
||||||
|
'header_bg_color' => '#000000',
|
||||||
|
'footer_bg_color' => '#000000',
|
||||||
|
'header_logo_id' => $headerLogo->id,
|
||||||
|
'footer_logo_id' => $footerLogo->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAttachment(string $name): Attachment
|
||||||
|
{
|
||||||
|
return Attachment::query()->create([
|
||||||
|
'path' => "test/{$name}.png",
|
||||||
|
'filename' => "{$name}.png",
|
||||||
|
'type' => AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,4 +135,36 @@ class RegisterControllerTest extends TestCase
|
|||||||
'password',
|
'password',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_can_reuse_the_email_of_a_soft_deleted_user(): void
|
||||||
|
{
|
||||||
|
$deletedUser = User::factory()->create([
|
||||||
|
'email' => 'reused@example.com',
|
||||||
|
]);
|
||||||
|
$deletedUser->delete();
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/register', [
|
||||||
|
'nombre_apellido' => 'New Account',
|
||||||
|
'email' => 'reused@example.com',
|
||||||
|
'password' => 'Secret!123',
|
||||||
|
'password_confirmation' => 'Secret!123',
|
||||||
|
])->assertCreated();
|
||||||
|
|
||||||
|
$newUserId = $response->json('data.id');
|
||||||
|
|
||||||
|
$this->assertNotSame($deletedUser->id, $newUserId);
|
||||||
|
$this->assertSame(
|
||||||
|
2,
|
||||||
|
User::withTrashed()->where('email', 'reused@example.com')->count(),
|
||||||
|
);
|
||||||
|
$this->assertDatabaseHas('users', [
|
||||||
|
'id' => $deletedUser->id,
|
||||||
|
'active_email' => null,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('users', [
|
||||||
|
'id' => $newUserId,
|
||||||
|
'active_email' => 'reused@example.com',
|
||||||
|
'deleted_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Auth\Models\LoginAttempt;
|
use App\Domains\Auth\Models\LoginAttempt;
|
||||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
@@ -14,6 +15,7 @@ use App\Domains\Tenant\Models\Tenant;
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ScannerLoginControllerTest extends TestCase
|
class ScannerLoginControllerTest extends TestCase
|
||||||
@@ -23,19 +25,15 @@ class ScannerLoginControllerTest extends TestCase
|
|||||||
public function test_it_logs_in_a_tenant_bound_user_with_scan_permission(): void
|
public function test_it_logs_in_a_tenant_bound_user_with_scan_permission(): void
|
||||||
{
|
{
|
||||||
$role = Role::query()->create([
|
$role = Role::query()->create([
|
||||||
'codigo' => RoleCode::AdminApp->value,
|
'codigo' => RoleCode::Scanner->value,
|
||||||
'nombre' => 'Operador',
|
'nombre' => 'Scanner',
|
||||||
]);
|
]);
|
||||||
$permission = Permission::query()->create([
|
$permission = Permission::query()->create([
|
||||||
'codigo' => PermissionCode::ScanTickets->value,
|
'codigo' => PermissionCode::ScanTickets->value,
|
||||||
'nombre' => 'Escanear tickets',
|
'nombre' => 'Escanear tickets',
|
||||||
]);
|
]);
|
||||||
$role->permissions()->attach($permission->codigo);
|
$role->permissions()->attach($permission->codigo);
|
||||||
$tenant = Tenant::query()->create([
|
$tenant = $this->createTenant();
|
||||||
'codigo' => 'acme',
|
|
||||||
'nombre' => 'Acme',
|
|
||||||
'dominio' => 'acme.test',
|
|
||||||
]);
|
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
'email' => 'scanner@example.com',
|
'email' => 'scanner@example.com',
|
||||||
'password' => Hash::make('secret123'),
|
'password' => Hash::make('secret123'),
|
||||||
@@ -51,23 +49,85 @@ class ScannerLoginControllerTest extends TestCase
|
|||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('user.id', $user->id)
|
->assertJsonPath('user.id', $user->id)
|
||||||
->assertJsonPath('user.rol_codigo', RoleCode::AdminApp->value)
|
->assertJsonPath('user.rol_codigo', RoleCode::Scanner->value)
|
||||||
->assertJsonPath('token_type', 'Bearer');
|
->assertJsonPath('token_type', 'Bearer');
|
||||||
|
|
||||||
$this->assertSame(['scanner'], $user->tokens()->sole()->abilities);
|
$this->assertSame(['scanner'], $user->tokens()->sole()->abilities);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_adminapp_credentials_even_when_the_role_has_scan_permission(): void
|
||||||
|
{
|
||||||
|
$role = Role::query()->create([
|
||||||
|
'codigo' => RoleCode::AdminApp->value,
|
||||||
|
'nombre' => 'Administrador',
|
||||||
|
]);
|
||||||
|
$permission = Permission::query()->create([
|
||||||
|
'codigo' => PermissionCode::ScanTickets->value,
|
||||||
|
'nombre' => 'Escanear tickets',
|
||||||
|
]);
|
||||||
|
$role->permissions()->attach($permission->codigo);
|
||||||
|
$tenant = $this->createTenant();
|
||||||
|
$adminApp = User::factory()->create([
|
||||||
|
'email' => 'shared@example.com',
|
||||||
|
'password' => Hash::make('admin-password'),
|
||||||
|
'rol_codigo' => $role->codigo,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/scanner/login', [
|
||||||
|
'email' => $adminApp->email,
|
||||||
|
'password' => 'admin-password',
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('personal_access_tokens', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shared_email_authenticates_the_scanner_identity_only(): void
|
||||||
|
{
|
||||||
|
$scannerRole = Role::query()->create([
|
||||||
|
'codigo' => RoleCode::Scanner->value,
|
||||||
|
'nombre' => 'Scanner',
|
||||||
|
]);
|
||||||
|
$adminAppRole = Role::query()->create([
|
||||||
|
'codigo' => RoleCode::AdminApp->value,
|
||||||
|
'nombre' => 'Administrador',
|
||||||
|
]);
|
||||||
|
$permission = Permission::query()->create([
|
||||||
|
'codigo' => PermissionCode::ScanTickets->value,
|
||||||
|
'nombre' => 'Escanear tickets',
|
||||||
|
]);
|
||||||
|
$scannerRole->permissions()->attach($permission->codigo);
|
||||||
|
$tenant = $this->createTenant();
|
||||||
|
User::factory()->create([
|
||||||
|
'email' => 'shared@example.com',
|
||||||
|
'password' => Hash::make('admin-password'),
|
||||||
|
'rol_codigo' => $adminAppRole->codigo,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
$scanner = User::factory()->create([
|
||||||
|
'email' => 'shared@example.com',
|
||||||
|
'password' => Hash::make('scanner-password'),
|
||||||
|
'rol_codigo' => $scannerRole->codigo,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/scanner/login', [
|
||||||
|
'email' => 'shared@example.com',
|
||||||
|
'password' => 'scanner-password',
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('user.id', $scanner->id)
|
||||||
|
->assertJsonPath('user.rol_codigo', RoleCode::Scanner->value);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_rejects_a_user_without_scan_permission(): void
|
public function test_it_rejects_a_user_without_scan_permission(): void
|
||||||
{
|
{
|
||||||
$role = Role::query()->create([
|
$role = Role::query()->create([
|
||||||
'codigo' => RoleCode::Scanner->value,
|
'codigo' => RoleCode::Scanner->value,
|
||||||
'nombre' => 'Scanner sin permiso',
|
'nombre' => 'Scanner sin permiso',
|
||||||
]);
|
]);
|
||||||
$tenant = Tenant::query()->create([
|
$tenant = $this->createTenant();
|
||||||
'codigo' => 'acme',
|
|
||||||
'nombre' => 'Acme',
|
|
||||||
'dominio' => 'acme.test',
|
|
||||||
]);
|
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
'email' => 'customer@example.com',
|
'email' => 'customer@example.com',
|
||||||
'password' => Hash::make('secret123'),
|
'password' => Hash::make('secret123'),
|
||||||
@@ -92,11 +152,7 @@ class ScannerLoginControllerTest extends TestCase
|
|||||||
'nombre' => 'Escanear tickets',
|
'nombre' => 'Escanear tickets',
|
||||||
]);
|
]);
|
||||||
$role->permissions()->attach($permission->codigo);
|
$role->permissions()->attach($permission->codigo);
|
||||||
$tenant = Tenant::query()->create([
|
$tenant = $this->createTenant();
|
||||||
'codigo' => 'acme',
|
|
||||||
'nombre' => 'Acme',
|
|
||||||
'dominio' => 'acme.test',
|
|
||||||
]);
|
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
'email' => 'scanner@example.com',
|
'email' => 'scanner@example.com',
|
||||||
'password' => Hash::make('correct-password'),
|
'password' => Hash::make('correct-password'),
|
||||||
@@ -134,11 +190,7 @@ class ScannerLoginControllerTest extends TestCase
|
|||||||
'nombre' => 'Escanear tickets',
|
'nombre' => 'Escanear tickets',
|
||||||
]);
|
]);
|
||||||
$role->permissions()->attach($permission->codigo);
|
$role->permissions()->attach($permission->codigo);
|
||||||
$tenant = Tenant::query()->create([
|
$tenant = $this->createTenant();
|
||||||
'codigo' => 'acme',
|
|
||||||
'nombre' => 'Acme',
|
|
||||||
'dominio' => 'acme.test',
|
|
||||||
]);
|
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
'email' => 'scanner@example.com',
|
'email' => 'scanner@example.com',
|
||||||
'password' => Hash::make('correct-password'),
|
'password' => Hash::make('correct-password'),
|
||||||
@@ -163,4 +215,30 @@ class ScannerLoginControllerTest extends TestCase
|
|||||||
&& $event->channel === PasswordResetRequested::CHANNEL_SCANNER,
|
&& $event->channel === PasswordResetRequested::CHANNEL_SCANNER,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createTenant(): Tenant
|
||||||
|
{
|
||||||
|
$logo = Attachment::query()->create([
|
||||||
|
'key' => (string) Str::uuid(),
|
||||||
|
'path' => 'tests/scanner-login-logo.png',
|
||||||
|
'filename' => 'scanner-login-logo.png',
|
||||||
|
'type' => 'image',
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'extension' => 'png',
|
||||||
|
'size' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.test',
|
||||||
|
'primary_color' => '#000000',
|
||||||
|
'secondary_color' => '#000000',
|
||||||
|
'danger_color' => '#000000',
|
||||||
|
'header_bg_color' => '#000000',
|
||||||
|
'footer_bg_color' => '#000000',
|
||||||
|
'header_logo_id' => $logo->id,
|
||||||
|
'footer_logo_id' => $logo->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Authorization\Enums\PermissionCode;
|
use App\Domains\Authorization\Enums\PermissionCode;
|
||||||
use App\Domains\Authorization\Enums\RoleCode;
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Domains\Authorization\Models\Permission;
|
use App\Domains\Authorization\Models\Permission;
|
||||||
use App\Domains\Authorization\Models\Role;
|
use App\Domains\Authorization\Models\Role;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Menu\Models\Menu;
|
use App\Domains\Menu\Models\Menu;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -28,11 +31,7 @@ class ScannerMeControllerTest extends TestCase
|
|||||||
'nombre' => 'Escanear tickets',
|
'nombre' => 'Escanear tickets',
|
||||||
]);
|
]);
|
||||||
$scannerRole->permissions()->attach($permission->codigo);
|
$scannerRole->permissions()->attach($permission->codigo);
|
||||||
$tenant = Tenant::query()->create([
|
$tenant = $this->createTenant();
|
||||||
'codigo' => 'acme',
|
|
||||||
'nombre' => 'Acme',
|
|
||||||
'dominio' => 'acme.test',
|
|
||||||
]);
|
|
||||||
$home = Menu::query()->create([
|
$home = Menu::query()->create([
|
||||||
'code' => 'scanner.inicio',
|
'code' => 'scanner.inicio',
|
||||||
'label' => 'Inicio',
|
'label' => 'Inicio',
|
||||||
@@ -65,4 +64,110 @@ class ScannerMeControllerTest extends TestCase
|
|||||||
->assertJsonCount(2, 'data.tenant.menues')
|
->assertJsonCount(2, 'data.tenant.menues')
|
||||||
->assertJsonMissing(['code' => $foreign->code]);
|
->assertJsonMissing(['code' => $foreign->code]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_an_adminapp_user_even_with_scan_permission(): void
|
||||||
|
{
|
||||||
|
$adminAppRole = Role::query()->create([
|
||||||
|
'codigo' => RoleCode::AdminApp->value,
|
||||||
|
'nombre' => 'Administrador',
|
||||||
|
]);
|
||||||
|
$permission = Permission::query()->create([
|
||||||
|
'codigo' => PermissionCode::ScanTickets->value,
|
||||||
|
'nombre' => 'Escanear tickets',
|
||||||
|
]);
|
||||||
|
$adminAppRole->permissions()->attach($permission->codigo);
|
||||||
|
$tenant = $this->createTenant();
|
||||||
|
$adminApp = User::factory()->create([
|
||||||
|
'rol_codigo' => $adminAppRole->codigo,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs($adminApp);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/scanner/me')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_assigned_scan_categories_when_validation_is_enabled(): void
|
||||||
|
{
|
||||||
|
$this->createScannerRole();
|
||||||
|
$tenant = $this->createTenant();
|
||||||
|
$scanner = User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::Scanner->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
$meals = Category::query()->create(['tenant_code' => $tenant->codigo, 'nombre' => 'Comidas']);
|
||||||
|
$entries = Category::query()->create(['tenant_code' => $tenant->codigo, 'nombre' => 'Entradas']);
|
||||||
|
$scanner->scanCategories()->attach([$meals->id, $entries->id]);
|
||||||
|
Sanctum::actingAs($scanner);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/scanner/me')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.user.categories.0.id', $meals->id)
|
||||||
|
->assertJsonPath('data.user.categories.0.nombre', 'Comidas')
|
||||||
|
->assertJsonPath('data.user.categories.1.id', $entries->id)
|
||||||
|
->assertJsonPath('data.user.categories.1.nombre', 'Entradas');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_omits_scan_categories_when_validation_is_disabled_or_none_are_assigned(): void
|
||||||
|
{
|
||||||
|
$this->createScannerRole();
|
||||||
|
$tenant = $this->createTenant();
|
||||||
|
$scanner = User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::Scanner->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs($scanner);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/scanner/me')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonMissingPath('data.user.categories');
|
||||||
|
|
||||||
|
$category = Category::query()->create(['tenant_code' => $tenant->codigo, 'nombre' => 'Entradas']);
|
||||||
|
$scanner->scanCategories()->attach($category);
|
||||||
|
$tenant->update(['scanner_category_validation_enabled' => false]);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/scanner/me')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonMissingPath('data.user.categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createScannerRole(): Role
|
||||||
|
{
|
||||||
|
$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);
|
||||||
|
|
||||||
|
return $role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTenant(): Tenant
|
||||||
|
{
|
||||||
|
$logo = Attachment::query()->create([
|
||||||
|
'key' => (string) Str::uuid(),
|
||||||
|
'path' => 'tests/scanner-me-logo.png',
|
||||||
|
'filename' => 'scanner-me-logo.png',
|
||||||
|
'type' => 'image',
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'extension' => 'png',
|
||||||
|
'size' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.test',
|
||||||
|
'primary_color' => '#000000',
|
||||||
|
'secondary_color' => '#000000',
|
||||||
|
'danger_color' => '#000000',
|
||||||
|
'header_bg_color' => '#000000',
|
||||||
|
'footer_bg_color' => '#000000',
|
||||||
|
'header_logo_id' => $logo->id,
|
||||||
|
'footer_logo_id' => $logo->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user