FirstAccessService.php 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  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): 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. $this->mediaService->uploadUserAvatar($photo, $user);
  75. return $user->fresh();
  76. });
  77. }
  78. private function findByRegistration(string $registration): ?User
  79. {
  80. return User::where('type', UserTypeEnum::ASSOCIADO)
  81. ->where('registration', $registration)
  82. ->first();
  83. }
  84. private function prefill(User $user): array
  85. {
  86. return [
  87. 'name' => $user->name,
  88. 'cpf' => $user->cpf,
  89. 'email' => $user->email,
  90. 'phone' => $user->phone,
  91. 'position_id' => $user->position_id,
  92. 'sector_id' => $user->sector_id,
  93. 'photo_url' => $user->photo_path
  94. ? Storage::disk('s3')->temporaryUrl($user->photo_path, now()->addHours(24))
  95. : null,
  96. ];
  97. }
  98. private function missingFields(User $user): array
  99. {
  100. $missing = array_values(array_filter(
  101. self::PROFILE_FIELDS,
  102. fn(string $field): bool => $this->isEmpty($user->{$field}),
  103. ));
  104. if (!$user->photo_path) {
  105. $missing[] = 'photo';
  106. }
  107. return $missing;
  108. }
  109. private function isEmpty(mixed $value): bool
  110. {
  111. return $value === null || $value === '';
  112. }
  113. private function issueToken(string $registration): string
  114. {
  115. return Crypt::encryptString(json_encode([
  116. 'registration' => $registration,
  117. 'expires_at' => now()->addMinutes(self::TOKEN_TTL_MINUTES)->timestamp,
  118. ]));
  119. }
  120. /**
  121. * Garante que o formulário enviado corresponde ao crachá consultado no passo anterior.
  122. */
  123. private function assertValidToken(?string $token, string $registration): void
  124. {
  125. $invalid = ValidationException::withMessages([
  126. 'token' => __('messages.first_access.invalid_token'),
  127. ]);
  128. if (!$token) {
  129. throw $invalid;
  130. }
  131. try {
  132. $payload = json_decode(Crypt::decryptString($token), true);
  133. } catch (DecryptException) {
  134. throw $invalid;
  135. }
  136. if (
  137. !is_array($payload) ||
  138. ($payload['registration'] ?? null) !== $registration ||
  139. ($payload['expires_at'] ?? 0) < now()->timestamp
  140. ) {
  141. throw $invalid;
  142. }
  143. }
  144. }