Browse Source

feat: ✨ feat (primeiro acesso associado) criado fluxo de primeiro acesso do associado

foi criado o fluxo de primeiro acesso do associado, acessando com cracha e preenchendo informacoes faltantes

fase:dev | origin:escopo
Gustavo Zanatta 10 hours ago
parent
commit
6e62c09810

+ 4 - 0
app/Http/Controllers/AuthController.php

@@ -35,6 +35,10 @@ class AuthController extends Controller
             return $this->errorResponse(message: __("auth.refused"), code: 403);
         }
 
+        if (isset($result["error"]) && $result["error"] === "first_access_required") {
+            return $this->errorResponse(message: __("auth.first_access_required"), code: 403);
+        }
+
         $cookieName = $this->getCookieName($request);
 
         return $this->successResponse(

+ 39 - 0
app/Http/Controllers/FirstAccessController.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\FirstAccessCheckRequest;
+use App\Http\Requests\FirstAccessRegisterRequest;
+use App\Http\Resources\UserResource;
+use App\Services\FirstAccessService;
+use Illuminate\Http\JsonResponse;
+
+class FirstAccessController extends Controller
+{
+    public function __construct(protected FirstAccessService $service) {}
+
+    public function check(FirstAccessCheckRequest $request): JsonResponse
+    {
+        $result = $this->service->check($request->validated()['registration']);
+
+        $message = $result['status'] === FirstAccessService::STATUS_DONE
+            ? __('messages.first_access.already_done')
+            : null;
+
+        return $this->successResponse(payload: $result, message: $message);
+    }
+
+    public function register(FirstAccessRegisterRequest $request): JsonResponse
+    {
+        $user = $this->service->register(
+            $request->validated(),
+            $request->file('photo'),
+        );
+
+        return $this->successResponse(
+            payload: new UserResource($user),
+            message: __('messages.first_access.register_success'),
+            code: 201,
+        );
+    }
+}

+ 1 - 0
app/Http/Controllers/UserController.php

@@ -118,6 +118,7 @@ class UserController extends Controller
             'name'           => 'sometimes|string|max:255',
             'cpf'            => 'sometimes|string|max:20',
             'email'          => 'sometimes|email|max:255',
+            'phone'          => 'sometimes|string|max:20',
             'position_id'    => 'sometimes|nullable|integer|exists:positions,id',
             'sector_id'      => 'sometimes|nullable|integer|exists:sectors,id',
         ]);

+ 15 - 0
app/Http/Requests/FirstAccessCheckRequest.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class FirstAccessCheckRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        return [
+            'registration' => 'required|string|max:255',
+        ];
+    }
+}

+ 40 - 0
app/Http/Requests/FirstAccessRegisterRequest.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Enums\UserTypeEnum;
+use App\Models\User;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\Rules\Password;
+
+class FirstAccessRegisterRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        $userId = $this->existingUserId();
+
+        return [
+            'token'        => 'required|string',
+            'registration' => ['required', 'string', 'max:255', Rule::unique('users', 'registration')->ignore($userId)],
+            'name'         => 'required|string|max:255',
+            'cpf'          => ['required', 'string', 'max:14', Rule::unique('users', 'cpf')->ignore($userId)],
+            'email'        => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)],
+            'phone'        => 'required|string|max:20',
+            'position_id'  => 'required|integer|exists:positions,id',
+            'sector_id'    => 'required|integer|exists:sectors,id',
+            'password'     => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
+            'photo'        => 'required|image|max:5120',
+        ];
+    }
+
+    /**
+     * Associado que já existe com este crachá — as regras de unicidade ignoram o próprio registro.
+     */
+    private function existingUserId(): ?int
+    {
+        return User::where('type', UserTypeEnum::ASSOCIADO)
+            ->where('registration', $this->input('registration'))
+            ->value('id');
+    }
+}

+ 14 - 6
app/Http/Requests/LandingPageRegisterRequest.php

@@ -9,12 +9,20 @@ class LandingPageRegisterRequest extends FormRequest
     public function rules(): array
     {
         return [
-            'name'           => 'required|string|max:255',
-            'cpf'            => 'required|string|max:14|unique:users,cpf',
-            'email'          => 'required|email|unique:users,email',
-            'phone'          => 'required|string|max:20',
-            'position_label' => 'required|string|max:255',
-            'sector_label'   => 'required|string|max:255',
+            'name'         => 'required|string|max:255',
+            'cpf'          => 'required|string|max:14|unique:users,cpf',
+            'registration' => 'required|string|max:255|unique:users,registration',
+            'email'        => 'required|email|unique:users,email',
+            'phone'        => 'required|string|max:20',
+            'position_id'  => 'required|integer|exists:positions,id',
+            'sector_id'    => 'required|integer|exists:sectors,id',
+        ];
+    }
+
+    public function messages(): array
+    {
+        return [
+            'registration.unique' => __('messages.landing.registration_taken'),
         ];
     }
 }

+ 1 - 0
app/Http/Resources/UserResource.php

@@ -36,6 +36,7 @@ class UserResource extends JsonResource
             'photo_url'                  => $this->photo_path
                                                 ? Storage::disk('s3')->temporaryUrl($this->photo_path, now()->addHours(24))
                                                 : null,
+            'first_access_completed_at'  => $this->first_access_completed_at?->format('Y-m-d H:i:s'),
             'on_leave_at'                => $this->on_leave_at?->format('Y-m-d H:i:s'),
             'on_leave_by_user_id'        => $this->on_leave_by_user_id,
             'first_access_at'            => $this->access_logs_min_accessed_at

+ 1 - 0
app/Models/User.php

@@ -77,6 +77,7 @@ class User extends Authenticatable
             "language"          => LanguageEnum::class,
             "status"            => UserStatusEnum::class,
             "admission_date"    => "date",
+            "first_access_completed_at" => "datetime",
             "expiry_date"       => "date",
             "excluded_at"       => "datetime",
             "on_leave_at"       => "datetime",

+ 2 - 13
app/Services/AssociadoImportService.php

@@ -68,12 +68,9 @@ class AssociadoImportService
                     $updated++;
                 }
             } else {
-                $firstName = $this->extractFirstName($name);
-                $password  = "{$firstName}2026";
-
                 User::create([
                     'name'           => $name,
-                    'password'       => Hash::make($password),
+                    'password'       => Hash::make(Str::random(32)),
                     'type'           => UserTypeEnum::ASSOCIADO,
                     'status'         => UserStatusEnum::ACTIVE,
                     'registration'   => $registration,
@@ -141,12 +138,9 @@ class AssociadoImportService
                     $onLeave++;
                 }
             } else {
-                $firstName = $this->extractFirstName($name);
-                $password  = "{$firstName}2026";
-
                 User::create([
                     'name'                 => $name,
-                    'password'             => Hash::make($password),
+                    'password'             => Hash::make(Str::random(32)),
                     'type'                 => UserTypeEnum::ASSOCIADO,
                     'status'               => UserStatusEnum::ON_LEAVE,
                     'registration'         => $registration,
@@ -167,9 +161,4 @@ class AssociadoImportService
         ];
     }
 
-    private function extractFirstName(string $fullName): string
-    {
-        $firstName = explode(' ', trim($fullName))[0];
-        return strtolower(Str::ascii($firstName));
-    }
 }

+ 4 - 0
app/Services/AuthService.php

@@ -29,6 +29,10 @@ class AuthService
             return ["error" => "refused"];
         }
 
+        if ($user->type === UserTypeEnum::ASSOCIADO && is_null($user->first_access_completed_at)) {
+            return ["error" => "first_access_required"];
+        }
+
         Auth::login($user);
 
         if ($user->type->value !== $tipo) {

+ 173 - 0
app/Services/FirstAccessService.php

@@ -0,0 +1,173 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\LanguageEnum;
+use App\Enums\UserStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Models\User;
+use Illuminate\Contracts\Encryption\DecryptException;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Crypt;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Validation\ValidationException;
+
+class FirstAccessService
+{
+    public const STATUS_NOT_FOUND = 'not_found';
+    public const STATUS_PENDING   = 'pending_first_access';
+    public const STATUS_DONE      = 'already_done';
+
+    private const TOKEN_TTL_MINUTES = 15;
+
+    private const PROFILE_FIELDS = ['name', 'cpf', 'email', 'phone', 'position_id', 'sector_id'];
+
+    public function __construct(protected MediaService $mediaService) {}
+
+    /**
+     * Consulta um crachá e diz se o associado pode seguir para o formulário de primeiro acesso.
+     */
+    public function check(string $registration): array
+    {
+        $user = $this->findByRegistration($registration);
+
+        if ($user?->first_access_completed_at) {
+            return ['status' => self::STATUS_DONE];
+        }
+
+        return [
+            'status'         => $user ? self::STATUS_PENDING : self::STATUS_NOT_FOUND,
+            'token'          => $this->issueToken($registration),
+            'registration'   => $registration,
+            'user'           => $user ? $this->prefill($user) : null,
+            'missing_fields' => $user ? $this->missingFields($user) : self::PROFILE_FIELDS,
+        ];
+    }
+
+    /**
+     * Conclui o primeiro acesso: cria o associado (ou completa o existente), define a senha e a foto.
+     */
+    public function register(array $data, UploadedFile $photo): User
+    {
+        $registration = $data['registration'];
+
+        $this->assertValidToken($data['token'] ?? null, $registration);
+
+        $user = $this->findByRegistration($registration);
+
+        if ($user?->first_access_completed_at) {
+            throw ValidationException::withMessages([
+                'registration' => __('messages.first_access.already_done'),
+            ]);
+        }
+
+        return DB::transaction(function () use ($data, $photo, $registration, $user): User {
+            if ($user) {
+                // Associado já existente (importação ou landing page): completa apenas o que falta,
+                // sem sobrescrever o que a administração já cadastrou, e mantém o status atual.
+                foreach (self::PROFILE_FIELDS as $field) {
+                    if ($this->isEmpty($user->{$field})) {
+                        $user->{$field} = $data[$field];
+                    }
+                }
+            } else {
+                $user = new User([
+                    'registration' => $registration,
+                    'type'         => UserTypeEnum::ASSOCIADO,
+                    'status'       => UserStatusEnum::PENDING,
+                    'language'     => LanguageEnum::PORTUGUESE,
+                ]);
+
+                foreach (self::PROFILE_FIELDS as $field) {
+                    $user->{$field} = $data[$field];
+                }
+            }
+
+            $user->password                  = $data['password'];
+            $user->first_access_completed_at = now();
+            $user->save();
+
+            $this->mediaService->uploadUserAvatar($photo, $user);
+
+            return $user->fresh();
+        });
+    }
+
+    private function findByRegistration(string $registration): ?User
+    {
+        return User::where('type', UserTypeEnum::ASSOCIADO)
+            ->where('registration', $registration)
+            ->first();
+    }
+
+    private function prefill(User $user): array
+    {
+        return [
+            'name'        => $user->name,
+            'cpf'         => $user->cpf,
+            'email'       => $user->email,
+            'phone'       => $user->phone,
+            'position_id' => $user->position_id,
+            'sector_id'   => $user->sector_id,
+            'photo_url'   => $user->photo_path
+                ? Storage::disk('s3')->temporaryUrl($user->photo_path, now()->addHours(24))
+                : null,
+        ];
+    }
+
+    private function missingFields(User $user): array
+    {
+        $missing = array_values(array_filter(
+            self::PROFILE_FIELDS,
+            fn(string $field): bool => $this->isEmpty($user->{$field}),
+        ));
+
+        if (!$user->photo_path) {
+            $missing[] = 'photo';
+        }
+
+        return $missing;
+    }
+
+    private function isEmpty(mixed $value): bool
+    {
+        return $value === null || $value === '';
+    }
+
+    private function issueToken(string $registration): string
+    {
+        return Crypt::encryptString(json_encode([
+            'registration' => $registration,
+            'expires_at'   => now()->addMinutes(self::TOKEN_TTL_MINUTES)->timestamp,
+        ]));
+    }
+
+    /**
+     * Garante que o formulário enviado corresponde ao crachá consultado no passo anterior.
+     */
+    private function assertValidToken(?string $token, string $registration): void
+    {
+        $invalid = ValidationException::withMessages([
+            'token' => __('messages.first_access.invalid_token'),
+        ]);
+
+        if (!$token) {
+            throw $invalid;
+        }
+
+        try {
+            $payload = json_decode(Crypt::decryptString($token), true);
+        } catch (DecryptException) {
+            throw $invalid;
+        }
+
+        if (
+            !is_array($payload) ||
+            ($payload['registration'] ?? null) !== $registration ||
+            ($payload['expires_at'] ?? 0) < now()->timestamp
+        ) {
+            throw $invalid;
+        }
+    }
+}

+ 11 - 10
app/Services/LandingPageService.php

@@ -13,16 +13,17 @@ class LandingPageService
     public function registerUser(array $data): User
     {
         return User::create([
-            'name'           => $data['name'],
-            'email'          => $data['email'],
-            'cpf'            => $data['cpf'],
-            'phone'          => $data['phone'],
-            'position_label' => $data['position_label'],
-            'sector_label'   => $data['sector_label'],
-            'status'         => UserStatusEnum::PENDING,
-            'type'           => UserTypeEnum::ASSOCIADO,
-            'language'       => LanguageEnum::PORTUGUESE,
-            'password'       => bcrypt(Str::random(32)),
+            'name'         => $data['name'],
+            'email'        => $data['email'],
+            'cpf'          => $data['cpf'],
+            'registration' => $data['registration'],
+            'phone'        => $data['phone'],
+            'position_id'  => $data['position_id'],
+            'sector_id'    => $data['sector_id'],
+            'status'       => UserStatusEnum::PENDING,
+            'type'         => UserTypeEnum::ASSOCIADO,
+            'language'     => LanguageEnum::PORTUGUESE,
+            'password'     => bcrypt(Str::random(32)),
         ]);
     }
 }

+ 22 - 0
database/migrations/2026_07_29_000001_add_first_access_completed_at_to_users_table.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->timestamp('first_access_completed_at')->nullable()->after('status');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropColumn('first_access_completed_at');
+        });
+    }
+};

+ 1 - 0
lang/en/auth.php

@@ -23,6 +23,7 @@ return [
     'session_expired' => 'Session expired',
     'wrong_type' => 'User found, but the selected type is incorrect. Please select the correct login type and try again.',
     'refused' => 'Your registration was refused. Please contact the administration for more information.',
+    'first_access_required' => 'You have not completed your first access yet. Use the "My First Access" option to finish your registration.',
     'password_reset_sent' => 'Verification code sent to your email.',
     'password_reset_invalid' => 'Invalid or expired code.',
     'password_reset_success' => 'Password changed successfully.',

+ 7 - 1
lang/en/messages.php

@@ -20,6 +20,12 @@ return [
     'not_found'               => 'Record not found',
     'unauthorized'            => 'Unauthorized action',
     'landing'                 => [
-        'register_success' => 'Association request sent successfully!',
+        'register_success'   => 'Association request sent successfully!',
+        'registration_taken' => 'There is already an account with this badge. Use the "My First Access" option on the login screen.',
+    ],
+    'first_access'            => [
+        'register_success' => 'Registration completed successfully!',
+        'already_done'     => 'The first access for this badge has already been completed. Please sign in or use "Forgot your password?".',
+        'invalid_token'    => 'Session expired. Please enter your badge again to continue.',
     ],
 ];

+ 1 - 0
lang/es/auth.php

@@ -23,6 +23,7 @@ return [
     'session_expired' => 'Sesión caducada',
     'wrong_type' => 'Usuario encontrado, pero el tipo seleccionado es incorrecto. Seleccione el tipo de inicio de sesión correcto e inténtelo de nuevo.',
     'refused' => 'Su registro fue rechazado. Comuníquese con la administración para más información.',
+    'first_access_required' => 'Aún no realizó su primer acceso. Use la opción "Mi Primer Acceso" para completar su registro.',
     'password_reset_sent' => 'Código de verificación enviado a su correo electrónico.',
     'password_reset_invalid' => 'Código inválido o caducado.',
     'password_reset_success' => 'Contraseña cambiada correctamente.',

+ 7 - 1
lang/es/messages.php

@@ -20,6 +20,12 @@ return [
     'not_found'               => 'Registro no encontrado',
     'unauthorized'            => 'Acción no autorizada',
     'landing'                 => [
-        'register_success' => '¡Solicitud de asociación enviada con éxito!',
+        'register_success'   => '¡Solicitud de asociación enviada con éxito!',
+        'registration_taken' => 'Ya existe un registro con esta credencial. Use la opción "Mi Primer Acceso" en el inicio de sesión.',
+    ],
+    'first_access'            => [
+        'register_success' => '¡Registro completado con éxito!',
+        'already_done'     => 'El primer acceso de esta credencial ya fue realizado. Inicie sesión normalmente o use "¿Olvidó su contraseña?".',
+        'invalid_token'    => 'Sesión expirada. Ingrese la credencial nuevamente para continuar.',
     ],
 ];

+ 1 - 0
lang/pt/auth.php

@@ -23,6 +23,7 @@ return [
     'session_expired' => 'Sessão expirada',
     'wrong_type' => 'Usuário encontrado, mas o tipo selecionado está incorreto. Selecione o login correto e tente novamente.',
     'refused' => 'Seu cadastro foi recusado. Entre em contato com a administração para mais informações.',
+    'first_access_required' => 'Você ainda não realizou seu primeiro acesso. Use a opção "Meu Primeiro Acesso" para concluir seu cadastro.',
     'password_reset_sent' => 'Código de verificação enviado para seu e-mail.',
     'password_reset_invalid' => 'Código inválido ou expirado.',
     'password_reset_success' => 'Senha alterada com sucesso.',

+ 7 - 1
lang/pt/messages.php

@@ -20,6 +20,12 @@ return [
     'not_found'               => 'Registro não encontrado',
     'unauthorized'            => 'Ação não autorizada',
     'landing'                 => [
-        'register_success' => 'Solicitação de associação enviada com sucesso!',
+        'register_success'   => 'Solicitação de associação enviada com sucesso!',
+        'registration_taken' => 'Já existe um cadastro com este crachá. Use a opção "Meu Primeiro Acesso" no login.',
+    ],
+    'first_access'            => [
+        'register_success' => 'Cadastro concluído com sucesso!',
+        'already_done'     => 'O primeiro acesso deste crachá já foi realizado. Faça login normalmente ou use "Esqueceu a senha?".',
+        'invalid_token'    => 'Sessão expirada. Informe o crachá novamente para continuar.',
     ],
 ];

+ 16 - 0
routes/noAuthRoutes/first_access.php

@@ -0,0 +1,16 @@
+<?php
+
+use App\Http\Controllers\FirstAccessController;
+use App\Http\Controllers\PositionController;
+use App\Http\Controllers\SectorController;
+use Illuminate\Support\Facades\Route;
+
+Route::middleware('throttle:10,1')->group(function () {
+    Route::controller(FirstAccessController::class)->prefix('first-access')->group(function () {
+        Route::post('/check', 'check');
+        Route::post('/register', 'register');
+    });
+});
+
+Route::get('/public/positions', [PositionController::class, 'index']);
+Route::get('/public/sectors', [SectorController::class, 'index']);