|
|
@@ -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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|