StudentRegistrationDraftService.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  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\Rule;
  10. use Illuminate\Validation\ValidationException;
  11. class StudentRegistrationDraftService
  12. {
  13. private const CACHE_PREFIX = 'student-registration-draft:';
  14. private const LOCK_KEY = 'student-registration-drafts:lock';
  15. private const LOCK_SECONDS = 60;
  16. private const TOKENS_KEY = self::CACHE_PREFIX.'tokens';
  17. private const UNIQUE_FIELDS = [
  18. 'document_number',
  19. 'email',
  20. ];
  21. public function store(User $user, array $data): array
  22. {
  23. // vincula o rascunho ao usuario e a unidade ativa
  24. $unitId = $this->resolveUnitId($user);
  25. $requestedToken = $data['registration_draft_token'] ?? null;
  26. unset($data['registration_draft_token']);
  27. return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
  28. $user,
  29. $unitId,
  30. $requestedToken,
  31. $data,
  32. ): array {
  33. $existingDraft = null;
  34. // reaproveita apenas um rascunho valido do mesmo usuario e unidade
  35. if ($requestedToken !== null) {
  36. $existingDraft = $this->findOwnedDraft($user, $requestedToken, $unitId);
  37. if ($existingDraft === null) {
  38. $this->invalidToken();
  39. }
  40. }
  41. $token = $requestedToken ?? (string) Str::uuid();
  42. $expiresAt = now()->addMinutes(config('students.registration_draft_ttl_minutes', 15));
  43. $oldReservations = $existingDraft['reservations'] ?? [];
  44. $newReservations = $this->reservationKeys($data);
  45. $acquiredReservations = [];
  46. // impede conflito com alunos ja cadastrados no banco
  47. $this->validateDatabaseUniques($data);
  48. try {
  49. // reserva os campos unicos enquanto o cadastro estiver em andamento
  50. foreach ($newReservations as $field => $key) {
  51. if (($oldReservations[$field] ?? null) === $key) {
  52. if (Cache::get($key) !== $token) {
  53. $this->invalidToken();
  54. }
  55. continue;
  56. }
  57. if (! Cache::add($key, $token, $expiresAt) && Cache::get($key) !== $token) {
  58. $this->uniqueReservationConflict($field);
  59. }
  60. $acquiredReservations[$field] = $key;
  61. }
  62. } catch (ValidationException $exception) {
  63. // desfaz somente as reservas obtidas nesta tentativa
  64. $this->releaseReservations($acquiredReservations, $token);
  65. throw $exception;
  66. }
  67. $removedReservations = array_diff($oldReservations, $newReservations);
  68. // libera valores que deixaram de fazer parte do rascunho
  69. $this->releaseReservations($removedReservations, $token);
  70. foreach ($newReservations as $key) {
  71. Cache::put($key, $token, $expiresAt);
  72. }
  73. $draft = [
  74. 'user_id' => $user->id,
  75. 'unit_id' => $unitId,
  76. 'data' => $data,
  77. 'reservations' => $newReservations,
  78. 'expires_at' => $expiresAt->toIso8601String(),
  79. ];
  80. Cache::put($this->draftKey($token), $draft, $expiresAt);
  81. // mantem um indice para permitir a limpeza em qualquer cache
  82. $this->registerToken($token);
  83. return [
  84. 'registration_draft_token' => $token,
  85. 'expires_at' => $draft['expires_at'],
  86. ];
  87. });
  88. }
  89. // conclui um cadastro salvo como rascunho
  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. // confirma a posse do rascunho e de todas as reservas
  101. if ($draft === null || ! $this->ownsAllReservations($draft['reservations'], $token)) {
  102. $this->invalidToken();
  103. }
  104. $this->validateDatabaseUniques($draft['data']);
  105. $result = $callback($draft['data'], $unitId);
  106. // remove o rascunho e suas reservas apos concluir o cadastro
  107. $this->releaseReservations($draft['reservations'], $token);
  108. Cache::forget($this->draftKey($token));
  109. $this->unregisterToken($token);
  110. return $result;
  111. });
  112. }
  113. public function executeWithoutDraft(User $user, array $data, Closure $callback): mixed
  114. {
  115. $unitId = $this->resolveUnitId($user);
  116. return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function () use (
  117. $data,
  118. $unitId,
  119. $callback,
  120. ): mixed {
  121. $this->validateDatabaseUniques($data);
  122. // bloqueia valores reservados por cadastros ainda nao concluidos
  123. foreach ($this->reservationKeys($data) as $field => $key) {
  124. if (Cache::has($key)) {
  125. $this->uniqueReservationConflict($field);
  126. }
  127. }
  128. return $callback($data, $unitId);
  129. });
  130. }
  131. public function findOwnedDraft(User $user, string $token, ?int $unitId = null): ?array
  132. {
  133. // aceita somente rascunhos do usuario e da unidade informados
  134. $draft = Cache::get($this->draftKey($token));
  135. if (! is_array($draft)) {
  136. return null;
  137. }
  138. $unitId ??= $this->resolveUnitId($user);
  139. if ($draft['user_id'] !== $user->id || $draft['unit_id'] !== $unitId) {
  140. return null;
  141. }
  142. return $draft;
  143. }
  144. // remove todos os rascunhos e reservas
  145. public function clearAll(): int
  146. {
  147. return Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS)->block(5, function (): int {
  148. // usa exclusao direta quando o cache esta no banco de dados
  149. $databaseStoreResult = $this->clearDatabaseStore();
  150. if ($databaseStoreResult !== null) {
  151. return $databaseStoreResult;
  152. }
  153. $clearedDrafts = 0;
  154. // nos demais caches percorre o indice de tokens registrados
  155. foreach ($this->registeredTokens() as $token) {
  156. $draft = Cache::get($this->draftKey($token));
  157. if (is_array($draft)) {
  158. $this->releaseReservations($draft['reservations'] ?? [], $token);
  159. $clearedDrafts++;
  160. }
  161. Cache::forget($this->draftKey($token));
  162. }
  163. Cache::forget(self::TOKENS_KEY);
  164. return $clearedDrafts;
  165. });
  166. }
  167. private function clearDatabaseStore(): ?int
  168. {
  169. $store = Cache::getStore();
  170. if (!$store instanceof DatabaseStore) {
  171. return null;
  172. }
  173. $table = config('cache.stores.'.config('cache.default').'.table', 'cache');
  174. $keyPrefix = $store->getPrefix().self::CACHE_PREFIX;
  175. // seleciona apenas as chaves pertencentes a este fluxo
  176. $keys = $store->getConnection()
  177. ->table($table)
  178. ->where('key', 'like', $keyPrefix.'%')
  179. ->pluck('key')
  180. ->filter(fn (string $key): bool => str_starts_with($key, $keyPrefix));
  181. $clearedDrafts = $keys
  182. ->map(fn (string $key): string => substr($key, strlen($keyPrefix)))
  183. ->filter(fn (string $key): bool => Str::isUuid($key))
  184. ->count();
  185. if ($keys->isNotEmpty()) {
  186. $store->getConnection()->table($table)->whereIn('key', $keys)->delete();
  187. }
  188. return $clearedDrafts;
  189. }
  190. // controla a posse das reservas temporarias
  191. private function ownsAllReservations(array $reservations, string $token): bool
  192. {
  193. foreach ($reservations as $key) {
  194. if (Cache::get($key) !== $token) {
  195. return false;
  196. }
  197. }
  198. return true;
  199. }
  200. private function releaseReservations(array $reservations, string $token): void
  201. {
  202. // libera apenas reservas que ainda pertencem ao token
  203. foreach ($reservations as $key) {
  204. if (Cache::get($key) === $token) {
  205. Cache::forget($key);
  206. }
  207. }
  208. }
  209. private function registeredTokens(): array
  210. {
  211. $tokens = Cache::get(self::TOKENS_KEY, []);
  212. return is_array($tokens) ? $tokens : [];
  213. }
  214. private function reservationKeys(array $data): array
  215. {
  216. $keys = [];
  217. foreach (self::UNIQUE_FIELDS as $field) {
  218. $value = $data[$field] ?? null;
  219. if (! is_string($value) || $value === '') {
  220. continue;
  221. }
  222. $normalizedValue = Str::lower(trim($value));
  223. // o hash evita armazenar dados pessoais na chave do cache
  224. $keys[$field] = self::CACHE_PREFIX."reservation:{$field}:".hash('sha256', $normalizedValue);
  225. }
  226. return $keys;
  227. }
  228. private function validateDatabaseUniques(array $data): void
  229. {
  230. Validator::make($data, [
  231. 'document_number' => ['sometimes', 'nullable', Rule::unique('students', 'document_number')->withoutTrashed()],
  232. 'email' => ['sometimes', 'nullable', Rule::unique('students', 'email')->withoutTrashed()],
  233. ])->validate();
  234. }
  235. private function uniqueReservationConflict(string $field): never
  236. {
  237. throw ValidationException::withMessages([
  238. $field => __('validation.unique', [
  239. 'attribute' => __("validation.attributes.{$field}"),
  240. ]),
  241. ]);
  242. }
  243. // resolve e valida a unidade usada no cadastro
  244. private function resolveUnitId(User $user): int
  245. {
  246. $activeUnitId = request()->input('active_unit_id');
  247. if ($activeUnitId) {
  248. $unit = $user->units()->where('units.id', $activeUnitId)->first();
  249. abort_if(! $unit, 403, 'Unidade não autorizada para este usuário.');
  250. return $unit->id;
  251. }
  252. $unit = $user->units()->first();
  253. abort_if(! $unit, 403, 'Usuário sem unidade associada.');
  254. return $unit->id;
  255. }
  256. // mantem o indice de rascunhos ativos
  257. private function registerToken(string $token): void
  258. {
  259. $tokens = array_values(array_filter(
  260. $this->registeredTokens(),
  261. fn (string $registeredToken): bool => Cache::has($this->draftKey($registeredToken)),
  262. ));
  263. $tokens[] = $token;
  264. Cache::forever(self::TOKENS_KEY, array_values(array_unique($tokens)));
  265. }
  266. private function unregisterToken(string $token): void
  267. {
  268. $tokens = array_values(array_filter(
  269. $this->registeredTokens(),
  270. fn (string $registeredToken): bool => $registeredToken !== $token,
  271. ));
  272. if ($tokens === []) {
  273. Cache::forget(self::TOKENS_KEY);
  274. return;
  275. }
  276. Cache::forever(self::TOKENS_KEY, $tokens);
  277. }
  278. // monta chaves e erros do fluxo
  279. private function draftKey(string $token): string
  280. {
  281. return self::CACHE_PREFIX.$token;
  282. }
  283. private function invalidToken(): never
  284. {
  285. throw ValidationException::withMessages([
  286. 'registration_draft_token' => __('validation.registration_draft_invalid'),
  287. ]);
  288. }
  289. }