FirstAccessService.php 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. <?php
  2. namespace App\Services;
  3. use App\Enums\LanguageEnum;
  4. use App\Enums\UserStatusEnum;
  5. use App\Enums\UserTypeEnum;
  6. use App\Models\User;
  7. use Illuminate\Contracts\Encryption\DecryptException;
  8. use Illuminate\Http\UploadedFile;
  9. use Illuminate\Support\Facades\Crypt;
  10. use Illuminate\Support\Facades\DB;
  11. use Illuminate\Support\Facades\Storage;
  12. use Illuminate\Validation\ValidationException;
  13. class FirstAccessService
  14. {
  15. public const STATUS_NOT_FOUND = 'not_found';
  16. public const STATUS_PENDING = 'pending_first_access';
  17. public const STATUS_DONE = 'already_done';
  18. private const TOKEN_TTL_MINUTES = 15;
  19. private const PROFILE_FIELDS = ['name', 'cpf', 'email', 'phone', 'position_id', 'sector_id'];
  20. public function __construct(protected MediaService $mediaService) {}
  21. /**
  22. * Consulta um crachá e diz se o associado pode seguir para o formulário de primeiro acesso.
  23. */
  24. public function check(string $registration): array
  25. {
  26. $user = $this->findByRegistration($registration);
  27. if ($user?->first_access_completed_at) {
  28. return ['status' => self::STATUS_DONE];
  29. }
  30. return [
  31. 'status' => $user ? self::STATUS_PENDING : self::STATUS_NOT_FOUND,
  32. 'token' => $this->issueToken($registration),
  33. 'registration' => $registration,
  34. 'user' => $user ? $this->prefill($user) : null,
  35. 'missing_fields' => $user ? $this->missingFields($user) : self::PROFILE_FIELDS,
  36. ];
  37. }
  38. /**
  39. * Conclui o primeiro acesso: cria o associado (ou completa o existente), define a senha e a foto.
  40. */
  41. public function register(array $data, ?UploadedFile $photo = null): User
  42. {
  43. $registration = $data['registration'];
  44. $this->assertValidToken($data['token'] ?? null, $registration);
  45. $user = $this->findByRegistration($registration);
  46. if ($user?->first_access_completed_at) {
  47. throw ValidationException::withMessages([
  48. 'registration' => __('messages.first_access.already_done'),
  49. ]);
  50. }
  51. return DB::transaction(function () use ($data, $photo, $registration, $user): User {
  52. if ($user) {
  53. // Associado já existente (importação ou landing page): completa apenas o que falta,
  54. // sem sobrescrever o que a administração já cadastrou, e mantém o status atual.
  55. foreach (self::PROFILE_FIELDS as $field) {
  56. if ($this->isEmpty($user->{$field})) {
  57. $user->{$field} = $data[$field];
  58. }
  59. }
  60. } else {
  61. $user = new User([
  62. 'registration' => $registration,
  63. 'type' => UserTypeEnum::ASSOCIADO,
  64. 'status' => UserStatusEnum::PENDING,
  65. 'language' => LanguageEnum::PORTUGUESE,
  66. ]);
  67. foreach (self::PROFILE_FIELDS as $field) {
  68. $user->{$field} = $data[$field];
  69. }
  70. }
  71. $user->password = $data['password'];
  72. $user->first_access_completed_at = now();
  73. $user->save();
  74. // Sem arquivo novo, mantém a foto que a administração já havia cadastrado.
  75. if ($photo) {
  76. $this->mediaService->uploadUserAvatar($photo, $user);
  77. }
  78. return $user->fresh();
  79. });
  80. }
  81. private function findByRegistration(string $registration): ?User
  82. {
  83. return User::where('type', UserTypeEnum::ASSOCIADO)
  84. ->where('registration', $registration)
  85. ->first();
  86. }
  87. private function prefill(User $user): array
  88. {
  89. return [
  90. 'name' => $user->name,
  91. 'cpf' => $user->cpf,
  92. 'email' => $user->email,
  93. 'phone' => $user->phone,
  94. 'position_id' => $user->position_id,
  95. 'sector_id' => $user->sector_id,
  96. 'photo_url' => $user->photo_path
  97. ? Storage::disk('s3')->temporaryUrl($user->photo_path, now()->addHours(24))
  98. : null,
  99. ];
  100. }
  101. private function missingFields(User $user): array
  102. {
  103. $missing = array_values(array_filter(
  104. self::PROFILE_FIELDS,
  105. fn(string $field): bool => $this->isEmpty($user->{$field}),
  106. ));
  107. if (!$user->photo_path) {
  108. $missing[] = 'photo';
  109. }
  110. return $missing;
  111. }
  112. private function isEmpty(mixed $value): bool
  113. {
  114. return $value === null || $value === '';
  115. }
  116. private function issueToken(string $registration): string
  117. {
  118. return Crypt::encryptString(json_encode([
  119. 'registration' => $registration,
  120. 'expires_at' => now()->addMinutes(self::TOKEN_TTL_MINUTES)->timestamp,
  121. ]));
  122. }
  123. /**
  124. * Garante que o formulário enviado corresponde ao crachá consultado no passo anterior.
  125. */
  126. private function assertValidToken(?string $token, string $registration): void
  127. {
  128. $invalid = ValidationException::withMessages([
  129. 'token' => __('messages.first_access.invalid_token'),
  130. ]);
  131. if (!$token) {
  132. throw $invalid;
  133. }
  134. try {
  135. $payload = json_decode(Crypt::decryptString($token), true);
  136. } catch (DecryptException) {
  137. throw $invalid;
  138. }
  139. if (
  140. !is_array($payload) ||
  141. ($payload['registration'] ?? null) !== $registration ||
  142. ($payload['expires_at'] ?? 0) < now()->timestamp
  143. ) {
  144. throw $invalid;
  145. }
  146. }
  147. }