id() ?? null); try { $user = DB::transaction(function () use ($payload, $actorId) { $data = $this->normalizeForCreate($payload); $user = User::query()->create($data); UserRole::query()->create([ 'user_id' => $user->id, 'role_id' => (int) $payload['role_id'], 'updated_by' => $actorId, ]); return $user; }); } catch (\Throwable $e) { return ['ok' => false, 'message' => 'Failed to create user.']; } $this->eventService->handleNewAccountAdded($user->toArray()); return ['ok' => true, 'user' => $user]; } public function update(int $userId, array $payload): array { $user = User::query()->find($userId); if (! $user) { return ['ok' => false, 'message' => 'User not found.']; } $data = $this->normalizeForUpdate($payload); if (empty($data)) { return ['ok' => false, 'message' => 'No changes requested.']; } try { $user->fill($data); $user->save(); } catch (\Throwable $e) { return ['ok' => false, 'message' => 'Failed to update user.']; } return ['ok' => true, 'user' => $user]; } public function delete(int $userId): array { $user = User::query()->find($userId); if (! $user) { return ['ok' => false, 'message' => 'User not found.']; } try { DB::transaction(function () use ($userId, $user) { UserRole::query()->where('user_id', $userId)->forceDelete(); $user->delete(); }); } catch (\Throwable $e) { return ['ok' => false, 'message' => 'Failed to delete user.']; } return ['ok' => true]; } public function assignRole(int $userId, int $roleId, ?int $actorId = null): bool { $actorId = $actorId ?? (auth()->id() ?? null); if (! User::query()->whereKey($userId)->exists()) { return false; } if (! Role::query()->whereKey($roleId)->exists()) { return false; } return UserRole::updateOrInsertRole($userId, $roleId, $actorId); } public function cleanupUnverifiedUsers(int $olderThanMinutes = 15): int { $cutoff = now()->subMinutes(max(1, $olderThanMinutes)); $deleted = User::query() ->where('is_verified', 0) ->where('created_at', '<=', $cutoff) ->delete(); Log::info(sprintf( 'Deleted %d unverified users created before %s.', $deleted, $cutoff->format('Y-m-d H:i:s') )); return $deleted; } public function validateRegistrationInput(array $data): array { $rules = [ 'firstname' => ['required', 'regex:/^[a-zA-Z\\s-]+$/', 'min:2', 'max:30'], 'lastname' => ['required', 'regex:/^[a-zA-Z\\s-]+$/', 'min:2', 'max:30'], 'gender' => ['required', 'in:Male,Female'], 'email' => ['required', 'email', 'max:50'], 'confirm_email' => ['required', 'same:email'], 'cellphone' => ['required', 'regex:/^[\\d\\s\\-\\(\\)\\.]+$/', 'min:10', 'max:20'], 'address_street' => ['nullable', 'regex:/^[a-zA-Z0-9\\s\\-]+$/', 'max:150'], 'apt' => ['nullable', 'regex:/^[a-zA-Z0-9\\s-]+$/', 'max:10'], 'city' => ['required', 'regex:/^[a-zA-Z\\s]+$/', 'min:2', 'max:30'], 'state' => ['required', 'in:CT,ME,MA,NH,NY,RI,VT'], 'zip' => ['required', 'regex:/^\\d{5}$/'], 'accept_school_policy' => ['required'], ]; $isParent = ! empty($data['is_parent']); $noSecondParent = ! empty($data['no_second_parent_info']); if ($isParent && ! $noSecondParent) { $rules += [ 'second_firstname' => ['required', 'regex:/^[a-zA-Z\\s\\-]+$/', 'min:2', 'max:30'], 'second_lastname' => ['required', 'regex:/^[a-zA-Z\\s\\-]+$/', 'min:2', 'max:30'], 'second_gender' => ['required', 'in:Male,Female'], 'second_email' => ['required', 'email', 'max:50'], 'second_cellphone' => ['required', 'regex:/^[\\d\\s\\-\\(\\)\\.]+$/', 'min:10', 'max:20'], ]; } $validator = Validator::make($data, $rules); if ($validator->fails()) { return $validator->errors()->toArray(); } return []; } private function normalizeForCreate(array $payload): array { return [ 'school_id' => $payload['school_id'] ?? null, 'firstname' => $this->titleCase($payload['firstname'] ?? ''), 'lastname' => $this->titleCase($payload['lastname'] ?? ''), 'gender' => $this->normalizeGender($payload['gender'] ?? null), 'cellphone' => (string) ($payload['cellphone'] ?? ''), 'email' => strtolower((string) ($payload['email'] ?? '')), 'address_street' => (string) ($payload['address_street'] ?? ''), 'apt' => $payload['apt'] ?? null, 'city' => $this->titleCase($payload['city'] ?? ''), 'state' => (string) ($payload['state'] ?? ''), 'zip' => (string) ($payload['zip'] ?? ''), 'accept_school_policy' => (bool) ($payload['accept_school_policy'] ?? false), 'is_verified' => isset($payload['is_verified']) ? (bool) $payload['is_verified'] : 0, 'status' => $payload['status'] ?? 'Inactive', 'is_suspended' => isset($payload['is_suspended']) ? (bool) $payload['is_suspended'] : 0, 'failed_attempts' => $payload['failed_attempts'] ?? null, 'password' => Hash::make((string) ($payload['password'] ?? '')), 'token' => $payload['token'] ?? null, 'account_id' => $payload['account_id'] ?? null, 'user_type' => $payload['user_type'] ?? 'primary', 'semester' => (string) ($payload['semester'] ?? ''), 'school_year' => $payload['school_year'] ?? null, 'rfid_tag' => $payload['rfid_tag'] ?? null, 'last_failed_at' => $this->normalizeDateTime($payload['last_failed_at'] ?? null), ]; } private function normalizeForUpdate(array $payload): array { $data = []; $map = [ 'school_id', 'firstname', 'lastname', 'gender', 'cellphone', 'email', 'address_street', 'apt', 'city', 'state', 'zip', 'accept_school_policy', 'user_type', 'rfid_tag', 'failed_attempts', 'last_failed_at', 'semester', 'school_year', 'status', 'is_suspended', 'is_verified', 'token', 'account_id', ]; foreach ($map as $field) { if (! array_key_exists($field, $payload)) { continue; } $value = $payload[$field]; switch ($field) { case 'firstname': case 'lastname': case 'city': $data[$field] = $this->titleCase($value); break; case 'email': $data[$field] = strtolower((string) $value); break; case 'gender': $data[$field] = $this->normalizeGender($value); break; case 'accept_school_policy': case 'is_suspended': case 'is_verified': $data[$field] = (bool) $value; break; case 'last_failed_at': $data[$field] = $this->normalizeDateTime($value); break; case 'failed_attempts': case 'school_id': $data[$field] = $value === '' ? null : $value; break; default: $data[$field] = $value; break; } } return $data; } private function normalizeGender(?string $value): ?string { $value = strtolower(trim((string) $value)); if ($value === '') { return null; } if ($value === 'male') { return 'Male'; } if ($value === 'female') { return 'Female'; } return null; } private function titleCase(?string $value): string { $value = trim((string) $value); if ($value === '') { return ''; } return ucfirst($value); } private function normalizeDateTime($value): ?string { $value = trim((string) ($value ?? '')); if ($value === '') { return null; } try { return CarbonImmutable::parse($value)->format('Y-m-d H:i:s'); } catch (\Throwable $e) { return null; } } }