Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: CI

on:
push:
pull_request:

jobs:
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run smoke
- run: npm run lint
- run: npm run build

backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --no-interaction --prefer-dist
- run: cp .env.example .env
- run: php artisan key:generate --force
- run: php artisan migrate:fresh --seed
- run: php artisan test
111 changes: 50 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,62 +1,51 @@
# E-learning Platform

## Tech Stack

Frontend
Next.js

Backend
Laravel

Database
MySQL

## Installation

### Backend

cd backend
composer install
php artisan migrate
php artisan serve

### Frontend

# E-learning Platform (MVP)

## Stack
- Frontend: Next.js
- Backend: Laravel
- Database: MySQL / SQLite (tests)

## Run local (MVP checklist)
```bash
# From repository root
./scripts/run_mvp_check.sh
```

Manual steps:
```bash
# Frontend
cd frontend
npm install
npm run dev


build
coverage
docker
license
version

Releases & Versioning

The project follows Semantic Versioning (SemVer).

Format :

MAJOR.MINOR.PATCH

Example :

1.0.0

Meaning :

Version Description
MAJOR Breaking changes
MINOR New features
PATCH Bug fixes
Release Cycle
Phase Description
Development Work happens on develop
Feature complete Release branch created
QA testing Bugs fixed
Production Merge to main and release
Release Branch Naming
release/v1.0.0
release/v1.1.0
npm ci
npm run smoke
npm run lint
npm run build

# Backend
cd ../backend
composer install --no-interaction --prefer-dist
cp .env.example .env
php artisan key:generate --force
php artisan migrate:fresh --seed
php artisan test
php artisan serve
```

## MVP scope in production
- ✅ Auth session flow: register/login/me/logout
- ✅ Catalogue cours
- ✅ Détail cours (modules + leçons)
- ✅ Lecture de leçon
- ✅ Soumission quiz
- ✅ Affichage progression
- ⛔ Live / certificats / paiement masqués côté UI tant qu'incomplets

## Basic security controls
- CORS limité à `FRONTEND_URL` avec cookies (`supports_credentials=true`).
- Session API activée via middleware `StartSession`.
- Validation requêtes auth + payload quiz/progression.
- Rate limit auth (`throttle:10,1` sur register/login).

## CI
- Workflow `.github/workflows/ci.yml`
- Frontend: `smoke + lint + build`
- Backend: `migrate:fresh --seed + php artisan test`
15 changes: 15 additions & 0 deletions backend/app/Http/Controllers/ProgressController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@

class ProgressController extends Controller
{
public function markLessonCompleted(Request $request, int $lessonId)
{
$request->merge(['lesson_id' => $lessonId]);

return $this->markCompleted($request);
}

public function showCourseProgress(Request $request, int $courseId)
{
$request->merge(['user_id' => Auth::id()]);
$course = Course::findOrFail($courseId);

return $this->getCourseProgress($request, $course);
}

/**
* Display a listing of progress records. Afficher la liste des progressions
*/
Expand Down
26 changes: 18 additions & 8 deletions backend/app/Http/Controllers/Quiz/AttemptController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@

class AttemptController extends Controller
{
private function resolveCourseFromQuiz(Quiz $quiz)
{
return $quiz->lesson->module->course;
}

private function resolveCourseFromAttempt(Attempt $attempt)
{
return $attempt->quiz->lesson->module->course;
}

/**
* Display a listing of attempts for a quiz (instructor only). Affichage d'une liste des tentatives pour un quiz,
* accessible uniquement à l'instructeur du cours, avec une vérification de l'autorisation de l'utilisateur, une
Expand All @@ -18,7 +28,7 @@ class AttemptController extends Controller
*/
public function index(Quiz $quiz)
{
$course = $quiz->course;
$course = $this->resolveCourseFromQuiz($quiz);

// Check if user is the instructor
if ($course->instructor_id !== Auth::id()) {
Expand All @@ -39,7 +49,7 @@ public function index(Quiz $quiz)
public function show(Attempt $attempt)
{
$user = Auth::user();
$course = $attempt->course;
$course = $this->resolveCourseFromAttempt($attempt);

// Check if user is the attempt owner or the course instructor verifier que c'est un instructeur qui est connecter
if ($attempt->user_id !== $user->id && $course->instructor_id !== $user->id) {
Expand All @@ -57,7 +67,7 @@ public function show(Attempt $attempt)
public function store(Request $request, Quiz $quiz)
{
$user = Auth::user();
$course = $quiz->course;
$course = $this->resolveCourseFromQuiz($quiz);

// Check if user is enrolled in the course
if (!$course->enrollments()->where('user_id', $user->id)->exists()) {
Expand Down Expand Up @@ -146,7 +156,7 @@ public function update(Request $request, Attempt $attempt)
public function destroy(Attempt $attempt)
{
$user = Auth::user();
$course = $attempt->course;
$course = $this->resolveCourseFromAttempt($attempt);

// Check if user is the attempt owner or the course instructor
if ($attempt->user_id !== $user->id && $course->instructor_id !== $user->id) {
Expand All @@ -164,7 +174,7 @@ public function destroy(Attempt $attempt)
public function userAttempts(Quiz $quiz)
{
$user = Auth::user();
$course = $quiz->course;
$course = $this->resolveCourseFromQuiz($quiz);

// Check if user is enrolled
if (!$course->enrollments()->where('user_id', $user->id)->exists()) {
Expand All @@ -187,7 +197,7 @@ public function myAttempts(Request $request)
/** @var \App\Models\User $user */
$user = Auth::user();

$query = $user->attempts()->with(['quiz.course:id,title']);
$query = $user->attempts()->with(['quiz.lesson.module.course:id,title']);

// Filter by course
if ($request->has('course_id')) {
Expand All @@ -206,7 +216,7 @@ public function myAttempts(Request $request)
*/
public function statistics(Quiz $quiz)
{
$course = $quiz->course;
$course = $this->resolveCourseFromQuiz($quiz);

// Check if user is the instructor
if ($course->instructor_id !== Auth::id()) {
Expand Down Expand Up @@ -250,7 +260,7 @@ public function statistics(Quiz $quiz)
public function results(Attempt $attempt)
{
$user = Auth::user();
$course = $attempt->course;
$course = $this->resolveCourseFromAttempt($attempt);

// Check if user is the attempt owner or the course instructor
if ($attempt->user_id !== $user->id && $course->instructor_id !== $user->id) {
Expand Down
2 changes: 1 addition & 1 deletion backend/app/Models/Lesson.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Lesson extends Model
{
use HasFactory;

protected $fillable = ['module_id', 'title', 'content', 'video_url', 'duration', 'position', 'pdf_file'];
protected $fillable = ['module_id', 'title', 'content', 'video_url', 'duration', 'position'];

protected $casts = [
'duration' => 'integer',
Expand Down
6 changes: 3 additions & 3 deletions backend/app/Models/Question.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ class Question extends Model

public $timestamps = false;

protected $fillable = ['evaluation_id', 'question', 'type', 'position',];
protected $fillable = ['quiz_id', 'question_text', 'type', 'position'];

protected $casts = [
'position' => 'integer',
];

public function evaluation(): BelongsTo
public function quiz(): BelongsTo
{
return $this->belongsTo(Evaluation::class);
return $this->belongsTo(Quiz::class);
}

public function answers(): HasMany
Expand Down
1 change: 0 additions & 1 deletion backend/app/Models/Quiz.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,3 @@ public function attempts(): HasMany
return $this->hasMany(Attempt::class);
}
}

39 changes: 39 additions & 0 deletions backend/bootstrap/app.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
<?php

use App\Exceptions\AlreadyEnrolledException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Validation\ValidationException;
use Throwable;

return Application::configure(basePath: dirname(__DIR__))
->withRouting(
Expand All @@ -21,7 +25,42 @@
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (AlreadyEnrolledException $exception) {
return response()->json([
'code' => 'already_enrolled',
'message' => $exception->getMessage(),
], 409);
});

$exceptions->render(function (ValidationException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}

return response()->json([
'code' => 'validation_error',
'message' => 'Validation failed.',
'errors' => $exception->errors(),
], 422);
});

$exceptions->render(function (AuthenticationException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}

return response()->json([
'code' => 'unauthenticated',
'message' => 'Authentication required.',
], 401);
});

$exceptions->render(function (Throwable $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}

return response()->json([
'code' => 'server_error',
'message' => 'Unexpected server error.',
], 500);
});
})->create();
4 changes: 2 additions & 2 deletions backend/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
})->middleware('auth:sanctum');

Route::prefix('auth')->group(function (): void {
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:10,1');
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:10,1');

Route::middleware('auth')->group(function (): void {
Route::get('/me', [AuthController::class, 'me']);
Expand Down
Loading
Loading