reconstruction of the project
This commit is contained in:
@@ -1,868 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\IpAttempt;
|
||||
use App\Models\LoginActivity;
|
||||
use App\Models\PasswordReset;
|
||||
use App\Models\PasswordResetRequest;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRole;
|
||||
use App\Services\EmailService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class UserController extends BaseApiController
|
||||
{
|
||||
protected User $user;
|
||||
protected UserRole $userRole;
|
||||
protected Role $role;
|
||||
protected PasswordReset $passwordReset;
|
||||
protected PasswordResetRequest $resetRequest;
|
||||
protected LoginActivity $loginActivity;
|
||||
protected IpAttempt $ipAttempt;
|
||||
protected EmailService $emailService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->user = model(User::class);
|
||||
$this->userRole = model(UserRole::class);
|
||||
$this->role = model(Role::class);
|
||||
$this->passwordReset = model(PasswordReset::class);
|
||||
$this->resetRequest = model(PasswordResetRequest::class);
|
||||
$this->loginActivity = model(LoginActivity::class);
|
||||
$this->ipAttempt = model(IpAttempt::class);
|
||||
$this->emailService = app(EmailService::class);
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$sort = (string) $this->laravelRequest->query('sort', 'id');
|
||||
$order = strtolower((string) $this->laravelRequest->query('order', 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||
$allowedFields = ['id', 'firstname', 'lastname', 'email', 'role'];
|
||||
|
||||
$builder = DB::table('users')
|
||||
->select(
|
||||
'users.id',
|
||||
'users.firstname',
|
||||
'users.lastname',
|
||||
'users.email',
|
||||
'user_roles.role_id',
|
||||
'roles.name as role',
|
||||
'users.status',
|
||||
'users.updated_at'
|
||||
)
|
||||
->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id')
|
||||
->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id');
|
||||
|
||||
if (in_array($sort, $allowedFields, true)) {
|
||||
$column = $sort === 'role' ? 'roles.name' : 'users.' . $sort;
|
||||
$builder->orderBy($column, $order);
|
||||
} else {
|
||||
$builder->orderBy('users.id', 'asc');
|
||||
}
|
||||
|
||||
$users = $builder->get()->map(fn($row) => (array) $row)->toArray();
|
||||
$roles = $this->role->newQuery()->orderBy('name')->get()->toArray();
|
||||
|
||||
return $this->respondSuccess([
|
||||
'users' => $users,
|
||||
'roles' => $roles,
|
||||
], 'Users retrieved successfully');
|
||||
}
|
||||
|
||||
public function listWithRoles(): JsonResponse
|
||||
{
|
||||
return $this->respondSuccess([
|
||||
'users' => $this->buildUsersWithRoles(),
|
||||
], 'Users with roles retrieved');
|
||||
}
|
||||
|
||||
public function store(): JsonResponse
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'firstname' => 'required|min:2|max:100',
|
||||
'lastname' => 'required|min:2|max:100',
|
||||
'password' => 'required|min:8',
|
||||
'cellphone' => 'required|min:7|max:50',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'address_street' => 'required|min:3|max:150',
|
||||
'city' => 'required|min:2|max:100',
|
||||
'state' => 'required|min:2|max:10',
|
||||
'zip' => 'required|min:3|max:20',
|
||||
'accept_school_policy' => 'required',
|
||||
'role_id' => 'required|integer|exists:roles,id',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$userData = [
|
||||
'lastname' => ucfirst(strtolower((string) $data['lastname'])),
|
||||
'firstname' => ucfirst(strtolower((string) $data['firstname'])),
|
||||
'cellphone' => trim((string) $data['cellphone']),
|
||||
'email' => strtolower(trim((string) $data['email'])),
|
||||
'address_street' => trim((string) $data['address_street']),
|
||||
'city' => ucfirst(strtolower((string) $data['city'])),
|
||||
'state' => strtoupper(trim((string) $data['state'])),
|
||||
'zip' => trim((string) $data['zip']),
|
||||
'accept_school_policy' => in_array($data['accept_school_policy'], [true, '1', 1, 'on'], true) ? 1 : 0,
|
||||
'password' => $this->hashPassword((string) $data['password']),
|
||||
'status' => 'Inactive',
|
||||
];
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$user = $this->user->newQuery()->create($userData);
|
||||
$this->userRole->newQuery()->create([
|
||||
'user_id' => $user->id,
|
||||
'role_id' => (int) $data['role_id'],
|
||||
'created_at' => utc_now(),
|
||||
]);
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Failed to create user: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to create user', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$created = $this->user->find($user->id);
|
||||
unset($created['password'], $created['token']);
|
||||
|
||||
return $this->success(['user' => $created], 'User created successfully', Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$user = $this->user->find($id);
|
||||
if (!$user) {
|
||||
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$this->userRole->newQuery()->where('user_id', $id)->delete();
|
||||
$this->user->delete($id);
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Failed to delete user: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete user', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return $this->respondSuccess(null, 'User deleted successfully');
|
||||
}
|
||||
|
||||
public function updateUser(int $id): JsonResponse
|
||||
{
|
||||
$payload = $this->payloadData();
|
||||
$user = $this->user->find($id);
|
||||
if (!$user) {
|
||||
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$validationData = array_merge($user, $payload);
|
||||
$rules = [
|
||||
'firstname' => 'required|min:2|max:100',
|
||||
'lastname' => 'required|min:2|max:100',
|
||||
'email' => 'required|email|unique:users,email,' . $id,
|
||||
'state' => 'nullable|max:10',
|
||||
'zip' => 'nullable|max:20',
|
||||
'cellphone' => 'nullable|max:50',
|
||||
'failed_attempts' => 'nullable|integer',
|
||||
'gender' => 'nullable|in:male,female,MALE,FEMALE',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($validationData, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$bool = static function ($value, $default = 0) {
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
return in_array($value, [true, 1, '1', 'true', 'on'], true) ? 1 : 0;
|
||||
};
|
||||
|
||||
$parseDateTime = static function ($value) {
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
$timestamp = strtotime((string) $value);
|
||||
return $timestamp ? date('Y-m-d H:i:s', $timestamp) : null;
|
||||
};
|
||||
|
||||
$data = [
|
||||
'id' => $id,
|
||||
'account_id' => trim((string) ($payload['account_id'] ?? $user['account_id'] ?? '')) ?: null,
|
||||
'lastname' => trim((string) ($payload['lastname'] ?? $user['lastname'] ?? '')),
|
||||
'firstname' => trim((string) ($payload['firstname'] ?? $user['firstname'] ?? '')),
|
||||
'gender' => trim((string) ($payload['gender'] ?? $user['gender'] ?? '')) ?: null,
|
||||
'cellphone' => trim((string) ($payload['cellphone'] ?? $user['cellphone'] ?? '')) ?: null,
|
||||
'email' => strtolower(trim((string) ($payload['email'] ?? $user['email'] ?? ''))),
|
||||
'address_street' => trim((string) ($payload['address_street'] ?? $user['address_street'] ?? '')) ?: null,
|
||||
'apt' => trim((string) ($payload['apt'] ?? $user['apt'] ?? '')) ?: null,
|
||||
'city' => trim((string) ($payload['city'] ?? $user['city'] ?? '')) ?: null,
|
||||
'state' => strtoupper(trim((string) ($payload['state'] ?? $user['state'] ?? ''))) ?: null,
|
||||
'zip' => trim((string) ($payload['zip'] ?? $user['zip'] ?? '')) ?: null,
|
||||
'accept_school_policy' => $bool($payload['accept_school_policy'] ?? $user['accept_school_policy'] ?? 0, (int) ($user['accept_school_policy'] ?? 0)),
|
||||
'user_type' => trim((string) ($payload['user_type'] ?? $user['user_type'] ?? '')) ?: null,
|
||||
'rfid_tag' => trim((string) ($payload['rfid_tag'] ?? $user['rfid_tag'] ?? '')) ?: null,
|
||||
'school_id' => trim((string) ($payload['school_id'] ?? $user['school_id'] ?? '')) ?: null,
|
||||
'failed_attempts' => ($payload['failed_attempts'] ?? '') === '' ? null : (int) $payload['failed_attempts'],
|
||||
'last_failed_at' => $parseDateTime($payload['last_failed_at'] ?? $user['last_failed_at'] ?? null),
|
||||
'semester' => trim((string) ($payload['semester'] ?? $user['semester'] ?? '')) ?: null,
|
||||
'school_year' => trim((string) ($payload['school_year'] ?? $user['school_year'] ?? '')) ?: null,
|
||||
'status' => trim((string) ($payload['status'] ?? $user['status'] ?? '')) ?: null,
|
||||
'is_suspended' => $bool($payload['is_suspended'] ?? $user['is_suspended'] ?? 0, (int) ($user['is_suspended'] ?? 0)),
|
||||
'is_verified' => $bool($payload['is_verified'] ?? $user['is_verified'] ?? 0, (int) ($user['is_verified'] ?? 0)),
|
||||
'token' => trim((string) ($payload['token'] ?? $user['token'] ?? '')) ?: null,
|
||||
'updated_at' => $parseDateTime($payload['updated_at'] ?? null) ?? $user['updated_at'],
|
||||
'created_at' => $parseDateTime($payload['created_at'] ?? null) ?? $user['created_at'],
|
||||
];
|
||||
|
||||
if (!$this->user->save($data)) {
|
||||
return $this->respondError('Failed to update user', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$updated = $this->user->find($id);
|
||||
unset($updated['password'], $updated['token']);
|
||||
|
||||
return $this->respondSuccess($updated, 'User updated successfully');
|
||||
}
|
||||
|
||||
public function profile(): JsonResponse
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$userData = $this->user->find($user->id);
|
||||
if (!$userData) {
|
||||
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$roles = $this->fetchUserRoles($user->id);
|
||||
unset($userData['password'], $userData['token']);
|
||||
$userData['roles'] = $roles;
|
||||
|
||||
return $this->success($userData, 'Profile retrieved successfully');
|
||||
}
|
||||
|
||||
public function profileAuth(): JsonResponse
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
if (empty($data['email']) || empty($data['password'])) {
|
||||
return $this->respondError('Email and password are required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$user = $this->user->where('email', $data['email'])->first();
|
||||
if (!$user || !$this->verifyPassword($data['password'], $user['password'] ?? '')) {
|
||||
return $this->respondError('Invalid credentials', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$roles = $this->fetchUserRoles($user['id']);
|
||||
$tokenPayload = $this->buildLoginPayload($user, $roles);
|
||||
|
||||
Session::put([
|
||||
'user_id' => (int) $user['id'],
|
||||
'roles' => $roles,
|
||||
]);
|
||||
|
||||
return $this->success($tokenPayload, 'Authenticated successfully');
|
||||
}
|
||||
|
||||
public function updateProfile(): JsonResponse
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
if (empty($data)) {
|
||||
return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'firstname',
|
||||
'lastname',
|
||||
'cellphone',
|
||||
'address_street',
|
||||
'apt',
|
||||
'city',
|
||||
'state',
|
||||
'zip',
|
||||
'gender',
|
||||
];
|
||||
|
||||
$updateData = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
$userRecord = $this->user->find($user->id);
|
||||
if (!$userRecord) {
|
||||
return $this->respondError('User not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$emailChanging = isset($data['email']) && $data['email'] !== $userRecord['email'];
|
||||
$passwordChanging = isset($data['password']) && trim((string) $data['password']) !== '';
|
||||
|
||||
if ($emailChanging) {
|
||||
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
return $this->respondError('Invalid email format', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$existing = $this->user->newQuery()
|
||||
->where('email', $data['email'])
|
||||
->where('id', '!=', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->respondError('Email already in use', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
if ($emailChanging || $passwordChanging) {
|
||||
$current = $data['current_password'] ?? '';
|
||||
if ($current === '' || !$this->verifyPassword($current, $userRecord['password'] ?? '')) {
|
||||
return $this->respondError('Current password is required to change email or password', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
if ($emailChanging) {
|
||||
$updateData['email'] = $data['email'];
|
||||
}
|
||||
|
||||
if ($passwordChanging) {
|
||||
$updateData['password'] = $this->hashPassword($data['password']);
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->respondError('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->user->where('id', $user->id)->update($updateData);
|
||||
$updated = $this->user->find($user->id);
|
||||
unset($updated['password'], $updated['token']);
|
||||
return $this->success($updated, 'Profile updated successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Profile update error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to update profile', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function requestPasswordReset(): JsonResponse
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$email = strtolower(trim((string) ($data['email'] ?? '')));
|
||||
if ($email === '') {
|
||||
return $this->respondValidationError(['email' => ['Email is required.']]);
|
||||
}
|
||||
|
||||
$ip = $this->laravelRequest->ip();
|
||||
$now = CarbonImmutable::now();
|
||||
$recentAttempts = $this->resetRequest->newQuery()
|
||||
->where('ip_address', $ip)
|
||||
->where('requested_at', '>=', $now->subDay()->toDateTimeString())
|
||||
->count();
|
||||
|
||||
if ($recentAttempts >= 3) {
|
||||
log_message('warning', 'Password reset blocked for IP: ' . $ip);
|
||||
return $this->respondError('Too many reset attempts. Try again later.', Response::HTTP_TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
$this->resetRequest->newQuery()->create([
|
||||
'ip_address' => $ip,
|
||||
'requested_at' => $now->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$user = $this->user->where('email', $email)->first();
|
||||
if (!$user) {
|
||||
return $this->respondSuccess(null, 'If the email is registered, a reset link will be sent.');
|
||||
}
|
||||
|
||||
if ((int) ($user['is_verified'] ?? 0) === 0) {
|
||||
return $this->respondError('Please activate your account before resetting the password.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$expiresAt = $now->addHour();
|
||||
|
||||
$this->passwordReset->newQuery()->create([
|
||||
'email' => $email,
|
||||
'token' => $token,
|
||||
'created_at' => $now->toDateTimeString(),
|
||||
'expires_at' => $expiresAt->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$this->sendResetEmail($email, $token);
|
||||
|
||||
return $this->respondSuccess(null, 'Password reset link sent if the email exists.');
|
||||
}
|
||||
|
||||
public function validateResetToken(string $token): JsonResponse
|
||||
{
|
||||
$now = CarbonImmutable::now()->toDateTimeString();
|
||||
$entry = $this->passwordReset->newQuery()
|
||||
->where('token', $token)
|
||||
->where('expires_at', '>=', $now)
|
||||
->first();
|
||||
|
||||
if (!$entry) {
|
||||
return $this->respondError('Invalid or expired reset token.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return $this->respondSuccess([
|
||||
'email' => $entry['email'],
|
||||
], 'Token valid');
|
||||
}
|
||||
|
||||
public function completePasswordReset(): JsonResponse
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'token' => 'required',
|
||||
'password' => 'required|min:8|regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\-=\+*#$%&!?]).{8,}$/',
|
||||
'password_confirmation' => 'required|same:password',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now()->toDateTimeString();
|
||||
$resetEntry = $this->passwordReset->newQuery()
|
||||
->where('token', $data['token'])
|
||||
->where('expires_at', '>=', $now)
|
||||
->first();
|
||||
|
||||
if (!$resetEntry) {
|
||||
return $this->respondError('Invalid or expired reset link.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$user = $this->user->where('email', $resetEntry['email'])->first();
|
||||
if (!$user) {
|
||||
return $this->respondError('User not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$hashedPassword = $this->hashPassword($data['password']);
|
||||
$this->user->update($user['id'], [
|
||||
'failed_attempts' => 0,
|
||||
'is_suspended' => 0,
|
||||
'last_failed_at' => null,
|
||||
'password' => $hashedPassword,
|
||||
'status' => 'Active',
|
||||
]);
|
||||
|
||||
$this->passwordReset->newQuery()->where('token', $data['token'])->delete();
|
||||
|
||||
$ip = $this->laravelRequest->ip();
|
||||
$attempt = $this->ipAttempt->newQuery()->where('ip_address', $ip)->first();
|
||||
if ($attempt) {
|
||||
$this->ipAttempt->newQuery()->where('ip_address', $ip)->delete();
|
||||
}
|
||||
|
||||
return $this->respondSuccess(null, 'Password has been reset successfully.');
|
||||
}
|
||||
|
||||
public function confirm(string $token): JsonResponse
|
||||
{
|
||||
$user = $this->user->where('token', $token)->first();
|
||||
if (!$user) {
|
||||
return $this->respondError('Invalid or expired confirmation token.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ((int) ($user['is_verified'] ?? 0) === 1) {
|
||||
return $this->respondSuccess([
|
||||
'user_id' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
], 'Account already verified.');
|
||||
}
|
||||
|
||||
return $this->respondSuccess([
|
||||
'user_id' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
], 'Token valid. Please set your password.');
|
||||
}
|
||||
|
||||
public function setPassword(): JsonResponse
|
||||
{
|
||||
$data = $this->payloadData();
|
||||
$rules = [
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
'token' => 'required',
|
||||
'password' => 'required|min:8|regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\-=\+*#$%&!?]).{8,}$/',
|
||||
'password_confirm' => 'required|same:password',
|
||||
];
|
||||
|
||||
$errors = $this->validateRequest($data, $rules);
|
||||
if (!empty($errors)) {
|
||||
return $this->respondValidationError($errors);
|
||||
}
|
||||
|
||||
$user = $this->user->newQuery()
|
||||
->where('id', $data['user_id'])
|
||||
->where('token', $data['token'])
|
||||
->first();
|
||||
|
||||
if (!$user) {
|
||||
return $this->respondError('Invalid token or user.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$accountId = $user['account_id'] ?? null;
|
||||
if (!$accountId) {
|
||||
$accountId = 'ACC' . str_pad((string) $user['id'], 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$this->user->update($user['id'], [
|
||||
'password' => $this->hashPassword($data['password']),
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'token' => null,
|
||||
'account_id' => $accountId,
|
||||
]);
|
||||
|
||||
return $this->respondSuccess(null, 'Password set successfully. You may now log in.');
|
||||
}
|
||||
|
||||
public function loginActivity(): JsonResponse
|
||||
{
|
||||
$perPage = (int) ($this->laravelRequest->query('per_page', 25));
|
||||
$page = (int) ($this->laravelRequest->query('page', 1));
|
||||
|
||||
return $this->respondSuccess(
|
||||
$this->buildLoginActivityPayload($perPage, $page),
|
||||
'Login activity retrieved'
|
||||
);
|
||||
}
|
||||
|
||||
protected function buildUsersWithRoles(): array
|
||||
{
|
||||
$users = $this->user->newQuery()->get()->map(fn($user) => (array) $user)->toArray();
|
||||
if (empty($users)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$guardianMap = [];
|
||||
try {
|
||||
$rows = DB::table('family_guardians')
|
||||
->select('user_id', DB::raw('MAX(is_primary) as max_primary'), DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('user_id')
|
||||
->get();
|
||||
foreach ($rows as $row) {
|
||||
$uid = (int) ($row->user_id ?? 0);
|
||||
if ($uid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$guardianMap[$uid] = ((int) ($row->max_primary ?? 0)) > 0 ? 'Primary' : 'Secondary';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// legacy table missing; ignore
|
||||
}
|
||||
|
||||
$secondByUserId = [];
|
||||
$secondByEmail = [];
|
||||
try {
|
||||
$uids = DB::table('parents')
|
||||
->select('secondparent_user_id as uid')
|
||||
->where('secondparent_user_id', '!=', 0)
|
||||
->groupBy('secondparent_user_id')
|
||||
->get();
|
||||
foreach ($uids as $row) {
|
||||
$u = (int) ($row->uid ?? 0);
|
||||
if ($u > 0) {
|
||||
$secondByUserId[$u] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$emails = DB::table('parents')
|
||||
->select(DB::raw('LOWER(TRIM(secondparent_email)) as email'))
|
||||
->where('secondparent_email', '!=', '')
|
||||
->groupBy('secondparent_email')
|
||||
->get();
|
||||
foreach ($emails as $row) {
|
||||
$em = trim(strtolower((string) ($row->email ?? '')));
|
||||
if ($em !== '') {
|
||||
$secondByEmail[$em] = true;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore legacy table issues
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($users as $user) {
|
||||
if (!is_array($user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$roles = $this->userRole->where('user_id', $user['id'] ?? 0)->findAll() ?? [];
|
||||
$roleNames = [];
|
||||
$roleIds = [];
|
||||
foreach ($roles as $userRole) {
|
||||
if (!is_array($userRole) || empty($userRole['role_id'])) {
|
||||
continue;
|
||||
}
|
||||
$role = $this->role->find($userRole['role_id']);
|
||||
if ($role) {
|
||||
$roleIds[] = (int) $role['id'];
|
||||
$roleNames[] = $role['name'];
|
||||
}
|
||||
}
|
||||
|
||||
$email = trim(strtolower((string) ($user['email'] ?? '')));
|
||||
$uid = (int) ($user['id'] ?? 0);
|
||||
$isSecond = ($uid > 0 && !empty($secondByUserId[$uid])) || ($email !== '' && !empty($secondByEmail[$email]));
|
||||
|
||||
$result[] = [
|
||||
'user' => $user,
|
||||
'roles' => $roleNames,
|
||||
'role_ids' => $roleIds,
|
||||
'parent_type' => $guardianMap[$uid] ?? '',
|
||||
'second_from_parents' => $isSecond ? 'Yes' : '',
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function sendResetEmail(string $email, string $token): void
|
||||
{
|
||||
$resetLink = url('/reset-password?token=' . urlencode($token));
|
||||
try {
|
||||
$html = view('emails/reset_password', ['resetLink' => $resetLink])->render();
|
||||
} catch (\Throwable $e) {
|
||||
$html = '<p>Click the link below to reset your password:</p><p><a href="' . $resetLink . '">' . $resetLink . '</a></p>';
|
||||
}
|
||||
|
||||
$this->emailService->send($email, 'Password Reset Request', $html, 'general');
|
||||
}
|
||||
|
||||
protected function fetchUserRoles(int $userId): array
|
||||
{
|
||||
return $this->userRole->newQuery()
|
||||
->select('roles.name')
|
||||
->join('roles', 'roles.id', '=', 'user_roles.role_id')
|
||||
->where('user_roles.user_id', $userId)
|
||||
->pluck('name')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function buildLoginPayload(array $user, array $roles): array
|
||||
{
|
||||
$now = time();
|
||||
$exp = $now + 60 * 60 * 24;
|
||||
|
||||
$payload = [
|
||||
'sub' => (int) $user['id'],
|
||||
'name' => trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')),
|
||||
'roles' => $roles,
|
||||
'iat' => $now,
|
||||
'exp' => $exp,
|
||||
];
|
||||
|
||||
$token = $this->encodeJwt($payload);
|
||||
$rolesMap = [];
|
||||
foreach ($roles as $role) {
|
||||
$rolesMap[$role] = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'id' => (int) $user['id'],
|
||||
'name' => $payload['name'],
|
||||
'email' => $user['email'],
|
||||
'roles' => (object) $rolesMap,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function encodeJwt(array $payload): string
|
||||
{
|
||||
$header = ['alg' => 'HS256', 'typ' => 'JWT'];
|
||||
$secret = env('JWT_SECRET', 'change-me-in-env');
|
||||
|
||||
$segments = [
|
||||
$this->base64UrlEncode(json_encode($header)),
|
||||
$this->base64UrlEncode(json_encode($payload)),
|
||||
];
|
||||
|
||||
$signature = hash_hmac('sha256', implode('.', $segments), $secret, true);
|
||||
$segments[] = $this->base64UrlEncode($signature);
|
||||
|
||||
return implode('.', $segments);
|
||||
}
|
||||
|
||||
protected function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
protected function verifyPassword(string $password, string $storedHash): bool
|
||||
{
|
||||
$delimiter = str_contains($storedHash, '$') ? '$' : ':';
|
||||
$parts = explode($delimiter, $storedHash);
|
||||
if (count($parts) !== 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$algo, $iterations, $salt, $hash] = $parts;
|
||||
$calc = hash_pbkdf2($algo, $password, $salt, (int) $iterations, strlen($hash));
|
||||
return hash_equals($hash, $calc);
|
||||
}
|
||||
|
||||
protected function hashPassword(string $password): string
|
||||
{
|
||||
$algo = 'sha256';
|
||||
$iterations = 200000;
|
||||
$salt = bin2hex(random_bytes(16));
|
||||
$hash = hash_pbkdf2($algo, $password, $salt, $iterations, 64);
|
||||
|
||||
return implode(':', [$algo, $iterations, $salt, $hash]);
|
||||
}
|
||||
|
||||
protected function buildLoginActivityPayload(int $perPage, int $page): array
|
||||
{
|
||||
$perPage = max(1, min($perPage, 200));
|
||||
$page = max(1, $page);
|
||||
|
||||
$query = $this->loginActivity->newQuery()->orderBy('login_time', 'DESC');
|
||||
$total = (int) $query->count();
|
||||
$activities = $query->forPage($page, $perPage)->get()->map(fn($row) => (array) $row)->toArray();
|
||||
|
||||
$normalized = array_map(static function ($activity) {
|
||||
return [
|
||||
'user_id' => $activity['user_id'] ?? null,
|
||||
'email' => $activity['email'] ?? null,
|
||||
'login_time' => $activity['login_time'] ?? null,
|
||||
'logout_time' => $activity['logout_time'] ?? null,
|
||||
'ip_address' => $activity['ip_address'] ?? null,
|
||||
'user_agent' => $activity['user_agent'] ?? null,
|
||||
];
|
||||
}, $activities);
|
||||
|
||||
$pageCount = (int) max(1, ceil($total / $perPage));
|
||||
|
||||
return [
|
||||
'activities' => $normalized,
|
||||
'pagination' => [
|
||||
'total' => $total,
|
||||
'perPage' => $perPage,
|
||||
'currentPage' => $page,
|
||||
'pageCount' => $pageCount,
|
||||
'hasNext' => $page < $pageCount,
|
||||
'hasPrevious' => $page > 1,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/users/select-role
|
||||
* Select and update user's role in database
|
||||
*/
|
||||
public function selectRole(): JsonResponse
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (!$user) {
|
||||
return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$data = $this->payloadData();
|
||||
$roleKey = (string) ($data['role'] ?? '');
|
||||
|
||||
if ($roleKey === '') {
|
||||
return $this->respondError('Role is required', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
log_message('info', 'Processing setRole form submission for user: ' . $user->id);
|
||||
log_message('info', 'Role selected: ' . $roleKey);
|
||||
|
||||
$route = $this->role->getRouteByNameOrSlug($roleKey);
|
||||
|
||||
if ($route === null) {
|
||||
log_message('error', 'Invalid or inactive role selected: ' . $roleKey);
|
||||
return $this->respondError('Invalid role selected.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Update the user's role field in the database
|
||||
$this->user->update($user->id, ['role' => $roleKey]);
|
||||
|
||||
log_message('info', 'Role updated in database for user: ' . $user->id);
|
||||
log_message('info', 'Redirecting to role dashboard: ' . $route);
|
||||
|
||||
return $this->success([
|
||||
'role' => $roleKey,
|
||||
'route' => $route,
|
||||
], 'Role selected successfully');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Role selection error: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to select role', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/users/roles/{roleId}
|
||||
* Delete a role and reassign users to Guest role
|
||||
*/
|
||||
public function deleteRole(int $roleId): JsonResponse
|
||||
{
|
||||
$role = $this->role->find($roleId);
|
||||
if (!$role) {
|
||||
return $this->respondError('Role not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Fetch the "Guest" role
|
||||
$guestRole = $this->role->where('name', 'Guest')->first();
|
||||
if (!$guestRole) {
|
||||
return $this->respondError('Guest role not found', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Update users with the deleted role to have the "Guest" role
|
||||
$this->user->newQuery()
|
||||
->where('role_id', $roleId)
|
||||
->update([
|
||||
'role_id' => $guestRole['id'],
|
||||
'role' => 'Guest',
|
||||
'status' => 'Inactive',
|
||||
]);
|
||||
|
||||
// Also update user_roles table
|
||||
$this->userRole->newQuery()
|
||||
->where('role_id', $roleId)
|
||||
->update(['role_id' => $guestRole['id']]);
|
||||
|
||||
// Delete the role
|
||||
$this->role->delete($roleId);
|
||||
|
||||
DB::commit();
|
||||
|
||||
log_message('info', 'Role deleted: ' . $roleId . ', users reassigned to Guest role');
|
||||
|
||||
return $this->respondSuccess(null, 'Role deleted successfully. Users with this role have been assigned the Guest role.');
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
log_message('error', 'Failed to delete role: ' . $e->getMessage());
|
||||
return $this->respondError('Failed to delete role', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user