add controllers, servoices
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Users;
|
||||
|
||||
use App\Models\LoginActivity;
|
||||
|
||||
class LoginActivityService
|
||||
{
|
||||
public function list(int $perPage, int $page): array
|
||||
{
|
||||
$perPage = max(1, min($perPage, 200));
|
||||
$page = max(1, $page);
|
||||
|
||||
$total = (int) LoginActivity::query()->count();
|
||||
$pageCount = (int) max(1, ceil($total / $perPage));
|
||||
|
||||
$activities = LoginActivity::query()
|
||||
->orderByDesc('login_time')
|
||||
->forPage($page, $perPage)
|
||||
->get();
|
||||
|
||||
$normalized = $activities->map(static function (LoginActivity $activity) {
|
||||
return [
|
||||
'user_id' => $activity->user_id,
|
||||
'email' => $activity->email,
|
||||
'login_time' => optional($activity->login_time)->toDateTimeString(),
|
||||
'logout_time' => optional($activity->logout_time)->toDateTimeString(),
|
||||
'ip_address' => $activity->ip_address,
|
||||
'user_agent' => $activity->user_agent,
|
||||
];
|
||||
})->all();
|
||||
|
||||
return [
|
||||
'activities' => $normalized,
|
||||
'pagination' => [
|
||||
'total' => $total,
|
||||
'perPage' => $perPage,
|
||||
'currentPage' => $page,
|
||||
'pageCount' => $pageCount,
|
||||
'hasNext' => $page < $pageCount,
|
||||
'hasPrevious' => $page > 1,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Users;
|
||||
|
||||
use App\Services\EmailService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UserEventService
|
||||
{
|
||||
public function __construct(private EmailService $emailService)
|
||||
{
|
||||
}
|
||||
|
||||
public function handleNewAccountAdded(array $userData): bool
|
||||
{
|
||||
$email = (string) ($userData['email'] ?? '');
|
||||
if ($email === '') {
|
||||
Log::warning('UserEventService: missing email for welcome message.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$name = trim((string) (($userData['firstname'] ?? '') . ' ' . ($userData['lastname'] ?? '')));
|
||||
$safeName = e($name !== '' ? $name : 'there');
|
||||
|
||||
$subject = 'Welcome to Al Rahma Sunday School!';
|
||||
$message = '<p>Hello ' . $safeName . ',</p>'
|
||||
. '<p>Your account has been created. Welcome to Al Rahma Sunday School.</p>';
|
||||
|
||||
return $this->emailService->send($email, $subject, $message, 'registration');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Users;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class UserListService
|
||||
{
|
||||
private const SORT_FIELDS = [
|
||||
'id',
|
||||
'firstname',
|
||||
'lastname',
|
||||
'email',
|
||||
'status',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
public function list(?string $sort, ?string $order): array
|
||||
{
|
||||
$sort = in_array($sort, self::SORT_FIELDS, true) ? $sort : 'id';
|
||||
$order = strtolower((string) $order) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$users = User::query()
|
||||
->with('roles')
|
||||
->orderBy($sort, $order)
|
||||
->get();
|
||||
|
||||
$guardianMap = $this->guardianTypeMap();
|
||||
[$secondByUserId, $secondByEmail] = $this->secondParentMaps();
|
||||
|
||||
$usersWithRoles = [];
|
||||
|
||||
foreach ($users as $user) {
|
||||
$roles = $user->roles->map(fn ($role) => (string) $role->name)->values()->all();
|
||||
$roleIds = $user->roles->map(fn ($role) => (int) $role->id)->values()->all();
|
||||
|
||||
$userId = (int) $user->id;
|
||||
$parentType = $guardianMap[$userId] ?? '';
|
||||
|
||||
$isSecond = false;
|
||||
if ($userId > 0 && !empty($secondByUserId[$userId])) {
|
||||
$isSecond = true;
|
||||
} else {
|
||||
$email = trim(strtolower((string) $user->email));
|
||||
if ($email !== '' && !empty($secondByEmail[$email])) {
|
||||
$isSecond = true;
|
||||
}
|
||||
}
|
||||
|
||||
$usersWithRoles[] = [
|
||||
'user' => $user->toArray(),
|
||||
'roles' => $roles,
|
||||
'role_ids' => $roleIds,
|
||||
'parent_type' => $parentType,
|
||||
'second_from_parents' => $isSecond ? 'Yes' : '',
|
||||
];
|
||||
}
|
||||
|
||||
return $usersWithRoles;
|
||||
}
|
||||
|
||||
public function getUsersWithRoles(?string $sort = 'id', ?string $order = 'asc'): array
|
||||
{
|
||||
$sort = (string) ($sort ?? 'id');
|
||||
$order = (string) ($order ?? 'asc');
|
||||
|
||||
$map = [
|
||||
'id' => 'users.id',
|
||||
'firstname' => 'users.firstname',
|
||||
'lastname' => 'users.lastname',
|
||||
'email' => 'users.email',
|
||||
'role' => 'role',
|
||||
];
|
||||
|
||||
$sortField = $map[$sort] ?? 'users.id';
|
||||
|
||||
return User::getUsersWithRoles($sortField, $order);
|
||||
}
|
||||
|
||||
public function getUnverifiedUsers(): array
|
||||
{
|
||||
return User::getUnverifiedUsers();
|
||||
}
|
||||
|
||||
public function getUsersByRole(string $role): array
|
||||
{
|
||||
return User::getUsersByRole($role);
|
||||
}
|
||||
|
||||
private function guardianTypeMap(): array
|
||||
{
|
||||
if (!Schema::hasTable('family_guardians')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('family_guardians')
|
||||
->selectRaw('user_id, MAX(is_primary) AS max_primary, COUNT(*) AS cnt')
|
||||
->groupBy('user_id')
|
||||
->get();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$userId = (int) ($row->user_id ?? 0);
|
||||
if ($userId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$count = (int) ($row->cnt ?? 0);
|
||||
if ($count <= 0) {
|
||||
continue;
|
||||
}
|
||||
$maxPrimary = (int) ($row->max_primary ?? 0);
|
||||
$map[$userId] = $maxPrimary > 0 ? 'Primary' : 'Secondary';
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function secondParentMaps(): array
|
||||
{
|
||||
if (!Schema::hasTable('parents')) {
|
||||
return [[], []];
|
||||
}
|
||||
|
||||
$byUserId = [];
|
||||
$byEmail = [];
|
||||
|
||||
if (Schema::hasColumn('parents', 'secondparent_user_id')) {
|
||||
$rows = DB::table('parents')
|
||||
->selectRaw('secondparent_user_id AS uid, COUNT(*) AS cnt')
|
||||
->where('secondparent_user_id', '!=', 0)
|
||||
->groupBy('secondparent_user_id')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$userId = (int) ($row->uid ?? 0);
|
||||
if ($userId > 0) {
|
||||
$byUserId[$userId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('parents', 'secondparent_email')) {
|
||||
$rows = DB::table('parents')
|
||||
->selectRaw('LOWER(TRIM(secondparent_email)) AS em, COUNT(*) AS cnt')
|
||||
->where('secondparent_email', '!=', '')
|
||||
->groupBy('secondparent_email')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$email = trim(strtolower((string) ($row->em ?? '')));
|
||||
if ($email !== '') {
|
||||
$byEmail[$email] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$byUserId, $byEmail];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?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)->delete();
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user