|
|
@@ -0,0 +1,237 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Services;
|
|
|
+
|
|
|
+use App\Models\User;
|
|
|
+use Closure;
|
|
|
+use Illuminate\Support\Facades\Cache;
|
|
|
+use Illuminate\Support\Facades\Validator;
|
|
|
+use Illuminate\Support\Str;
|
|
|
+use Illuminate\Validation\ValidationException;
|
|
|
+
|
|
|
+class StudentRegistrationDraftService
|
|
|
+{
|
|
|
+ private const CACHE_PREFIX = 'student-registration-draft:';
|
|
|
+
|
|
|
+ private const LOCK_KEY = 'student-registration-drafts:lock';
|
|
|
+
|
|
|
+ private const LOCK_SECONDS = 60;
|
|
|
+
|
|
|
+ private const UNIQUE_FIELDS = [
|
|
|
+ 'email',
|
|
|
+ ];
|
|
|
+
|
|
|
+ public function store(User $user, array $data): array
|
|
|
+ {
|
|
|
+ $unitId = $this->resolveUnitId($user);
|
|
|
+
|
|
|
+ $requestedToken = $data['registration_draft_token'] ?? null;
|
|
|
+
|
|
|
+ unset($data['registration_draft_token']);
|
|
|
+
|
|
|
+ return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
|
|
|
+ $user,
|
|
|
+ $unitId,
|
|
|
+ $requestedToken,
|
|
|
+ $data,
|
|
|
+ ): array {
|
|
|
+ $existingDraft = null;
|
|
|
+
|
|
|
+ if ($requestedToken !== null) {
|
|
|
+ $existingDraft = $this->findOwnedDraft($user, $requestedToken, $unitId);
|
|
|
+
|
|
|
+ if ($existingDraft === null) {
|
|
|
+ $this->invalidToken();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $token = $requestedToken ?? (string) Str::uuid();
|
|
|
+
|
|
|
+ $expiresAt = now()->addMinutes(config('students.registration_draft_ttl_minutes', 15));
|
|
|
+
|
|
|
+ $oldReservations = $existingDraft['reservations'] ?? [];
|
|
|
+
|
|
|
+ $newReservations = $this->reservationKeys($data);
|
|
|
+
|
|
|
+ $acquiredReservations = [];
|
|
|
+
|
|
|
+ $this->validateDatabaseUniques($data);
|
|
|
+
|
|
|
+ try {
|
|
|
+ foreach ($newReservations as $field => $key) {
|
|
|
+ if (($oldReservations[$field] ?? null) === $key) {
|
|
|
+ if (Cache::get($key) !== $token) {
|
|
|
+ $this->invalidToken();
|
|
|
+ }
|
|
|
+
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! Cache::add($key, $token, $expiresAt) && Cache::get($key) !== $token) {
|
|
|
+ $this->uniqueReservationConflict($field);
|
|
|
+ }
|
|
|
+
|
|
|
+ $acquiredReservations[$field] = $key;
|
|
|
+ }
|
|
|
+ } catch (ValidationException $exception) {
|
|
|
+ $this->releaseReservations($acquiredReservations, $token);
|
|
|
+
|
|
|
+ throw $exception;
|
|
|
+ }
|
|
|
+
|
|
|
+ $removedReservations = array_diff($oldReservations, $newReservations);
|
|
|
+
|
|
|
+ $this->releaseReservations($removedReservations, $token);
|
|
|
+
|
|
|
+ foreach ($newReservations as $key) {
|
|
|
+ Cache::put($key, $token, $expiresAt);
|
|
|
+ }
|
|
|
+
|
|
|
+ $draft = [
|
|
|
+ 'user_id' => $user->id,
|
|
|
+ 'unit_id' => $unitId,
|
|
|
+ 'data' => $data,
|
|
|
+ 'reservations' => $newReservations,
|
|
|
+ 'expires_at' => $expiresAt->toIso8601String(),
|
|
|
+ ];
|
|
|
+
|
|
|
+ Cache::put($this->draftKey($token), $draft, $expiresAt);
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'registration_draft_token' => $token,
|
|
|
+ 'expires_at' => $draft['expires_at'],
|
|
|
+ ];
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ public function findOwnedDraft(User $user, string $token, ?int $unitId = null): ?array
|
|
|
+ {
|
|
|
+ $draft = Cache::get($this->draftKey($token));
|
|
|
+
|
|
|
+ if (! is_array($draft)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $unitId ??= $this->resolveUnitId($user);
|
|
|
+
|
|
|
+ if ($draft['user_id'] !== $user->id || $draft['unit_id'] !== $unitId) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $draft;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function consume(User $user, string $token, Closure $callback): mixed
|
|
|
+ {
|
|
|
+ $unitId = $this->resolveUnitId($user);
|
|
|
+
|
|
|
+ return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
|
|
|
+ $user,
|
|
|
+ $token,
|
|
|
+ $unitId,
|
|
|
+ $callback,
|
|
|
+ ): mixed {
|
|
|
+ $draft = $this->findOwnedDraft($user, $token, $unitId);
|
|
|
+
|
|
|
+ if ($draft === null || ! $this->ownsAllReservations($draft['reservations'], $token)) {
|
|
|
+ $this->invalidToken();
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->validateDatabaseUniques($draft['data']);
|
|
|
+
|
|
|
+ $result = $callback($draft['data'], $unitId);
|
|
|
+
|
|
|
+ $this->releaseReservations($draft['reservations'], $token);
|
|
|
+
|
|
|
+ Cache::forget($this->draftKey($token));
|
|
|
+
|
|
|
+ return $result;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private function resolveUnitId(User $user): int
|
|
|
+ {
|
|
|
+ $activeUnitId = request()->input('active_unit_id');
|
|
|
+
|
|
|
+ if ($activeUnitId) {
|
|
|
+ $unit = $user->units()->where('units.id', $activeUnitId)->first();
|
|
|
+
|
|
|
+ abort_if(! $unit, 403, 'Unidade não autorizada para este usuário.');
|
|
|
+
|
|
|
+ return $unit->id;
|
|
|
+ }
|
|
|
+
|
|
|
+ $unit = $user->units()->first();
|
|
|
+
|
|
|
+ abort_if(! $unit, 403, 'Usuário sem unidade associada.');
|
|
|
+
|
|
|
+ return $unit->id;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function reservationKeys(array $data): array
|
|
|
+ {
|
|
|
+ $keys = [];
|
|
|
+
|
|
|
+ foreach (self::UNIQUE_FIELDS as $field) {
|
|
|
+ $value = $data[$field] ?? null;
|
|
|
+
|
|
|
+ if (! is_string($value) || $value === '') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $normalizedValue = Str::lower(trim($value));
|
|
|
+
|
|
|
+ $keys[$field] = self::CACHE_PREFIX."reservation:{$field}:".hash('sha256', $normalizedValue);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $keys;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function ownsAllReservations(array $reservations, string $token): bool
|
|
|
+ {
|
|
|
+ foreach ($reservations as $key) {
|
|
|
+ if (Cache::get($key) !== $token) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function releaseReservations(array $reservations, string $token): void
|
|
|
+ {
|
|
|
+ foreach ($reservations as $key) {
|
|
|
+ if (Cache::get($key) === $token) {
|
|
|
+ Cache::forget($key);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private function validateDatabaseUniques(array $data): void
|
|
|
+ {
|
|
|
+ Validator::make($data, [
|
|
|
+ 'email' => 'sometimes|nullable|unique:students,email',
|
|
|
+ ])->validate();
|
|
|
+ }
|
|
|
+
|
|
|
+ private function uniqueReservationConflict(string $field): never
|
|
|
+ {
|
|
|
+ throw ValidationException::withMessages([
|
|
|
+ $field => __('validation.unique', [
|
|
|
+ 'attribute' => __("validation.attributes.{$field}"),
|
|
|
+ ]),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function invalidToken(): never
|
|
|
+ {
|
|
|
+ throw ValidationException::withMessages([
|
|
|
+ 'registration_draft_token' => __('validation.registration_draft_invalid'),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function draftKey(string $token): string
|
|
|
+ {
|
|
|
+ return self::CACHE_PREFIX.$token;
|
|
|
+ }
|
|
|
+}
|