init commit

This commit is contained in:
David Melendez
2026-01-14 22:38:44 +01:00
parent 4e0c415f0b
commit e25d53d054
124 changed files with 21653 additions and 1 deletions

27
app/Console/Kernel.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* Define the application's command schedule.
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

51
app/DTOs/UserDTO.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\DTOs;
/**
* User Data Transfer Object
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
class UserDTO
{
public function __construct(
public readonly string $first_name,
public readonly string $last_name,
public readonly string $email,
public readonly ?string $phone = null,
public readonly ?string $profession = null,
public readonly ?string $location = null,
public readonly ?string $status = 'active'
) {}
public function toArray(): array
{
return [
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
'phone' => $this->phone,
'profession' => $this->profession,
'location' => $this->location,
'status' => $this->status,
];
}
public static function fromArray(array $data): self
{
return new self(
first_name: $data['first_name'],
last_name: $data['last_name'],
email: $data['email'],
phone: $data['phone'] ?? null,
profession: $data['profession'] ?? null,
location: $data['location'] ?? null,
status: $data['status'] ?? 'active'
);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
});
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
/**
* API Authentication Controller
* Handles API authentication endpoints
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-09
* @location Made in Germany 🇩🇪
*/
class AuthController extends Controller
{
/**
* Handle API login
*/
public function login(Request $request): JsonResponse
{
return response()->json([
'message' => 'API login not implemented yet',
'status' => 'error'
], 501);
}
/**
* Handle API registration
*/
public function register(Request $request): JsonResponse
{
return response()->json([
'message' => 'API registration not implemented yet',
'status' => 'error'
], 501);
}
/**
* Handle forgot password
*/
public function forgotPassword(Request $request): JsonResponse
{
return response()->json([
'message' => 'API forgot password not implemented yet',
'status' => 'error'
], 501);
}
/**
* Handle password reset
*/
public function resetPassword(Request $request): JsonResponse
{
return response()->json([
'message' => 'API password reset not implemented yet',
'status' => 'error'
], 501);
}
/**
* Refresh token
*/
public function refresh(Request $request): JsonResponse
{
return response()->json([
'message' => 'API token refresh not implemented yet',
'status' => 'error'
], 501);
}
/**
* Handle logout
*/
public function logout(Request $request): JsonResponse
{
return response()->json([
'message' => 'API logout not implemented yet',
'status' => 'error'
], 501);
}
/**
* Get current user
*/
public function me(Request $request): JsonResponse
{
return response()->json([
'message' => 'API user profile not implemented yet',
'status' => 'error'
], 501);
}
/**
* Update user profile
*/
public function updateProfile(Request $request): JsonResponse
{
return response()->json([
'message' => 'API profile update not implemented yet',
'status' => 'error'
], 501);
}
/**
* Update password
*/
public function updatePassword(Request $request): JsonResponse
{
return response()->json([
'message' => 'API password update not implemented yet',
'status' => 'error'
], 501);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
/**
* Base API Controller for V1
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
abstract class ApiController extends Controller
{
/**
* Success response
*/
protected function successResponse($data = null, string $message = 'Success', int $code = 200): JsonResponse
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data,
], $code);
}
/**
* Error response
*/
protected function errorResponse(string $message = 'Error', int $code = 400, $errors = null): JsonResponse
{
$response = [
'success' => false,
'message' => $message,
];
if ($errors) {
$response['errors'] = $errors;
}
return response()->json($response, $code);
}
/**
* Validation error response
*/
protected function validationErrorResponse($errors): JsonResponse
{
return $this->errorResponse('Validation failed', 422, $errors);
}
}

View File

@@ -0,0 +1,586 @@
<?php
/**
* Authentication Controller
* Professional Resume Builder - Enterprise Auth System
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\UpdateProfileRequest;
use App\Interfaces\UserRepositoryInterface;
use App\Services\AuthService;
use App\Services\ProfileCompletionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
use Exception;
/**
* Authentication Controller
* Handles user authentication, registration, and session management with repository pattern
*/
class AuthController extends Controller
{
/**
* User repository instance
*/
private UserRepositoryInterface $userRepository;
/**
* AuthService instance
*/
private AuthService $authService;
/**
* Profile completion service instance
*/
private ProfileCompletionService $profileCompletionService;
/**
* Maximum login attempts before lockout
*/
private const MAX_LOGIN_ATTEMPTS = 5;
/**
* Account lockout duration in minutes
*/
private const LOCKOUT_DURATION = 15;
/**
* Create a new controller instance with dependency injection
*/
public function __construct(
UserRepositoryInterface $userRepository,
AuthService $authService,
ProfileCompletionService $profileCompletionService
) {
$this->userRepository = $userRepository;
$this->authService = $authService;
$this->profileCompletionService = $profileCompletionService;
$this->middleware('guest')->except(['logout', 'profile', 'updateProfile', 'activity']);
$this->middleware('auth')->only(['logout', 'profile', 'updateProfile', 'activity']);
}
/**
* Show the login form
*/
public function showLoginForm(): View
{
return view('auth.login', [
'title' => 'Sign In - Professional Resume Builder',
'description' => 'Access your professional resume builder account'
]);
}
/**
* Handle a login request with enhanced maintainability
*
* @param LoginRequest $request
* @return RedirectResponse
*/
public function login(LoginRequest $request): RedirectResponse
{
try {
$credentials = $request->validated();
$loginResult = $this->authService->attemptLogin($credentials);
return $this->handleLoginResult($loginResult, $request);
} catch (ValidationException $e) {
return $this->handleLoginValidationError($e, $request);
} catch (Exception $e) {
return $this->handleLoginSystemError($e, $request);
}
}
/**
* Handle the result of a login attempt
*
* @param array $loginResult
* @param LoginRequest $request
* @return RedirectResponse
*/
private function handleLoginResult(array $loginResult, LoginRequest $request): RedirectResponse
{
if ($loginResult['success']) {
return $this->handleSuccessfulLogin($loginResult, $request);
}
return $this->handleFailedLogin($loginResult, $request);
}
/**
* Handle successful login response
*
* @param array $loginResult
* @param LoginRequest $request
* @return RedirectResponse
*/
private function handleSuccessfulLogin(array $loginResult, LoginRequest $request): RedirectResponse
{
$request->session()->regenerate();
Log::info('User logged in successfully', [
'user_id' => $loginResult['user']->id,
'email' => $loginResult['user']->email,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'login_method' => 'standard'
]);
$welcomeMessage = $this->getWelcomeMessage($loginResult['user']);
$redirectUrl = $this->getPostLoginRedirectUrl($loginResult['user']);
return redirect()->intended($redirectUrl)->with('success', $welcomeMessage);
}
/**
* Handle failed login response
*
* @param array $loginResult
* @param LoginRequest $request
* @return RedirectResponse
*/
private function handleFailedLogin(array $loginResult, LoginRequest $request): RedirectResponse
{
$errorMessage = $this->getLoginErrorMessage($loginResult['reason']);
Log::warning('Login attempt failed', [
'email' => $request->input('email'),
'reason' => $loginResult['reason'],
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent()
]);
return back()
->withErrors(['email' => $errorMessage])
->onlyInput('email');
}
/**
* Handle validation errors during login
*
* @param ValidationException $e
* @param LoginRequest $request
* @return RedirectResponse
*/
private function handleLoginValidationError(ValidationException $e, LoginRequest $request): RedirectResponse
{
Log::warning('Login validation error', [
'email' => $request->input('email'),
'errors' => $e->errors(),
'ip_address' => $request->ip()
]);
return back()
->withErrors($e->errors())
->onlyInput('email');
}
/**
* Handle system errors during login
*
* @param Exception $e
* @param LoginRequest $request
* @return RedirectResponse
*/
private function handleLoginSystemError(Exception $e, LoginRequest $request): RedirectResponse
{
Log::error('Login system error', [
'error_message' => $e->getMessage(),
'error_trace' => $e->getTraceAsString(),
'email' => $request->input('email'),
'ip_address' => $request->ip(),
'timestamp' => now()
]);
return back()
->withErrors(['email' => 'A system error occurred. Please try again or contact support.'])
->onlyInput('email');
}
/**
* Get personalized welcome message
*
* @param User $user
* @return string
*/
private function getWelcomeMessage(User $user): string
{
$timeOfDay = $this->getTimeOfDay();
$firstName = $user->first_name ?? 'there';
return "Good {$timeOfDay}, {$firstName}! Welcome back to your professional resume builder.";
}
/**
* Get post-login redirect URL based on user profile and activity state
*
* Intelligently determines the most appropriate landing page based on:
* - Profile completion percentage (incomplete profiles → profile page)
* - Resume creation status (no resumes → dashboard for guidance)
* - Default → main dashboard
*
* @param User $user The authenticated user
* @return string The route name for redirection
*/
private function getPostLoginRedirectUrl(User $user): string
{
$profileCompletion = $this->profileCompletionService->calculateCompletion($user);
if ($profileCompletion < 50) {
return route('profile');
}
$userStats = $this->userRepository->getUserStatistics($user->id);
if (($userStats['resumes_count'] ?? 0) === 0) {
return route('dashboard');
}
return route('dashboard');
}
/**
* Get appropriate error message based on failure reason
*
* @param string $reason
* @return string
*/
private function getLoginErrorMessage(string $reason): string
{
switch ($reason) {
case 'account_locked':
return 'Account is temporarily locked due to too many failed attempts. Please try again later.';
case 'account_inactive':
return 'Your account has been deactivated. Please contact support.';
case 'invalid_credentials':
return 'The provided credentials do not match our records.';
case 'too_many_attempts':
return 'Too many failed attempts. Account has been locked for security.';
case 'rate_limited':
return 'Too many login attempts. Please wait before trying again.';
default:
return 'Login failed. Please check your credentials and try again.';
}
}
/**
* Get time of day greeting
*
* @return string
*/
private function getTimeOfDay(): string
{
$hour = now()->hour;
if ($hour < 12) {
return 'morning';
} elseif ($hour < 17) {
return 'afternoon';
} else {
return 'evening';
}
}
/**
* Show the registration form
*/
public function showRegistrationForm(): View
{
return view('auth.register', [
'title' => 'Create Account - Professional Resume Builder',
'description' => 'Join thousands of professionals creating outstanding resumes'
]);
}
/**
* Handle a registration request
*/
public function register(RegisterRequest $request): RedirectResponse
{
try {
$userData = $request->validated();
$user = $this->authService->createUser($userData);
if ($user) {
Auth::login($user);
// Log successful registration
logger()->info('User registered successfully', [
'user_id' => $user->id,
'email' => $user->email,
'ip' => $request->ip(),
'user_agent' => $request->userAgent()
]);
return redirect()->route('dashboard')
->with('success', 'Welcome! Your account has been created successfully.');
}
return back()->withErrors([
'email' => 'Unable to create account. Please try again.',
])->withInput();
} catch (\Exception $e) {
logger()->error('Registration error', [
'error' => $e->getMessage(),
'email' => $request->input('email'),
'ip' => $request->ip()
]);
return back()->withErrors([
'email' => 'An error occurred during registration. Please try again.',
])->withInput();
}
}
/**
* Handle logout request
*/
public function logout(Request $request): RedirectResponse
{
// Log the logout
logger()->info('User logged out', [
'user_id' => Auth::id(),
'ip' => $request->ip()
]);
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')
->with('success', 'You have been successfully logged out.');
}
/**
* Show user profile with comprehensive analytics
*/
public function profile(): View
{
try {
$user = Auth::user();
// Get user statistics from repository
$userStats = $this->userRepository->getUserStatistics($user->id);
// Get profile completion analysis from service
$profileCompletion = $this->profileCompletionService->calculateCompletion($user);
$profileAnalysis = $this->profileCompletionService->getDetailedAnalysis($user);
// Prepare view data
$viewData = [
'title' => 'Profile Settings - Professional Resume Builder',
'user' => $user,
'userStats' => $userStats,
'profileCompletion' => $profileCompletion,
'profileAnalysis' => $profileAnalysis,
'accountSecurity' => [
'two_factor_enabled' => $user->two_factor_secret !== null,
'email_verified' => $user->email_verified_at !== null,
'is_locked' => $this->userRepository->isAccountLocked($user->email)
]
];
// Log profile view for analytics
Log::info('User profile viewed', [
'user_id' => $user->id,
'profile_completion' => $profileCompletion,
'ip_address' => request()->ip(),
'timestamp' => now()
]);
return view('auth.profile', $viewData);
} catch (Exception $e) {
Log::error('Error loading user profile', [
'error_message' => $e->getMessage(),
'user_id' => Auth::id(),
'ip_address' => request()->ip(),
'timestamp' => now()
]);
// Fallback with minimal data
return view('auth.profile', [
'title' => 'Profile Settings',
'user' => Auth::user(),
'userStats' => [],
'profileCompletion' => 0,
'error' => 'Unable to load profile data completely'
]);
}
}
/**
* Update user profile using professional form request validation
*
* Handles comprehensive profile updates with:
* - Repository pattern for data persistence
* - Structured logging for security audit trails
* - Real-time profile completion calculation
* - User-friendly completion status messaging
*
* @param UpdateProfileRequest $request Validated profile update data
* @return RedirectResponse Response with success message or error state
* @throws Exception When profile update fails due to system errors
*/
public function updateProfile(UpdateProfileRequest $request): RedirectResponse
{
try {
$user = Auth::user();
$updateData = $request->validated();
$updatedUser = $this->userRepository->update($user->id, $updateData);
Log::info('User profile updated successfully', [
'user_id' => $user->id,
'updated_fields' => array_keys($updateData),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'timestamp' => now()
]);
$completionPercentage = $this->profileCompletionService
->calculateCompletion($updatedUser);
$message = "Profile updated successfully! ";
if ($completionPercentage < 100) {
$message .= "Your profile is {$completionPercentage}% complete.";
} else {
$message .= "Your profile is now 100% complete!";
}
return back()->with('success', $message);
} catch (Exception $e) {
Log::error('Profile update failed', [
'error_message' => $e->getMessage(),
'error_trace' => $e->getTraceAsString(),
'user_id' => Auth::id(),
'request_data' => $request->except(['password', 'password_confirmation']),
'ip_address' => $request->ip(),
'timestamp' => now()
]);
return back()
->withErrors(['error' => 'Unable to update profile. Please try again.'])
->withInput();
}
}
/**
* Retrieve comprehensive user activity and analytics data
*
* Provides detailed activity analytics including:
* - User profile and authentication information
* - Login activity patterns and security metrics
* - Profile completion analysis with detailed breakdown
* - Account security status and recommendations
* - Usage statistics (resumes, templates, exports)
*
* @return JsonResponse Structured activity data with metadata
* @throws Exception When analytics data retrieval fails
*/
public function activity(): JsonResponse
{
try {
$user = Auth::user();
$userStats = $this->userRepository->getUserStatistics($user->id);
$profileCompletion = $this->profileCompletionService
->calculateCompletion($user);
$profileAnalysis = $this->profileCompletionService
->getDetailedAnalysis($user);
$activityData = [
'user_info' => [
'id' => $user->id,
'name' => $user->first_name . ' ' . $user->last_name,
'email' => $user->email,
'member_since' => $user->created_at->format('F Y'),
'account_status' => $user->status ?? 'active'
],
'login_activity' => [
'last_login' => $user->last_login_at ? $user->last_login_at->format('Y-m-d H:i:s') : null,
'last_login_ip' => $user->last_login_ip,
'total_logins' => $userStats['total_logins'] ?? 0,
'failed_attempts' => $user->login_attempts ?? 0
],
'profile_metrics' => [
'completion_percentage' => $profileCompletion,
'completed_fields' => $profileAnalysis['completed_fields'],
'missing_fields' => $profileAnalysis['missing_fields'],
'completion_score' => $profileAnalysis['weighted_score']
],
'account_security' => [
'two_factor_enabled' => $user->two_factor_secret !== null,
'email_verified' => $user->email_verified_at !== null,
'password_updated' => $user->password_updated_at ? $user->password_updated_at->format('Y-m-d') : null,
'is_locked' => $this->userRepository->isAccountLocked($user->email)
],
'activity_summary' => [
'resumes_created' => $userStats['resumes_count'] ?? 0,
'templates_used' => $userStats['templates_used'] ?? 0,
'exports_generated' => $userStats['exports_count'] ?? 0,
'profile_views' => $userStats['profile_views'] ?? 0
]
];
Log::info('User activity data requested', [
'user_id' => $user->id,
'ip_address' => request()->ip(),
'timestamp' => now()
]);
return response()->json([
'success' => true,
'data' => $activityData,
'meta' => [
'generated_at' => now()->toISOString(),
'version' => '1.0'
]
]);
} catch (Exception $e) {
Log::error('Failed to retrieve user activity data', [
'error_message' => $e->getMessage(),
'user_id' => Auth::id(),
'ip_address' => request()->ip(),
'timestamp' => now()
]);
return response()->json([
'success' => false,
'message' => 'Unable to retrieve activity data',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal server error'
], 500);
}
}
/**
* Show forgot password form
*/
public function showForgotPasswordForm(): View
{
return view('auth.forgot-password', [
'title' => 'Reset Password - Professional Resume Builder',
'description' => 'Enter your email to receive password reset instructions'
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
/**
* Base Controller Class
* Professional Resume Builder - Laravel Application
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
/**
* Base Controller
* Parent class for all application controllers
*/
abstract class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
}

View File

@@ -0,0 +1,154 @@
<?php
/**
* Dashboard Controller
* Professional Resume Builder - Main Dashboard
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
namespace App\Http\Controllers;
use App\Interfaces\UserRepositoryInterface;
use App\Interfaces\ResumeRepositoryInterface;
use App\Services\ResumeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Dashboard Controller
* Handles the main dashboard functionality with repository pattern
*/
class DashboardController extends Controller
{
/**
* User repository instance
*/
protected UserRepositoryInterface $userRepository;
/**
* Resume repository instance
*/
protected ResumeRepositoryInterface $resumeRepository;
/**
* Resume service instance
*/
protected ResumeService $resumeService;
/**
* Create a new controller instance with dependency injection
*/
public function __construct(
UserRepositoryInterface $userRepository,
ResumeRepositoryInterface $resumeRepository,
ResumeService $resumeService
) {
$this->userRepository = $userRepository;
$this->resumeRepository = $resumeRepository;
$this->resumeService = $resumeService;
$this->middleware('auth');
}
/**
* Show the application dashboard
*/
public function index(): View
{
$user = Auth::user();
// Get user's resumes using repository
$resumes = $this->resumeRepository->getUserResumes($user->id);
// Get resume statistics using repository
$resumeStats = $this->resumeRepository->getResumeStatistics($user->id);
// Get recent resumes (last 5)
$recentResumes = $this->resumeRepository
->orderBy('updated_at', 'desc')
->limit(5)
->getUserResumes($user->id);
// Calculate profile completion
$profileCompletion = $this->calculateProfileCompletion($user);
return view('dashboard.index', [
'title' => 'Dashboard - Professional Resume Builder',
'user' => $user,
'resumes' => $resumes,
'resumeStats' => $resumeStats,
'recentResumes' => $recentResumes,
'profileCompletion' => $profileCompletion,
'stats' => [
'total_resumes' => $resumeStats['total_resumes'],
'published_resumes' => $resumeStats['published_resumes'],
'draft_resumes' => $resumeStats['draft_resumes'],
'completed_resumes' => $resumeStats['completed_resumes'],
'avg_completion' => $resumeStats['avg_completion'],
'total_views' => $resumeStats['total_views'],
'total_downloads' => $resumeStats['total_downloads'],
'profile_completion' => $profileCompletion
]
]);
}
/**
* Calculate profile completion percentage
*/
private function calculateProfileCompletion($user): int
{
$requiredFields = [
'first_name', 'last_name', 'email', 'phone',
'bio', 'website', 'linkedin'
];
$completed = 0;
foreach ($requiredFields as $field) {
if (!empty($user->$field)) {
$completed++;
}
}
return (int) (($completed / count($requiredFields)) * 100);
}
/**
* Get dashboard analytics data
*/
public function analytics(): \Illuminate\Http\JsonResponse
{
$user = Auth::user();
$analytics = [
'user_stats' => $this->userRepository->getUserStatistics(),
'resume_stats' => $this->resumeRepository->getResumeStatistics($user->id),
'recent_activity' => $this->getRecentActivity($user->id),
];
return response()->json($analytics);
}
/**
* Get recent user activity
*/
private function getRecentActivity(int $userId): array
{
$recentResumes = $this->resumeRepository
->orderBy('updated_at', 'desc')
->limit(10)
->getUserResumes($userId);
return $recentResumes->map(function ($resume) {
return [
'id' => $resume->id,
'title' => $resume->title,
'action' => 'updated',
'timestamp' => $resume->updated_at,
'status' => $resume->status,
];
})->toArray();
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
class HomeController extends Controller
{
/**
* Display the homepage.
*/
public function index(): View|RedirectResponse
{
// If user is authenticated, redirect to dashboard
if (Auth::check()) {
return redirect()->route('dashboard');
}
// If not authenticated, redirect to login
return redirect()->route('login');
}
/**
* Display the features page.
*/
public function features(): View
{
return view('home.features');
}
/**
* Display the pricing page.
*/
public function pricing(): View
{
return view('home.pricing');
}
/**
* Display the help page.
*/
public function help(): View
{
return view('home.help');
}
/**
* Display the contact page.
*/
public function contact(): View
{
return view('home.contact');
}
/**
* Handle contact form submission.
*/
public function submitContact(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'subject' => 'required|string|max:255',
'message' => 'required|string',
]);
// Handle contact form logic here
// You can send email, save to database, etc.
return redirect()->route('contact')->with('success', 'Your message has been sent successfully!');
}
/**
* Display the privacy policy page.
*/
public function privacy(): View
{
return view('home.privacy');
}
/**
* Display the terms of service page.
*/
public function terms(): View
{
return view('home.terms');
}
/**
* Display the support page.
*/
public function support(): View
{
return view('home.support');
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\Password;
class ProfileController extends Controller
{
/**
* Display the user's profile.
*/
public function show(): View
{
$user = Auth::user();
return view('profile.show', compact('user'));
}
/**
* Show the form for editing the user's profile.
*/
public function edit(): View
{
$user = Auth::user();
return view('profile.edit', compact('user'));
}
/**
* Update the user's profile information.
*/
public function update(Request $request): RedirectResponse
{
$user = Auth::user();
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email,' . $user->id,
'phone' => 'nullable|string|max:20',
'bio' => 'nullable|string|max:1000',
]);
$user->update($request->only(['name', 'email', 'phone', 'bio']));
return redirect()->route('profile.show')->with('success', 'Profile updated successfully!');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => 'required|current_password',
]);
$user = Auth::user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/')->with('success', 'Your account has been deleted.');
}
/**
* Show the settings page.
*/
public function settings(): View
{
$user = Auth::user();
return view('profile.settings', compact('user'));
}
/**
* Update the user's password.
*/
public function updatePassword(Request $request): RedirectResponse
{
$request->validate([
'current_password' => 'required|current_password',
'password' => ['required', 'confirmed', Password::defaults()],
]);
Auth::user()->update([
'password' => Hash::make($request->password),
]);
return redirect()->route('profile.settings')->with('success', 'Password updated successfully!');
}
/**
* Update user preferences.
*/
public function updatePreferences(Request $request): RedirectResponse
{
$request->validate([
'theme' => 'required|in:light,dark,auto',
'notifications_email' => 'boolean',
'notifications_browser' => 'boolean',
'language' => 'required|string|max:5',
]);
$user = Auth::user();
// If your User model has a preferences JSON column
$preferences = $user->preferences ?? [];
$preferences['theme'] = $request->theme;
$preferences['notifications_email'] = $request->boolean('notifications_email');
$preferences['notifications_browser'] = $request->boolean('notifications_browser');
$preferences['language'] = $request->language;
$user->update(['preferences' => $preferences]);
return redirect()->route('profile.settings')->with('success', 'Preferences updated successfully!');
}
/**
* Update user avatar.
*/
public function updateAvatar(Request $request): RedirectResponse
{
$request->validate([
'avatar' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',
]);
$user = Auth::user();
// Delete old avatar if exists
if ($user->avatar && Storage::exists('public/' . $user->avatar)) {
Storage::delete('public/' . $user->avatar);
}
// Store new avatar
$avatarPath = $request->file('avatar')->store('avatars', 'public');
$user->update(['avatar' => $avatarPath]);
return redirect()->route('profile.settings')->with('success', 'Avatar updated successfully!');
}
/**
* Show profile completion status.
*/
public function completion(): View
{
$user = Auth::user();
// Calculate completion percentage
$fields = ['name', 'email', 'phone', 'bio', 'avatar'];
$completedFields = 0;
foreach ($fields as $field) {
if (!empty($user->$field)) {
$completedFields++;
}
}
$completionPercentage = round(($completedFields / count($fields)) * 100);
return view('profile.completion', compact('user', 'completionPercentage'));
}
}

View File

@@ -0,0 +1,356 @@
<?php
/**
* Resume Builder Controller
* Professional Resume Builder - Resume Creation and Management
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
namespace App\Http\Controllers;
use App\Http\Requests\Resume\StoreResumeRequest;
use App\Http\Requests\Resume\UpdateResumeRequest;
use App\Models\Resume;
use App\Interfaces\ResumeRepositoryInterface;
use App\Interfaces\UserRepositoryInterface;
use App\Services\ResumeService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Resume Builder Controller
* Handles resume creation, editing, and management with repository pattern
*/
class ResumeBuilderController extends Controller
{
/**
* Resume repository instance
*/
protected ResumeRepositoryInterface $resumeRepository;
/**
* User repository instance
*/
protected UserRepositoryInterface $userRepository;
/**
* ResumeService instance
*/
protected ResumeService $resumeService;
/**
* Create a new controller instance with dependency injection
*/
public function __construct(
ResumeRepositoryInterface $resumeRepository,
UserRepositoryInterface $userRepository,
ResumeService $resumeService
) {
$this->resumeRepository = $resumeRepository;
$this->userRepository = $userRepository;
$this->resumeService = $resumeService;
$this->middleware('auth');
}
/**
* Display a listing of user's resumes
*/
public function index(Request $request): View
{
$userId = Auth::id();
// Get paginated resumes using repository
$resumes = $this->resumeRepository->getPaginatedUserResumes($userId, 12);
// Get resume statistics using repository
$resumeStats = $this->resumeRepository->getResumeStatistics($userId);
return view('resume-builder.index', [
'title' => 'My Resumes - Resume Builder',
'resumes' => $resumes,
'resumeStats' => $resumeStats
]);
}
/**
* Show the form for creating a new resume
*/
public function create(): View
{
$userId = Auth::id();
// Check if user can create more resumes
if (!$this->resumeRepository->canUserCreateMoreResumes($userId, 10)) {
return redirect()->route('resume-builder.index')
->with('error', 'You have reached the maximum number of resumes allowed.');
}
$templates = $this->resumeService->getAvailableTemplates();
return view('resume-builder.create', [
'title' => 'Create New Resume',
'templates' => $templates
]);
}
/**
* Store a newly created resume
*/
public function store(StoreResumeRequest $request): RedirectResponse
{
try {
$userId = Auth::id();
// Check if user can create more resumes
if (!$this->resumeRepository->canUserCreateMoreResumes($userId, 10)) {
return back()->withErrors([
'title' => 'You have reached the maximum number of resumes allowed.'
])->withInput();
}
$resumeData = $request->validated();
$resumeData['user_id'] = $userId;
// Create resume using repository
$resume = $this->resumeRepository->create($resumeData);
// Update completion percentage
$this->resumeRepository->updateCompletionPercentage($resume->id);
logger()->info('Resume created', [
'user_id' => $userId,
'resume_id' => $resume->id,
'title' => $resume->title
]);
return redirect()->route('resume-builder.edit', $resume)
->with('success', 'Resume created successfully! Start building your professional CV.');
} catch (\Exception $e) {
logger()->error('Resume creation error', [
'error' => $e->getMessage(),
'user_id' => Auth::id()
]);
return back()->withErrors([
'title' => 'Unable to create resume. Please try again.'
])->withInput();
}
}
/**
* Show the form for editing the specified resume
*/
public function edit(Resume $resume): View
{
$this->authorize('update', $resume);
return view('resume-builder.edit', [
'title' => 'Edit Resume - ' . $resume->title,
'resume' => $resume,
'templates' => $this->resumeService->getAvailableTemplates()
]);
}
/**
* Update the specified resume
*/
public function update(UpdateResumeRequest $request, Resume $resume): RedirectResponse
{
$this->authorize('update', $resume);
try {
$resumeData = $request->validated();
// Update resume using repository
$this->resumeRepository->update($resume->id, $resumeData);
// Update completion percentage
$this->resumeRepository->updateCompletionPercentage($resume->id);
logger()->info('Resume updated', [
'user_id' => Auth::id(),
'resume_id' => $resume->id
]);
return back()->with('success', 'Resume updated successfully!');
} catch (\Exception $e) {
logger()->error('Resume update error', [
'error' => $e->getMessage(),
'user_id' => Auth::id(),
'resume_id' => $resume->id
]);
return back()->withErrors([
'title' => 'Unable to update resume. Please try again.'
])->withInput();
}
}
/**
* Display the specified resume preview
*/
public function preview(Resume $resume): View
{
$this->authorize('view', $resume);
// Increment view count using repository
$this->resumeRepository->incrementViewCount($resume->id);
return view('resume-builder.preview', [
'title' => 'Preview - ' . $resume->title,
'resume' => $resume
]);
}
/**
* Remove the specified resume
*/
public function destroy(Resume $resume): RedirectResponse
{
$this->authorize('delete', $resume);
try {
// Delete resume using repository
$this->resumeRepository->delete($resume->id);
logger()->info('Resume deleted', [
'user_id' => Auth::id(),
'resume_id' => $resume->id,
'title' => $resume->title
]);
return redirect()->route('resume-builder.index')
->with('success', 'Resume deleted successfully.');
} catch (\Exception $e) {
logger()->error('Resume deletion error', [
'error' => $e->getMessage(),
'user_id' => Auth::id(),
'resume_id' => $resume->id
]);
return back()->withErrors([
'error' => 'Unable to delete resume. Please try again.'
]);
}
}
/**
* Download resume as PDF
*/
public function downloadPdf(Resume $resume)
{
$this->authorize('view', $resume);
try {
// Increment download count using repository
$this->resumeRepository->incrementDownloadCount($resume->id);
return $this->resumeService->generatePdf($resume);
} catch (\Exception $e) {
logger()->error('PDF generation error', [
'error' => $e->getMessage(),
'user_id' => Auth::id(),
'resume_id' => $resume->id
]);
return back()->withErrors([
'error' => 'Unable to generate PDF. Please try again.'
]);
}
}
/**
* Duplicate an existing resume
*/
public function duplicate(Resume $resume): RedirectResponse
{
$this->authorize('view', $resume);
try {
$userId = Auth::id();
// Check if user can create more resumes
if (!$this->resumeRepository->canUserCreateMoreResumes($userId, 10)) {
return back()->withErrors([
'error' => 'You have reached the maximum number of resumes allowed.'
]);
}
$newTitle = $resume->title . ' (Copy)';
$duplicatedResume = $this->resumeRepository->duplicateResume($resume->id, $userId, $newTitle);
logger()->info('Resume duplicated', [
'user_id' => $userId,
'original_resume_id' => $resume->id,
'new_resume_id' => $duplicatedResume->id
]);
return redirect()->route('resume-builder.edit', $duplicatedResume)
->with('success', 'Resume duplicated successfully!');
} catch (\Exception $e) {
logger()->error('Resume duplication error', [
'error' => $e->getMessage(),
'user_id' => Auth::id(),
'resume_id' => $resume->id
]);
return back()->withErrors([
'error' => 'Unable to duplicate resume. Please try again.'
]);
}
}
/**
* Search user's resumes
*/
public function search(Request $request): \Illuminate\Http\JsonResponse
{
$query = $request->input('query', '');
$userId = Auth::id();
if (empty($query)) {
return response()->json([]);
}
$resumes = $this->resumeRepository
->getUserResumes($userId)
->filter(function ($resume) use ($query) {
return stripos($resume->title, $query) !== false ||
stripos($resume->description, $query) !== false;
})
->take(10)
->values();
return response()->json($resumes);
}
/**
* Get resume analytics
*/
public function analytics(Resume $resume): \Illuminate\Http\JsonResponse
{
$this->authorize('view', $resume);
$analytics = [
'view_count' => $resume->view_count,
'download_count' => $resume->download_count,
'completion_percentage' => $resume->completion_percentage,
'last_viewed_at' => $resume->last_viewed_at,
'last_downloaded_at' => $resume->last_downloaded_at,
'created_at' => $resume->created_at,
'updated_at' => $resume->updated_at,
];
return response()->json($analytics);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\View\View;
class TemplateController extends Controller
{
/**
* Display a listing of available templates.
*/
public function index(): View
{
// Get available templates
$templates = [
// You can replace this with actual template data from database
[
'id' => 1,
'name' => 'Professional',
'description' => 'Clean and professional template',
'preview_image' => '/images/templates/professional.jpg',
'is_premium' => false,
],
[
'id' => 2,
'name' => 'Modern',
'description' => 'Modern and stylish template',
'preview_image' => '/images/templates/modern.jpg',
'is_premium' => true,
],
[
'id' => 3,
'name' => 'Classic',
'description' => 'Traditional classic template',
'preview_image' => '/images/templates/classic.jpg',
'is_premium' => false,
],
];
return view('templates.index', compact('templates'));
}
/**
* Display the specified template.
*/
public function show(string $template): View
{
// Find template by ID or slug
// This is a placeholder - replace with actual template fetching logic
$templateData = [
'id' => 1,
'name' => ucfirst($template),
'description' => 'Template description',
'preview_image' => "/images/templates/{$template}.jpg",
'is_premium' => false,
];
return view('templates.show', compact('templateData'));
}
/**
* Preview the specified template.
*/
public function preview(string $template): View
{
// Generate preview for the template
// This would typically include sample data
$templateData = [
'id' => 1,
'name' => ucfirst($template),
'description' => 'Template description',
'preview_image' => "/images/templates/{$template}.jpg",
'is_premium' => false,
];
$sampleData = [
'name' => 'John Doe',
'title' => 'Software Developer',
'email' => 'john.doe@example.com',
'phone' => '+1 (555) 123-4567',
'summary' => 'Experienced software developer with expertise in web technologies.',
];
return view('templates.preview', compact('templateData', 'sampleData'));
}
}

76
app/Http/Kernel.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
/**
* HTTP Kernel
* Professional Resume Builder - Laravel Application
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's middleware aliases.
*
* Aliases may be used instead of class names to conveniently assign middleware to routes and groups.
*
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*/
protected function redirectTo(Request $request): ?string
{
return $request->expectsJson() ? null : route('login');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array<int, string>
*/
protected $except = [
'current_password',
'password',
'password_confirmation',
];
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array<int, string|null>
*/
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Routing\Middleware\ValidateSignature as Middleware;
class ValidateSignature extends Middleware
{
/**
* The names of the query string parameters that should be ignored.
*
* @var array<int, string>
*/
protected $except = [
// 'fbclid',
// 'utm_campaign',
// 'utm_content',
// 'utm_medium',
// 'utm_source',
// 'utm_term',
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
/**
* Login Form Request
*
* Validates user login credentials with enterprise security standards.
* Implements rate limiting and security validations.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'email' => [
'required',
'string',
'email:rfc,dns',
'max:255',
'exists:users,email'
],
'password' => [
'required',
'string',
'min:6',
'max:255'
],
'remember' => [
'boolean'
]
];
}
/**
* Get custom error messages for validation rules.
*/
public function messages(): array
{
return [
'email.required' => 'Email address is required.',
'email.email' => 'Please enter a valid email address.',
'email.exists' => 'No account found with this email address.',
'password.required' => 'Password is required.',
'password.min' => 'Password must be at least 6 characters long.',
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'email' => 'email address',
'password' => 'password',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'email' => strtolower(trim($this->email)),
'remember' => $this->boolean('remember'),
]);
}
/**
* Handle failed validation attempts for security monitoring
*
* Logs failed login validation attempts with security context
* including IP address, user agent, and validation errors.
* Essential for detecting potential security threats.
*
* @param \Illuminate\Contracts\Validation\Validator $validator
* @return void
* @throws \Illuminate\Validation\ValidationException
*/
protected function failedValidation($validator): void
{
logger()->warning('Login validation failed', [
'email' => $this->input('email'),
'ip' => $this->ip(),
'user_agent' => $this->userAgent(),
'errors' => $validator->errors()->toArray(),
]);
parent::failedValidation($validator);
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
/**
* Registration Form Request
*
* Validates user registration data with comprehensive security checks.
* Implements password strength validation and data sanitization.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
class RegisterRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'first_name' => [
'required',
'string',
'min:2',
'max:50',
'regex:/^[a-zA-ZÀ-ÿ\s\-\'\.]+$/',
],
'last_name' => [
'required',
'string',
'min:2',
'max:50',
'regex:/^[a-zA-ZÀ-ÿ\s\-\'\.]+$/',
],
'email' => [
'required',
'string',
'email:rfc,dns',
'max:255',
'unique:users,email',
],
'password' => [
'required',
'string',
'confirmed',
Password::min(8)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(),
],
'password_confirmation' => [
'required',
'string',
'same:password',
],
'phone' => [
'nullable',
'string',
'min:10',
'max:20',
'regex:/^[\+]?[0-9\s\-\(\)]+$/',
],
'terms' => [
'required',
'accepted',
],
'newsletter' => [
'boolean',
],
'marketing' => [
'boolean',
],
];
}
/**
* Get custom error messages for validation rules.
*/
public function messages(): array
{
return [
'first_name.required' => 'First name is required.',
'first_name.min' => 'First name must be at least 2 characters.',
'first_name.regex' => 'First name contains invalid characters.',
'last_name.required' => 'Last name is required.',
'last_name.min' => 'Last name must be at least 2 characters.',
'last_name.regex' => 'Last name contains invalid characters.',
'email.required' => 'Email address is required.',
'email.email' => 'Please enter a valid email address.',
'email.unique' => 'An account with this email address already exists.',
'password.required' => 'Password is required.',
'password.min' => 'Password must be at least 8 characters long.',
'password.confirmed' => 'Password confirmation does not match.',
'password_confirmation.required' => 'Password confirmation is required.',
'password_confirmation.same' => 'Password confirmation must match the password.',
'phone.regex' => 'Please enter a valid phone number.',
'terms.required' => 'You must accept the terms and conditions.',
'terms.accepted' => 'You must accept the terms and conditions.',
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'first_name' => 'first name',
'last_name' => 'last name',
'email' => 'email address',
'password' => 'password',
'password_confirmation' => 'password confirmation',
'phone' => 'phone number',
'terms' => 'terms and conditions',
'newsletter' => 'newsletter subscription',
'marketing' => 'marketing communications',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'first_name' => ucfirst(strtolower(trim($this->first_name))),
'last_name' => ucfirst(strtolower(trim($this->last_name))),
'email' => strtolower(trim($this->email)),
'phone' => $this->phone ? preg_replace('/[^\+0-9]/', '', $this->phone) : null,
'newsletter' => $this->boolean('newsletter'),
'marketing' => $this->boolean('marketing'),
]);
}
/**
* Configure advanced validation rules and security checks
*
* Implements additional validation logic beyond standard rules:
* - Name similarity validation
* - Suspicious pattern detection
* - Data integrity verification
*
* @param \Illuminate\Validation\Validator $validator
* @return void
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if ($this->first_name === $this->last_name) {
$validator->errors()->add(
'last_name',
'Last name should be different from first name.'
);
}
$suspiciousPatterns = ['test', 'admin', 'root', 'null', 'undefined'];
$fullName = strtolower($this->first_name . ' ' . $this->last_name);
foreach ($suspiciousPatterns as $pattern) {
if (strpos($fullName, $pattern) !== false) {
$validator->errors()->add(
'first_name',
'Please enter your real name.'
);
break;
}
}
});
}
/**
* Handle failed registration validation for security monitoring
*
* Logs failed registration attempts with comprehensive security context
* including user input, IP address, and validation errors.
* Critical for detecting registration abuse and attacks.
*
* @param \Illuminate\Contracts\Validation\Validator $validator
* @return void
* @throws \Illuminate\Validation\ValidationException
*/
protected function failedValidation($validator): void
{
logger()->warning('Registration validation failed', [
'email' => $this->input('email'),
'first_name' => $this->input('first_name'),
'last_name' => $this->input('last_name'),
'ip' => $this->ip(),
'user_agent' => $this->userAgent(),
'errors' => $validator->errors()->toArray(),
]);
parent::failedValidation($validator);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
/**
* Update Profile Form Request
*
* Validates user profile update data with comprehensive security checks.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
class UpdateProfileRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return Auth::check();
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'first_name' => [
'required',
'string',
'min:2',
'max:50',
'regex:/^[a-zA-ZÀ-ÿ\s\-\'\.]+$/',
],
'last_name' => [
'required',
'string',
'min:2',
'max:50',
'regex:/^[a-zA-ZÀ-ÿ\s\-\'\.]+$/',
],
'phone' => [
'nullable',
'string',
'min:10',
'max:20',
'regex:/^[\+]?[0-9\s\-\(\)]+$/',
],
'bio' => [
'nullable',
'string',
'max:1000',
],
'website' => [
'nullable',
'url',
'max:255',
'regex:/^https?:\/\/.+$/',
],
'linkedin' => [
'nullable',
'url',
'max:255',
'regex:/^https:\/\/(www\.)?linkedin\.com\/in\/[a-zA-Z0-9\-]+\/?$/',
],
'github' => [
'nullable',
'url',
'max:255',
'regex:/^https:\/\/(www\.)?github\.com\/[a-zA-Z0-9\-]+\/?$/',
],
'twitter' => [
'nullable',
'url',
'max:255',
'regex:/^https:\/\/(www\.)?twitter\.com\/[a-zA-Z0-9_]+\/?$/',
],
];
}
/**
* Get custom error messages for validation rules.
*/
public function messages(): array
{
return [
'first_name.required' => 'First name is required.',
'first_name.regex' => 'First name contains invalid characters.',
'last_name.required' => 'Last name is required.',
'last_name.regex' => 'Last name contains invalid characters.',
'phone.regex' => 'Please enter a valid phone number.',
'website.regex' => 'Website URL must start with http:// or https://',
'linkedin.regex' => 'Please enter a valid LinkedIn profile URL.',
'github.regex' => 'Please enter a valid GitHub profile URL.',
'twitter.regex' => 'Please enter a valid Twitter profile URL.',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'first_name' => ucfirst(strtolower(trim($this->first_name ?? ''))),
'last_name' => ucfirst(strtolower(trim($this->last_name ?? ''))),
'phone' => $this->phone ? preg_replace('/[^\+0-9]/', '', $this->phone) : null,
'bio' => $this->bio ? trim($this->bio) : null,
'website' => $this->website ? strtolower(trim($this->website)) : null,
'linkedin' => $this->linkedin ? strtolower(trim($this->linkedin)) : null,
'github' => $this->github ? strtolower(trim($this->github)) : null,
'twitter' => $this->twitter ? strtolower(trim($this->twitter)) : null,
]);
}
}

View File

@@ -0,0 +1,336 @@
<?php
namespace App\Http\Requests\Resume;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
/**
* Store Resume Form Request
*
* Validates data for creating a new resume with comprehensive validation rules.
* Implements business logic validations and data sanitization.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
class StoreResumeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return Auth::check();
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'title' => [
'required',
'string',
'min:3',
'max:255',
'regex:/^[a-zA-Z0-9\s\-\_\.\,\!\?]+$/',
],
'description' => [
'nullable',
'string',
'max:1000',
],
'template' => [
'required',
'string',
'in:professional,modern,executive,minimal,technical',
],
'content' => [
'nullable',
'array',
],
'content.personal_info' => [
'nullable',
'array',
],
'content.personal_info.full_name' => [
'nullable',
'string',
'max:255',
],
'content.personal_info.title' => [
'nullable',
'string',
'max:255',
],
'content.personal_info.email' => [
'nullable',
'email',
'max:255',
],
'content.personal_info.phone' => [
'nullable',
'string',
'max:20',
],
'content.personal_info.location' => [
'nullable',
'string',
'max:255',
],
'content.personal_info.website' => [
'nullable',
'url',
'max:255',
],
'content.personal_info.linkedin' => [
'nullable',
'url',
'max:255',
],
'content.personal_info.github' => [
'nullable',
'url',
'max:255',
],
'content.summary' => [
'nullable',
'string',
'max:2000',
],
'content.experience' => [
'nullable',
'array',
'max:20',
],
'content.experience.*.company' => [
'required_with:content.experience',
'string',
'max:255',
],
'content.experience.*.position' => [
'required_with:content.experience',
'string',
'max:255',
],
'content.experience.*.start_date' => [
'required_with:content.experience',
'date',
'before_or_equal:today',
],
'content.experience.*.end_date' => [
'nullable',
'date',
'after:content.experience.*.start_date',
'before_or_equal:today',
],
'content.experience.*.current' => [
'boolean',
],
'content.experience.*.description' => [
'nullable',
'string',
'max:2000',
],
'content.education' => [
'nullable',
'array',
'max:10',
],
'content.education.*.institution' => [
'required_with:content.education',
'string',
'max:255',
],
'content.education.*.degree' => [
'required_with:content.education',
'string',
'max:255',
],
'content.education.*.field' => [
'nullable',
'string',
'max:255',
],
'content.education.*.start_date' => [
'required_with:content.education',
'date',
'before_or_equal:today',
],
'content.education.*.end_date' => [
'nullable',
'date',
'after:content.education.*.start_date',
'before_or_equal:today',
],
'content.skills' => [
'nullable',
'array',
],
'settings' => [
'nullable',
'array',
],
'settings.color_scheme' => [
'nullable',
'string',
'in:blue,green,red,purple,orange,gray,black',
],
'settings.font_family' => [
'nullable',
'string',
'in:Arial,Times New Roman,Calibri,Helvetica,Georgia',
],
'settings.font_size' => [
'nullable',
'integer',
'min:8',
'max:16',
],
'is_public' => [
'boolean',
],
'allow_comments' => [
'boolean',
],
];
}
/**
* Get custom error messages for validation rules.
*/
public function messages(): array
{
return [
'title.required' => 'Resume title is required.',
'title.min' => 'Resume title must be at least 3 characters.',
'title.max' => 'Resume title cannot exceed 255 characters.',
'title.regex' => 'Resume title contains invalid characters.',
'description.max' => 'Description cannot exceed 1000 characters.',
'template.required' => 'Please select a resume template.',
'template.in' => 'Selected template is not available.',
'content.experience.max' => 'You cannot add more than 20 work experiences.',
'content.education.max' => 'You cannot add more than 10 education entries.',
'content.personal_info.email.email' => 'Please enter a valid email address.',
'content.personal_info.website.url' => 'Please enter a valid website URL.',
'content.personal_info.linkedin.url' => 'Please enter a valid LinkedIn URL.',
'content.personal_info.github.url' => 'Please enter a valid GitHub URL.',
'content.experience.*.company.required_with' => 'Company name is required for work experience.',
'content.experience.*.position.required_with' => 'Position title is required for work experience.',
'content.experience.*.start_date.required_with' => 'Start date is required for work experience.',
'content.experience.*.start_date.before_or_equal' => 'Start date cannot be in the future.',
'content.experience.*.end_date.after' => 'End date must be after start date.',
'content.experience.*.end_date.before_or_equal' => 'End date cannot be in the future.',
'content.education.*.institution.required_with' => 'Institution name is required for education.',
'content.education.*.degree.required_with' => 'Degree is required for education.',
'content.education.*.start_date.required_with' => 'Start date is required for education.',
'settings.font_size.min' => 'Font size must be at least 8.',
'settings.font_size.max' => 'Font size cannot exceed 16.',
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'title' => 'resume title',
'description' => 'resume description',
'template' => 'resume template',
'content.personal_info.full_name' => 'full name',
'content.personal_info.title' => 'professional title',
'content.personal_info.email' => 'email address',
'content.personal_info.phone' => 'phone number',
'content.personal_info.location' => 'location',
'content.personal_info.website' => 'website',
'content.personal_info.linkedin' => 'LinkedIn profile',
'content.personal_info.github' => 'GitHub profile',
'content.summary' => 'professional summary',
'is_public' => 'public visibility',
'allow_comments' => 'allow comments',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'title' => trim($this->title),
'description' => $this->description ? trim($this->description) : null,
'is_public' => $this->boolean('is_public'),
'allow_comments' => $this->boolean('allow_comments'),
]);
if ($this->has('content.personal_info')) {
$personalInfo = $this->input('content.personal_info', []);
if (isset($personalInfo['email'])) {
$personalInfo['email'] = strtolower(trim($personalInfo['email']));
}
if (isset($personalInfo['full_name'])) {
$personalInfo['full_name'] = trim($personalInfo['full_name']);
}
$this->merge(['content' => array_merge($this->input('content', []), [
'personal_info' => $personalInfo
])]);
}
}
/**
* Configure the validator instance.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$userId = Auth::id();
$existingResume = \App\Models\Resume::where('user_id', $userId)
->where('title', $this->title)
->first();
if ($existingResume) {
$validator->errors()->add(
'title',
'You already have a resume with this title. Please choose a different title.'
);
}
if ($this->has('content.experience') && is_array($this->content['experience'])) {
foreach ($this->content['experience'] as $index => $experience) {
if (!empty($experience['start_date']) && !empty($experience['end_date'])) {
$startDate = Carbon::parse($experience['start_date']);
$endDate = Carbon::parse($experience['end_date']);
if ($startDate->greaterThan($endDate)) {
$validator->errors()->add(
"content.experience.{$index}.end_date",
'End date must be after start date.'
);
}
}
}
}
});
}
/**
* Handle a failed validation attempt.
*/
protected function failedValidation($validator): void
{
logger()->warning('Resume creation validation failed', [
'user_id' => Auth::id(),
'title' => $this->input('title'),
'template' => $this->input('template'),
'errors' => $validator->errors()->toArray(),
]);
parent::failedValidation($validator);
}
}

View File

@@ -0,0 +1,465 @@
<?php
namespace App\Http\Requests\Resume;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
/**
* Update Resume Form Request
*
* Validates data for updating an existing resume with comprehensive validation rules.
* Implements business logic validations and data sanitization for resume updates.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
class UpdateResumeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$resume = $this->route('resume');
return Auth::check() && $resume && $resume->user_id === Auth::id();
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$resumeId = $this->route('resume')->id ?? null;
return [
'title' => [
'sometimes',
'required',
'string',
'min:3',
'max:255',
'regex:/^[a-zA-Z0-9\s\-\_\.\,\!\?]+$/',
],
'description' => [
'nullable',
'string',
'max:1000',
],
'template' => [
'sometimes',
'required',
'string',
'in:professional,modern,executive,minimal,technical',
],
'status' => [
'sometimes',
'string',
'in:draft,published,archived',
],
'content' => [
'nullable',
'array',
],
'content.personal_info' => [
'nullable',
'array',
],
'content.personal_info.full_name' => [
'nullable',
'string',
'max:255',
],
'content.personal_info.title' => [
'nullable',
'string',
'max:255',
],
'content.personal_info.email' => [
'nullable',
'email',
'max:255',
],
'content.personal_info.phone' => [
'nullable',
'string',
'max:20',
'regex:/^[\+]?[0-9\s\-\(\)]+$/',
],
'content.personal_info.location' => [
'nullable',
'string',
'max:255',
],
'content.personal_info.website' => [
'nullable',
'url',
'max:255',
],
'content.personal_info.linkedin' => [
'nullable',
'url',
'max:255',
'regex:/^https:\/\/(www\.)?linkedin\.com\/in\/[a-zA-Z0-9\-]+\/?$/',
],
'content.personal_info.github' => [
'nullable',
'url',
'max:255',
'regex:/^https:\/\/(www\.)?github\.com\/[a-zA-Z0-9\-]+\/?$/',
],
'content.summary' => [
'nullable',
'string',
'max:2000',
],
'content.experience' => [
'nullable',
'array',
'max:20',
],
'content.experience.*.company' => [
'required_with:content.experience',
'string',
'max:255',
],
'content.experience.*.position' => [
'required_with:content.experience',
'string',
'max:255',
],
'content.experience.*.location' => [
'nullable',
'string',
'max:255',
],
'content.experience.*.start_date' => [
'required_with:content.experience',
'date',
'before_or_equal:today',
],
'content.experience.*.end_date' => [
'nullable',
'date',
'after:content.experience.*.start_date',
'before_or_equal:today',
],
'content.experience.*.current' => [
'boolean',
],
'content.experience.*.description' => [
'nullable',
'string',
'max:2000',
],
'content.experience.*.achievements' => [
'nullable',
'array',
'max:10',
],
'content.experience.*.achievements.*' => [
'string',
'max:500',
],
'content.education' => [
'nullable',
'array',
'max:10',
],
'content.education.*.institution' => [
'required_with:content.education',
'string',
'max:255',
],
'content.education.*.degree' => [
'required_with:content.education',
'string',
'max:255',
],
'content.education.*.field' => [
'nullable',
'string',
'max:255',
],
'content.education.*.start_date' => [
'required_with:content.education',
'date',
'before_or_equal:today',
],
'content.education.*.end_date' => [
'nullable',
'date',
'after:content.education.*.start_date',
'before_or_equal:today',
],
'content.education.*.gpa' => [
'nullable',
'numeric',
'min:0',
'max:4',
],
'content.education.*.description' => [
'nullable',
'string',
'max:1000',
],
'content.skills' => [
'nullable',
'array',
],
'content.projects' => [
'nullable',
'array',
'max:15',
],
'content.projects.*.name' => [
'required_with:content.projects',
'string',
'max:255',
],
'content.projects.*.description' => [
'nullable',
'string',
'max:1000',
],
'content.projects.*.url' => [
'nullable',
'url',
'max:255',
],
'content.projects.*.technologies' => [
'nullable',
'array',
'max:20',
],
'content.certifications' => [
'nullable',
'array',
'max:20',
],
'content.certifications.*.name' => [
'required_with:content.certifications',
'string',
'max:255',
],
'content.certifications.*.issuer' => [
'required_with:content.certifications',
'string',
'max:255',
],
'content.certifications.*.date' => [
'required_with:content.certifications',
'date',
'before_or_equal:today',
],
'content.certifications.*.url' => [
'nullable',
'url',
'max:255',
],
'content.languages' => [
'nullable',
'array',
'max:10',
],
'content.languages.*.language' => [
'required_with:content.languages',
'string',
'max:100',
],
'content.languages.*.level' => [
'required_with:content.languages',
'string',
'in:Native,Fluent,Advanced,Intermediate,Basic',
],
'settings' => [
'nullable',
'array',
],
'settings.color_scheme' => [
'nullable',
'string',
'in:blue,green,red,purple,orange,gray,black',
],
'settings.font_family' => [
'nullable',
'string',
'in:Arial,Times New Roman,Calibri,Helvetica,Georgia',
],
'settings.font_size' => [
'nullable',
'integer',
'min:8',
'max:16',
],
'settings.line_spacing' => [
'nullable',
'numeric',
'min:1.0',
'max:2.0',
],
'settings.margin' => [
'nullable',
'integer',
'min:10',
'max:30',
],
'settings.show_photo' => [
'boolean',
],
'is_public' => [
'boolean',
],
'allow_comments' => [
'boolean',
],
'password' => [
'nullable',
'string',
'min:6',
'max:50',
],
'expires_at' => [
'nullable',
'date',
'after:today',
],
];
}
/**
* Get custom error messages for validation rules.
*/
public function messages(): array
{
return [
'title.required' => 'Resume title is required.',
'title.min' => 'Resume title must be at least 3 characters.',
'title.regex' => 'Resume title contains invalid characters.',
'template.in' => 'Selected template is not available.',
'status.in' => 'Invalid resume status.',
'content.personal_info.email.email' => 'Please enter a valid email address.',
'content.personal_info.phone.regex' => 'Please enter a valid phone number.',
'content.personal_info.linkedin.regex' => 'Please enter a valid LinkedIn profile URL.',
'content.personal_info.github.regex' => 'Please enter a valid GitHub profile URL.',
'content.experience.max' => 'You cannot have more than 20 work experiences.',
'content.education.max' => 'You cannot have more than 10 education entries.',
'content.projects.max' => 'You cannot have more than 15 projects.',
'content.certifications.max' => 'You cannot have more than 20 certifications.',
'content.languages.max' => 'You cannot have more than 10 languages.',
'content.experience.*.end_date.after' => 'End date must be after start date.',
'content.education.*.gpa.max' => 'GPA cannot exceed 4.0.',
'content.languages.*.level.in' => 'Invalid language proficiency level.',
'settings.font_size.min' => 'Font size must be at least 8.',
'settings.font_size.max' => 'Font size cannot exceed 16.',
'settings.line_spacing.min' => 'Line spacing must be at least 1.0.',
'settings.line_spacing.max' => 'Line spacing cannot exceed 2.0.',
'expires_at.after' => 'Expiration date must be in the future.',
'password.min' => 'Password must be at least 6 characters.',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
if ($this->has('title')) {
$this->merge(['title' => trim($this->title)]);
}
if ($this->has('description')) {
$this->merge(['description' => $this->description ? trim($this->description) : null]);
}
$this->merge([
'is_public' => $this->boolean('is_public'),
'allow_comments' => $this->boolean('allow_comments'),
]);
if ($this->has('content.personal_info')) {
$personalInfo = $this->input('content.personal_info', []);
if (isset($personalInfo['email'])) {
$personalInfo['email'] = strtolower(trim($personalInfo['email']));
}
$this->merge(['content' => array_merge($this->input('content', []), [
'personal_info' => $personalInfo
])]);
}
}
/**
* Configure the validator instance.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$resume = $this->route('resume');
if ($this->has('title')) {
$userId = Auth::id();
$existingResume = \App\Models\Resume::where('user_id', $userId)
->where('title', $this->title)
->where('id', '!=', $resume->id)
->first();
if ($existingResume) {
$validator->errors()->add(
'title',
'You already have a resume with this title. Please choose a different title.'
);
}
}
if ($this->has('content.experience') && is_array($this->content['experience'])) {
foreach ($this->content['experience'] as $index => $experience) {
if (!empty($experience['start_date']) && !empty($experience['end_date'])) {
$startDate = Carbon::parse($experience['start_date']);
$endDate = Carbon::parse($experience['end_date']);
if ($startDate->greaterThan($endDate)) {
$validator->errors()->add(
"content.experience.{$index}.end_date",
'End date must be after start date.'
);
}
}
}
}
if ($this->has('status') && $this->status === 'published') {
$content = $this->input('content', []);
$requiredSections = ['personal_info', 'summary'];
foreach ($requiredSections as $section) {
if (empty($content[$section])) {
$validator->errors()->add(
'status',
"Cannot publish resume: {$section} section is required."
);
}
}
}
});
}
/**
* Handle a failed validation attempt.
*/
protected function failedValidation($validator): void
{
$resume = $this->route('resume');
logger()->warning('Resume update validation failed', [
'user_id' => Auth::id(),
'resume_id' => $resume->id ?? null,
'title' => $this->input('title'),
'errors' => $validator->errors()->toArray(),
]);
parent::failedValidation($validator);
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Interfaces;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
/**
* Base Repository Interface
*
* Defines the contract for all repository implementations.
* Provides a consistent API for data access operations.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
interface BaseRepositoryInterface
{
/**
* Get all records
*
* @param array $columns
* @return Collection
*/
public function all(array $columns = ['*']): Collection;
/**
* Get records with pagination
*
* @param int $perPage
* @param array $columns
* @return LengthAwarePaginator
*/
public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator;
/**
* Find a record by ID
*
* @param int $id
* @param array $columns
* @return Model|null
*/
public function find(int $id, array $columns = ['*']): ?Model;
/**
* Find a record by ID or throw an exception
*
* @param int $id
* @param array $columns
* @return Model
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function findOrFail(int $id, array $columns = ['*']): Model;
/**
* Find records by criteria
*
* @param array $criteria
* @param array $columns
* @return Collection
*/
public function findBy(array $criteria, array $columns = ['*']): Collection;
/**
* Find a single record by criteria
*
* @param array $criteria
* @param array $columns
* @return Model|null
*/
public function findOneBy(array $criteria, array $columns = ['*']): ?Model;
/**
* Create a new record
*
* @param array $data
* @return Model
*/
public function create(array $data): Model;
/**
* Update an existing record
*
* @param int $id
* @param array $data
* @return bool
*/
public function update(int $id, array $data): bool;
/**
* Update or create a record
*
* @param array $attributes
* @param array $values
* @return Model
*/
public function updateOrCreate(array $attributes, array $values = []): Model;
/**
* Delete a record by ID
*
* @param int $id
* @return bool
*/
public function delete(int $id): bool;
/**
* Delete records by criteria
*
* @param array $criteria
* @return int Number of deleted records
*/
public function deleteBy(array $criteria): int;
/**
* Count records
*
* @param array $criteria
* @return int
*/
public function count(array $criteria = []): int;
/**
* Check if record exists
*
* @param array $criteria
* @return bool
*/
public function exists(array $criteria): bool;
/**
* Get records with relationships
*
* @param array $relations
* @return BaseRepositoryInterface
*/
public function with(array $relations): BaseRepositoryInterface;
/**
* Order records
*
* @param string $column
* @param string $direction
* @return BaseRepositoryInterface
*/
public function orderBy(string $column, string $direction = 'asc'): BaseRepositoryInterface;
/**
* Limit records
*
* @param int $limit
* @return BaseRepositoryInterface
*/
public function limit(int $limit): BaseRepositoryInterface;
}

View File

@@ -0,0 +1,206 @@
<?php
namespace App\Interfaces;
use App\Models\Resume;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
/**
* Resume Repository Interface
*
* Defines the contract for resume-specific data operations.
* Extends the base repository with resume-specific methods.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
interface ResumeRepositoryInterface extends BaseRepositoryInterface
{
/**
* Get user's resumes
*
* @param int $userId
* @param array $columns
* @return Collection
*/
public function getUserResumes(int $userId, array $columns = ['*']): Collection;
/**
* Get user's resumes with pagination
*
* @param int $userId
* @param int $perPage
* @return LengthAwarePaginator
*/
public function getPaginatedUserResumes(int $userId, int $perPage = 10): LengthAwarePaginator;
/**
* Find resume by public URL
*
* @param string $publicUrl
* @return Resume|null
*/
public function findByPublicUrl(string $publicUrl): ?Resume;
/**
* Get public resumes
*
* @param int $limit
* @return Collection
*/
public function getPublicResumes(int $limit = 20): Collection;
/**
* Get resumes by template
*
* @param string $template
* @return Collection
*/
public function getResumesByTemplate(string $template): Collection;
/**
* Get resumes by status
*
* @param string $status
* @return Collection
*/
public function getResumesByStatus(string $status): Collection;
/**
* Get popular resumes (most viewed)
*
* @param int $limit
* @param int $days
* @return Collection
*/
public function getPopularResumes(int $limit = 10, int $days = 30): Collection;
/**
* Get recent resumes
*
* @param int $limit
* @param int $days
* @return Collection
*/
public function getRecentResumes(int $limit = 10, int $days = 7): Collection;
/**
* Search resumes
*
* @param string $query
* @param array $filters
* @param int $limit
* @return Collection
*/
public function searchResumes(string $query, array $filters = [], int $limit = 20): Collection;
/**
* Get incomplete resumes
*
* @param int $threshold
* @return Collection
*/
public function getIncompleteResumes(int $threshold = 50): Collection;
/**
* Duplicate resume for user
*
* @param int $resumeId
* @param int $userId
* @param string $newTitle
* @return Resume
*/
public function duplicateResume(int $resumeId, int $userId, string $newTitle): Resume;
/**
* Update resume view count
*
* @param int $resumeId
* @return bool
*/
public function incrementViewCount(int $resumeId): bool;
/**
* Update resume download count
*
* @param int $resumeId
* @return bool
*/
public function incrementDownloadCount(int $resumeId): bool;
/**
* Get resume statistics
*
* @param int|null $userId
* @return array
*/
public function getResumeStatistics(?int $userId = null): array;
/**
* Get resumes requiring PDF regeneration
*
* @return Collection
*/
public function getResumesNeedingPdfRegeneration(): Collection;
/**
* Archive old resumes
*
* @param int $days
* @return int Number of archived resumes
*/
public function archiveOldResumes(int $days = 365): int;
/**
* Get user's resume by title
*
* @param int $userId
* @param string $title
* @return Resume|null
*/
public function getUserResumeByTitle(int $userId, string $title): ?Resume;
/**
* Check if user can create more resumes
*
* @param int $userId
* @param int $limit
* @return bool
*/
public function canUserCreateMoreResumes(int $userId, int $limit = 10): bool;
/**
* Get featured resumes for showcase
*
* @param int $limit
* @return Collection
*/
public function getFeaturedResumes(int $limit = 6): Collection;
/**
* Update resume completion percentage
*
* @param int $resumeId
* @return bool
*/
public function updateCompletionPercentage(int $resumeId): bool;
/**
* Get resumes expiring soon
*
* @param int $days
* @return Collection
*/
public function getResumesExpiringSoon(int $days = 7): Collection;
/**
* Bulk update resume status
*
* @param array $resumeIds
* @param string $status
* @return int Number of updated resumes
*/
public function bulkUpdateStatus(array $resumeIds, string $status): int;
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Interfaces;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
/**
* User Repository Interface
*
* Defines the contract for user-specific data operations.
* Extends the base repository with user-specific methods.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
interface UserRepositoryInterface extends BaseRepositoryInterface
{
/**
* Find a user by email address
*
* @param string $email
* @return User|null
*/
public function findByEmail(string $email): ?User;
/**
* Find users by status
*
* @param string $status
* @return Collection
*/
public function findByStatus(string $status): Collection;
/**
* Get users who haven't logged in recently
*
* @param int $days
* @return Collection
*/
public function getInactiveUsers(int $days = 30): Collection;
/**
* Get users with completed profiles
*
* @return Collection
*/
public function getUsersWithCompletedProfiles(): Collection;
/**
* Get users with incomplete profiles
*
* @return Collection
*/
public function getUsersWithIncompleteProfiles(): Collection;
/**
* Get users subscribed to newsletter
*
* @return Collection
*/
public function getNewsletterSubscribers(): Collection;
/**
* Search users by name or email
*
* @param string $query
* @param int $limit
* @return Collection
*/
public function searchUsers(string $query, int $limit = 10): Collection;
/**
* Get user statistics
*
* @return array
*/
public function getUserStatistics(): array;
/**
* Update user's last login information
*
* @param int $userId
* @param string $ipAddress
* @return bool
*/
public function updateLastLogin(int $userId, string $ipAddress): bool;
/**
* Increment user's login attempts
*
* @param string $email
* @return bool
*/
public function incrementLoginAttempts(string $email): bool;
/**
* Reset user's login attempts
*
* @param string $email
* @return bool
*/
public function resetLoginAttempts(string $email): bool;
/**
* Lock user account
*
* @param string $email
* @param int $minutes
* @return bool
*/
public function lockAccount(string $email, int $minutes = 15): bool;
/**
* Check if account is locked
*
* @param string $email
* @return bool
*/
public function isAccountLocked(string $email): bool;
/**
* Suspend user account
*
* @param int $userId
* @param string $reason
* @return bool
*/
public function suspendAccount(int $userId, string $reason): bool;
/**
* Reactivate user account
*
* @param int $userId
* @return bool
*/
public function reactivateAccount(int $userId): bool;
/**
* Get users registered in date range
*
* @param string $startDate
* @param string $endDate
* @return Collection
*/
public function getUsersRegisteredBetween(string $startDate, string $endDate): Collection;
/**
* Get paginated users with filters
*
* @param array $filters
* @param int $perPage
* @return LengthAwarePaginator
*/
public function getPaginatedUsersWithFilters(array $filters = [], int $perPage = 15): LengthAwarePaginator;
}

260
app/Models/Resume.php Normal file
View File

@@ -0,0 +1,260 @@
<?php
/**
* Resume Model
* Professional Resume Builder - Resume Entity
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Resume Model
* Represents user resumes with all personal and professional information
*/
class Resume extends Model
{
use HasFactory, SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'title',
'template_id',
'slug',
'personal_info',
'professional_summary',
'work_experiences',
'education',
'skills',
'languages',
'certifications',
'projects',
'references',
'custom_sections',
'settings',
'is_active',
'is_completed',
'is_public',
'public_url',
'created_at',
'updated_at'
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'personal_info' => 'array',
'work_experiences' => 'array',
'education' => 'array',
'skills' => 'array',
'languages' => 'array',
'certifications' => 'array',
'projects' => 'array',
'references' => 'array',
'custom_sections' => 'array',
'settings' => 'array',
'is_active' => 'boolean',
'is_completed' => 'boolean',
'is_public' => 'boolean',
];
/**
* Get the user that owns the resume.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the resume's public URL.
*/
public function getPublicUrlAttribute(): ?string
{
if ($this->is_public && $this->public_url) {
return route('public.resume', $this->public_url);
}
return null;
}
/**
* Get the resume's preview URL.
*/
public function getPreviewUrlAttribute(): string
{
return route('resume-builder.preview', $this);
}
/**
* Get the resume's edit URL.
*/
public function getEditUrlAttribute(): string
{
return route('resume-builder.edit', $this);
}
/**
* Get the resume's PDF download URL.
*/
public function getPdfUrlAttribute(): string
{
return route('resume-builder.download-pdf', $this);
}
/**
* Calculate completion percentage of the resume.
*/
public function getCompletionPercentageAttribute(): int
{
$sections = [
'personal_info' => 25,
'professional_summary' => 10,
'work_experiences' => 30,
'education' => 15,
'skills' => 10,
'languages' => 5,
'certifications' => 5,
];
$completed = 0;
foreach ($sections as $section => $weight) {
if ($this->isSectionCompleted($section)) {
$completed += $weight;
}
}
return min(100, $completed);
}
/**
* Check if a specific section is completed.
*/
public function isSectionCompleted(string $section): bool
{
$data = $this->$section;
if (empty($data)) {
return false;
}
switch ($section) {
case 'personal_info':
$required = ['first_name', 'last_name', 'email', 'phone'];
foreach ($required as $field) {
if (empty($data[$field] ?? null)) {
return false;
}
}
return true;
case 'professional_summary':
return !empty($data) && strlen($data) >= 50;
case 'work_experiences':
return is_array($data) && count($data) > 0 && !empty($data[0]['company'] ?? null);
case 'education':
return is_array($data) && count($data) > 0 && !empty($data[0]['institution'] ?? null);
case 'skills':
return is_array($data) && count($data) > 0;
default:
return !empty($data);
}
}
/**
* Generate a unique slug for the resume.
*/
public function generateSlug(): string
{
$baseSlug = str()->slug($this->title);
$slug = $baseSlug;
$counter = 1;
while (static::where('slug', $slug)->where('id', '!=', $this->id)->exists()) {
$slug = $baseSlug . '-' . $counter;
$counter++;
}
return $slug;
}
/**
* Generate a unique public URL for the resume.
*/
public function generatePublicUrl(): string
{
return str()->random(32);
}
/**
* Mark the resume as completed.
*/
public function markAsCompleted(): void
{
$this->update(['is_completed' => true]);
}
/**
* Scope a query to only include active resumes.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope a query to only include completed resumes.
*/
public function scopeCompleted($query)
{
return $query->where('is_completed', true);
}
/**
* Scope a query to only include public resumes.
*/
public function scopePublic($query)
{
return $query->where('is_public', true);
}
/**
* Boot the model.
*/
protected static function boot()
{
parent::boot();
static::creating(function ($resume) {
if (empty($resume->slug)) {
$resume->slug = $resume->generateSlug();
}
});
static::updating(function ($resume) {
if ($resume->isDirty('title')) {
$resume->slug = $resume->generateSlug();
}
});
}
}

191
app/Models/User.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
/**
* User Model
* Professional Resume Builder - User Entity
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
/**
* User Model
* Represents application users with authentication and profile features
*/
class User extends Authenticatable implements MustVerifyEmail
{
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'first_name',
'last_name',
'email',
'password',
'phone',
'date_of_birth',
'gender',
'avatar',
'bio',
'website',
'linkedin',
'github',
'twitter',
'preferences',
'newsletter_subscribed',
'last_login_at',
'last_login_ip',
'status',
'suspended_at',
'suspension_reason',
'login_attempts',
'locked_until',
'locale',
'timezone',
'two_factor_enabled',
'two_factor_secret',
'two_factor_recovery_codes',
'profile_completed_at'
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'date_of_birth' => 'date',
'suspended_at' => 'datetime',
'locked_until' => 'datetime',
'profile_completed_at' => 'datetime',
'newsletter_subscribed' => 'boolean',
'two_factor_enabled' => 'boolean',
'preferences' => 'array',
'two_factor_recovery_codes' => 'array',
'password' => 'hashed',
];
}
/**
* Get the user's full name.
*/
public function getFullNameAttribute(): string
{
return "{$this->first_name} {$this->last_name}";
}
/**
* Get the user's initials.
*/
public function getInitialsAttribute(): string
{
return strtoupper(substr($this->first_name, 0, 1) . substr($this->last_name, 0, 1));
}
/**
* Get the user's avatar URL.
*/
public function getAvatarUrlAttribute(): string
{
if ($this->avatar) {
return asset('storage/avatars/' . $this->avatar);
}
return "https://ui-avatars.com/api/?name={$this->initials}&background=667eea&color=fff&size=200";
}
/**
* Check if user has completed their profile.
*/
public function hasCompletedProfile(): bool
{
$requiredFields = ['first_name', 'last_name', 'email', 'phone', 'city', 'country'];
foreach ($requiredFields as $field) {
if (empty($this->$field)) {
return false;
}
}
return true;
}
/**
* Get the user's resumes.
*/
public function resumes(): HasMany
{
return $this->hasMany(Resume::class)->orderBy('updated_at', 'desc');
}
/**
* Get the user's active resumes.
*/
public function activeResumes(): HasMany
{
return $this->resumes()->where('is_active', true);
}
/**
* Get the user's completed resumes.
*/
public function completedResumes(): HasMany
{
return $this->resumes()->where('is_completed', true);
}
/**
* Update user's last login information.
*/
public function updateLastLogin(string $ipAddress): void
{
$this->update([
'last_login_at' => now(),
'last_login_ip' => $ipAddress,
]);
}
/**
* Scope a query to only include active users.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope a query to only include verified users.
*/
public function scopeVerified($query)
{
return $query->whereNotNull('email_verified_at');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Providers;
// use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
/**
* The model to policy mappings for the application.
*
* @var array<class-string, class-string>
*/
protected $policies = [
//
];
/**
* Register any authentication / authorization services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Broadcast::routes();
require base_path('routes/channels.php');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Providers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider
{
/**
* The event to listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
*/
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
];
/**
* Register any events for your application.
*/
public function boot(): void
{
//
}
/**
* Determine if events and listeners should be automatically discovered.
*/
public function shouldDiscoverEvents(): bool
{
return false;
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
// Interfaces
use App\Interfaces\UserRepositoryInterface;
use App\Interfaces\ResumeRepositoryInterface;
// Repositories
use App\Repositories\UserRepository;
use App\Repositories\ResumeRepository;
// Models
use App\Models\User;
use App\Models\Resume;
/**
* Repository Service Provider
*
* Binds repository interfaces to their concrete implementations.
* Enables dependency injection throughout the application.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
class RepositoryServiceProvider extends ServiceProvider
{
/**
* All of the container bindings that should be registered.
*
* @var array
*/
public array $bindings = [
UserRepositoryInterface::class => UserRepository::class,
ResumeRepositoryInterface::class => ResumeRepository::class,
];
/**
* Register services.
*/
public function register(): void
{
// Bind User Repository
$this->app->bind(UserRepositoryInterface::class, function ($app) {
return new UserRepository(new User());
});
// Bind Resume Repository
$this->app->bind(ResumeRepositoryInterface::class, function ($app) {
return new ResumeRepository(new Resume());
});
}
/**
* Bootstrap services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
/**
* The path to your application's "home" route.
*
* Typically, users are redirected here after authentication.
*
* @var string
*/
public const HOME = '/home';
/**
* Define your route model bindings, pattern filters, and other route configuration.
*/
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
$this->routes(function () {
Route::middleware('api')
->prefix('api')
->group(base_path('routes/api.php'));
Route::middleware('web')
->group(base_path('routes/web.php'));
});
}
}

View File

@@ -0,0 +1,345 @@
<?php
namespace App\Repositories;
use App\Interfaces\BaseRepositoryInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\ModelNotFoundException;
/**
* Base Repository Implementation
*
* Provides common repository functionality for all models.
* Implements the BaseRepositoryInterface with reusable methods.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
abstract class BaseRepository implements BaseRepositoryInterface
{
/**
* The model instance
*
* @var Model
*/
protected Model $model;
/**
* The query builder instance
*
* @var Builder
*/
protected Builder $query;
/**
* Create a new repository instance
*
* @param Model $model
*/
public function __construct(Model $model)
{
$this->model = $model;
$this->resetQuery();
}
/**
* Get all records
*
* @param array $columns
* @return Collection
*/
public function all(array $columns = ['*']): Collection
{
$result = $this->query->get($columns);
$this->resetQuery();
return $result;
}
/**
* Get records with pagination
*
* @param int $perPage
* @param array $columns
* @return LengthAwarePaginator
*/
public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator
{
$result = $this->query->paginate($perPage, $columns);
$this->resetQuery();
return $result;
}
/**
* Find a record by ID
*
* @param int $id
* @param array $columns
* @return Model|null
*/
public function find(int $id, array $columns = ['*']): ?Model
{
$result = $this->query->find($id, $columns);
$this->resetQuery();
return $result;
}
/**
* Find a record by ID or throw an exception
*
* @param int $id
* @param array $columns
* @return Model
* @throws ModelNotFoundException
*/
public function findOrFail(int $id, array $columns = ['*']): Model
{
$result = $this->query->findOrFail($id, $columns);
$this->resetQuery();
return $result;
}
/**
* Find records by criteria
*
* @param array $criteria
* @param array $columns
* @return Collection
*/
public function findBy(array $criteria, array $columns = ['*']): Collection
{
$this->applyCriteria($criteria);
$result = $this->query->get($columns);
$this->resetQuery();
return $result;
}
/**
* Find a single record by criteria
*
* @param array $criteria
* @param array $columns
* @return Model|null
*/
public function findOneBy(array $criteria, array $columns = ['*']): ?Model
{
$this->applyCriteria($criteria);
$result = $this->query->first($columns);
$this->resetQuery();
return $result;
}
/**
* Create a new record
*
* @param array $data
* @return Model
*/
public function create(array $data): Model
{
return $this->model->create($data);
}
/**
* Update an existing record
*
* @param int $id
* @param array $data
* @return bool
*/
public function update(int $id, array $data): bool
{
$model = $this->findOrFail($id);
return $model->update($data);
}
/**
* Update or create a record
*
* @param array $attributes
* @param array $values
* @return Model
*/
public function updateOrCreate(array $attributes, array $values = []): Model
{
return $this->model->updateOrCreate($attributes, $values);
}
/**
* Delete a record by ID
*
* @param int $id
* @return bool
*/
public function delete(int $id): bool
{
$model = $this->findOrFail($id);
return $model->delete();
}
/**
* Delete records by criteria
*
* @param array $criteria
* @return int Number of deleted records
*/
public function deleteBy(array $criteria): int
{
$this->applyCriteria($criteria);
$result = $this->query->delete();
$this->resetQuery();
return $result;
}
/**
* Count records
*
* @param array $criteria
* @return int
*/
public function count(array $criteria = []): int
{
if (!empty($criteria)) {
$this->applyCriteria($criteria);
}
$result = $this->query->count();
$this->resetQuery();
return $result;
}
/**
* Check if record exists
*
* @param array $criteria
* @return bool
*/
public function exists(array $criteria): bool
{
$this->applyCriteria($criteria);
$result = $this->query->exists();
$this->resetQuery();
return $result;
}
/**
* Get records with relationships
*
* @param array $relations
* @return BaseRepositoryInterface
*/
public function with(array $relations): BaseRepositoryInterface
{
$this->query = $this->query->with($relations);
return $this;
}
/**
* Order records
*
* @param string $column
* @param string $direction
* @return BaseRepositoryInterface
*/
public function orderBy(string $column, string $direction = 'asc'): BaseRepositoryInterface
{
$this->query = $this->query->orderBy($column, $direction);
return $this;
}
/**
* Limit records
*
* @param int $limit
* @return BaseRepositoryInterface
*/
public function limit(int $limit): BaseRepositoryInterface
{
$this->query = $this->query->limit($limit);
return $this;
}
/**
* Apply criteria to the query
*
* @param array $criteria
* @return void
*/
protected function applyCriteria(array $criteria): void
{
foreach ($criteria as $field => $value) {
if (is_array($value)) {
$this->query = $this->query->whereIn($field, $value);
} elseif (is_null($value)) {
$this->query = $this->query->whereNull($field);
} else {
$this->query = $this->query->where($field, $value);
}
}
}
/**
* Reset the query builder
*
* @return void
*/
protected function resetQuery(): void
{
$this->query = $this->model->newQuery();
}
/**
* Get the model instance
*
* @return Model
*/
public function getModel(): Model
{
return $this->model;
}
/**
* Set the model instance
*
* @param Model $model
* @return void
*/
public function setModel(Model $model): void
{
$this->model = $model;
$this->resetQuery();
}
/**
* Execute a callback within a database transaction
*
* @param callable $callback
* @return mixed
* @throws \Throwable
*/
protected function transaction(callable $callback)
{
return $this->model->getConnection()->transaction($callback);
}
/**
* Get fresh timestamp for the model
*
* @return \Carbon\Carbon
*/
protected function freshTimestamp(): \Carbon\Carbon
{
return $this->model->freshTimestamp();
}
}

View File

@@ -0,0 +1,522 @@
<?php
namespace App\Repositories;
use App\Interfaces\ResumeRepositoryInterface;
use App\Models\Resume;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Carbon\Carbon;
/**
* Resume Repository Implementation
*
* Handles all resume-related data operations.
* Implements ResumeRepositoryInterface with resume-specific business logic.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
class ResumeRepository extends BaseRepository implements ResumeRepositoryInterface
{
/**
* Create a new ResumeRepository instance
*
* @param Resume $model
*/
public function __construct(Resume $model)
{
parent::__construct($model);
}
/**
* Get user's resumes
*
* @param int $userId
* @param array $columns
* @return Collection
*/
public function getUserResumes(int $userId, array $columns = ['*']): Collection
{
return $this->orderBy('updated_at', 'desc')
->findBy(['user_id' => $userId], $columns);
}
/**
* Get user's resumes with pagination
*
* @param int $userId
* @param int $perPage
* @return LengthAwarePaginator
*/
public function getPaginatedUserResumes(int $userId, int $perPage = 10): LengthAwarePaginator
{
$result = $this->query
->where('user_id', $userId)
->orderBy('updated_at', 'desc')
->paginate($perPage);
$this->resetQuery();
return $result;
}
/**
* Find resume by public URL
*
* @param string $publicUrl
* @return Resume|null
*/
public function findByPublicUrl(string $publicUrl): ?Resume
{
return $this->findOneBy([
'public_url' => $publicUrl,
'is_public' => true,
'status' => 'published'
]);
}
/**
* Get public resumes
*
* @param int $limit
* @return Collection
*/
public function getPublicResumes(int $limit = 20): Collection
{
$result = $this->query
->where('is_public', true)
->where('status', 'published')
->whereNull('expires_at')
->orWhere('expires_at', '>', Carbon::now())
->orderBy('published_at', 'desc')
->limit($limit)
->with(['user:id,first_name,last_name'])
->get();
$this->resetQuery();
return $result;
}
/**
* Get resumes by template
*
* @param string $template
* @return Collection
*/
public function getResumesByTemplate(string $template): Collection
{
return $this->orderBy('created_at', 'desc')
->findBy(['template' => $template]);
}
/**
* Get resumes by status
*
* @param string $status
* @return Collection
*/
public function getResumesByStatus(string $status): Collection
{
return $this->orderBy('updated_at', 'desc')
->findBy(['status' => $status]);
}
/**
* Get popular resumes (most viewed)
*
* @param int $limit
* @param int $days
* @return Collection
*/
public function getPopularResumes(int $limit = 10, int $days = 30): Collection
{
$cutoffDate = Carbon::now()->subDays($days);
$result = $this->query
->where('is_public', true)
->where('status', 'published')
->where('last_viewed_at', '>=', $cutoffDate)
->orderBy('view_count', 'desc')
->limit($limit)
->with(['user:id,first_name,last_name'])
->get();
$this->resetQuery();
return $result;
}
/**
* Get recent resumes
*
* @param int $limit
* @param int $days
* @return Collection
*/
public function getRecentResumes(int $limit = 10, int $days = 7): Collection
{
$cutoffDate = Carbon::now()->subDays($days);
$result = $this->query
->where('is_public', true)
->where('status', 'published')
->where('published_at', '>=', $cutoffDate)
->orderBy('published_at', 'desc')
->limit($limit)
->with(['user:id,first_name,last_name'])
->get();
$this->resetQuery();
return $result;
}
/**
* Search resumes
*
* @param string $query
* @param array $filters
* @param int $limit
* @return Collection
*/
public function searchResumes(string $query, array $filters = [], int $limit = 20): Collection
{
$searchTerm = '%' . $query . '%';
$queryBuilder = $this->query
->where('is_public', true)
->where('status', 'published')
->where(function ($q) use ($searchTerm) {
$q->where('title', 'like', $searchTerm)
->orWhere('description', 'like', $searchTerm)
->orWhereJsonContains('content->summary', $searchTerm)
->orWhereJsonLike('content->skills', $searchTerm);
});
// Apply filters
if (isset($filters['template']) && !empty($filters['template'])) {
$queryBuilder = $queryBuilder->where('template', $filters['template']);
}
if (isset($filters['min_completion']) && is_numeric($filters['min_completion'])) {
$queryBuilder = $queryBuilder->where('completion_percentage', '>=', $filters['min_completion']);
}
$result = $queryBuilder
->orderBy('view_count', 'desc')
->limit($limit)
->with(['user:id,first_name,last_name'])
->get();
$this->resetQuery();
return $result;
}
/**
* Get incomplete resumes
*
* @param int $threshold
* @return Collection
*/
public function getIncompleteResumes(int $threshold = 50): Collection
{
$result = $this->query
->where('completion_percentage', '<', $threshold)
->where('status', 'draft')
->orderBy('updated_at', 'desc')
->with(['user:id,first_name,last_name,email'])
->get();
$this->resetQuery();
return $result;
}
/**
* Duplicate resume for user
*
* @param int $resumeId
* @param int $userId
* @param string $newTitle
* @return Resume
*/
public function duplicateResume(int $resumeId, int $userId, string $newTitle): Resume
{
return $this->transaction(function () use ($resumeId, $userId, $newTitle) {
$originalResume = $this->findOrFail($resumeId);
$duplicateData = $originalResume->toArray();
// Remove unique fields and reset for new resume
unset($duplicateData['id'], $duplicateData['created_at'], $duplicateData['updated_at'], $duplicateData['deleted_at']);
$duplicateData['user_id'] = $userId;
$duplicateData['title'] = $newTitle;
$duplicateData['status'] = 'draft';
$duplicateData['is_public'] = false;
$duplicateData['public_url'] = null;
$duplicateData['published_at'] = null;
$duplicateData['view_count'] = 0;
$duplicateData['download_count'] = 0;
$duplicateData['last_viewed_at'] = null;
$duplicateData['last_downloaded_at'] = null;
$duplicateData['pdf_path'] = null;
$duplicateData['pdf_generated_at'] = null;
$duplicateData['pdf_version'] = 1;
return $this->create($duplicateData);
});
}
/**
* Update resume view count
*
* @param int $resumeId
* @return bool
*/
public function incrementViewCount(int $resumeId): bool
{
$resume = $this->find($resumeId);
if (!$resume) {
return false;
}
return $resume->update([
'view_count' => $resume->view_count + 1,
'last_viewed_at' => $this->freshTimestamp(),
]);
}
/**
* Update resume download count
*
* @param int $resumeId
* @return bool
*/
public function incrementDownloadCount(int $resumeId): bool
{
$resume = $this->find($resumeId);
if (!$resume) {
return false;
}
return $resume->update([
'download_count' => $resume->download_count + 1,
'last_downloaded_at' => $this->freshTimestamp(),
]);
}
/**
* Get resume statistics
*
* @param int|null $userId
* @return array
*/
public function getResumeStatistics(?int $userId = null): array
{
$query = $this->query;
if ($userId) {
$query = $query->where('user_id', $userId);
}
$stats = [
'total_resumes' => $query->count(),
'draft_resumes' => $query->where('status', 'draft')->count(),
'published_resumes' => $query->where('status', 'published')->count(),
'archived_resumes' => $query->where('status', 'archived')->count(),
'public_resumes' => $query->where('is_public', true)->count(),
'private_resumes' => $query->where('is_public', false)->count(),
];
// Template statistics
$templateStats = $query->select('template', DB::raw('count(*) as count'))
->groupBy('template')
->pluck('count', 'template')
->toArray();
$stats['by_template'] = $templateStats;
// Completion statistics
$stats['avg_completion'] = $query->avg('completion_percentage') ?? 0;
$stats['completed_resumes'] = $query->where('completion_percentage', '>=', 90)->count();
// View statistics
$stats['total_views'] = $query->sum('view_count');
$stats['total_downloads'] = $query->sum('download_count');
// Recent activity (last 30 days)
$thirtyDaysAgo = Carbon::now()->subDays(30);
$stats['created_last_30_days'] = $query->where('created_at', '>=', $thirtyDaysAgo)->count();
$stats['updated_last_30_days'] = $query->where('updated_at', '>=', $thirtyDaysAgo)->count();
$this->resetQuery();
return $stats;
}
/**
* Get resumes requiring PDF regeneration
*
* @return Collection
*/
public function getResumesNeedingPdfRegeneration(): Collection
{
$result = $this->query
->where('status', 'published')
->where(function ($q) {
$q->whereNull('pdf_generated_at')
->orWhere('updated_at', '>', DB::raw('pdf_generated_at'));
})
->orderBy('updated_at', 'desc')
->get();
$this->resetQuery();
return $result;
}
/**
* Archive old resumes
*
* @param int $days
* @return int Number of archived resumes
*/
public function archiveOldResumes(int $days = 365): int
{
$cutoffDate = Carbon::now()->subDays($days);
$result = $this->query
->where('status', 'draft')
->where('updated_at', '<', $cutoffDate)
->update(['status' => 'archived']);
$this->resetQuery();
return $result;
}
/**
* Get user's resume by title
*
* @param int $userId
* @param string $title
* @return Resume|null
*/
public function getUserResumeByTitle(int $userId, string $title): ?Resume
{
return $this->findOneBy([
'user_id' => $userId,
'title' => $title
]);
}
/**
* Check if user can create more resumes
*
* @param int $userId
* @param int $limit
* @return bool
*/
public function canUserCreateMoreResumes(int $userId, int $limit = 10): bool
{
$userResumeCount = $this->count(['user_id' => $userId]);
return $userResumeCount < $limit;
}
/**
* Get featured resumes for showcase
*
* @param int $limit
* @return Collection
*/
public function getFeaturedResumes(int $limit = 6): Collection
{
$result = $this->query
->where('is_public', true)
->where('status', 'published')
->where('completion_percentage', '>=', 90)
->where('view_count', '>', 50)
->orderBy('view_count', 'desc')
->orderBy('download_count', 'desc')
->limit($limit)
->with(['user:id,first_name,last_name'])
->get();
$this->resetQuery();
return $result;
}
/**
* Update resume completion percentage
*
* @param int $resumeId
* @return bool
*/
public function updateCompletionPercentage(int $resumeId): bool
{
$resume = $this->find($resumeId);
if (!$resume) {
return false;
}
$content = $resume->content ?? [];
$sections = ['personal_info', 'summary', 'experience', 'education', 'skills'];
$completedSections = 0;
$totalSections = count($sections);
foreach ($sections as $section) {
if (isset($content[$section]) && !empty($content[$section])) {
$completedSections++;
}
}
$percentage = $totalSections > 0 ? intval(($completedSections / $totalSections) * 100) : 0;
return $resume->update([
'completion_percentage' => $percentage,
'completion_sections' => array_fill_keys($sections, false)
]);
}
/**
* Get resumes expiring soon
*
* @param int $days
* @return Collection
*/
public function getResumesExpiringSoon(int $days = 7): Collection
{
$futureDate = Carbon::now()->addDays($days);
$result = $this->query
->where('is_public', true)
->whereNotNull('expires_at')
->whereBetween('expires_at', [Carbon::now(), $futureDate])
->orderBy('expires_at', 'asc')
->with(['user:id,first_name,last_name,email'])
->get();
$this->resetQuery();
return $result;
}
/**
* Bulk update resume status
*
* @param array $resumeIds
* @param string $status
* @return int Number of updated resumes
*/
public function bulkUpdateStatus(array $resumeIds, string $status): int
{
$result = $this->query
->whereIn('id', $resumeIds)
->update([
'status' => $status,
'updated_at' => $this->freshTimestamp()
]);
$this->resetQuery();
return $result;
}
}

View File

@@ -0,0 +1,410 @@
<?php
namespace App\Repositories;
use App\Interfaces\UserRepositoryInterface;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
/**
* User Repository Implementation
*
* Handles all user-related data operations.
* Implements UserRepositoryInterface with user-specific business logic.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
class UserRepository extends BaseRepository implements UserRepositoryInterface
{
/**
* Create a new UserRepository instance
*
* @param User $model
*/
public function __construct(User $model)
{
parent::__construct($model);
}
/**
* Find a user by email address
*
* @param string $email
* @return User|null
*/
public function findByEmail(string $email): ?User
{
return $this->findOneBy(['email' => $email]);
}
/**
* Find users by status
*
* @param string $status
* @return Collection
*/
public function findByStatus(string $status): Collection
{
return $this->findBy(['status' => $status]);
}
/**
* Get users who haven't logged in recently
*
* @param int $days
* @return Collection
*/
public function getInactiveUsers(int $days = 30): Collection
{
$cutoffDate = Carbon::now()->subDays($days);
$result = $this->query
->where(function ($query) use ($cutoffDate) {
$query->where('last_login_at', '<', $cutoffDate)
->orWhereNull('last_login_at');
})
->where('status', 'active')
->orderBy('last_login_at', 'asc')
->get();
$this->resetQuery();
return $result;
}
/**
* Get users with completed profiles
*
* @return Collection
*/
public function getUsersWithCompletedProfiles(): Collection
{
$result = $this->query
->whereNotNull('profile_completed_at')
->where('status', 'active')
->orderBy('profile_completed_at', 'desc')
->get();
$this->resetQuery();
return $result;
}
/**
* Get users with incomplete profiles
*
* @return Collection
*/
public function getUsersWithIncompleteProfiles(): Collection
{
$result = $this->query
->whereNull('profile_completed_at')
->where('status', 'active')
->orderBy('created_at', 'desc')
->get();
$this->resetQuery();
return $result;
}
/**
* Get users subscribed to newsletter
*
* @return Collection
*/
public function getNewsletterSubscribers(): Collection
{
return $this->findBy([
'newsletter_subscribed' => true,
'status' => 'active'
]);
}
/**
* Search users by name or email
*
* @param string $query
* @param int $limit
* @return Collection
*/
public function searchUsers(string $query, int $limit = 10): Collection
{
$searchTerm = '%' . $query . '%';
$result = $this->query
->where(function ($q) use ($searchTerm) {
$q->where('first_name', 'like', $searchTerm)
->orWhere('last_name', 'like', $searchTerm)
->orWhere('email', 'like', $searchTerm)
->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", [$searchTerm]);
})
->where('status', 'active')
->orderBy('last_login_at', 'desc')
->limit($limit)
->get();
$this->resetQuery();
return $result;
}
/**
* Retrieve comprehensive user statistics and analytics
*
* Aggregates key user metrics including:
* - Total users by status (active, inactive, suspended)
* - Email verification status counts
* - Registration trends (30-day window)
* - Profile completion metrics
* - Newsletter subscription data
* - Daily active user statistics
*
* Used for admin dashboards, reporting, and user behavior analysis.
*
* @return array Comprehensive statistics array with user metrics
* @throws Exception When database queries fail
*/
public function getUserStatistics(): array
{
$stats = [
'total_users' => $this->count(),
'active_users' => $this->count(['status' => 'active']),
'inactive_users' => $this->count(['status' => 'inactive']),
'suspended_users' => $this->count(['status' => 'suspended']),
'verified_users' => $this->query->whereNotNull('email_verified_at')->count(),
'unverified_users' => $this->query->whereNull('email_verified_at')->count(),
];
$thirtyDaysAgo = Carbon::now()->subDays(30);
$stats['new_users_30_days'] = $this->query
->where('created_at', '>=', $thirtyDaysAgo)
->count();
$stats['completed_profiles'] = $this->query
->whereNotNull('profile_completed_at')
->count();
$stats['newsletter_subscribers'] = $this->count(['newsletter_subscribed' => true]);
$stats['active_today'] = $this->query
->whereDate('last_login_at', Carbon::today())
->count();
$this->resetQuery();
return $stats;
}
/**
* Update user's last login metadata with security tracking
*
* Records successful login timestamp, IP address, and resets
* security-related fields including login attempts and account locks.
* Essential for authentication audit trails and security monitoring.
*
* @param int $userId The user ID to update
* @param string $ipAddress Client IP address for security tracking
* @return bool True if update succeeded, false otherwise
* @throws Exception When user record update fails
*/
public function updateLastLogin(int $userId, string $ipAddress): bool
{
return $this->update($userId, [
'last_login_at' => $this->freshTimestamp(),
'last_login_ip' => $ipAddress,
'login_attempts' => 0,
'locked_until' => null,
]);
}
/**
* Increment failed login attempt counter for user account
*
* Increases the login_attempts field by 1 for security tracking.
* Used in conjunction with account locking mechanisms to prevent
* brute force attacks. Should be called after each failed login.
*
* @param string $email User email address to track attempts for
* @return bool True if increment succeeded, false if user not found
*/
public function incrementLoginAttempts(string $email): bool
{
$user = $this->findByEmail($email);
if (!$user) {
return false;
}
return $user->increment('login_attempts');
}
/**
* Reset user's login attempts
*
* @param string $email
* @return bool
*/
public function resetLoginAttempts(string $email): bool
{
$user = $this->findByEmail($email);
if (!$user) {
return false;
}
return $user->update([
'login_attempts' => 0,
'locked_until' => null,
]);
}
/**
* Temporarily lock user account for security purposes
*
* Implements account lockout mechanism to prevent brute force attacks
* by setting locked_until timestamp. Account remains inaccessible
* until the lockout period expires or is manually cleared.
*
* @param string $email User email address to lock
* @param int $minutes Lockout duration in minutes (default: 15)
* @return bool True if lock applied successfully, false if user not found
*/
public function lockAccount(string $email, int $minutes = 15): bool
{
$user = $this->findByEmail($email);
if (!$user) {
return false;
}
return $user->update([
'locked_until' => Carbon::now()->addMinutes($minutes),
]);
}
/**
* Determine if user account is currently locked
*
* Validates account lockout status by checking if locked_until
* timestamp exists and is still in the future. Used before
* authentication attempts to enforce security policies.
*
* @param string $email User email address to check
* @return bool True if account is locked, false if accessible or user not found
*/
public function isAccountLocked(string $email): bool
{
$user = $this->findByEmail($email);
if (!$user || !$user->locked_until) {
return false;
}
return Carbon::now()->lessThan($user->locked_until);
}
/**
* Suspend user account
*
* @param int $userId
* @param string $reason
* @return bool
*/
public function suspendAccount(int $userId, string $reason): bool
{
return $this->update($userId, [
'status' => 'suspended',
'suspended_at' => $this->freshTimestamp(),
'suspension_reason' => $reason,
]);
}
/**
* Reactivate user account
*
* @param int $userId
* @return bool
*/
public function reactivateAccount(int $userId): bool
{
return $this->update($userId, [
'status' => 'active',
'suspended_at' => null,
'suspension_reason' => null,
]);
}
/**
* Get users registered in date range
*
* @param string $startDate
* @param string $endDate
* @return Collection
*/
public function getUsersRegisteredBetween(string $startDate, string $endDate): Collection
{
$result = $this->query
->whereBetween('created_at', [$startDate, $endDate])
->orderBy('created_at', 'desc')
->get();
$this->resetQuery();
return $result;
}
/**
* Get paginated users with filters
*
* @param array $filters
* @param int $perPage
* @return LengthAwarePaginator
*/
public function getPaginatedUsersWithFilters(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = $this->query;
// Apply filters
if (isset($filters['status']) && !empty($filters['status'])) {
$query = $query->where('status', $filters['status']);
}
if (isset($filters['verified']) && $filters['verified'] !== '') {
if ($filters['verified']) {
$query = $query->whereNotNull('email_verified_at');
} else {
$query = $query->whereNull('email_verified_at');
}
}
if (isset($filters['newsletter']) && $filters['newsletter'] !== '') {
$query = $query->where('newsletter_subscribed', (bool) $filters['newsletter']);
}
if (isset($filters['search']) && !empty($filters['search'])) {
$searchTerm = '%' . $filters['search'] . '%';
$query = $query->where(function ($q) use ($searchTerm) {
$q->where('first_name', 'like', $searchTerm)
->orWhere('last_name', 'like', $searchTerm)
->orWhere('email', 'like', $searchTerm);
});
}
if (isset($filters['date_from']) && !empty($filters['date_from'])) {
$query = $query->whereDate('created_at', '>=', $filters['date_from']);
}
if (isset($filters['date_to']) && !empty($filters['date_to'])) {
$query = $query->whereDate('created_at', '<=', $filters['date_to']);
}
// Default ordering
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query = $query->orderBy($sortBy, $sortDirection);
$result = $query->paginate($perPage);
$this->resetQuery();
return $result;
}
}

View File

@@ -0,0 +1,447 @@
<?php
/**
* Authentication Service
* Professional Resume Builder - Enterprise Auth System
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
namespace App\Services;
use App\Interfaces\UserRepositoryInterface;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Exception;
/**
* Authentication Service
* Handles user authentication, registration, and security features with repository pattern
*/
class AuthService
{
/**
* User repository instance
*/
private UserRepositoryInterface $userRepository;
/**
* Maximum login attempts per minute
*/
protected const MAX_LOGIN_ATTEMPTS = 5;
/**
* Login throttle key prefix
*/
protected const THROTTLE_KEY_PREFIX = 'login_attempts:';
/**
* Account lockout duration in minutes
*/
protected const LOCKOUT_DURATION = 15;
/**
* Create a new service instance with dependency injection
*/
public function __construct(UserRepositoryInterface $userRepository)
{
$this->userRepository = $userRepository;
}
/**
* Attempt user authentication with comprehensive security measures
*
* Implements enterprise-grade authentication with:
* - Rate limiting to prevent brute force attacks
* - Account lockout mechanisms for security
* - Detailed security event logging
* - Structured response format for consistent handling
*
* @param array $credentials User login credentials (email, password, remember)
* @return array Structured authentication result with success status and details
* @throws ValidationException When rate limiting is exceeded or validation fails
* @throws Exception When system errors occur during authentication
*/
public function attemptLogin(array $credentials): array
{
$email = $credentials['email'];
$password = $credentials['password'];
$remember = $credentials['remember'] ?? false;
try {
$this->checkRateLimit($email);
if ($this->userRepository->isAccountLocked($email)) {
Log::warning('Login attempt on locked account', [
'email' => $email,
'ip_address' => request()->ip()
]);
return [
'success' => false,
'reason' => 'account_locked',
'message' => 'Account is temporarily locked'
];
}
$user = $this->userRepository->findByEmail($email);
if (!$user) {
$this->incrementRateLimit($email);
return [
'success' => false,
'reason' => 'invalid_credentials',
'message' => 'User not found'
];
}
if (!$user->is_active) {
$this->incrementRateLimit($email);
Log::warning('Login attempt on inactive account', [
'user_id' => $user->id,
'email' => $email,
'ip_address' => request()->ip()
]);
return [
'success' => false,
'reason' => 'account_inactive',
'message' => 'Account is deactivated'
];
}
if (!Hash::check($password, $user->password)) {
$this->incrementRateLimit($email);
$this->userRepository->incrementLoginAttempts($email);
$updatedUser = $this->userRepository->findByEmail($email);
if ($updatedUser && $updatedUser->login_attempts >= self::MAX_LOGIN_ATTEMPTS) {
$this->userRepository->lockAccount($email, self::LOCKOUT_DURATION);
Log::warning('Account locked due to excessive failed attempts', [
'user_id' => $user->id,
'email' => $email,
'attempts' => $updatedUser->login_attempts
]);
return [
'success' => false,
'reason' => 'too_many_attempts',
'message' => 'Account locked due to failed attempts'
];
}
return [
'success' => false,
'reason' => 'invalid_credentials',
'message' => 'Invalid password'
];
}
Auth::login($user, $remember);
$this->userRepository->updateLastLogin($user->id, request()->ip());
$this->userRepository->resetLoginAttempts($email);
$this->clearRateLimit($email);
Log::info('Successful user authentication', [
'user_id' => $user->id,
'email' => $email,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent()
]);
return [
'success' => true,
'user' => $user,
'message' => 'Login successful'
];
} catch (ValidationException $e) {
throw $e;
} catch (Exception $e) {
Log::error('Authentication service error', [
'error_message' => $e->getMessage(),
'email' => $email,
'ip_address' => request()->ip(),
'trace' => $e->getTraceAsString()
]);
return [
'success' => false,
'reason' => 'system_error',
'message' => 'System error occurred'
];
}
}
/**
* Create new user account with comprehensive security validation
*
* Implements secure user registration with:
* - Duplicate email detection using repository layer
* - Secure password hashing with bcrypt
* - Security-focused default settings
* - Comprehensive audit logging
* - Prepared data structure for repository storage
*
* @param array $userData User registration data (first_name, last_name, email, password)
* @return User The created user instance
* @throws ValidationException When email already exists or validation fails
* @throws Exception When user creation fails due to system errors
*/
public function createUser(array $userData): User
{
try {
if ($this->userRepository->findByEmail($userData['email'])) {
Log::warning('Registration attempt with existing email', [
'email' => $userData['email'],
'ip_address' => request()->ip()
]);
throw ValidationException::withMessages([
'email' => 'An account with this email address already exists.',
]);
}
$preparedData = [
'first_name' => $userData['first_name'],
'last_name' => $userData['last_name'],
'email' => $userData['email'],
'password' => Hash::make($userData['password']),
'status' => 'active',
'locale' => $userData['locale'] ?? 'en',
'timezone' => $userData['timezone'] ?? 'UTC',
'login_attempts' => 0,
'locked_until' => null,
'email_verified_at' => null,
'last_login_at' => null,
'last_login_ip' => null
];
$user = $this->userRepository->create($preparedData);
if ($user) {
Log::info('New user account created successfully', [
'user_id' => $user->id,
'email' => $user->email,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent()
]);
}
return $user;
} catch (ValidationException $e) {
throw $e;
} catch (Exception $e) {
Log::error('User creation failed', [
'error_message' => $e->getMessage(),
'email' => $userData['email'] ?? 'unknown',
'ip_address' => request()->ip(),
'trace' => $e->getTraceAsString()
]);
throw new Exception('Failed to create user account');
}
}
/**
* Update user profile information
*/
public function updateProfile(User $user, array $profileData): User
{
unset($profileData['password'], $profileData['email'], $profileData['is_active']);
$user->update($profileData);
logger()->info('User profile updated', [
'user_id' => $user->id,
'email' => $user->email,
'ip' => request()->ip(),
]);
return $user;
}
/**
* Change user password
*/
public function changePassword(User $user, string $currentPassword, string $newPassword): bool
{
if (!Hash::check($currentPassword, $user->password)) {
throw new \InvalidArgumentException('Current password is incorrect.');
}
$user->update(['password' => Hash::make($newPassword)]);
logger()->info('User password changed', [
'user_id' => $user->id,
'email' => $user->email
]);
return true;
}
/**
* Deactivate user account
*/
public function deactivateAccount(User $user): bool
{
$user->update(['is_active' => false]);
logger()->info('User account deactivated', [
'user_id' => $user->id,
'email' => $user->email,
'ip' => request()->ip(),
]);
return true;
}
/**
* Activate user account
*/
public function activateAccount(User $user): bool
{
$user->update(['is_active' => true]);
logger()->info('User account activated', [
'user_id' => $user->id,
'email' => $user->email,
'ip' => request()->ip(),
]);
return true;
}
/**
* Check if user has exceeded login rate limit
*/
protected function checkRateLimit(string $email): void
{
$key = $this->getRateLimitKey($email);
if (RateLimiter::tooManyAttempts($key, self::MAX_LOGIN_ATTEMPTS)) {
$seconds = RateLimiter::availableIn($key);
throw ValidationException::withMessages([
'email' => "Too many login attempts. Please try again in {$seconds} seconds.",
]);
}
}
/**
* Increment rate limit counter
*/
protected function incrementRateLimit(string $email): void
{
$key = $this->getRateLimitKey($email);
RateLimiter::hit($key, 60);
}
/**
* Clear rate limit counter
*/
protected function clearRateLimit(string $email): void
{
$key = $this->getRateLimitKey($email);
RateLimiter::clear($key);
}
/**
* Get rate limit key for email
*/
protected function getRateLimitKey(string $email): string
{
return self::THROTTLE_KEY_PREFIX . $email . '|' . request()->ip();
}
/**
* Send password reset notification
*/
public function sendPasswordResetNotification(string $email): bool
{
$user = User::where('email', $email)->first();
if (!$user) {
// Don't reveal if email exists or not for security
return true;
}
// Generate and send password reset token
// Implementation depends on your notification preferences
// $user->sendPasswordResetNotification($token);
logger()->info('Password reset requested', [
'user_id' => $user->id,
'email' => $user->email,
'ip' => request()->ip(),
]);
return true;
}
/**
* Verify email address
*/
public function verifyEmail(User $user): bool
{
if ($user->hasVerifiedEmail()) {
return true;
}
$user->markEmailAsVerified();
logger()->info('Email verified', [
'user_id' => $user->id,
'email' => $user->email,
'ip' => request()->ip(),
]);
return true;
}
/**
* Get user statistics
*/
public function getUserStats(User $user): array
{
return [
'account_created' => $user->created_at,
'last_login' => $user->last_login_at,
'profile_completion' => $this->calculateProfileCompletion($user),
'total_resumes' => $user->resumes()->count(),
'completed_resumes' => $user->completedResumes()->count(),
'is_verified' => $user->hasVerifiedEmail(),
];
}
/**
* Calculate profile completion percentage
*/
protected function calculateProfileCompletion(User $user): int
{
$fields = [
'first_name', 'last_name', 'email', 'phone',
'address', 'city', 'country', 'profession'
];
$completed = 0;
foreach ($fields as $field) {
if (!empty($user->$field)) {
$completed++;
}
}
return (int) (($completed / count($fields)) * 100);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Services;
use App\Models\User;
/**
* Profile Completion Service
*
* Handles user profile completion calculations and analytics.
*
* @author David Valera Melendez <david@valera-melendez.de>
* @since February 2025
*/
class ProfileCompletionService
{
/**
* Required fields for profile completion
*/
private const REQUIRED_FIELDS = [
'first_name' => 10,
'last_name' => 10,
'email' => 15,
'phone' => 10,
'bio' => 20,
'website' => 10,
'linkedin' => 15,
'github' => 10,
];
/**
* Calculate profile completion percentage
*/
public function calculateCompletion(User $user): int
{
$totalWeight = array_sum(self::REQUIRED_FIELDS);
$completedWeight = 0;
foreach (self::REQUIRED_FIELDS as $field => $weight) {
if (!empty($user->$field)) {
$completedWeight += $weight;
}
}
return (int) (($completedWeight / $totalWeight) * 100);
}
/**
* Get missing profile fields
*/
public function getMissingFields(User $user): array
{
$missing = [];
foreach (self::REQUIRED_FIELDS as $field => $weight) {
if (empty($user->$field)) {
$missing[] = [
'field' => $field,
'label' => $this->getFieldLabel($field),
'weight' => $weight,
];
}
}
return $missing;
}
/**
* Get user-friendly field labels
*/
private function getFieldLabel(string $field): string
{
$labels = [
'first_name' => 'First Name',
'last_name' => 'Last Name',
'email' => 'Email Address',
'phone' => 'Phone Number',
'bio' => 'Professional Bio',
'website' => 'Website',
'linkedin' => 'LinkedIn Profile',
'github' => 'GitHub Profile',
];
return $labels[$field] ?? ucfirst(str_replace('_', ' ', $field));
}
/**
* Check if profile is considered complete
*/
public function isProfileComplete(User $user): bool
{
return $this->calculateCompletion($user) >= 80;
}
/**
* Get profile completion statistics
*/
public function getCompletionStats(User $user): array
{
$completion = $this->calculateCompletion($user);
$missingFields = $this->getMissingFields($user);
return [
'completion_percentage' => $completion,
'is_complete' => $this->isProfileComplete($user),
'missing_fields' => $missingFields,
'missing_count' => count($missingFields),
'status' => $this->getCompletionStatus($completion),
];
}
/**
* Get completion status message
*/
private function getCompletionStatus(int $completion): string
{
if ($completion >= 90) {
return 'excellent';
} elseif ($completion >= 70) {
return 'good';
} elseif ($completion >= 50) {
return 'fair';
} else {
return 'incomplete';
}
}
}

View File

@@ -0,0 +1,380 @@
<?php
/**
* Resume Service
* Professional Resume Builder - Resume Management Service
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
namespace App\Services;
use App\Models\Resume;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Response;
use Barryvdh\DomPDF\Facade\Pdf;
/**
* Resume Service
* Handles resume creation, management, and operations
*/
class ResumeService
{
/**
* Available resume templates
*/
protected array $templates = [
'professional' => [
'id' => 'professional',
'name' => 'Professional Classic',
'description' => 'Clean and professional design perfect for corporate environments',
'preview' => 'templates/professional-preview.jpg',
'category' => 'Professional'
],
'modern' => [
'id' => 'modern',
'name' => 'Modern Creative',
'description' => 'Contemporary design with creative elements for modern companies',
'preview' => 'templates/modern-preview.jpg',
'category' => 'Creative'
],
'executive' => [
'id' => 'executive',
'name' => 'Executive',
'description' => 'Sophisticated layout for senior-level positions',
'preview' => 'templates/executive-preview.jpg',
'category' => 'Executive'
],
'minimal' => [
'id' => 'minimal',
'name' => 'Minimal Clean',
'description' => 'Simple and clean design focusing on content',
'preview' => 'templates/minimal-preview.jpg',
'category' => 'Minimal'
],
'technical' => [
'id' => 'technical',
'name' => 'Technical',
'description' => 'Optimized for technical and engineering roles',
'preview' => 'templates/technical-preview.jpg',
'category' => 'Technical'
]
];
/**
* Get all resumes for a specific user
*/
public function getUserResumes(int $userId): Collection
{
return Resume::where('user_id', $userId)
->orderBy('updated_at', 'desc')
->get();
}
/**
* Create a new resume
*/
public function createResume(array $resumeData): Resume
{
// Set default values
$resumeData['is_active'] = true;
$resumeData['is_completed'] = false;
$resumeData['is_public'] = false;
// Initialize empty sections
$resumeData['personal_info'] = $resumeData['personal_info'] ?? [];
$resumeData['work_experiences'] = $resumeData['work_experiences'] ?? [];
$resumeData['education'] = $resumeData['education'] ?? [];
$resumeData['skills'] = $resumeData['skills'] ?? [];
$resumeData['languages'] = $resumeData['languages'] ?? [];
$resumeData['certifications'] = $resumeData['certifications'] ?? [];
$resumeData['projects'] = $resumeData['projects'] ?? [];
$resumeData['references'] = $resumeData['references'] ?? [];
$resumeData['custom_sections'] = $resumeData['custom_sections'] ?? [];
$resumeData['settings'] = $resumeData['settings'] ?? $this->getDefaultSettings();
$resume = Resume::create($resumeData);
logger()->info('Resume created', [
'resume_id' => $resume->id,
'user_id' => $resume->user_id,
'title' => $resume->title,
]);
return $resume;
}
/**
* Update an existing resume
*/
public function updateResume(Resume $resume, array $resumeData): Resume
{
// Update completion status based on content
if ($this->shouldMarkAsCompleted($resumeData)) {
$resumeData['is_completed'] = true;
}
$resume->update($resumeData);
logger()->info('Resume updated', [
'resume_id' => $resume->id,
'user_id' => $resume->user_id,
'completion' => $resume->completion_percentage,
]);
return $resume;
}
/**
* Delete a resume
*/
public function deleteResume(Resume $resume): bool
{
$resumeId = $resume->id;
$userId = $resume->user_id;
$deleted = $resume->delete();
if ($deleted) {
logger()->info('Resume deleted', [
'resume_id' => $resumeId,
'user_id' => $userId,
]);
}
return $deleted;
}
/**
* Duplicate a resume
*/
public function duplicateResume(Resume $resume, string $newTitle): Resume
{
$resumeData = $resume->toArray();
// Remove unique identifiers
unset($resumeData['id'], $resumeData['slug'], $resumeData['public_url']);
// Set new title and reset status
$resumeData['title'] = $newTitle;
$resumeData['is_public'] = false;
$resumeData['created_at'] = now();
$resumeData['updated_at'] = now();
$newResume = Resume::create($resumeData);
logger()->info('Resume duplicated', [
'original_resume_id' => $resume->id,
'new_resume_id' => $newResume->id,
'user_id' => $resume->user_id,
]);
return $newResume;
}
/**
* Generate PDF for a resume
*/
public function generatePdf(Resume $resume): Response
{
$template = $this->getTemplate($resume->template_id ?? 'professional');
$pdf = Pdf::loadView('pdf.resume.' . $template['id'], [
'resume' => $resume,
'template' => $template
]);
$fileName = str()->slug($resume->title) . '-resume.pdf';
logger()->info('PDF generated', [
'resume_id' => $resume->id,
'user_id' => $resume->user_id,
'template' => $template['id'],
]);
return $pdf->download($fileName);
}
/**
* Make resume public
*/
public function makePublic(Resume $resume): Resume
{
if (!$resume->public_url) {
$resume->public_url = $resume->generatePublicUrl();
}
$resume->update(['is_public' => true]);
logger()->info('Resume made public', [
'resume_id' => $resume->id,
'user_id' => $resume->user_id,
'public_url' => $resume->public_url,
]);
return $resume;
}
/**
* Make resume private
*/
public function makePrivate(Resume $resume): Resume
{
$resume->update(['is_public' => false]);
logger()->info('Resume made private', [
'resume_id' => $resume->id,
'user_id' => $resume->user_id,
]);
return $resume;
}
/**
* Get available templates
*/
public function getAvailableTemplates(): array
{
return $this->templates;
}
/**
* Get specific template
*/
public function getTemplate(string $templateId): array
{
return $this->templates[$templateId] ?? $this->templates['professional'];
}
/**
* Get resume analytics
*/
public function getResumeAnalytics(Resume $resume): array
{
return [
'completion_percentage' => $resume->completion_percentage,
'sections_completed' => $this->getCompletedSections($resume),
'total_sections' => 7,
'word_count' => $this->calculateWordCount($resume),
'last_updated' => $resume->updated_at,
'views' => 0, // Implement view tracking if needed
];
}
/**
* Get completed sections count
*/
protected function getCompletedSections(Resume $resume): int
{
$sections = ['personal_info', 'professional_summary', 'work_experiences',
'education', 'skills', 'languages', 'certifications'];
$completed = 0;
foreach ($sections as $section) {
if ($resume->isSectionCompleted($section)) {
$completed++;
}
}
return $completed;
}
/**
* Calculate total word count in resume
*/
protected function calculateWordCount(Resume $resume): int
{
$wordCount = 0;
// Professional summary
if ($resume->professional_summary) {
$wordCount += str_word_count(strip_tags($resume->professional_summary));
}
// Work experiences
if ($resume->work_experiences) {
foreach ($resume->work_experiences as $experience) {
$wordCount += str_word_count(strip_tags($experience['description'] ?? ''));
}
}
// Education
if ($resume->education) {
foreach ($resume->education as $education) {
$wordCount += str_word_count(strip_tags($education['description'] ?? ''));
}
}
// Projects
if ($resume->projects) {
foreach ($resume->projects as $project) {
$wordCount += str_word_count(strip_tags($project['description'] ?? ''));
}
}
return $wordCount;
}
/**
* Check if resume should be marked as completed
*/
protected function shouldMarkAsCompleted(array $resumeData): bool
{
$requiredSections = ['personal_info', 'work_experiences', 'education'];
foreach ($requiredSections as $section) {
if (empty($resumeData[$section])) {
return false;
}
}
return true;
}
/**
* Get default resume settings
*/
protected function getDefaultSettings(): array
{
return [
'theme' => 'light',
'font_family' => 'Roboto',
'font_size' => 14,
'color_scheme' => 'blue',
'show_photo' => false,
'show_references' => true,
'show_certifications' => true,
'show_projects' => true,
'section_order' => [
'personal_info',
'professional_summary',
'work_experiences',
'education',
'skills',
'languages',
'certifications',
'projects',
'references'
]
];
}
/**
* Get resume statistics for user
*/
public function getUserResumeStats(int $userId): array
{
$resumes = $this->getUserResumes($userId);
return [
'total_resumes' => $resumes->count(),
'completed_resumes' => $resumes->where('is_completed', true)->count(),
'public_resumes' => $resumes->where('is_public', true)->count(),
'avg_completion' => $resumes->avg('completion_percentage') ?? 0,
'last_updated' => $resumes->max('updated_at'),
];
}
}

View File

@@ -0,0 +1,400 @@
<?php
/**
* Security Service
* Professional Resume Builder - Enterprise Security Service
*
* @author David Valera Melendez <david@valera-melendez.de>
* @created 2025-08-08
* @location Made in Germany 🇩🇪
*/
namespace App\Services;
use App\Interfaces\UserRepositoryInterface;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Cache;
use Carbon\Carbon;
use Exception;
/**
* Security Service
* Handles security-related operations and monitoring
*/
class SecurityService
{
/**
* User repository instance
*/
private UserRepositoryInterface $userRepository;
/**
* Maximum failed attempts before account lockout
*/
private const MAX_FAILED_ATTEMPTS = 5;
/**
* Account lockout duration in minutes
*/
private const LOCKOUT_DURATION = 15;
/**
* Password reset token expiry in hours
*/
private const PASSWORD_RESET_EXPIRY = 24;
/**
* Create a new service instance
*/
public function __construct(UserRepositoryInterface $userRepository)
{
$this->userRepository = $userRepository;
}
/**
* Generate secure password reset token
*
* @param User $user
* @return string
*/
public function generatePasswordResetToken(User $user): string
{
$token = bin2hex(random_bytes(32));
$expiry = now()->addHours(self::PASSWORD_RESET_EXPIRY);
// Store token in cache with expiry
Cache::put(
"password_reset:{$user->id}:{$token}",
[
'user_id' => $user->id,
'email' => $user->email,
'expires_at' => $expiry,
'created_at' => now()
],
$expiry
);
Log::info('Password reset token generated', [
'user_id' => $user->id,
'email' => $user->email,
'expires_at' => $expiry,
'ip_address' => request()->ip()
]);
return $token;
}
/**
* Validate password reset token
*
* @param string $token
* @param int $userId
* @return bool
*/
public function validatePasswordResetToken(string $token, int $userId): bool
{
$cacheKey = "password_reset:{$userId}:{$token}";
$tokenData = Cache::get($cacheKey);
if (!$tokenData) {
Log::warning('Invalid password reset token used', [
'user_id' => $userId,
'token' => substr($token, 0, 8) . '...',
'ip_address' => request()->ip()
]);
return false;
}
if ($tokenData['user_id'] !== $userId) {
Log::warning('Password reset token user mismatch', [
'expected_user_id' => $userId,
'token_user_id' => $tokenData['user_id'],
'ip_address' => request()->ip()
]);
return false;
}
return true;
}
/**
* Reset user password using token
*
* @param string $token
* @param int $userId
* @param string $newPassword
* @return bool
* @throws Exception
*/
public function resetPassword(string $token, int $userId, string $newPassword): bool
{
try {
if (!$this->validatePasswordResetToken($token, $userId)) {
throw new Exception('Invalid or expired password reset token');
}
$user = $this->userRepository->findById($userId);
if (!$user) {
throw new Exception('User not found');
}
// Update password
$hashedPassword = Hash::make($newPassword);
$updateData = [
'password' => $hashedPassword,
'password_updated_at' => now(),
'login_attempts' => 0,
'account_locked_until' => null
];
$success = $this->userRepository->update($userId, $updateData);
if ($success) {
// Remove used token
$cacheKey = "password_reset:{$userId}:{$token}";
Cache::forget($cacheKey);
Log::info('Password reset completed successfully', [
'user_id' => $userId,
'email' => $user->email,
'ip_address' => request()->ip()
]);
}
return $success;
} catch (Exception $e) {
Log::error('Password reset failed', [
'error_message' => $e->getMessage(),
'user_id' => $userId,
'ip_address' => request()->ip()
]);
throw $e;
}
}
/**
* Check if user account is secure
*
* @param User $user
* @return array
*/
public function checkAccountSecurity(User $user): array
{
$securityChecks = [
'password_strength' => $this->checkPasswordStrength($user),
'two_factor_enabled' => $user->two_factor_secret !== null,
'email_verified' => $user->email_verified_at !== null,
'recent_password_change' => $this->checkRecentPasswordChange($user),
'suspicious_activity' => $this->checkSuspiciousActivity($user),
'account_locked' => $this->userRepository->isAccountLocked($user->email)
];
$securityScore = $this->calculateSecurityScore($securityChecks);
return [
'security_score' => $securityScore,
'checks' => $securityChecks,
'recommendations' => $this->getSecurityRecommendations($securityChecks)
];
}
/**
* Log security event
*
* @param string $event
* @param User $user
* @param array $details
*/
public function logSecurityEvent(string $event, User $user, array $details = []): void
{
$logData = array_merge([
'event' => $event,
'user_id' => $user->id,
'email' => $user->email,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'timestamp' => now()->toISOString()
], $details);
Log::warning("Security Event: {$event}", $logData);
// Store in security events table (implement if needed)
// SecurityEvent::create($logData);
}
/**
* Generate two-factor authentication secret
*
* @param User $user
* @return string
*/
public function generateTwoFactorSecret(User $user): string
{
$secret = bin2hex(random_bytes(16));
$this->userRepository->update($user->id, [
'two_factor_secret' => $secret,
'two_factor_recovery_codes' => $this->generateRecoveryCodes()
]);
Log::info('Two-factor authentication enabled', [
'user_id' => $user->id,
'email' => $user->email,
'ip_address' => request()->ip()
]);
return $secret;
}
/**
* Verify two-factor authentication code
*
* @param User $user
* @param string $code
* @return bool
*/
public function verifyTwoFactorCode(User $user, string $code): bool
{
if (!$user->two_factor_secret) {
return false;
}
// Implement TOTP verification logic here
// This is a simplified version
$isValid = $this->validateTOTP($user->two_factor_secret, $code);
if ($isValid) {
Log::info('Two-factor authentication verified', [
'user_id' => $user->id,
'email' => $user->email,
'ip_address' => request()->ip()
]);
} else {
Log::warning('Invalid two-factor authentication code', [
'user_id' => $user->id,
'email' => $user->email,
'ip_address' => request()->ip()
]);
}
return $isValid;
}
/**
* Check password strength
*
* @param User $user
* @return bool
*/
private function checkPasswordStrength(User $user): bool
{
// This is a simplified check - implement proper password strength validation
return $user->password_updated_at &&
$user->password_updated_at->gt(now()->subMonths(6));
}
/**
* Check if password was changed recently
*
* @param User $user
* @return bool
*/
private function checkRecentPasswordChange(User $user): bool
{
return $user->password_updated_at &&
$user->password_updated_at->gt(now()->subMonths(3));
}
/**
* Check for suspicious activity
*
* @param User $user
* @return bool
*/
private function checkSuspiciousActivity(User $user): bool
{
// Check failed login attempts
return $user->login_attempts > 2;
}
/**
* Calculate security score
*
* @param array $checks
* @return int
*/
private function calculateSecurityScore(array $checks): int
{
$score = 0;
$totalChecks = count($checks);
foreach ($checks as $check) {
if ($check === true) {
$score++;
}
}
return (int) (($score / $totalChecks) * 100);
}
/**
* Get security recommendations
*
* @param array $checks
* @return array
*/
private function getSecurityRecommendations(array $checks): array
{
$recommendations = [];
if (!$checks['two_factor_enabled']) {
$recommendations[] = 'Enable two-factor authentication for enhanced security';
}
if (!$checks['email_verified']) {
$recommendations[] = 'Verify your email address';
}
if (!$checks['recent_password_change']) {
$recommendations[] = 'Consider changing your password regularly';
}
if ($checks['suspicious_activity']) {
$recommendations[] = 'Review recent login activity for suspicious behavior';
}
return $recommendations;
}
/**
* Generate recovery codes
*
* @return array
*/
private function generateRecoveryCodes(): array
{
$codes = [];
for ($i = 0; $i < 10; $i++) {
$codes[] = strtoupper(bin2hex(random_bytes(4)));
}
return $codes;
}
/**
* Validate TOTP code (simplified implementation)
*
* @param string $secret
* @param string $code
* @return bool
*/
private function validateTOTP(string $secret, string $code): bool
{
// This is a placeholder - implement proper TOTP validation
// You would typically use a library like spomky-labs/otphp
return strlen($code) === 6 && is_numeric($code);
}
}