FirstAccessService.php 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. <?php
  2. namespace App\Services;
  3. use App\Enums\UserStatusEnum;
  4. use App\Enums\UserTypeEnum;
  5. use App\Models\User;
  6. use Illuminate\Contracts\Encryption\DecryptException;
  7. use Illuminate\Http\UploadedFile;
  8. use Illuminate\Support\Facades\Crypt;
  9. use Illuminate\Support\Facades\DB;
  10. use Illuminate\Support\Facades\Storage;
  11. use Illuminate\Validation\ValidationException;
  12. class FirstAccessService
  13. {
  14. public const STATUS_NOT_FOUND = 'not_found';
  15. public const STATUS_PENDING = 'pending_first_access';
  16. public const STATUS_DONE = 'already_done';
  17. public const STATUS_NOT_APPROVED = 'not_approved';
  18. public const STATUS_REFUSED = 'refused';
  19. /** Situações em que o crachá não libera o formulário de primeiro acesso. */
  20. public const BLOCKING_STATUSES = [
  21. self::STATUS_NOT_FOUND,
  22. self::STATUS_DONE,
  23. self::STATUS_NOT_APPROVED,
  24. self::STATUS_REFUSED,
  25. ];
  26. private const TOKEN_TTL_MINUTES = 15;
  27. private const PROFILE_FIELDS = ['name', 'cpf', 'email', 'phone', 'position_id', 'sector_id'];
  28. public function __construct(protected MediaService $mediaService) {}
  29. /**
  30. * Consulta um crachá e diz se o associado pode seguir para o formulário de primeiro acesso.
  31. * Só crachá já liberado pela administração passa: cadastro inexistente, pendente de aprovação
  32. * ou recusado é bloqueado aqui, antes de o formulário abrir.
  33. */
  34. public function check(string $registration): array
  35. {
  36. $user = $this->findByRegistration($registration);
  37. if ($blockedStatus = $this->blockingStatus($user)) {
  38. return ['status' => $blockedStatus];
  39. }
  40. return [
  41. 'status' => self::STATUS_PENDING,
  42. 'token' => $this->issueToken($registration),
  43. 'registration' => $registration,
  44. 'user' => $this->prefill($user),
  45. 'missing_fields' => $this->missingFields($user),
  46. ];
  47. }
  48. private function blockingStatus(?User $user): ?string
  49. {
  50. return match (true) {
  51. !$user => self::STATUS_NOT_FOUND,
  52. (bool) $user->first_access_completed_at => self::STATUS_DONE,
  53. $user->status === UserStatusEnum::REFUSED => self::STATUS_REFUSED,
  54. $user->status === UserStatusEnum::PENDING => self::STATUS_NOT_APPROVED,
  55. default => null,
  56. };
  57. }
  58. public function blockedMessage(string $status): string
  59. {
  60. return match ($status) {
  61. self::STATUS_DONE => __('messages.first_access.already_done'),
  62. self::STATUS_NOT_APPROVED => __('messages.first_access.not_approved'),
  63. self::STATUS_REFUSED => __('messages.first_access.refused'),
  64. default => __('messages.first_access.not_found'),
  65. };
  66. }
  67. public function register(array $data, ?UploadedFile $photo = null): User
  68. {
  69. $registration = $data['registration'];
  70. $this->assertValidToken($data['token'] ?? null, $registration);
  71. $user = $this->findByRegistration($registration);
  72. if ($blockedStatus = $this->blockingStatus($user)) {
  73. throw ValidationException::withMessages([
  74. 'registration' => $this->blockedMessage($blockedStatus),
  75. ]);
  76. }
  77. $data['name'] = $this->fullName($data['name'], $data['last_name'] ?? null);
  78. return DB::transaction(function () use ($data, $photo, $user): User {
  79. foreach (self::PROFILE_FIELDS as $field) {
  80. if ($this->isEmpty($user->{$field})) {
  81. $user->{$field} = $data[$field];
  82. }
  83. }
  84. $user->name = $data['name'];
  85. $user->password = $data['password'];
  86. $user->first_access_completed_at = now();
  87. $user->save();
  88. // Sem arquivo novo, mantém a foto que a administração já havia cadastrado.
  89. if ($photo) {
  90. $this->mediaService->uploadUserAvatar($photo, $user);
  91. }
  92. return $user->fresh();
  93. });
  94. }
  95. private function findByRegistration(string $registration): ?User
  96. {
  97. return User::where('type', UserTypeEnum::ASSOCIADO)
  98. ->where('registration', $registration)
  99. ->first();
  100. }
  101. private function prefill(User $user): array
  102. {
  103. [$name, $lastName] = $this->splitName($user->name);
  104. return [
  105. 'name' => $name,
  106. 'last_name' => $lastName,
  107. 'cpf' => $user->cpf,
  108. 'email' => $user->email,
  109. 'phone' => $user->phone,
  110. 'position_id' => $user->position_id,
  111. 'sector_id' => $user->sector_id,
  112. 'photo_url' => $user->photo_path
  113. ? Storage::disk('s3')->temporaryUrl($user->photo_path, now()->addHours(24))
  114. : null,
  115. ];
  116. }
  117. private function missingFields(User $user): array
  118. {
  119. $missing = array_values(array_filter(
  120. self::PROFILE_FIELDS,
  121. fn(string $field): bool => $this->isEmpty($user->{$field}),
  122. ));
  123. if ($this->isEmpty($this->splitName($user->name)[1])) {
  124. $missing[] = 'last_name';
  125. }
  126. if (!$user->photo_path) {
  127. $missing[] = 'photo';
  128. }
  129. return $missing;
  130. }
  131. /**
  132. * Quebra o nome gravado em primeiro nome + sobrenome para preencher o formulário.
  133. *
  134. * @return array{0: string, 1: string}
  135. */
  136. private function splitName(?string $name): array
  137. {
  138. $parts = preg_split('/\s+/', trim((string) $name), 2, PREG_SPLIT_NO_EMPTY) ?: [];
  139. return [$parts[0] ?? '', $parts[1] ?? ''];
  140. }
  141. private function fullName(string $name, ?string $lastName): string
  142. {
  143. return trim(preg_replace('/\s+/', ' ', $name . ' ' . $lastName));
  144. }
  145. private function isEmpty(mixed $value): bool
  146. {
  147. return $value === null || $value === '';
  148. }
  149. private function issueToken(string $registration): string
  150. {
  151. return Crypt::encryptString(json_encode([
  152. 'registration' => $registration,
  153. 'expires_at' => now()->addMinutes(self::TOKEN_TTL_MINUTES)->timestamp,
  154. ]));
  155. }
  156. /**
  157. * Garante que o formulário enviado corresponde ao crachá consultado no passo anterior.
  158. */
  159. private function assertValidToken(?string $token, string $registration): void
  160. {
  161. $invalid = ValidationException::withMessages([
  162. 'token' => __('messages.first_access.invalid_token'),
  163. ]);
  164. if (!$token) {
  165. throw $invalid;
  166. }
  167. try {
  168. $payload = json_decode(Crypt::decryptString($token), true);
  169. } catch (DecryptException) {
  170. throw $invalid;
  171. }
  172. if (
  173. !is_array($payload) ||
  174. ($payload['registration'] ?? null) !== $registration ||
  175. ($payload['expires_at'] ?? 0) < now()->timestamp
  176. ) {
  177. throw $invalid;
  178. }
  179. }
  180. }