294 lines
9.5 KiB
PHP
294 lines
9.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Users;
|
|
|
|
use App\Models\Role;
|
|
use App\Models\User;
|
|
use App\Models\UserRole;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
class UserManagementService
|
|
{
|
|
public function __construct(private UserEventService $eventService)
|
|
{
|
|
}
|
|
|
|
public function create(array $payload, ?int $actorId = null): array
|
|
{
|
|
$actorId = $actorId ?? (auth()->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' => pbkdf2_hash((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;
|
|
}
|
|
}
|
|
}
|