Initial commit

This commit is contained in:
jeremy bayse
2026-03-20 08:25:58 +01:00
commit a55a33ae2a
143 changed files with 19599 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Attempt;
use App\Models\Answer;
use App\Models\Quiz;
use App\Models\Option;
use App\Models\AdminLog;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class AttemptController extends Controller
{
public function destroy(Attempt $attempt)
{
$this->authorizeAdmin();
$candidateName = $attempt->candidate->user->name;
$quizTitle = $attempt->quiz->title;
DB::transaction(function () use ($attempt, $candidateName, $quizTitle) {
// Log the action
AdminLog::create([
'user_id' => auth()->id(),
'action' => 'DELETE_ATTEMPT',
'description' => "Suppression du test '{$quizTitle}' pour le candidat '{$candidateName}'."
]);
// Delete attempt (cascades to answers)
$attempt->delete();
// Re-evaluate candidate status if needed
$candidate = $attempt->candidate;
if ($candidate->attempts()->count() === 0) {
$candidate->update(['status' => 'en_attente']);
}
});
return back()->with('success', 'Tentative de test supprimée et action journalisée.');
}
private function authorizeAdmin()
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
}
public function show(Quiz $quiz)
{
$candidate = auth()->user()->candidate;
if (!$candidate) {
abort(403, 'Seuls les candidats peuvent passer des quiz.');
}
$attempt = Attempt::where('candidate_id', $candidate->id)
->where('quiz_id', $quiz->id)
->first();
if ($attempt && $attempt->finished_at) {
return Inertia::render('Candidate/Thanks');
}
if (!$attempt) {
$attempt = Attempt::create([
'candidate_id' => $candidate->id,
'quiz_id' => $quiz->id,
'started_at' => now(),
]);
$candidate->update(['status' => 'en_cours']);
}
$quiz->load(['questions.options']);
return Inertia::render('Candidate/QuizInterface', [
'quiz' => $quiz,
'attempt' => $attempt->load('answers')
]);
}
public function saveAnswer(Request $request, Attempt $attempt)
{
$request->validate([
'question_id' => 'required|exists:questions,id',
'option_id' => 'nullable|exists:options,id',
'text_content' => 'nullable|string',
]);
Answer::updateOrCreate(
[
'attempt_id' => $attempt->id,
'question_id' => $request->question_id,
],
[
'option_id' => $request->option_id,
'text_content' => $request->text_content,
]
);
return response()->json(['status' => 'success']);
}
public function finish(Attempt $attempt)
{
if ($attempt->finished_at) {
return redirect()->route('dashboard');
}
$attempt->load(['quiz.questions.options', 'answers']);
$score = 0;
$maxScore = $attempt->quiz->questions->sum('points');
foreach ($attempt->quiz->questions as $question) {
if ($question->type === 'qcm') {
$userAnswer = $attempt->answers->where('question_id', $question->id)->first();
if ($userAnswer && $userAnswer->option_id) {
$option = $question->options->where('id', $userAnswer->option_id)->first();
if ($option && $option->is_correct) {
$score += $question->points;
}
}
}
}
$attempt->update([
'score' => $score,
'max_score' => $maxScore,
'finished_at' => now(),
]);
$attempt->candidate->update(['status' => 'termine']);
return redirect()->route('dashboard');
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): Response
{
return Inertia::render('Auth/Login', [
'canResetPassword' => Route::has('password.request'),
'status' => session('status'),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): Response
{
return Inertia::render('Auth/ConfirmPassword');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|Response
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: Inertia::render('Auth/VerifyEmail', ['status' => session('status')]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): Response
{
return Inertia::render('Auth/ResetPassword', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PASSWORD_RESET) {
return redirect()->route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): Response
{
return Inertia::render('Auth/ForgotPassword', [
'status' => session('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => 'required|email',
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
if ($status == Password::RESET_LINK_SENT) {
return back()->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): Response
{
return Inertia::render('Auth/Register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Candidate;
use App\Models\Document;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class CandidateController extends Controller
{
public function index()
{
$candidates = Candidate::with(['user', 'documents'])->latest()->get();
return \Inertia\Inertia::render('Admin/Candidates/Index', [
'candidates' => $candidates
]);
}
public function comparative()
{
$candidates = Candidate::with(['user', 'attempts.quiz'])
->whereHas('attempts', function($query) {
$query->whereNotNull('finished_at');
})
->get();
return \Inertia\Inertia::render('Admin/Comparative', [
'candidates' => $candidates
]);
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'phone' => 'nullable|string|max:20',
'linkedin_url' => 'nullable|url|max:255',
'cv' => 'nullable|file|mimes:pdf|max:5120',
'cover_letter' => 'nullable|file|mimes:pdf|max:5120',
]);
$password = Str::random(10);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($password),
'role' => 'candidate',
]);
$candidate = Candidate::create([
'user_id' => $user->id,
'phone' => $request->phone,
'linkedin_url' => $request->linkedin_url,
'status' => 'en_attente',
]);
$this->storeDocument($candidate, $request->file('cv'), 'cv');
$this->storeDocument($candidate, $request->file('cover_letter'), 'cover_letter');
return back()->with('success', 'Candidat créé avec succès. Mot de passe généré: ' . $password);
}
public function show(Candidate $candidate)
{
$candidate->load([
'user',
'documents',
'attempts.quiz',
'attempts.answers.question',
'attempts.answers.option'
]);
return \Inertia\Inertia::render('Admin/Candidates/Show', [
'candidate' => $candidate
]);
}
public function destroy(Candidate $candidate)
{
$user = $candidate->user;
// Delete files
foreach ($candidate->documents as $doc) {
Storage::disk('local')->delete($doc->file_path);
}
// Delete user (cascades to candidate, documents, attempts via DB constraints usually)
$user->delete();
return redirect()->route('admin.candidates.index')->with('success', 'Candidat supprimé avec succès.');
}
public function update(Request $request, Candidate $candidate)
{
$request->validate([
'cv' => 'nullable|file|mimes:pdf|max:5120',
'cover_letter' => 'nullable|file|mimes:pdf|max:5120',
]);
if ($request->hasFile('cv')) {
$this->replaceDocument($candidate, $request->file('cv'), 'cv');
}
if ($request->hasFile('cover_letter')) {
$this->replaceDocument($candidate, $request->file('cover_letter'), 'cover_letter');
}
return back()->with('success', 'Documents mis à jour avec succès.');
}
public function resetPassword(Candidate $candidate)
{
$password = Str::random(10);
$candidate->user->update([
'password' => Hash::make($password)
]);
return back()->with('success', 'Nouveau mot de passe généré: ' . $password);
}
private function replaceDocument(Candidate $candidate, $file, string $type)
{
// Delete old one if exists
$oldDoc = $candidate->documents()->where('type', $type)->first();
if ($oldDoc) {
Storage::disk('local')->delete($oldDoc->file_path);
$oldDoc->delete();
}
$this->storeDocument($candidate, $file, $type);
}
private function storeDocument(Candidate $candidate, $file, string $type)
{
if (!$file) {
return;
}
$path = $file->store('private/documents/' . $candidate->id, 'local');
Document::create([
'candidate_id' => $candidate->id,
'type' => $type,
'file_path' => $path,
'original_name' => $file->getClientOriginalName(),
]);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Document;
use Illuminate\Support\Facades\Storage;
class DocumentController extends Controller
{
public function show(Document $document)
{
// Permission check is already handled by middleware in the route
if (!Storage::disk('local')->exists($document->file_path)) {
abort(404, 'File not found');
}
return Storage::disk('local')->response($document->file_path);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): Response
{
return Inertia::render('Profile/Edit', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => session('status'),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Question;
use App\Models\Option;
use App\Models\Quiz;
use Illuminate\Support\Facades\DB;
class QuestionController extends Controller
{
public function store(Request $request, Quiz $quiz)
{
$request->validate([
'title' => 'nullable|string|max:255',
'context' => 'nullable|string',
'label' => 'required|string',
'points' => 'required|integer|min:1',
'type' => 'required|in:qcm,open',
'options' => 'required_if:type,qcm|array',
'options.*.option_text' => 'nullable|required_if:type,qcm|string',
'options.*.is_correct' => 'nullable|required_if:type,qcm|boolean',
]);
DB::transaction(function () use ($request, $quiz) {
$question = Question::create([
'quiz_id' => $quiz->id,
'title' => $request->title,
'context' => $request->context,
'label' => $request->label,
'points' => $request->points,
'type' => $request->type
]);
if ($request->type === 'qcm') {
foreach ($request->options as $opt) {
Option::create([
'question_id' => $question->id,
'option_text' => $opt['option_text'],
'is_correct' => $opt['is_correct']
]);
}
}
});
return back()->with('success', 'Question ajoutée avec succès.');
}
public function update(Request $request, Quiz $quiz, Question $question)
{
$request->validate([
'title' => 'nullable|string|max:255',
'context' => 'nullable|string',
'label' => 'required|string',
'points' => 'required|integer|min:1',
'type' => 'required|in:qcm,open',
'options' => 'required_if:type,qcm|array',
'options.*.option_text' => 'nullable|required_if:type,qcm|string',
'options.*.is_correct' => 'nullable|required_if:type,qcm|boolean',
]);
DB::transaction(function () use ($request, $question) {
$question->update([
'title' => $request->title,
'context' => $request->context,
'label' => $request->label,
'points' => $request->points,
'type' => $request->type
]);
if ($request->type === 'qcm') {
$question->options()->delete();
foreach ($request->options as $opt) {
Option::create([
'question_id' => $question->id,
'option_text' => $opt['option_text'],
'is_correct' => $opt['is_correct']
]);
}
} else {
$question->options()->delete();
}
});
return back()->with('success', 'Question mise à jour avec succès.');
}
public function destroy(Quiz $quiz, Question $question)
{
$question->delete();
return back()->with('success', 'Question supprimée avec succès.');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Quiz;
use Inertia\Inertia;
class QuizController extends Controller
{
public function index()
{
$quizzes = Quiz::latest()->get();
return Inertia::render('Admin/Quizzes/Index', [
'quizzes' => $quizzes
]);
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'duration_minutes' => 'required|integer|min:1',
]);
Quiz::create($request->all());
return back()->with('success', 'Quiz créé avec succès.');
}
public function show(Quiz $quiz)
{
$quiz->load(['questions.options']);
return Inertia::render('Admin/Quizzes/Show', [
'quiz' => $quiz
]);
}
public function update(Request $request, Quiz $quiz)
{
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'duration_minutes' => 'required|integer|min:1',
]);
$quiz->update($request->all());
return back()->with('success', 'Quiz mis à jour avec succès.');
}
public function destroy(Quiz $quiz)
{
$quiz->delete();
return back()->with('success', 'Quiz supprimé avec succès.');
}
}