StudentRegistrationDraftService.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. <?php
  2. namespace App\Services;
  3. use App\Models\User;
  4. use Closure;
  5. use Illuminate\Cache\DatabaseStore;
  6. use Illuminate\Support\Facades\Cache;
  7. use Illuminate\Support\Facades\Validator;
  8. use Illuminate\Support\Str;
  9. use Illuminate\Validation\ValidationException;
  10. class StudentRegistrationDraftService
  11. {
  12. private const CACHE_PREFIX = 'student-registration-draft:';
  13. private const LOCK_KEY = 'student-registration-drafts:lock';
  14. private const LOCK_SECONDS = 60;
  15. private const TOKENS_KEY = self::CACHE_PREFIX.'tokens';
  16. private const UNIQUE_FIELDS = [
  17. 'document_number',
  18. 'email',
  19. ];
  20. public function store(User $user, array $data): array
  21. {
  22. $unitId = $this->resolveUnitId($user);
  23. $requestedToken = $data['registration_draft_token'] ?? null;
  24. unset($data['registration_draft_token']);
  25. return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
  26. $user,
  27. $unitId,
  28. $requestedToken,
  29. $data,
  30. ): array {
  31. $existingDraft = null;
  32. if ($requestedToken !== null) {
  33. $existingDraft = $this->findOwnedDraft($user, $requestedToken, $unitId);
  34. if ($existingDraft === null) {
  35. $this->invalidToken();
  36. }
  37. }
  38. $token = $requestedToken ?? (string) Str::uuid();
  39. $expiresAt = now()->addMinutes(config('students.registration_draft_ttl_minutes', 15));
  40. $oldReservations = $existingDraft['reservations'] ?? [];
  41. $newReservations = $this->reservationKeys($data);
  42. $acquiredReservations = [];
  43. $this->validateDatabaseUniques($data);
  44. try {
  45. foreach ($newReservations as $field => $key) {
  46. if (($oldReservations[$field] ?? null) === $key) {
  47. if (Cache::get($key) !== $token) {
  48. $this->invalidToken();
  49. }
  50. continue;
  51. }
  52. if (! Cache::add($key, $token, $expiresAt) && Cache::get($key) !== $token) {
  53. $this->uniqueReservationConflict($field);
  54. }
  55. $acquiredReservations[$field] = $key;
  56. }
  57. } catch (ValidationException $exception) {
  58. $this->releaseReservations($acquiredReservations, $token);
  59. throw $exception;
  60. }
  61. $removedReservations = array_diff($oldReservations, $newReservations);
  62. $this->releaseReservations($removedReservations, $token);
  63. foreach ($newReservations as $key) {
  64. Cache::put($key, $token, $expiresAt);
  65. }
  66. $draft = [
  67. 'user_id' => $user->id,
  68. 'unit_id' => $unitId,
  69. 'data' => $data,
  70. 'reservations' => $newReservations,
  71. 'expires_at' => $expiresAt->toIso8601String(),
  72. ];
  73. Cache::put($this->draftKey($token), $draft, $expiresAt);
  74. $this->registerToken($token);
  75. return [
  76. 'registration_draft_token' => $token,
  77. 'expires_at' => $draft['expires_at'],
  78. ];
  79. });
  80. }
  81. //
  82. public function consume(User $user, string $token, Closure $callback): mixed
  83. {
  84. $unitId = $this->resolveUnitId($user);
  85. return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
  86. $user,
  87. $token,
  88. $unitId,
  89. $callback,
  90. ): mixed {
  91. $draft = $this->findOwnedDraft($user, $token, $unitId);
  92. if ($draft === null || ! $this->ownsAllReservations($draft['reservations'], $token)) {
  93. $this->invalidToken();
  94. }
  95. $this->validateDatabaseUniques($draft['data']);
  96. $result = $callback($draft['data'], $unitId);
  97. $this->releaseReservations($draft['reservations'], $token);
  98. Cache::forget($this->draftKey($token));
  99. $this->unregisterToken($token);
  100. return $result;
  101. });
  102. }
  103. public function executeWithoutDraft(User $user, array $data, Closure $callback): mixed
  104. {
  105. $unitId = $this->resolveUnitId($user);
  106. return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
  107. $data,
  108. $unitId,
  109. $callback,
  110. ): mixed {
  111. $this->validateDatabaseUniques($data);
  112. foreach ($this->reservationKeys($data) as $field => $key) {
  113. if (Cache::has($key)) {
  114. $this->uniqueReservationConflict($field);
  115. }
  116. }
  117. return $callback($data, $unitId);
  118. });
  119. }
  120. public function findOwnedDraft(User $user, string $token, ?int $unitId = null): ?array
  121. {
  122. $draft = Cache::get($this->draftKey($token));
  123. if (! is_array($draft)) {
  124. return null;
  125. }
  126. $unitId ??= $this->resolveUnitId($user);
  127. if ($draft['user_id'] !== $user->id || $draft['unit_id'] !== $unitId) {
  128. return null;
  129. }
  130. return $draft;
  131. }
  132. //
  133. public function clearAll(): int
  134. {
  135. return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function (): int {
  136. $databaseStoreResult = $this->clearDatabaseStore();
  137. if ($databaseStoreResult !== null) {
  138. return $databaseStoreResult;
  139. }
  140. $clearedDrafts = 0;
  141. foreach ($this->registeredTokens() as $token) {
  142. $draft = Cache::get($this->draftKey($token));
  143. if (is_array($draft)) {
  144. $this->releaseReservations($draft['reservations'] ?? [], $token);
  145. $clearedDrafts++;
  146. }
  147. Cache::forget($this->draftKey($token));
  148. }
  149. Cache::forget(self::TOKENS_KEY);
  150. return $clearedDrafts;
  151. });
  152. }
  153. private function clearDatabaseStore(): ?int
  154. {
  155. $store = Cache::getStore();
  156. if (!$store instanceof DatabaseStore) {
  157. return null;
  158. }
  159. $table = config('cache.stores.'.config('cache.default').'.table', 'cache');
  160. $keyPrefix = $store->getPrefix().self::CACHE_PREFIX;
  161. $keys = $store->getConnection()
  162. ->table($table)
  163. ->where('key', 'like', $keyPrefix.'%')
  164. ->pluck('key')
  165. ->filter(fn (string $key): bool => str_starts_with($key, $keyPrefix));
  166. $clearedDrafts = $keys
  167. ->map(fn (string $key): string => substr($key, strlen($keyPrefix)))
  168. ->filter(fn (string $key): bool => Str::isUuid($key))
  169. ->count();
  170. if ($keys->isNotEmpty()) {
  171. $store->getConnection()->table($table)->whereIn('key', $keys)->delete();
  172. }
  173. return $clearedDrafts;
  174. }
  175. //
  176. private function ownsAllReservations(array $reservations, string $token): bool
  177. {
  178. foreach ($reservations as $key) {
  179. if (Cache::get($key) !== $token) {
  180. return false;
  181. }
  182. }
  183. return true;
  184. }
  185. private function releaseReservations(array $reservations, string $token): void
  186. {
  187. foreach ($reservations as $key) {
  188. if (Cache::get($key) === $token) {
  189. Cache::forget($key);
  190. }
  191. }
  192. }
  193. private function registeredTokens(): array
  194. {
  195. $tokens = Cache::get(self::TOKENS_KEY, []);
  196. return is_array($tokens) ? $tokens : [];
  197. }
  198. private function reservationKeys(array $data): array
  199. {
  200. $keys = [];
  201. foreach (self::UNIQUE_FIELDS as $field) {
  202. $value = $data[$field] ?? null;
  203. if (! is_string($value) || $value === '') {
  204. continue;
  205. }
  206. $normalizedValue = Str::lower(trim($value));
  207. $keys[$field] = self::CACHE_PREFIX."reservation:{$field}:".hash('sha256', $normalizedValue);
  208. }
  209. return $keys;
  210. }
  211. private function validateDatabaseUniques(array $data): void
  212. {
  213. Validator::make($data, [
  214. 'document_number' => 'sometimes|nullable|unique:students,document_number',
  215. 'email' => 'sometimes|nullable|unique:students,email',
  216. ])->validate();
  217. }
  218. private function uniqueReservationConflict(string $field): never
  219. {
  220. throw ValidationException::withMessages([
  221. $field => __('validation.unique', [
  222. 'attribute' => __("validation.attributes.{$field}"),
  223. ]),
  224. ]);
  225. }
  226. //
  227. private function resolveUnitId(User $user): int
  228. {
  229. $activeUnitId = request()->input('active_unit_id');
  230. if ($activeUnitId) {
  231. $unit = $user->units()->where('units.id', $activeUnitId)->first();
  232. abort_if(! $unit, 403, 'Unidade não autorizada para este usuário.');
  233. return $unit->id;
  234. }
  235. $unit = $user->units()->first();
  236. abort_if(! $unit, 403, 'Usuário sem unidade associada.');
  237. return $unit->id;
  238. }
  239. //
  240. private function registerToken(string $token): void
  241. {
  242. $tokens = array_values(array_filter(
  243. $this->registeredTokens(),
  244. fn (string $registeredToken): bool => Cache::has($this->draftKey($registeredToken)),
  245. ));
  246. $tokens[] = $token;
  247. Cache::forever(self::TOKENS_KEY, array_values(array_unique($tokens)));
  248. }
  249. private function unregisterToken(string $token): void
  250. {
  251. $tokens = array_values(array_filter(
  252. $this->registeredTokens(),
  253. fn (string $registeredToken): bool => $registeredToken !== $token,
  254. ));
  255. if ($tokens === []) {
  256. Cache::forget(self::TOKENS_KEY);
  257. return;
  258. }
  259. Cache::forever(self::TOKENS_KEY, $tokens);
  260. }
  261. //
  262. private function draftKey(string $token): string
  263. {
  264. return self::CACHE_PREFIX.$token;
  265. }
  266. private function invalidToken(): never
  267. {
  268. throw ValidationException::withMessages([
  269. 'registration_draft_token' => __('validation.registration_draft_invalid'),
  270. ]);
  271. }
  272. }