Compare commits

..

3 Commits

14 changed files with 228 additions and 11 deletions

View File

@@ -13,16 +13,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[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
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
protected $attributes = [
'rol_codigo' => RoleCode::User->value,

View File

@@ -21,7 +21,13 @@ class RegisterUserRequest extends FormRequest
return [
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
'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', 'email')->whereNull('deleted_at'),
],
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
'dni' => ['nullable', 'string', 'max:255'],
'telefono' => ['nullable', 'string', 'max:255'],

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class UpdateProfileRequest extends FormRequest
{
@@ -19,11 +20,13 @@ class UpdateProfileRequest extends FormRequest
'email' => [
'required',
'email',
Rule::unique('users', 'email')->ignore($this->user()->id),
Rule::unique('users', 'email')
->whereNull('deleted_at')
->ignore($this->user()->id),
],
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
'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()],
];
}
}

View File

@@ -104,7 +104,10 @@ class InvitationPurchaseProvisioner
private function userId(DateTimeInterface $now): int
{
$user = DB::table('users')->where('email', self::USER_EMAIL)->first();
$user = DB::table('users')
->where('email', self::USER_EMAIL)
->whereNull('deleted_at')
->first();
if ($user !== null) {
if ($user->tenant_codigo !== self::TENANT_CODE) {

View File

@@ -23,7 +23,12 @@ class StoreStaffRequest extends FormRequest
return [
'nombre_apellido' => ['required', 'string', 'max:255'],
'dni' => ['required', 'string', 'max:50'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'email' => [
'required',
'email',
'max:255',
Rule::unique('users', 'email')->whereNull('deleted_at'),
],
'category_ids' => $categoryRules,
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
];

View File

@@ -28,7 +28,9 @@ class UpdateStaffRequest extends FormRequest
'required',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($staffId),
Rule::unique('users', 'email')
->whereNull('deleted_at')
->ignore($staffId),
],
'category_ids' => $categoryRules,
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],

View File

@@ -94,7 +94,12 @@ class StaffService
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

View File

@@ -78,7 +78,7 @@ class Ticket extends Model
/** @return BelongsTo<User, $this> */
public function scannerUser(): BelongsTo
{
return $this->belongsTo(User::class, 'scanner_user_id');
return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed();
}
/** @return BelongsTo<PurchaseItem, $this> */

View File

@@ -293,6 +293,7 @@ class AdminAppTicketService
'id' => 'tickets.id',
'amount' => $this->purchaseItemColumnQuery('precio_unitario'),
'scanned_by' => User::query()
->withTrashed()
->select('nombre_apellido')
->whereColumn('users.id', 'tickets.scanner_user_id'),
'product' => $tenant->codigo === 'fiesta_futbol_infantil'

View File

@@ -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();
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -135,4 +135,36 @@ class RegisterControllerTest extends TestCase
'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,
]);
}
}

View File

@@ -2,6 +2,8 @@
namespace Tests\Feature\Staff;
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;
@@ -9,9 +11,11 @@ use App\Domains\Catalog\Models\Category;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
@@ -30,11 +34,21 @@ class StaffControllerTest extends TestCase
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,
@@ -92,8 +106,56 @@ class StaffControllerTest extends TestCase
'categoria_id' => $firstCategory->id,
]);
$ticket = Ticket::query()->create([
'tenant_code' => $this->tenant->codigo,
'ticket' => (string) Str::uuid(),
'user_id' => $this->admin->id,
'used_at' => now(),
'scanner_user_id' => $staffId,
]);
$accessTokenId = User::query()
->findOrFail($staffId)
->createToken('scanner', ['scanner'])
->accessToken
->getKey();
$this->deleteJson("/api/v1/adminapp/tenant/staff/{$staffId}")->assertNoContent();
$this->assertDatabaseMissing('users', ['id' => $staffId]);
$this->assertSoftDeleted('users', ['id' => $staffId]);
$this->assertDatabaseHas('users', [
'id' => $staffId,
'email' => 'ada@example.test',
'active_email' => null,
]);
$this->assertDatabaseMissing('personal_access_tokens', ['id' => $accessTokenId]);
$this->assertDatabaseHas('category_scanners', [
'user_id' => $staffId,
'categoria_id' => $secondCategory->id,
]);
$this->assertSame($staffId, $ticket->fresh()->scanner_user_id);
$this->assertSame('Ada Byron', $ticket->fresh()->scannerUser?->nombre_apellido);
$this->getJson('/api/v1/adminapp/tenant/staff')
->assertOk()
->assertJsonCount(0, 'data');
$replacementResponse = $this->postJson('/api/v1/adminapp/tenant/staff', [
'nombre_apellido' => 'Nueva Ada',
'dni' => '11223344',
'email' => 'ADA@example.test',
'category_ids' => [$firstCategory->id],
])->assertSuccessful()
->assertJsonPath('data.email', 'ada@example.test');
$replacementStaffId = $replacementResponse->json('data.id');
$this->assertNotSame($staffId, $replacementStaffId);
$this->assertSame('Ada Byron', $ticket->fresh()->scannerUser?->nombre_apellido);
$this->assertDatabaseHas('users', [
'id' => $replacementStaffId,
'email' => 'ada@example.test',
'active_email' => 'ada@example.test',
'deleted_at' => null,
]);
}
public function test_admin_cannot_assign_another_tenants_category(): void
@@ -104,6 +166,14 @@ class StaffControllerTest extends TestCase
'nombre' => 'Other',
'dominio' => 'other.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' => $this->tenant->header_logo_id,
'footer_logo_id' => $this->tenant->footer_logo_id,
]);
$foreignCategory = Category::query()->create([
'tenant_code' => $otherTenant->codigo,
@@ -183,6 +253,16 @@ class StaffControllerTest extends TestCase
$this->getJson('/api/v1/adminapp/tenant/staff')->assertForbidden();
}
private function createAttachment(string $filename): Attachment
{
return Attachment::query()->create([
'path' => "test/{$filename}",
'filename' => $filename,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
private function createCategory(string $name): Category
{
return Category::query()->create([