Ver Fonte

feat: protege para que responsaveis por estudantes nao sejam menores de idade

Gustavo Mantovani há 1 dia atrás
pai
commit
1bfb648b74

+ 42 - 42
app/Http/Requests/StoreStudentRequest.php

@@ -2,68 +2,68 @@
 
 namespace App\Http\Requests;
 
+use App\Models\User;
 use App\Services\StudentRegistrationDraftService;
-use App\ValueObjects\Cpf;
-use Carbon\Carbon;
-use Illuminate\Foundation\Http\FormRequest;
 use Illuminate\Support\Facades\Auth;
+use Illuminate\Validation\Rule;
 use Illuminate\Validation\Validator;
 
-class StoreStudentRequest extends FormRequest
+class StoreStudentRequest extends StudentRequest
 {
+    private ?array $registrationDraft = null;
+
+    protected function prepareForValidation(): void
+    {
+        $token = $this->input('registration_draft_token');
+
+        $user = Auth::user();
+
+        if (!is_string($token) || !$user instanceof User) {
+            return;
+        }
+
+        $this->registrationDraft = app(StudentRegistrationDraftService::class)
+            ->findOwnedDraft($user, $token);
+
+        if ($this->registrationDraft !== null) {
+            // Os dados validados do rascunho são a fonte oficial. Assim, o
+            // payload final não consegue sobrescrevê-los sem nova validação.
+
+            $this->merge($this->registrationDraft['data']);
+        }
+    }
+
     public function rules(): array
     {
-        return [
-            'registration_draft_token'   => 'required|uuid',
-            'avatar'                     => 'sometimes|nullable|image',
-            'responsible'                => 'sometimes|nullable|array|required_array_keys:name,birth_date,cpf,degree,email,phone,postal_code,street,neighborhood,city_id,state_id',
-            'responsible.name'           => 'required_with:responsible|string|max:255',
-            'responsible.birth_date'     => 'required_with:responsible|date',
-            'responsible.cpf'            => ['required_with:responsible', 'string', 'max:20', Cpf::rule()],
-            'responsible.gender'         => 'sometimes|nullable|string|in:no_preference,male,female,other',
-            'responsible.degree'         => 'required_with:responsible|string|max:255',
-            'responsible.email'          => 'required_with:responsible|email|max:255|unique:student_responsibles,email',
-            'responsible.phone'          => 'required_with:responsible|string|max:20',
-            'responsible.postal_code'    => 'required_with:responsible|string|max:10',
-            'responsible.street'         => 'required_with:responsible|string|max:255',
-            'responsible.address_number' => 'sometimes|nullable|string|max:20',
-            'responsible.neighborhood'   => 'required_with:responsible|string|max:255',
-            'responsible.city_id'        => 'required_with:responsible|integer|exists:cities,id',
-            'responsible.state_id'       => 'required_with:responsible|integer|exists:states,id',
-            'responsible.complement'     => 'sometimes|nullable|string|max:255',
-            'responsible.notes'          => 'sometimes|nullable|string',
+        $rules = parent::rules();
+
+        $rules['registration_draft_token'] = [
+            'nullable',
+            'uuid',
+            Rule::requiredIf(fn (): bool => $this->studentIsMinor()),
         ];
+
+        $rules['responsible'][]      = 'required_array_keys:name,birth_date,cpf,degree,email,phone,postal_code,street,neighborhood,city_id,state_id';
+        $rules['responsible.email'] .= '|unique:student_responsibles,email';
+
+        return $rules;
     }
 
     public function after(): array
     {
         return [
             function (Validator $validator): void {
-                if ($validator->errors()->has('registration_draft_token')) {
-                    return;
-                }
-
-                $draft = app(StudentRegistrationDraftService::class)->findOwnedDraft(
-                    Auth::user(),
-                    $this->string('registration_draft_token')->toString(),
-                );
-
-                if ($draft === null) {
-                    $validator->errors()->add(
-                        'registration_draft_token',
-                        __('validation.registration_draft_invalid'),
-                    );
-
+                if ($this->input('registration_draft_token') === null) {
                     return;
                 }
 
                 if (
-                    Carbon::parse($draft['data']['birth_date'])->age < 18
-                    && empty($this->input('responsible'))
+                    !$validator->errors()->has('registration_draft_token')
+                    && $this->registrationDraft === null
                 ) {
                     $validator->errors()->add(
-                        'responsible',
-                        __('validation.minor_requires_responsible'),
+                        'registration_draft_token',
+                        __('validation.registration_draft_invalid'),
                     );
                 }
             },

+ 3 - 2
app/Http/Requests/StudentRequest.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Requests;
 
+use App\Rules\AdultBirthDate;
 use App\ValueObjects\Cpf;
 use Carbon\Carbon;
 use Illuminate\Foundation\Http\FormRequest;
@@ -43,7 +44,7 @@ public function rules(): array
             ];
 
             $rules['responsible.name']           = 'required_with:responsible|string|max:255';
-            $rules['responsible.birth_date']     = 'required_with:responsible|date';
+            $rules['responsible.birth_date']     = ['required_with:responsible', 'date', new AdultBirthDate];
             $rules['responsible.cpf']            = ['required_with:responsible', 'string', 'max:20', Cpf::rule()];
             $rules['responsible.gender']         = 'sometimes|nullable|string|in:no_preference,male,female,other';
             $rules['responsible.degree']         = 'required_with:responsible|string|max:255';
@@ -64,7 +65,7 @@ public function rules(): array
         return $rules;
     }
 
-    private function studentIsMinor(): bool
+    protected function studentIsMinor(): bool
     {
         $birthDate = $this->input('birth_date');
 

+ 2 - 1
app/Http/Requests/StudentResponsibleRequest.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Requests;
 
+use App\Rules\AdultBirthDate;
 use App\ValueObjects\Cpf;
 use Illuminate\Foundation\Http\FormRequest;
 
@@ -12,7 +13,7 @@ public function rules(): array
         return [
             'student_id'     => 'required|exists:students,id',
             'name'           => 'required|string|max:255',
-            'birth_date'     => 'required|date',
+            'birth_date'     => ['required', 'date', new AdultBirthDate],
             'cpf'            => ['required', 'string', 'max:20', Cpf::rule()],
             'gender'         => 'nullable|string|in:male,female,other,no_preference',
             'degree'         => 'required|string|max:255',

+ 12 - 0
app/Models/StudentResponsible.php

@@ -7,6 +7,7 @@
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Validation\ValidationException;
 
 /**
  * @property int $id
@@ -76,6 +77,17 @@ class StudentResponsible extends Model
         'deleted_at' => 'datetime',
     ];
 
+    protected static function booted(): void
+    {
+        static::saving(function (StudentResponsible $responsible): void {
+            if ($responsible->birth_date !== null && $responsible->birth_date->age < 18) {
+                throw ValidationException::withMessages([
+                    'birth_date' => __('validation.responsible_must_be_adult'),
+                ]);
+            }
+        });
+    }
+
     public function student(): BelongsTo
     {
         return $this->belongsTo(Student::class);

+ 25 - 0
app/Rules/AdultBirthDate.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Rules;
+
+use Carbon\Carbon;
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
+use Throwable;
+
+class AdultBirthDate implements ValidationRule
+{
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        try {
+            $isAdult = Carbon::parse($value)->age >= 18;
+        } catch (Throwable) {
+            // A regra `date` é responsável pela mensagem de data inválida.
+            return;
+        }
+
+        if (!$isAdult) {
+            $fail(__('validation.responsible_must_be_adult'));
+        }
+    }
+}

+ 21 - 0
app/Services/StudentRegistrationDraftService.php

@@ -149,6 +149,27 @@ public function consume(User $user, string $token, Closure $callback): mixed
         });
     }
 
+    public function executeWithoutDraft(User $user, array $data, Closure $callback): mixed
+    {
+        $unitId = $this->resolveUnitId($user);
+
+        return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
+            $data,
+            $unitId,
+            $callback,
+        ): mixed {
+            $this->validateDatabaseUniques($data);
+
+            foreach ($this->reservationKeys($data) as $field => $key) {
+                if (Cache::has($key)) {
+                    $this->uniqueReservationConflict($field);
+                }
+            }
+
+            return $callback($data, $unitId);
+        });
+    }
+
     private function resolveUnitId(User $user): int
     {
         $activeUnitId = request()->input('active_unit_id');

+ 33 - 14
app/Services/StudentService.php

@@ -93,12 +93,26 @@ public function findById(int $id): ?Student
 
     public function create(User $user, array $data): Student
     {
-        $token = $data['registration_draft_token'];
+        $token = $data['registration_draft_token'] ?? null;
 
         $responsibleData = $data['responsible'] ?? null;
 
         $avatar = $data['avatar'] ?? null;
 
+        unset($data['registration_draft_token'], $data['responsible']);
+
+        if ($token === null) {
+            return $this->registrationDraftService->executeWithoutDraft(
+                $user,
+                $data,
+                fn (array $studentData, int $unitId): Student => $this->persistStudent(
+                    $studentData,
+                    $responsibleData,
+                    $unitId,
+                ),
+            );
+        }
+
         return $this->registrationDraftService->consume(
             $user,
             $token,
@@ -107,23 +121,28 @@ function (array $studentData, int $unitId) use ($responsibleData, $avatar): Stud
                     $studentData['avatar'] = $avatar;
                 }
 
-                $studentData = $this->handlePhoto($studentData);
+                return $this->persistStudent($studentData, $responsibleData, $unitId);
+            },
+        );
+    }
+
+    private function persistStudent(array $data, ?array $responsibleData, int $unitId): Student
+    {
+        $data = $this->handlePhoto($data);
 
-                return DB::transaction(function () use ($studentData, $responsibleData, $unitId): Student {
-                    $student = (new Student)
-                        ->fill(array_merge($studentData, ['unit_id' => $unitId]))
-                        ->withResponsibleForCreation($responsibleData);
+        return DB::transaction(function () use ($data, $responsibleData, $unitId): Student {
+            $student = (new Student)
+                ->fill(array_merge($data, ['unit_id' => $unitId]))
+                ->withResponsibleForCreation($responsibleData);
 
-                    $student->save();
+            $student->save();
 
-                    if ($responsibleData !== null) {
-                        $student->responsibles()->create($responsibleData);
-                    }
+            if ($responsibleData !== null) {
+                $student->responsibles()->create($responsibleData);
+            }
 
-                    return $student->load('responsibles');
-                });
-            },
-        );
+            return $student->load('responsibles');
+        });
     }
 
     public function update(int $id, array $data): ?Student

+ 1 - 0
lang/en/validation.php

@@ -98,6 +98,7 @@
     'mac_address' => 'The :attribute field must be a valid MAC address.',
     'minor_requires_responsible' => 'A responsible adult is required for students under 18 years old.',
     'registration_draft_invalid' => 'The registration draft has expired or is invalid.',
+    'responsible_must_be_adult' => 'The guardian must be at least 18 years old.',
     'max' => [
         'array' => 'The :attribute field must not have more than :max items.',
         'file' => 'The :attribute field must not be greater than :max kilobytes.',

+ 1 - 0
lang/es/validation.php

@@ -98,6 +98,7 @@
     'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.',
     'minor_requires_responsible' => 'Es obligatorio informar un responsable para estudiantes menores de edad.',
     'registration_draft_invalid' => 'El borrador del registro ha caducado o no es válido.',
+    'responsible_must_be_adult' => 'El responsable debe tener al menos 18 años.',
     'max' => [
         'array' => 'El campo :attribute no debe tener más de :max elementos.',
         'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.',

+ 1 - 0
lang/pt/validation.php

@@ -99,6 +99,7 @@
     'mac_address' => 'O campo :attribute deve ser um endereço MAC válido.',
     'minor_requires_responsible' => 'É obrigatório informar um responsável para estudantes menores de idade.',
     'registration_draft_invalid' => 'O rascunho do cadastro expirou ou não é válido.',
+    'responsible_must_be_adult' => 'O responsável deve ter pelo menos 18 anos.',
     'max' => [
         'array' => 'O campo :attribute não deve ter mais de :max itens.',
         'file' => 'O campo :attribute não deve ser maior que :max kilobytes.',

+ 41 - 0
tests/Unit/Models/StudentResponsibleTest.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Tests\Unit\Models;
+
+use App\Models\StudentResponsible;
+use Illuminate\Validation\ValidationException;
+use Tests\TestCase;
+
+class StudentResponsibleTest extends TestCase
+{
+    public function test_it_rejects_a_minor_when_saving_the_model(): void
+    {
+        $responsible = new TestableStudentResponsible;
+
+        $responsible->birth_date = now()->subYears(17)->toDateString();
+
+        $this->expectException(ValidationException::class);
+        $this->expectExceptionMessage(__('validation.responsible_must_be_adult'));
+
+        $responsible->fireSavingEvent();
+    }
+
+    public function test_it_allows_an_adult_when_saving_the_model(): void
+    {
+        $responsible = new TestableStudentResponsible;
+
+        $responsible->birth_date = now()->subYears(18)->toDateString();
+
+        $responsible->fireSavingEvent();
+
+        $this->assertSame(18, $responsible->birth_date->age);
+    }
+}
+
+class TestableStudentResponsible extends StudentResponsible
+{
+    public function fireSavingEvent(): mixed
+    {
+        return $this->fireModelEvent('saving');
+    }
+}

+ 57 - 0
tests/Unit/Rules/AdultBirthDateTest.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Http\Requests\StudentRequest;
+use App\Http\Requests\StudentResponsibleRequest;
+use App\Rules\AdultBirthDate;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class AdultBirthDateTest extends TestCase
+{
+    public function test_it_rejects_a_birth_date_from_a_minor(): void
+    {
+        $validator = Validator::make(
+            ['birth_date' => now()->subYears(17)->toDateString()],
+            ['birth_date' => ['date', new AdultBirthDate]],
+        );
+
+        $this->assertTrue($validator->fails());
+
+        $this->assertSame(
+            __('validation.responsible_must_be_adult'),
+            $validator->errors()->first('birth_date'),
+        );
+    }
+
+    public function test_it_accepts_a_birth_date_from_an_adult(): void
+    {
+        $validator = Validator::make(
+            ['birth_date' => now()->subYears(18)->toDateString()],
+            ['birth_date' => ['date', new AdultBirthDate]],
+        );
+
+        $this->assertFalse($validator->fails());
+    }
+
+    public function test_it_is_used_by_both_responsible_requests(): void
+    {
+        $studentRules = StudentRequest::create('/', 'POST')->rules();
+
+        $responsibleRules = StudentResponsibleRequest::create('/', 'POST')->rules();
+
+        $studentAdultRules = array_filter(
+            $studentRules['responsible.birth_date'],
+            fn (mixed $rule): bool => $rule instanceof AdultBirthDate,
+        );
+
+        $responsibleAdultRules = array_filter(
+            $responsibleRules['birth_date'],
+            fn (mixed $rule): bool => $rule instanceof AdultBirthDate,
+        );
+
+        $this->assertCount(1, $studentAdultRules);
+        $this->assertCount(1, $responsibleAdultRules);
+    }
+}