Răsfoiți Sursa

feat: add obrigatoriedade para cadastrar responsavel quando student eh menor de idade

Gustavo Mantovani 1 zi în urmă
părinte
comite
7b9d0dcc1a

+ 2 - 1
.env.example

@@ -13,6 +13,7 @@ APP_MAINTENANCE_DRIVER=file
 APP_MAINTENANCE_STORE=database
 
 BCRYPT_ROUNDS=12
+STUDENT_REGISTRATION_DRAFT_TTL_MINUTES=15
 
 LOG_CHANNEL=stack
 LOG_STACK=single
@@ -66,4 +67,4 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
 
 ASAAS_BASE_URL=https://sandbox.asaas.com/api/v3
 ASAAS_API_KEY=
-ASAAS_WEBHOOK_TOKEN=
+ASAAS_WEBHOOK_TOKEN=

+ 13 - 2
app/Http/Controllers/StudentController.php

@@ -2,9 +2,12 @@
 
 namespace App\Http\Controllers;
 
-use App\Services\StudentService;
+use App\Http\Requests\StoreStudentRequest;
+use App\Http\Requests\StudentRegistrationDraftRequest;
 use App\Http\Requests\StudentRequest;
 use App\Http\Resources\StudentResource;
+use App\Services\StudentRegistrationDraftService;
+use App\Services\StudentService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Support\Facades\Auth;
 
@@ -12,6 +15,7 @@ class StudentController extends Controller
 {
     public function __construct(
         protected StudentService $service,
+        protected StudentRegistrationDraftService $registrationDraftService,
     ) {}
 
     public function index(): JsonResponse
@@ -43,7 +47,14 @@ public function franchisorSummary(): JsonResponse
         return $this->successResponse(payload: $summary);
     }
 
-    public function store(StudentRequest $request): JsonResponse
+    public function storeRegistrationDraft(StudentRegistrationDraftRequest $request): JsonResponse
+    {
+        $draft = $this->registrationDraftService->store(Auth::user(), $request->validated());
+
+        return $this->successResponse(payload: $draft);
+    }
+
+    public function store(StoreStudentRequest $request): JsonResponse
     {
         $item = $this->service->create(Auth::user(), $request->validated());
         return $this->successResponse(

+ 72 - 0
app/Http/Requests/StoreStudentRequest.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Services\StudentRegistrationDraftService;
+use App\ValueObjects\Cpf;
+use Carbon\Carbon;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Validation\Validator;
+
+class StoreStudentRequest extends FormRequest
+{
+    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',
+        ];
+    }
+
+    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'),
+                    );
+
+                    return;
+                }
+
+                if (
+                    Carbon::parse($draft['data']['birth_date'])->age < 18
+                    && empty($this->input('responsible'))
+                ) {
+                    $validator->errors()->add(
+                        'responsible',
+                        __('validation.minor_requires_responsible'),
+                    );
+                }
+            },
+        ];
+    }
+}

+ 32 - 0
app/Http/Requests/StudentRegistrationDraftRequest.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\ValueObjects\Cpf;
+use Illuminate\Foundation\Http\FormRequest;
+
+class StudentRegistrationDraftRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        return [
+            'registration_draft_token' => 'sometimes|nullable|uuid',
+            'name' => 'required|string|max:255',
+            'birth_date' => 'required|date',
+            'document_number' => ['sometimes', 'nullable', 'string', 'max:20', Cpf::rule()],
+            'gender' => 'sometimes|nullable|string|in:no_preference,male,female,other',
+            'email' => 'sometimes|nullable|email|unique:students,email',
+            'phone' => 'sometimes|nullable|string|max:20',
+            'postal_code' => 'sometimes|nullable|string|max:10',
+            'street' => 'sometimes|nullable|string|max:255',
+            'address_number' => 'sometimes|nullable|string|max:20',
+            'neighborhood' => 'sometimes|nullable|string|max:255',
+            'city_id' => 'sometimes|nullable|integer|exists:cities,id',
+            'state_id' => 'sometimes|nullable|integer|exists:states,id',
+            'complement' => 'sometimes|nullable|string|max:255',
+            'payer_name' => 'sometimes|nullable|string|max:255',
+            'how_did_you_know_us' => 'sometimes|nullable|string|in:referral,social_media,google,other',
+            'notes' => 'sometimes|nullable|string',
+        ];
+    }
+}

+ 23 - 23
app/Http/Requests/StudentRequest.php

@@ -15,7 +15,7 @@ public function rules(): array
         $rules = [
             'avatar'              => 'sometimes|nullable|image',
             'birth_date'          => 'sometimes|nullable|date',
-            'document_number'     => 'sometimes|nullable|string|max:20',
+            'document_number'     => ['sometimes', 'nullable', 'string', 'max:20', Cpf::rule()],
             'gender'              => 'sometimes|nullable|string|in:no_preference,male,female,other',
             'email'               => 'sometimes|nullable|email',
             'phone'               => 'sometimes|nullable|string|max:20',
@@ -29,34 +29,34 @@ public function rules(): array
             'payer_name'          => 'sometimes|nullable|string|max:255',
             'how_did_you_know_us' => 'sometimes|nullable|string|in:referral,social_media,google,other',
             'notes'               => 'sometimes|nullable|string',
-
-            'responsible' => [
-                'nullable',
-                'array',
-                Rule::requiredIf(fn (): bool => $this->studentIsMinor()),
-            ],
-
-            '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',
-            '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',
         ];
 
         if ($this->isMethod('post')) {
             $rules['name']       = 'required|string|max:255';
             $rules['birth_date'] = 'required|date';
             $rules['email']      = 'sometimes|nullable|email|unique:students,email';
+
+            $rules['responsible'] = [
+                'nullable',
+                'array',
+                Rule::requiredIf(fn (): bool => $this->studentIsMinor()),
+            ];
+
+            $rules['responsible.name']           = 'required_with:responsible|string|max:255';
+            $rules['responsible.birth_date']     = 'required_with:responsible|date';
+            $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';
+            $rules['responsible.email']          = 'required_with:responsible|email|max:255';
+            $rules['responsible.phone']          = 'required_with:responsible|string|max:20';
+            $rules['responsible.postal_code']    = 'required_with:responsible|string|max:10';
+            $rules['responsible.street']         = 'required_with:responsible|string|max:255';
+            $rules['responsible.address_number'] = 'sometimes|nullable|string|max:20';
+            $rules['responsible.neighborhood']   = 'required_with:responsible|string|max:255';
+            $rules['responsible.city_id']        = 'required_with:responsible|integer|exists:cities,id';
+            $rules['responsible.state_id']       = 'required_with:responsible|integer|exists:states,id';
+            $rules['responsible.complement']     = 'sometimes|nullable|string|max:255';
+            $rules['responsible.notes']          = 'sometimes|nullable|string';
         } else {
             $rules['name'] = 'sometimes|string|max:255';
         }

+ 7 - 5
app/Models/Student.php

@@ -2,6 +2,7 @@
 
 namespace App\Models;
 
+use App\ValueObjects\Cpf;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,7 +14,7 @@
  * @property int $id
  * @property string $name
  * @property \Illuminate\Support\Carbon|null $birth_date
- * @property string|null $document_number
+ * @property \App\ValueObjects\Cpf|null $document_number
  * @property string|null $gender
  * @property string|null $email
  * @property string|null $phone
@@ -82,10 +83,11 @@ class Student extends Model
     protected $guarded = ['id'];
 
     protected $casts = [
-        'birth_date' => 'date',
-        'created_at' => 'datetime',
-        'updated_at' => 'datetime',
-        'deleted_at' => 'datetime',
+        'birth_date'      => 'date',
+        'document_number' => Cpf::class,
+        'created_at'      => 'datetime',
+        'updated_at'      => 'datetime',
+        'deleted_at'      => 'datetime',
     ];
 
     protected static function booted(): void

+ 237 - 0
app/Services/StudentRegistrationDraftService.php

@@ -0,0 +1,237 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\User;
+use Closure;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
+use Illuminate\Validation\ValidationException;
+
+class StudentRegistrationDraftService
+{
+    private const CACHE_PREFIX = 'student-registration-draft:';
+
+    private const LOCK_KEY = 'student-registration-drafts:lock';
+
+    private const LOCK_SECONDS = 60;
+
+    private const UNIQUE_FIELDS = [
+        'email',
+    ];
+
+    public function store(User $user, array $data): array
+    {
+        $unitId = $this->resolveUnitId($user);
+
+        $requestedToken = $data['registration_draft_token'] ?? null;
+
+        unset($data['registration_draft_token']);
+
+        return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
+            $user,
+            $unitId,
+            $requestedToken,
+            $data,
+        ): array {
+            $existingDraft = null;
+
+            if ($requestedToken !== null) {
+                $existingDraft = $this->findOwnedDraft($user, $requestedToken, $unitId);
+
+                if ($existingDraft === null) {
+                    $this->invalidToken();
+                }
+            }
+
+            $token = $requestedToken ?? (string) Str::uuid();
+
+            $expiresAt = now()->addMinutes(config('students.registration_draft_ttl_minutes', 15));
+
+            $oldReservations = $existingDraft['reservations'] ?? [];
+
+            $newReservations = $this->reservationKeys($data);
+
+            $acquiredReservations = [];
+
+            $this->validateDatabaseUniques($data);
+
+            try {
+                foreach ($newReservations as $field => $key) {
+                    if (($oldReservations[$field] ?? null) === $key) {
+                        if (Cache::get($key) !== $token) {
+                            $this->invalidToken();
+                        }
+
+                        continue;
+                    }
+
+                    if (! Cache::add($key, $token, $expiresAt) && Cache::get($key) !== $token) {
+                        $this->uniqueReservationConflict($field);
+                    }
+
+                    $acquiredReservations[$field] = $key;
+                }
+            } catch (ValidationException $exception) {
+                $this->releaseReservations($acquiredReservations, $token);
+
+                throw $exception;
+            }
+
+            $removedReservations = array_diff($oldReservations, $newReservations);
+
+            $this->releaseReservations($removedReservations, $token);
+
+            foreach ($newReservations as $key) {
+                Cache::put($key, $token, $expiresAt);
+            }
+
+            $draft = [
+                'user_id'      => $user->id,
+                'unit_id'      => $unitId,
+                'data'         => $data,
+                'reservations' => $newReservations,
+                'expires_at'   => $expiresAt->toIso8601String(),
+            ];
+
+            Cache::put($this->draftKey($token), $draft, $expiresAt);
+
+            return [
+                'registration_draft_token' => $token,
+                'expires_at'               => $draft['expires_at'],
+            ];
+        });
+    }
+
+    public function findOwnedDraft(User $user, string $token, ?int $unitId = null): ?array
+    {
+        $draft = Cache::get($this->draftKey($token));
+
+        if (! is_array($draft)) {
+            return null;
+        }
+
+        $unitId ??= $this->resolveUnitId($user);
+
+        if ($draft['user_id'] !== $user->id || $draft['unit_id'] !== $unitId) {
+            return null;
+        }
+
+        return $draft;
+    }
+
+    public function consume(User $user, string $token, Closure $callback): mixed
+    {
+        $unitId = $this->resolveUnitId($user);
+
+        return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
+            $user,
+            $token,
+            $unitId,
+            $callback,
+        ): mixed {
+            $draft = $this->findOwnedDraft($user, $token, $unitId);
+
+            if ($draft === null || ! $this->ownsAllReservations($draft['reservations'], $token)) {
+                $this->invalidToken();
+            }
+
+            $this->validateDatabaseUniques($draft['data']);
+
+            $result = $callback($draft['data'], $unitId);
+
+            $this->releaseReservations($draft['reservations'], $token);
+
+            Cache::forget($this->draftKey($token));
+
+            return $result;
+        });
+    }
+
+    private function resolveUnitId(User $user): int
+    {
+        $activeUnitId = request()->input('active_unit_id');
+
+        if ($activeUnitId) {
+            $unit = $user->units()->where('units.id', $activeUnitId)->first();
+
+            abort_if(! $unit, 403, 'Unidade não autorizada para este usuário.');
+
+            return $unit->id;
+        }
+
+        $unit = $user->units()->first();
+
+        abort_if(! $unit, 403, 'Usuário sem unidade associada.');
+
+        return $unit->id;
+    }
+
+    private function reservationKeys(array $data): array
+    {
+        $keys = [];
+
+        foreach (self::UNIQUE_FIELDS as $field) {
+            $value = $data[$field] ?? null;
+
+            if (! is_string($value) || $value === '') {
+                continue;
+            }
+
+            $normalizedValue = Str::lower(trim($value));
+
+            $keys[$field] = self::CACHE_PREFIX."reservation:{$field}:".hash('sha256', $normalizedValue);
+        }
+
+        return $keys;
+    }
+
+    private function ownsAllReservations(array $reservations, string $token): bool
+    {
+        foreach ($reservations as $key) {
+            if (Cache::get($key) !== $token) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    private function releaseReservations(array $reservations, string $token): void
+    {
+        foreach ($reservations as $key) {
+            if (Cache::get($key) === $token) {
+                Cache::forget($key);
+            }
+        }
+    }
+
+    private function validateDatabaseUniques(array $data): void
+    {
+        Validator::make($data, [
+            'email' => 'sometimes|nullable|unique:students,email',
+        ])->validate();
+    }
+
+    private function uniqueReservationConflict(string $field): never
+    {
+        throw ValidationException::withMessages([
+            $field => __('validation.unique', [
+                'attribute' => __("validation.attributes.{$field}"),
+            ]),
+        ]);
+    }
+
+    private function invalidToken(): never
+    {
+        throw ValidationException::withMessages([
+            'registration_draft_token' => __('validation.registration_draft_invalid'),
+        ]);
+    }
+
+    private function draftKey(string $token): string
+    {
+        return self::CACHE_PREFIX.$token;
+    }
+}

+ 27 - 13
app/Services/StudentService.php

@@ -12,6 +12,10 @@
 
 class StudentService
 {
+    public function __construct(
+        private readonly StudentRegistrationDraftService $registrationDraftService,
+    ) {}
+
     public function getAll(User $user, array $filters = []): Collection
     {
         $unitId = $this->resolveUnitId($user);
@@ -89,27 +93,37 @@ public function findById(int $id): ?Student
 
     public function create(User $user, array $data): Student
     {
-        $unitId = $this->resolveUnitId($user);
+        $token = $data['registration_draft_token'];
 
         $responsibleData = $data['responsible'] ?? null;
 
-        unset($data['responsible']);
+        $avatar = $data['avatar'] ?? null;
 
-        $data = $this->handlePhoto($data);
+        return $this->registrationDraftService->consume(
+            $user,
+            $token,
+            function (array $studentData, int $unitId) use ($responsibleData, $avatar): Student {
+                if ($avatar !== null) {
+                    $studentData['avatar'] = $avatar;
+                }
 
-        return DB::transaction(function () use ($data, $responsibleData, $unitId): Student {
-            $student = (new Student)
-                ->fill(array_merge($data, ['unit_id' => $unitId]))
-                ->withResponsibleForCreation($responsibleData);
+                $studentData = $this->handlePhoto($studentData);
 
-            $student->save();
+                return DB::transaction(function () use ($studentData, $responsibleData, $unitId): Student {
+                    $student = (new Student)
+                        ->fill(array_merge($studentData, ['unit_id' => $unitId]))
+                        ->withResponsibleForCreation($responsibleData);
 
-            if ($responsibleData !== null) {
-                $student->responsibles()->create($responsibleData);
-            }
+                    $student->save();
 
-            return $student->load('responsibles');
-        });
+                    if ($responsibleData !== null) {
+                        $student->responsibles()->create($responsibleData);
+                    }
+
+                    return $student->load('responsibles');
+                });
+            },
+        );
     }
 
     public function update(int $id, array $data): ?Student

+ 5 - 0
config/students.php

@@ -0,0 +1,5 @@
+<?php
+
+return [
+    'registration_draft_ttl_minutes' => (int) env('STUDENT_REGISTRATION_DRAFT_TTL_MINUTES', 15),
+];

+ 3 - 0
lang/en/validation.php

@@ -97,6 +97,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.',
     'max' => [
         'array' => 'The :attribute field must not have more than :max items.',
         'file' => 'The :attribute field must not be greater than :max kilobytes.',
@@ -218,6 +219,8 @@
         'city_id' => 'City',
         'state_id' => 'State',
         'email' => 'Email',
+        'document_number' => 'Student CPF',
+        'registration_draft_token' => 'Registration Draft',
         'secondary_email' => 'Administrative Email',
         'phone_number' => 'Landline',
         'cell_number' => 'Mobile Phone',

+ 3 - 0
lang/es/validation.php

@@ -97,6 +97,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.',
     'max' => [
         'array' => 'El campo :attribute no debe tener más de :max elementos.',
         'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.',
@@ -218,6 +219,8 @@
         'city_id' => 'Ciudad',
         'state_id' => 'Estado',
         'email' => 'Correo Electrónico',
+        'document_number' => 'CPF del Estudiante',
+        'registration_draft_token' => 'Borrador del Registro',
         'secondary_email' => 'Correo Electrónico Administrativo',
         'phone_number' => 'Teléfono Fijo',
         'cell_number' => 'Celular',

+ 3 - 0
lang/pt/validation.php

@@ -98,6 +98,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.',
     '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.',
@@ -219,6 +220,8 @@
         'city_id' => 'Cidade',
         'state_id' => 'Estado',
         'email' => 'E-mail',
+        'document_number' => 'CPF do Estudante',
+        'registration_draft_token' => 'Rascunho do Cadastro',
         'secondary_email' => 'E-mail Administrativo',
         'phone_number' => 'Telefone Fixo',
         'cell_number' => 'Celular',

+ 1 - 0
routes/authRoutes/franchisee_students.php

@@ -8,6 +8,7 @@
 
 Route::prefix('franchisee/students')->group(function () {
     Route::get('/', [StudentController::class, 'index'])->middleware('permission:franchisee_students,view');
+    Route::post('/registration-drafts', [StudentController::class, 'storeRegistrationDraft'])->middleware('permission:franchisee_students,add');
     Route::post('/', [StudentController::class, 'store'])->middleware('permission:franchisee_students,add');
     Route::get('/{id}', [StudentController::class, 'show'])->whereNumber('id')->middleware('permission:franchisee_students,view');
     Route::put('/{id}', [StudentController::class, 'update'])->whereNumber('id')->middleware('permission:franchisee_students,edit');

+ 11 - 0
tests/Unit/Models/StudentTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit\Models;
 
 use App\Models\Student;
+use App\ValueObjects\Cpf;
 use Illuminate\Validation\ValidationException;
 use Tests\TestCase;
 
@@ -44,6 +45,16 @@ public function test_it_allows_an_adult_without_a_responsible_when_creating(): v
 
         $this->assertFalse($student->isMinor());
     }
+
+    public function test_it_casts_the_student_document_number_to_cpf(): void
+    {
+        $student = new Student;
+
+        $student->document_number = '529.982.247-25';
+
+        $this->assertInstanceOf(Cpf::class, $student->document_number);
+        $this->assertSame('52998224725', $student->document_number->value());
+    }
 }
 
 class TestableStudent extends Student