StudentRegistrationDraftService.php 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. <?php
  2. namespace App\Services;
  3. use App\Models\User;
  4. use Closure;
  5. use Illuminate\Support\Facades\Cache;
  6. use Illuminate\Support\Facades\Validator;
  7. use Illuminate\Support\Str;
  8. use Illuminate\Validation\ValidationException;
  9. class StudentRegistrationDraftService
  10. {
  11. private const CACHE_PREFIX = 'student-registration-draft:';
  12. private const LOCK_KEY = 'student-registration-drafts:lock';
  13. private const LOCK_SECONDS = 60;
  14. private const UNIQUE_FIELDS = [
  15. 'document_number',
  16. 'email',
  17. ];
  18. public function store(User $user, array $data): array
  19. {
  20. $unitId = $this->resolveUnitId($user);
  21. $requestedToken = $data['registration_draft_token'] ?? null;
  22. unset($data['registration_draft_token']);
  23. return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
  24. $user,
  25. $unitId,
  26. $requestedToken,
  27. $data,
  28. ): array {
  29. $existingDraft = null;
  30. if ($requestedToken !== null) {
  31. $existingDraft = $this->findOwnedDraft($user, $requestedToken, $unitId);
  32. if ($existingDraft === null) {
  33. $this->invalidToken();
  34. }
  35. }
  36. $token = $requestedToken ?? (string) Str::uuid();
  37. $expiresAt = now()->addMinutes(config('students.registration_draft_ttl_minutes', 15));
  38. $oldReservations = $existingDraft['reservations'] ?? [];
  39. $newReservations = $this->reservationKeys($data);
  40. $acquiredReservations = [];
  41. $this->validateDatabaseUniques($data);
  42. try {
  43. foreach ($newReservations as $field => $key) {
  44. if (($oldReservations[$field] ?? null) === $key) {
  45. if (Cache::get($key) !== $token) {
  46. $this->invalidToken();
  47. }
  48. continue;
  49. }
  50. if (! Cache::add($key, $token, $expiresAt) && Cache::get($key) !== $token) {
  51. $this->uniqueReservationConflict($field);
  52. }
  53. $acquiredReservations[$field] = $key;
  54. }
  55. } catch (ValidationException $exception) {
  56. $this->releaseReservations($acquiredReservations, $token);
  57. throw $exception;
  58. }
  59. $removedReservations = array_diff($oldReservations, $newReservations);
  60. $this->releaseReservations($removedReservations, $token);
  61. foreach ($newReservations as $key) {
  62. Cache::put($key, $token, $expiresAt);
  63. }
  64. $draft = [
  65. 'user_id' => $user->id,
  66. 'unit_id' => $unitId,
  67. 'data' => $data,
  68. 'reservations' => $newReservations,
  69. 'expires_at' => $expiresAt->toIso8601String(),
  70. ];
  71. Cache::put($this->draftKey($token), $draft, $expiresAt);
  72. return [
  73. 'registration_draft_token' => $token,
  74. 'expires_at' => $draft['expires_at'],
  75. ];
  76. });
  77. }
  78. public function findOwnedDraft(User $user, string $token, ?int $unitId = null): ?array
  79. {
  80. $draft = Cache::get($this->draftKey($token));
  81. if (! is_array($draft)) {
  82. return null;
  83. }
  84. $unitId ??= $this->resolveUnitId($user);
  85. if ($draft['user_id'] !== $user->id || $draft['unit_id'] !== $unitId) {
  86. return null;
  87. }
  88. return $draft;
  89. }
  90. public function consume(User $user, string $token, Closure $callback): mixed
  91. {
  92. $unitId = $this->resolveUnitId($user);
  93. return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
  94. $user,
  95. $token,
  96. $unitId,
  97. $callback,
  98. ): mixed {
  99. $draft = $this->findOwnedDraft($user, $token, $unitId);
  100. if ($draft === null || ! $this->ownsAllReservations($draft['reservations'], $token)) {
  101. $this->invalidToken();
  102. }
  103. $this->validateDatabaseUniques($draft['data']);
  104. $result = $callback($draft['data'], $unitId);
  105. $this->releaseReservations($draft['reservations'], $token);
  106. Cache::forget($this->draftKey($token));
  107. return $result;
  108. });
  109. }
  110. public function executeWithoutDraft(User $user, array $data, Closure $callback): mixed
  111. {
  112. $unitId = $this->resolveUnitId($user);
  113. return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
  114. $data,
  115. $unitId,
  116. $callback,
  117. ): mixed {
  118. $this->validateDatabaseUniques($data);
  119. foreach ($this->reservationKeys($data) as $field => $key) {
  120. if (Cache::has($key)) {
  121. $this->uniqueReservationConflict($field);
  122. }
  123. }
  124. return $callback($data, $unitId);
  125. });
  126. }
  127. //
  128. private function resolveUnitId(User $user): int
  129. {
  130. $activeUnitId = request()->input('active_unit_id');
  131. if ($activeUnitId) {
  132. $unit = $user->units()->where('units.id', $activeUnitId)->first();
  133. abort_if(! $unit, 403, 'Unidade não autorizada para este usuário.');
  134. return $unit->id;
  135. }
  136. $unit = $user->units()->first();
  137. abort_if(! $unit, 403, 'Usuário sem unidade associada.');
  138. return $unit->id;
  139. }
  140. private function reservationKeys(array $data): array
  141. {
  142. $keys = [];
  143. foreach (self::UNIQUE_FIELDS as $field) {
  144. $value = $data[$field] ?? null;
  145. if (! is_string($value) || $value === '') {
  146. continue;
  147. }
  148. $normalizedValue = Str::lower(trim($value));
  149. $keys[$field] = self::CACHE_PREFIX."reservation:{$field}:".hash('sha256', $normalizedValue);
  150. }
  151. return $keys;
  152. }
  153. private function ownsAllReservations(array $reservations, string $token): bool
  154. {
  155. foreach ($reservations as $key) {
  156. if (Cache::get($key) !== $token) {
  157. return false;
  158. }
  159. }
  160. return true;
  161. }
  162. private function releaseReservations(array $reservations, string $token): void
  163. {
  164. foreach ($reservations as $key) {
  165. if (Cache::get($key) === $token) {
  166. Cache::forget($key);
  167. }
  168. }
  169. }
  170. private function validateDatabaseUniques(array $data): void
  171. {
  172. Validator::make($data, [
  173. 'document_number' => 'sometimes|nullable|unique:students,document_number',
  174. 'email' => 'sometimes|nullable|unique:students,email',
  175. ])->validate();
  176. }
  177. private function uniqueReservationConflict(string $field): never
  178. {
  179. throw ValidationException::withMessages([
  180. $field => __('validation.unique', [
  181. 'attribute' => __("validation.attributes.{$field}"),
  182. ]),
  183. ]);
  184. }
  185. private function invalidToken(): never
  186. {
  187. throw ValidationException::withMessages([
  188. 'registration_draft_token' => __('validation.registration_draft_invalid'),
  189. ]);
  190. }
  191. private function draftKey(string $token): string
  192. {
  193. return self::CACHE_PREFIX.$token;
  194. }
  195. }