Compare commits
5 Commits
3a039a6055
...
b294e5c46e
| Author | SHA1 | Date | |
|---|---|---|---|
| b294e5c46e | |||
| d02b0c5551 | |||
| ee911171be | |||
| e17d1fa15e | |||
| 2a15dc92be |
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable(['user_id', 'codigo', 'status'])]
|
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
||||||
#[Hidden(['codigo'])]
|
#[Hidden(['codigo'])]
|
||||||
class ResetPasswordAttempt extends Model
|
class ResetPasswordAttempt extends Model
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ namespace App\Domains\Auth\Models;
|
|||||||
|
|
||||||
use App\Domains\Authorization\Enums\RoleCode;
|
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\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
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;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
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\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
@@ -59,6 +61,17 @@ class User extends Authenticatable
|
|||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<Category, $this> */
|
||||||
|
public function scanCategories(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
Category::class,
|
||||||
|
'category_scanners',
|
||||||
|
'user_id',
|
||||||
|
'categoria_id',
|
||||||
|
)->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ use App\Domains\Authorization\Enums\RoleCode;
|
|||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class PasswordLoginService
|
class PasswordLoginService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws AccountLockedException
|
* @throws AccountLockedException
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
@@ -118,7 +123,7 @@ class PasswordLoginService
|
|||||||
|
|
||||||
if ($user === null || ! Hash::check($password, $user->password)) {
|
if ($user === null || ! Hash::check($password, $user->password)) {
|
||||||
if ($user !== null) {
|
if ($user !== null) {
|
||||||
$this->registerFailure($user, $now);
|
$this->registerFailure($user, $now, $tenantCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
$outcome = $user?->locked_until?->isFuture()
|
$outcome = $user?->locked_until?->isFuture()
|
||||||
@@ -177,7 +182,7 @@ class PasswordLoginService
|
|||||||
return $result['user'];
|
return $result['user'];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function registerFailure(User $user, CarbonImmutable $now): void
|
private function registerFailure(User $user, CarbonImmutable $now, string $tenantCode): void
|
||||||
{
|
{
|
||||||
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
|
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
|
||||||
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
|
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
|
||||||
@@ -189,6 +194,8 @@ class PasswordLoginService
|
|||||||
? $user->failed_login_attempts + 1
|
? $user->failed_login_attempts + 1
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
|
$previousAttempts = $user->failed_login_attempts;
|
||||||
|
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'failed_login_attempts' => $attempts,
|
'failed_login_attempts' => $attempts,
|
||||||
'last_failed_login_at' => $now,
|
'last_failed_login_at' => $now,
|
||||||
@@ -196,6 +203,17 @@ class PasswordLoginService
|
|||||||
? $now->addMinutes($lockMinutes)
|
? $now->addMinutes($lockMinutes)
|
||||||
: null,
|
: null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
|
||||||
|
try {
|
||||||
|
$this->resetPasswordAttemptService->createForEmail($user->email, $tenantCode, 'account_locked');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Failed to trigger reset password on account lock', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'exception' => $e
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordAttempt(
|
private function recordAttempt(
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ use Throwable;
|
|||||||
|
|
||||||
class ResetPasswordAttemptService
|
class ResetPasswordAttemptService
|
||||||
{
|
{
|
||||||
public function createForEmail(string $email, string $tenantCode): void
|
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint): ?int {
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('email', $email)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -39,6 +39,7 @@ class ResetPasswordAttemptService
|
|||||||
|
|
||||||
$attempt = $user->resetPasswordAttempts()->create([
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
'codigo' => $this->generateCode(),
|
'codigo' => $this->generateCode(),
|
||||||
|
'reason' => $reason,
|
||||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ enum RoleCode: string
|
|||||||
{
|
{
|
||||||
case Admin = 'admin';
|
case Admin = 'admin';
|
||||||
case AdminApp = 'adminapp';
|
case AdminApp = 'adminapp';
|
||||||
|
case Scanner = 'scanner';
|
||||||
case User = 'user';
|
case User = 'user';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
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;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -64,4 +66,15 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(CatalogItem::class);
|
return $this->hasMany(CatalogItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<User, $this> */
|
||||||
|
public function scanners(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
User::class,
|
||||||
|
'category_scanners',
|
||||||
|
'categoria_id',
|
||||||
|
'user_id',
|
||||||
|
)->withTimestamps();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,25 +95,7 @@ class Variant extends Model
|
|||||||
|
|
||||||
public function getName(): string
|
public function getName(): string
|
||||||
{
|
{
|
||||||
$name = $this->catalogItem->nombre;
|
return $this->catalogItem->nombre;
|
||||||
$this->loadMissing(['definitions.itemAttribute.attribute', 'eventDate']);
|
|
||||||
$definitions = $this->definitions
|
|
||||||
->map(function (VariantDefinition $definition): ?string {
|
|
||||||
$attributeName = $definition->itemAttribute?->attribute?->nombre;
|
|
||||||
|
|
||||||
return $attributeName
|
|
||||||
? "{$attributeName}: {$definition->value}"
|
|
||||||
: $definition->value;
|
|
||||||
})
|
|
||||||
->filter();
|
|
||||||
|
|
||||||
if ($this->eventDate !== null) {
|
|
||||||
$definitions->push('Fecha: '.$this->eventDate->date->format('Y-m-d'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$description = $definitions->implode(', ');
|
|
||||||
|
|
||||||
return $description === '' ? $name : "{$name} ({$description})";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMinimumUseDate(): ?CarbonInterface
|
public function getMinimumUseDate(): ?CarbonInterface
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\StaffFormResource;
|
||||||
|
use App\Domains\Forms\Services\StaffFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class StaffFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected StaffFormService $staffFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): StaffFormResource
|
||||||
|
{
|
||||||
|
return StaffFormResource::make(
|
||||||
|
$this->staffFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Domains/Forms/Resources/StaffFormResource.php
Normal file
20
app/Domains/Forms/Resources/StaffFormResource.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class StaffFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'categories' => $this->resource['categories']->map(fn ($category) => [
|
||||||
|
'id' => $category->id,
|
||||||
|
'nombre' => $category->nombre,
|
||||||
|
])->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Domains/Forms/Services/StaffFormService.php
Normal file
27
app/Domains/Forms/Services/StaffFormService.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class StaffFormService
|
||||||
|
{
|
||||||
|
/** @return array{categories: Collection<int, Category>} */
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'categories' => Category::query()
|
||||||
|
->whereNull('categoria_id')
|
||||||
|
->where(function (Builder $query) use ($tenant): void {
|
||||||
|
$query->where('tenant_code', $tenant->codigo)
|
||||||
|
->orWhereHas('catalogItems', fn (Builder $items) => $items
|
||||||
|
->where('tenant_code', $tenant->codigo));
|
||||||
|
})
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
|
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
|
||||||
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/forms')
|
Route::prefix('v1/adminapp/forms')
|
||||||
@@ -9,4 +10,5 @@ Route::prefix('v1/adminapp/forms')
|
|||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('event', EventFormController::class);
|
Route::get('event', EventFormController::class);
|
||||||
Route::get('sale', SaleFormController::class);
|
Route::get('sale', SaleFormController::class);
|
||||||
|
Route::get('staff', StaffFormController::class);
|
||||||
});
|
});
|
||||||
|
|||||||
49
app/Domains/Staff/Controllers/AdminAppStaffController.php
Normal file
49
app/Domains/Staff/Controllers/AdminAppStaffController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Staff\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Staff\Requests\StoreStaffRequest;
|
||||||
|
use App\Domains\Staff\Requests\UpdateStaffRequest;
|
||||||
|
use App\Domains\Staff\Resources\StaffResource;
|
||||||
|
use App\Domains\Staff\Services\StaffService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AdminAppStaffController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly StaffService $staffService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return StaffResource::collection($this->staffService->list(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->string('search')->trim()->toString() ?: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreStaffRequest $request): StaffResource
|
||||||
|
{
|
||||||
|
return StaffResource::make($this->staffService->create(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->validated(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateStaffRequest $request, int $staff): StaffResource
|
||||||
|
{
|
||||||
|
return StaffResource::make($this->staffService->update(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$staff,
|
||||||
|
$request->validated(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $staff): Response
|
||||||
|
{
|
||||||
|
$this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Staff/Requests/StoreStaffRequest.php
Normal file
26
app/Domains/Staff/Requests/StoreStaffRequest.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Staff\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreStaffRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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', 'unique:users,email'],
|
||||||
|
'category_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'category_ids.*' => ['required', 'integer', 'distinct', Rule::exists('categorias', 'id')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Domains/Staff/Requests/UpdateStaffRequest.php
Normal file
33
app/Domains/Staff/Requests/UpdateStaffRequest.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Staff\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateStaffRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$staffId = (int) $this->route('staff');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'email')->ignore($staffId),
|
||||||
|
],
|
||||||
|
'category_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'category_ids.*' => ['required', 'integer', 'distinct', Rule::exists('categorias', 'id')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Domains/Staff/Resources/StaffResource.php
Normal file
32
app/Domains/Staff/Resources/StaffResource.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Staff\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin User */
|
||||||
|
class StaffResource 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,
|
||||||
|
]),
|
||||||
|
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
|
||||||
|
->map(fn ($category) => [
|
||||||
|
'id' => $category->id,
|
||||||
|
'nombre' => $category->nombre,
|
||||||
|
])->values()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/Domains/Staff/Services/StaffService.php
Normal file
113
app/Domains/Staff/Services/StaffService.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Staff\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
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 StaffService
|
||||||
|
{
|
||||||
|
/** @return Collection<int, User> */
|
||||||
|
public function list(Tenant $tenant, ?string $search = null): Collection
|
||||||
|
{
|
||||||
|
return $this->staffQuery($tenant)
|
||||||
|
->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')])
|
||||||
|
->when($search, function (Builder $query, string $search): void {
|
||||||
|
$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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Category> */
|
||||||
|
private function assignableCategories(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
return Category::query()
|
||||||
|
->whereNull('categoria_id')
|
||||||
|
->where(function (Builder $query) use ($tenant): void {
|
||||||
|
$query->where('tenant_code', $tenant->codigo)
|
||||||
|
->orWhereHas('catalogItems', fn (Builder $items) => $items
|
||||||
|
->where('tenant_code', $tenant->codigo));
|
||||||
|
})
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function create(Tenant $tenant, array $data): User
|
||||||
|
{
|
||||||
|
$this->assertCategoriesBelongToTenant($tenant, $data['category_ids']);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($tenant, $data): User {
|
||||||
|
$staff = User::query()->create([
|
||||||
|
...Arr::only($data, ['nombre_apellido', 'dni', 'email']),
|
||||||
|
'email' => mb_strtolower(trim((string) $data['email'])),
|
||||||
|
'password' => Str::random(64),
|
||||||
|
'rol_codigo' => RoleCode::Scanner->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
$staff->scanCategories()->sync($data['category_ids']);
|
||||||
|
|
||||||
|
return $staff->load('role', 'scanCategories');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function update(Tenant $tenant, int $staffId, array $data): User
|
||||||
|
{
|
||||||
|
$staff = $this->find($tenant, $staffId);
|
||||||
|
$this->assertCategoriesBelongToTenant($tenant, $data['category_ids']);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($staff, $data): User {
|
||||||
|
$attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']);
|
||||||
|
$attributes['email'] = mb_strtolower(trim((string) $data['email']));
|
||||||
|
$staff->update($attributes);
|
||||||
|
$staff->scanCategories()->sync($data['category_ids']);
|
||||||
|
|
||||||
|
return $staff->load('role', 'scanCategories');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Tenant $tenant, int $staffId): void
|
||||||
|
{
|
||||||
|
$this->find($tenant, $staffId)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(Tenant $tenant, int $staffId): User
|
||||||
|
{
|
||||||
|
return $this->staffQuery($tenant)->findOrFail($staffId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function staffQuery(Tenant $tenant): Builder
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('rol_codigo', RoleCode::Scanner->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, int> $categoryIds */
|
||||||
|
private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds): void
|
||||||
|
{
|
||||||
|
$validIds = $this->assignableCategories($tenant)
|
||||||
|
->whereIn('id', $categoryIds)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
if ($validIds->count() !== count($categoryIds)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'category_ids' => 'Una o más categorías no pertenecen al tenant.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Domains/Staff/routes/api.php
Normal file
10
app/Domains/Staff/routes/api.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Staff\Controllers\AdminAppStaffController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::apiResource('staff', AdminAppStaffController::class)->except('show');
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
'starts_at',
|
'starts_at',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
'used_at',
|
'used_at',
|
||||||
|
'scanner_user_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
])]
|
])]
|
||||||
class Ticket extends Model
|
class Ticket extends Model
|
||||||
@@ -47,6 +48,7 @@ class Ticket extends Model
|
|||||||
'starts_at' => 'datetime',
|
'starts_at' => 'datetime',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'used_at' => 'datetime',
|
'used_at' => 'datetime',
|
||||||
|
'scanner_user_id' => 'integer',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,12 @@ class Ticket extends Model
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function scannerUser(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'scanner_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Purchase, $this> */
|
/** @return BelongsTo<Purchase, $this> */
|
||||||
public function sourcePurchase(): BelongsTo
|
public function sourcePurchase(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class TicketResource extends JsonResource
|
|||||||
'starts_at' => $this->getEffectiveStartsAt(),
|
'starts_at' => $this->getEffectiveStartsAt(),
|
||||||
'expires_at' => $this->getEffectiveExpiresAt(),
|
'expires_at' => $this->getEffectiveExpiresAt(),
|
||||||
'used_at' => $this->used_at,
|
'used_at' => $this->used_at,
|
||||||
|
'scanner_user_id' => $this->scanner_user_id,
|
||||||
'is_valid' => $this->is_valid,
|
'is_valid' => $this->is_valid,
|
||||||
'is_expired' => $this->is_expired,
|
'is_expired' => $this->is_expired,
|
||||||
'is_used' => $this->is_used,
|
'is_used' => $this->is_used,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('category_scanners', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnUpdate()->cascadeOnDelete();
|
||||||
|
$table->foreignId('categoria_id')->constrained('categorias')->cascadeOnUpdate()->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'categoria_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('category_scanners');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('scanner_user_id')
|
||||||
|
->nullable()
|
||||||
|
->after('used_at')
|
||||||
|
->constrained('users')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('scanner_user_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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->string('reason')->default('manual')->after('codigo');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('reason');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -81,6 +81,10 @@ class AuthorizationSeeder extends Seeder
|
|||||||
'nombre' => 'Gestionar tickets',
|
'nombre' => 'Gestionar tickets',
|
||||||
'descripcion' => 'Permite emitir, invalidar o regenerar tickets.',
|
'descripcion' => 'Permite emitir, invalidar o regenerar tickets.',
|
||||||
],
|
],
|
||||||
|
'tickets.escanear' => [
|
||||||
|
'nombre' => 'Escanear tickets',
|
||||||
|
'descripcion' => 'Permite validar y consumir tickets de las categorías asignadas al usuario.',
|
||||||
|
],
|
||||||
'contenido.gestionar' => [
|
'contenido.gestionar' => [
|
||||||
'nombre' => 'Gestionar contenido',
|
'nombre' => 'Gestionar contenido',
|
||||||
'descripcion' => 'Permite administrar menús, carruseles, destacados y redes sociales.',
|
'descripcion' => 'Permite administrar menús, carruseles, destacados y redes sociales.',
|
||||||
@@ -117,6 +121,11 @@ class AuthorizationSeeder extends Seeder
|
|||||||
'descripcion' => 'Accede a los menús administrativos de la aplicación.',
|
'descripcion' => 'Accede a los menús administrativos de la aplicación.',
|
||||||
'permisos' => [],
|
'permisos' => [],
|
||||||
],
|
],
|
||||||
|
RoleCode::Scanner->value => [
|
||||||
|
'nombre' => 'Scanner',
|
||||||
|
'descripcion' => 'Valida y consume tickets de las categorías que tiene asignadas.',
|
||||||
|
'permisos' => ['tickets.escanear'],
|
||||||
|
],
|
||||||
RoleCode::User->value => [
|
RoleCode::User->value => [
|
||||||
'nombre' => 'Usuario',
|
'nombre' => 'Usuario',
|
||||||
'descripcion' => 'Cliente final limitado a sus propios datos y operaciones.',
|
'descripcion' => 'Cliente final limitado a sus propios datos y operaciones.',
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
Recuperá tu contraseña
|
Recuperá tu contraseña
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
@if($attempt->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>
|
<p>
|
||||||
Hola {{ $attempt->user->nombre_apellido }}, recibimos una solicitud para restablecer
|
Hola {{ $attempt->user->nombre_apellido }}, recibimos una solicitud para restablecer
|
||||||
la contraseña de tu cuenta.
|
la contraseña de tu cuenta.
|
||||||
</p>
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
|
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
|
||||||
|
|
||||||
@@ -15,6 +21,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$recoveryUrl = 'https://' . $tenant->dominio . '/recuperar-contrasena/codigo?email=' . urlencode($attempt->user->email);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-bottom: 28px;">
|
||||||
|
<a href="{{ $recoveryUrl }}"
|
||||||
|
style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||||
|
Ingresar código ahora
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p style="color: #64748b; font-size: 14px;">
|
<p style="color: #64748b; font-size: 14px;">
|
||||||
|
@if($attempt->reason === 'account_locked')
|
||||||
|
Si no fuiste vos, por favor desestimá y borrá este correo. Tu cuenta seguirá protegida.
|
||||||
|
@else
|
||||||
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
|
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
|
||||||
|
@endif
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ 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/Bootstrap/routes/api.php';
|
require __DIR__.'/../app/Domains/Bootstrap/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';
|
||||||
|
|||||||
84
tests/Feature/Forms/AdminAppStaffFormControllerTest.php
Normal file
84
tests/Feature/Forms/AdminAppStaffFormControllerTest.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Forms;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAppStaffFormControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(AuthorizationSeeder::class);
|
||||||
|
WebsiteType::query()->create([
|
||||||
|
'codigo' => 'onticket',
|
||||||
|
'nombre' => 'OnTicket',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authentication_is_required(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/staff')->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_adminapp_user_gets_only_its_tenant_categories(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$otherTenant = $this->createTenant('other');
|
||||||
|
$category = Category::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'nombre' => 'Bebidas',
|
||||||
|
]);
|
||||||
|
Category::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'categoria_id' => $category->id,
|
||||||
|
'nombre' => 'Gaseosas',
|
||||||
|
]);
|
||||||
|
Category::query()->create([
|
||||||
|
'tenant_code' => $otherTenant->codigo,
|
||||||
|
'nombre' => 'Privada',
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/staff')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data.categories')
|
||||||
|
->assertJsonPath('data.categories.0.id', $category->id)
|
||||||
|
->assertJsonPath('data.categories.0.nombre', 'Bebidas')
|
||||||
|
->assertJsonMissingPath('data.categories.0.categoria_id')
|
||||||
|
->assertJsonMissingPath('data.roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_customer_cannot_get_staff_form(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/staff')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTenant(string $code): Tenant
|
||||||
|
{
|
||||||
|
return Tenant::query()->create([
|
||||||
|
'codigo' => $code,
|
||||||
|
'nombre' => ucfirst($code),
|
||||||
|
'dominio' => "{$code}.test",
|
||||||
|
'website_type_code' => 'onticket',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
tests/Feature/Staff/StaffControllerTest.php
Normal file
138
tests/Feature/Staff/StaffControllerTest.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Staff;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class StaffControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Tenant $tenant;
|
||||||
|
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(AuthorizationSeeder::class);
|
||||||
|
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
|
||||||
|
$this->tenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.test',
|
||||||
|
'website_type_code' => 'onticket',
|
||||||
|
]);
|
||||||
|
$this->admin = User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_adminapp_can_create_update_list_and_delete_staff_with_categories(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
$firstCategory = $this->createCategory('Bebidas');
|
||||||
|
$secondCategory = $this->createCategory('Comidas');
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/adminapp/tenant/staff', [
|
||||||
|
'nombre_apellido' => 'Ada Lovelace',
|
||||||
|
'dni' => '12345678',
|
||||||
|
'email' => 'ADA@example.test',
|
||||||
|
'category_ids' => [$firstCategory->id],
|
||||||
|
])->assertSuccessful()
|
||||||
|
->assertJsonPath('data.email', 'ada@example.test')
|
||||||
|
->assertJsonPath('data.role.codigo', RoleCode::Scanner->value)
|
||||||
|
->assertJsonPath('data.categories.0.id', $firstCategory->id);
|
||||||
|
|
||||||
|
$staffId = $response->json('data.id');
|
||||||
|
$this->assertDatabaseHas('category_scanners', [
|
||||||
|
'user_id' => $staffId,
|
||||||
|
'categoria_id' => $firstCategory->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/staff?search=ada')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data');
|
||||||
|
|
||||||
|
$this->putJson("/api/v1/adminapp/tenant/staff/{$staffId}", [
|
||||||
|
'nombre_apellido' => 'Ada Byron',
|
||||||
|
'dni' => '12345678',
|
||||||
|
'email' => 'ada@example.test',
|
||||||
|
'category_ids' => [$secondCategory->id],
|
||||||
|
])->assertOk()
|
||||||
|
->assertJsonPath('data.nombre_apellido', 'Ada Byron')
|
||||||
|
->assertJsonPath('data.categories.0.id', $secondCategory->id);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('category_scanners', [
|
||||||
|
'user_id' => $staffId,
|
||||||
|
'categoria_id' => $firstCategory->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/adminapp/tenant/staff/{$staffId}")->assertNoContent();
|
||||||
|
$this->assertDatabaseMissing('users', ['id' => $staffId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_cannot_assign_another_tenants_category(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
$otherTenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'other',
|
||||||
|
'nombre' => 'Other',
|
||||||
|
'dominio' => 'other.test',
|
||||||
|
'website_type_code' => 'onticket',
|
||||||
|
]);
|
||||||
|
$foreignCategory = Category::query()->create([
|
||||||
|
'tenant_code' => $otherTenant->codigo,
|
||||||
|
'nombre' => 'Privada',
|
||||||
|
]);
|
||||||
|
$payload = [
|
||||||
|
'nombre_apellido' => 'Grace Hopper',
|
||||||
|
'dni' => '87654321',
|
||||||
|
'email' => 'grace@example.test',
|
||||||
|
'category_ids' => [$foreignCategory->id],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/staff', $payload)
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('category_ids');
|
||||||
|
|
||||||
|
$parent = $this->createCategory('Local');
|
||||||
|
$child = Category::query()->create([
|
||||||
|
'tenant_code' => $this->tenant->codigo,
|
||||||
|
'categoria_id' => $parent->id,
|
||||||
|
'nombre' => 'Subcategoría',
|
||||||
|
]);
|
||||||
|
$payload['category_ids'] = [$child->id];
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/staff', $payload)
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('category_ids');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_customer_cannot_manage_staff(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/staff')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createCategory(string $name): Category
|
||||||
|
{
|
||||||
|
return Category::query()->create([
|
||||||
|
'tenant_code' => $this->tenant->codigo,
|
||||||
|
'nombre' => $name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,7 +195,7 @@ class CatalogModelsTest extends TestCase
|
|||||||
$variant->setRelation('eventDate', $eventDate);
|
$variant->setRelation('eventDate', $eventDate);
|
||||||
$variant->setRelation('definitions', new EloquentCollection);
|
$variant->setRelation('definitions', new EloquentCollection);
|
||||||
|
|
||||||
$this->assertSame('Entrada General (Fecha: 2026-10-09)', $variant->getName());
|
$this->assertSame('Entrada General', $variant->getName());
|
||||||
$this->assertSame('2026-10-09 09:00:00', $variant->getMinimumUseDate()->format('Y-m-d H:i:s'));
|
$this->assertSame('2026-10-09 09:00:00', $variant->getMinimumUseDate()->format('Y-m-d H:i:s'));
|
||||||
$this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s'));
|
$this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class TicketTest extends TestCase
|
|||||||
'starts_at' => '2026-07-21 10:00:00',
|
'starts_at' => '2026-07-21 10:00:00',
|
||||||
'expires_at' => '2026-07-22 10:00:00',
|
'expires_at' => '2026-07-22 10:00:00',
|
||||||
'used_at' => null,
|
'used_at' => null,
|
||||||
|
'scanner_user_id' => '15',
|
||||||
'user_id' => '10',
|
'user_id' => '10',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -39,9 +40,11 @@ class TicketTest extends TestCase
|
|||||||
$this->assertInstanceOf(Carbon::class, $ticket->starts_at);
|
$this->assertInstanceOf(Carbon::class, $ticket->starts_at);
|
||||||
$this->assertInstanceOf(Carbon::class, $ticket->expires_at);
|
$this->assertInstanceOf(Carbon::class, $ticket->expires_at);
|
||||||
$this->assertNull($ticket->used_at);
|
$this->assertNull($ticket->used_at);
|
||||||
|
$this->assertSame(15, $ticket->scanner_user_id);
|
||||||
$this->assertSame(10, $ticket->user_id);
|
$this->assertSame(10, $ticket->user_id);
|
||||||
$this->assertInstanceOf(Tenant::class, $ticket->tenant()->getRelated());
|
$this->assertInstanceOf(Tenant::class, $ticket->tenant()->getRelated());
|
||||||
$this->assertInstanceOf(User::class, $ticket->user()->getRelated());
|
$this->assertInstanceOf(User::class, $ticket->user()->getRelated());
|
||||||
|
$this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated());
|
||||||
$this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated());
|
$this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated());
|
||||||
$this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated());
|
$this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user