commit b590ecdc357e9f9a2837e097a24bd98ec71989c4 Author: ikuzenkuna Date: Tue May 5 10:39:43 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cbe95e --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +## .NET +bin/ +obj/ +*.user +*.suo +.vs/ +*.log + +## NuGet +packages/ +*.nupkg + +## EF Core Migrations (keep the folder but ignore local override files) +# Migrations/ -- do NOT ignore, we want migrations in source control + +## Environment / secrets +appsettings.Local.json +appsettings.*.local.json +.env + +## Docker volumes +sqlserver_data/ + +## OS +.DS_Store +Thumbs.db diff --git a/BACKEND_API_DOCS.md b/BACKEND_API_DOCS.md new file mode 100644 index 0000000..9e73b6d --- /dev/null +++ b/BACKEND_API_DOCS.md @@ -0,0 +1,490 @@ +# MetaCourse API — Frontend Integration Guide + +> Target stack: Angular + Capacitor +> Base URL (local): `http://localhost:8080` +> Base URL (production): `http://romaric-thibault.fr` + +--- + +## Deployment + +### Requirements +- Docker + Docker Compose installed on the server + +### Start the stack +```bash +docker compose up -d --build +``` + +The API will be available on port **8080**. SQL Server runs on port **1433** (internal). Migrations run automatically on startup. + +### Swagger UI +``` +http://:8080/swagger +``` + +--- + +## Authentication + +There is **no JWT** currently. Login returns user info directly. Store `UserId` in local storage / Capacitor Preferences to identify the current user across requests. + +--- + +## Data Models (TypeScript interfaces) + +```typescript +// Enums +type CourseStatus = 'Draft' | 'Published'; +type ResourceType = 'Url' | 'Video' | 'Text' | 'File'; + +interface User { + id: string; // Guid + name: string; + email: string; + createdAt: string; // ISO date +} + +interface LoginResponse { + userId: string; + name: string; + email: string; +} + +interface Course { + id: string; + title: string; + description: string; + status: CourseStatus; + creatorId: string; + creatorName: string; + topicCount: number; + createdAt: string; + updatedAt: string; +} + +interface CourseDetails extends Course { + topics: Topic[]; +} + +interface Topic { + id: string; + title: string; + description?: string; + position: number; + courseId: string; + resources: Resource[]; +} + +interface Resource { + id: string; + type: ResourceType; + title: string; + content: string; // URL, video URL, text body, or file path + createdAt: string; +} + +interface Enrollment { + userId: string; + courseId: string; + courseTitle: string; + enrolledAt: string; + completedAt?: string; +} + +interface CourseProgress { + courseId: string; + userId: string; + totalTopics: number; + completedTopics: number; + totalResources: number; + completedResources: number; + progressPercentage: number; // 0.0 - 100.0 +} +``` + +--- + +## API Endpoints + +### Users + +#### Register +``` +POST /api/users/register +``` +**Body:** +```json +{ + "name": "Alice", + "email": "alice@example.com", + "password": "Password1" +} +``` +**Validation:** name 2–100 chars, valid email, password ≥8 chars + 1 uppercase + 1 digit +**Response:** `201` → `User` +**Errors:** `400` validation | `409` email already taken + +--- + +#### Login +``` +POST /api/users/login +``` +**Body:** +```json +{ + "email": "alice@example.com", + "password": "Password1" +} +``` +**Response:** `200` → `LoginResponse` +**Errors:** `401` wrong credentials + +--- + +#### Get User Profile +``` +GET /api/users/{id} +``` +**Response:** `200` → `User` | `404` + +--- + +### Courses + +#### List Published Courses +``` +GET /api/courses +``` +Optional query param: `?search=angular` +**Response:** `200` → `Course[]` (only Published courses, newest first) + +--- + +#### Get Course with Topics & Resources +``` +GET /api/courses/{id} +``` +**Response:** `200` → `CourseDetails` | `404` + +--- + +#### Create Course +``` +POST /api/courses +``` +**Body:** +```json +{ + "title": "Introduction to Angular", + "description": "Learn Angular from scratch", + "creatorId": "" +} +``` +**Response:** `201` → `Course` (status = `Draft`) + +--- + +#### Update Course +``` +PUT /api/courses/{id} +``` +**Body:** +```json +{ + "id": "", + "title": "Updated Title", + "description": "Updated description" +} +``` +**Response:** `200` → `Course` | `404` + +--- + +#### Publish Course +``` +PATCH /api/courses/{id}/publish +``` +**Response:** `200` → `Course` | `422` if no topics yet | `404` + +--- + +#### Delete Course +``` +DELETE /api/courses/{id} +``` +**Response:** `204` | `409` if course has enrollments | `404` + +--- + +#### Courses Created by a User +``` +GET /api/users/{userId}/courses +``` +**Response:** `200` → `Course[]` + +--- + +### Topics + +#### Get Topic +``` +GET /api/topics/{id} +``` +**Response:** `200` → `Topic` (includes resources ordered by position) | `404` + +--- + +#### Create Topic +``` +POST /api/topics +``` +**Body:** +```json +{ + "courseId": "", + "title": "Components", + "description": "Optional description", + "position": 1 +} +``` +**Response:** `201` → `Topic` + +--- + +#### Update Topic +``` +PUT /api/topics/{id} +``` +**Body:** +```json +{ + "id": "", + "title": "Updated Title", + "description": "Updated", + "position": 2 +} +``` +**Response:** `200` → `Topic` | `404` + +--- + +#### Delete Topic +``` +DELETE /api/topics/{id} +``` +**Response:** `204` | `404` + +--- + +#### Link Resource to Topic +``` +POST /api/topics/{topicId}/resources/{resourceId} +``` +**Body:** +```json +{ "position": 1 } +``` +**Response:** `204` + +--- + +#### Unlink Resource from Topic +``` +DELETE /api/topics/{topicId}/resources/{resourceId} +``` +**Response:** `204` + +--- + +### Resources + +#### List All Resources +``` +GET /api/resources +``` +**Response:** `200` → `Resource[]` (ordered by creation date desc) + +--- + +#### Get Resource +``` +GET /api/resources/{id} +``` +**Response:** `200` → `Resource` | `404` + +--- + +#### Create Resource +``` +POST /api/resources +``` +**Body:** +```json +{ + "type": "Url", + "title": "Angular Docs", + "content": "https://angular.io" +} +``` +**Types & content:** +| Type | content field | +|------|--------------| +| `Url` | Full URL (https required) | +| `Video` | Video URL (https required) | +| `Text` | Markdown or plain text body | +| `File` | File path or download URL | + +**Response:** `201` → `Resource` + +--- + +#### Update Resource +``` +PUT /api/resources/{id} +``` +**Body:** +```json +{ + "id": "", + "type": "Video", + "title": "Updated", + "content": "https://youtube.com/..." +} +``` +**Response:** `200` → `Resource` | `404` + +--- + +#### Delete Resource +``` +DELETE /api/resources/{id} +``` +**Response:** `204` | `404` + +--- + +### Enrollments + +#### Enroll in a Course +``` +POST /api/courses/{courseId}/enroll +``` +**Body:** +```json +{ + "userId": "", + "courseId": "" +} +``` +**Response:** `201` → `Enrollment` +**Errors:** `409` already enrolled | `422` course not Published | `404` + +--- + +#### User's Enrollments +``` +GET /api/users/{userId}/enrollments +``` +**Response:** `200` → `Enrollment[]` + +--- + +### Progress + +#### Mark Topic Progress +``` +POST /api/topics/{topicId}/progress +``` +**Body:** +```json +{ + "userId": "", + "topicId": "", + "completed": true +} +``` +**Response:** `204` (upsert — safe to call multiple times) + +--- + +#### Mark Resource Progress +``` +POST /api/resources/{resourceId}/progress +``` +**Body:** +```json +{ + "userId": "", + "resourceId": "", + "completed": true +} +``` +**Response:** `204` (upsert) + +--- + +#### Get Course Progress +``` +GET /api/courses/{courseId}/progress?userId={userId} +``` +**Response:** `200` → `CourseProgress` +```json +{ + "courseId": "...", + "userId": "...", + "totalTopics": 5, + "completedTopics": 3, + "totalResources": 10, + "completedResources": 7, + "progressPercentage": 66.67 +} +``` + +--- + +## Error Response Format + +All validation and business errors follow this shape: +```json +{ + "statusCode": 400, + "errors": { + "email": ["'Email' is not a valid email address."], + "password": ["Password must be at least 8 characters."] + } +} +``` + +--- + +## CORS + +All origins, methods, and headers are allowed (`AllowAll` policy). No special headers needed from Angular/Capacitor. + +--- + +## Typical User Flow (Angular/Capacitor) + +``` +1. POST /api/users/register → store userId in Capacitor Preferences +2. POST /api/users/login → verify credentials +3. GET /api/courses → show published course catalog +4. GET /api/courses/{id} → show course detail with topics +5. POST /api/courses/{id}/enroll → enroll user +6. GET /api/users/{id}/enrollments → show My Courses +7. POST /api/topics/{id}/progress → mark topic done +8. POST /api/resources/{id}/progress → mark resource done +9. GET /api/courses/{id}/progress?userId=... → show progress bar +``` + +--- + +## Notes for Angular Service Layer + +- All IDs are **UUIDs (string)** — no integer IDs. +- Dates are **ISO 8601 UTC strings** — use `new Date(dateString)` or a pipe. +- `progressPercentage` is a `number` (float 0–100), round for display. +- `status` and `type` come back as **strings**, not numbers. +- No auth headers needed currently — all endpoints are `AllowAnonymous`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c777480 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +COPY MetaCourse.Api/MetaCourse.Api.csproj MetaCourse.Api/ +RUN dotnet restore MetaCourse.Api/MetaCourse.Api.csproj + +COPY MetaCourse.Api/ MetaCourse.Api/ +WORKDIR /src/MetaCourse.Api +RUN dotnet publish -c Release -o /app/publish + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime +WORKDIR /app + +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "MetaCourse.Api.dll"] diff --git a/MetaCourse.Api/DTOs/Courses/CreateCourseDto.cs b/MetaCourse.Api/DTOs/Courses/CreateCourseDto.cs new file mode 100644 index 0000000..63ae634 --- /dev/null +++ b/MetaCourse.Api/DTOs/Courses/CreateCourseDto.cs @@ -0,0 +1,8 @@ +namespace MetaCourse.Api.DTOs.Courses; + +public class CreateCourseDto +{ + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public Guid CreatorId { get; set; } +} diff --git a/MetaCourse.Api/DTOs/Courses/GetCourseDetailsDto.cs b/MetaCourse.Api/DTOs/Courses/GetCourseDetailsDto.cs new file mode 100644 index 0000000..fe9c659 --- /dev/null +++ b/MetaCourse.Api/DTOs/Courses/GetCourseDetailsDto.cs @@ -0,0 +1,16 @@ +using MetaCourse.Api.DTOs.Topics; + +namespace MetaCourse.Api.DTOs.Courses; + +public class GetCourseDetailsDto +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string CreatorName { get; set; } = string.Empty; + public Guid CreatorId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List Topics { get; set; } = new(); +} diff --git a/MetaCourse.Api/DTOs/Courses/GetCourseDto.cs b/MetaCourse.Api/DTOs/Courses/GetCourseDto.cs new file mode 100644 index 0000000..5bc1c77 --- /dev/null +++ b/MetaCourse.Api/DTOs/Courses/GetCourseDto.cs @@ -0,0 +1,16 @@ +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.DTOs.Courses; + +public class GetCourseDto +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string CreatorName { get; set; } = string.Empty; + public Guid CreatorId { get; set; } + public int TopicCount { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/MetaCourse.Api/DTOs/Courses/UpdateCourseDto.cs b/MetaCourse.Api/DTOs/Courses/UpdateCourseDto.cs new file mode 100644 index 0000000..6a4a0fc --- /dev/null +++ b/MetaCourse.Api/DTOs/Courses/UpdateCourseDto.cs @@ -0,0 +1,8 @@ +namespace MetaCourse.Api.DTOs.Courses; + +public class UpdateCourseDto +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; +} diff --git a/MetaCourse.Api/DTOs/Progress/CourseProgressDto.cs b/MetaCourse.Api/DTOs/Progress/CourseProgressDto.cs new file mode 100644 index 0000000..bb9eb53 --- /dev/null +++ b/MetaCourse.Api/DTOs/Progress/CourseProgressDto.cs @@ -0,0 +1,12 @@ +namespace MetaCourse.Api.DTOs.Progress; + +public class CourseProgressDto +{ + public Guid CourseId { get; set; } + public Guid UserId { get; set; } + public int TotalTopics { get; set; } + public int CompletedTopics { get; set; } + public int TotalResources { get; set; } + public int CompletedResources { get; set; } + public double ProgressPercentage { get; set; } +} diff --git a/MetaCourse.Api/DTOs/Progress/MarkResourceProgressDto.cs b/MetaCourse.Api/DTOs/Progress/MarkResourceProgressDto.cs new file mode 100644 index 0000000..807c71b --- /dev/null +++ b/MetaCourse.Api/DTOs/Progress/MarkResourceProgressDto.cs @@ -0,0 +1,8 @@ +namespace MetaCourse.Api.DTOs.Progress; + +public class MarkResourceProgressDto +{ + public Guid UserId { get; set; } + public Guid ResourceId { get; set; } + public bool Completed { get; set; } +} diff --git a/MetaCourse.Api/DTOs/Progress/MarkTopicProgressDto.cs b/MetaCourse.Api/DTOs/Progress/MarkTopicProgressDto.cs new file mode 100644 index 0000000..4cc70eb --- /dev/null +++ b/MetaCourse.Api/DTOs/Progress/MarkTopicProgressDto.cs @@ -0,0 +1,8 @@ +namespace MetaCourse.Api.DTOs.Progress; + +public class MarkTopicProgressDto +{ + public Guid UserId { get; set; } + public Guid TopicId { get; set; } + public bool Completed { get; set; } +} diff --git a/MetaCourse.Api/DTOs/Resources/CreateResourceDto.cs b/MetaCourse.Api/DTOs/Resources/CreateResourceDto.cs new file mode 100644 index 0000000..a3f4539 --- /dev/null +++ b/MetaCourse.Api/DTOs/Resources/CreateResourceDto.cs @@ -0,0 +1,10 @@ +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.DTOs.Resources; + +public class CreateResourceDto +{ + public ResourceType Type { get; set; } + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; +} diff --git a/MetaCourse.Api/DTOs/Resources/GetResourceDto.cs b/MetaCourse.Api/DTOs/Resources/GetResourceDto.cs new file mode 100644 index 0000000..0842a35 --- /dev/null +++ b/MetaCourse.Api/DTOs/Resources/GetResourceDto.cs @@ -0,0 +1,10 @@ +namespace MetaCourse.Api.DTOs.Resources; + +public class GetResourceDto +{ + public Guid Id { get; set; } + public string Type { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} diff --git a/MetaCourse.Api/DTOs/Resources/UpdateResourceDto.cs b/MetaCourse.Api/DTOs/Resources/UpdateResourceDto.cs new file mode 100644 index 0000000..e3afcb4 --- /dev/null +++ b/MetaCourse.Api/DTOs/Resources/UpdateResourceDto.cs @@ -0,0 +1,11 @@ +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.DTOs.Resources; + +public class UpdateResourceDto +{ + public Guid Id { get; set; } + public ResourceType Type { get; set; } + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; +} diff --git a/MetaCourse.Api/DTOs/Topics/CreateTopicDto.cs b/MetaCourse.Api/DTOs/Topics/CreateTopicDto.cs new file mode 100644 index 0000000..bd83f15 --- /dev/null +++ b/MetaCourse.Api/DTOs/Topics/CreateTopicDto.cs @@ -0,0 +1,9 @@ +namespace MetaCourse.Api.DTOs.Topics; + +public class CreateTopicDto +{ + public Guid CourseId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public int Position { get; set; } +} diff --git a/MetaCourse.Api/DTOs/Topics/GetTopicDto.cs b/MetaCourse.Api/DTOs/Topics/GetTopicDto.cs new file mode 100644 index 0000000..165b66c --- /dev/null +++ b/MetaCourse.Api/DTOs/Topics/GetTopicDto.cs @@ -0,0 +1,13 @@ +using MetaCourse.Api.DTOs.Resources; + +namespace MetaCourse.Api.DTOs.Topics; + +public class GetTopicDto +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public int Position { get; set; } + public Guid CourseId { get; set; } + public List Resources { get; set; } = new(); +} diff --git a/MetaCourse.Api/DTOs/Topics/UpdateTopicDto.cs b/MetaCourse.Api/DTOs/Topics/UpdateTopicDto.cs new file mode 100644 index 0000000..3e8aab7 --- /dev/null +++ b/MetaCourse.Api/DTOs/Topics/UpdateTopicDto.cs @@ -0,0 +1,9 @@ +namespace MetaCourse.Api.DTOs.Topics; + +public class UpdateTopicDto +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public int Position { get; set; } +} diff --git a/MetaCourse.Api/DTOs/UserCourses/EnrollDto.cs b/MetaCourse.Api/DTOs/UserCourses/EnrollDto.cs new file mode 100644 index 0000000..086005e --- /dev/null +++ b/MetaCourse.Api/DTOs/UserCourses/EnrollDto.cs @@ -0,0 +1,7 @@ +namespace MetaCourse.Api.DTOs.UserCourses; + +public class EnrollDto +{ + public Guid UserId { get; set; } + public Guid CourseId { get; set; } +} diff --git a/MetaCourse.Api/DTOs/UserCourses/GetEnrollmentDto.cs b/MetaCourse.Api/DTOs/UserCourses/GetEnrollmentDto.cs new file mode 100644 index 0000000..f8b46b1 --- /dev/null +++ b/MetaCourse.Api/DTOs/UserCourses/GetEnrollmentDto.cs @@ -0,0 +1,10 @@ +namespace MetaCourse.Api.DTOs.UserCourses; + +public class GetEnrollmentDto +{ + public Guid UserId { get; set; } + public Guid CourseId { get; set; } + public string CourseTitle { get; set; } = string.Empty; + public DateTime EnrolledAt { get; set; } + public DateTime? CompletedAt { get; set; } +} diff --git a/MetaCourse.Api/DTOs/Users/GetUserDto.cs b/MetaCourse.Api/DTOs/Users/GetUserDto.cs new file mode 100644 index 0000000..a2e8eeb --- /dev/null +++ b/MetaCourse.Api/DTOs/Users/GetUserDto.cs @@ -0,0 +1,9 @@ +namespace MetaCourse.Api.DTOs.Users; + +public class GetUserDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} diff --git a/MetaCourse.Api/DTOs/Users/LoginResponseDto.cs b/MetaCourse.Api/DTOs/Users/LoginResponseDto.cs new file mode 100644 index 0000000..51f8ea0 --- /dev/null +++ b/MetaCourse.Api/DTOs/Users/LoginResponseDto.cs @@ -0,0 +1,8 @@ +namespace MetaCourse.Api.DTOs.Users; + +public class LoginResponseDto +{ + public Guid UserId { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} diff --git a/MetaCourse.Api/DTOs/Users/LoginUserDto.cs b/MetaCourse.Api/DTOs/Users/LoginUserDto.cs new file mode 100644 index 0000000..7c26f8c --- /dev/null +++ b/MetaCourse.Api/DTOs/Users/LoginUserDto.cs @@ -0,0 +1,7 @@ +namespace MetaCourse.Api.DTOs.Users; + +public class LoginUserDto +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} diff --git a/MetaCourse.Api/DTOs/Users/RegisterUserDto.cs b/MetaCourse.Api/DTOs/Users/RegisterUserDto.cs new file mode 100644 index 0000000..815405e --- /dev/null +++ b/MetaCourse.Api/DTOs/Users/RegisterUserDto.cs @@ -0,0 +1,8 @@ +namespace MetaCourse.Api.DTOs.Users; + +public class RegisterUserDto +{ + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} diff --git a/MetaCourse.Api/Data/AppDbContext.cs b/MetaCourse.Api/Data/AppDbContext.cs new file mode 100644 index 0000000..176aaeb --- /dev/null +++ b/MetaCourse.Api/Data/AppDbContext.cs @@ -0,0 +1,182 @@ +using MetaCourse.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + + public DbSet Users => Set(); + public DbSet Courses => Set(); + public DbSet Topics => Set(); + public DbSet Resources => Set(); + public DbSet TopicResources => Set(); + public DbSet UserCourses => Set(); + public DbSet UserTopicProgresses => Set(); + public DbSet UserResourceProgresses => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // TopicResource composite key + modelBuilder.Entity() + .HasKey(tr => new { tr.TopicId, tr.ResourceId }); + + modelBuilder.Entity() + .HasOne(tr => tr.Topic) + .WithMany(t => t.TopicResources) + .HasForeignKey(tr => tr.TopicId); + + modelBuilder.Entity() + .HasOne(tr => tr.Resource) + .WithMany(r => r.TopicResources) + .HasForeignKey(tr => tr.ResourceId); + + // UserCourse composite key + modelBuilder.Entity() + .HasKey(uc => new { uc.UserId, uc.CourseId }); + + modelBuilder.Entity() + .HasOne(uc => uc.User) + .WithMany(u => u.UserCourses) + .HasForeignKey(uc => uc.UserId); + + modelBuilder.Entity() + .HasOne(uc => uc.Course) + .WithMany(c => c.UserCourses) + .HasForeignKey(uc => uc.CourseId); + + // UserTopicProgress composite key + modelBuilder.Entity() + .HasKey(utp => new { utp.UserId, utp.TopicId }); + + modelBuilder.Entity() + .HasOne(utp => utp.User) + .WithMany(u => u.TopicProgresses) + .HasForeignKey(utp => utp.UserId); + + modelBuilder.Entity() + .HasOne(utp => utp.Topic) + .WithMany(t => t.UserProgresses) + .HasForeignKey(utp => utp.TopicId); + + // UserResourceProgress composite key + modelBuilder.Entity() + .HasKey(urp => new { urp.UserId, urp.ResourceId }); + + modelBuilder.Entity() + .HasOne(urp => urp.User) + .WithMany(u => u.ResourceProgresses) + .HasForeignKey(urp => urp.UserId); + + modelBuilder.Entity() + .HasOne(urp => urp.Resource) + .WithMany(r => r.UserProgresses) + .HasForeignKey(urp => urp.ResourceId); + + // Unique email + modelBuilder.Entity() + .HasIndex(u => u.Email) + .IsUnique(); + + // Course -> Creator (restrict delete to avoid cascade) + modelBuilder.Entity() + .HasOne(c => c.Creator) + .WithMany(u => u.CreatedCourses) + .HasForeignKey(c => c.CreatorId) + .OnDelete(DeleteBehavior.Restrict); + + // Enum stored as string + modelBuilder.Entity() + .Property(c => c.Status) + .HasConversion(); + + modelBuilder.Entity() + .Property(r => r.Type) + .HasConversion(); + + SeedData(modelBuilder); + } + + private static void SeedData(ModelBuilder modelBuilder) + { + var userId1 = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var userId2 = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + var courseId1 = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + var courseId2 = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + var topicId1 = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + var topicId2 = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + var topicId3 = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + + var resourceId1 = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + var resourceId2 = Guid.Parse("00000000-0000-0000-aaaa-000000000001"); + var resourceId3 = Guid.Parse("00000000-0000-0000-aaaa-000000000002"); + + var now = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + modelBuilder.Entity().HasData( + new User + { + Id = userId1, + Name = "Alice Dupont", + Email = "alice@metacourse.io", + PasswordHash = "$2a$11$EuQ5pngrcHnxS6BhcMo6Mut73tAaJSDYB7K9TxahwJa5wAnJCF2o6", + CreatedAt = now + }, + new User + { + Id = userId2, + Name = "Bob Martin", + Email = "bob@metacourse.io", + PasswordHash = "$2a$11$4.r72zZul8Pj.QJ5kmVNE.0dRJuAmIefGvZtt9xZ1.fAzztjGKqtS", + CreatedAt = now + } + ); + + modelBuilder.Entity().HasData( + new Course + { + Id = courseId1, + Title = "Développement Web Moderne avec React", + Description = "Maîtrisez React, Tailwind CSS et TypeScript pour créer des applications web performantes.", + Status = CourseStatus.Published, + CreatorId = userId1, + CreatedAt = now, + UpdatedAt = now + }, + new Course + { + Id = courseId2, + Title = "API REST avec .NET et FastEndpoints", + Description = "Apprenez à construire des APIs REST robustes avec ASP.NET Core et FastEndpoints.", + Status = CourseStatus.Draft, + CreatorId = userId2, + CreatedAt = now, + UpdatedAt = now + } + ); + + modelBuilder.Entity().HasData( + new Topic { Id = topicId1, Title = "Introduction à React", Description = "Les bases de React : composants, JSX, props.", Position = 1, CourseId = courseId1 }, + new Topic { Id = topicId2, Title = "Hooks et State", Description = "useState, useEffect et hooks personnalisés.", Position = 2, CourseId = courseId1 }, + new Topic { Id = topicId3, Title = "Fondamentaux REST", Description = "Principes REST et verbes HTTP.", Position = 1, CourseId = courseId2 } + ); + + modelBuilder.Entity().HasData( + new Resource { Id = resourceId1, Type = ResourceType.Video, Title = "React en 30 minutes", Content = "https://youtube.com/watch?v=example1", CreatedAt = now }, + new Resource { Id = resourceId2, Type = ResourceType.Text, Title = "Guide des Hooks", Content = "Les hooks permettent d'utiliser le state et d'autres fonctionnalités React dans des composants fonctionnels.", CreatedAt = now }, + new Resource { Id = resourceId3, Type = ResourceType.Url, Title = "Documentation officielle React", Content = "https://react.dev", CreatedAt = now } + ); + + modelBuilder.Entity().HasData( + new TopicResource { TopicId = topicId1, ResourceId = resourceId1, Position = 1 }, + new TopicResource { TopicId = topicId1, ResourceId = resourceId3, Position = 2 }, + new TopicResource { TopicId = topicId2, ResourceId = resourceId2, Position = 1 } + ); + + modelBuilder.Entity().HasData( + new UserCourse { UserId = userId2, CourseId = courseId1, EnrolledAt = now } + ); + } +} diff --git a/MetaCourse.Api/Endpoints/Courses/CreateCourseEndpoint.cs b/MetaCourse.Api/Endpoints/Courses/CreateCourseEndpoint.cs new file mode 100644 index 0000000..4b045a1 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Courses/CreateCourseEndpoint.cs @@ -0,0 +1,40 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Courses; +using MetaCourse.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Courses; + +public class CreateCourseEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Post("api/courses"); + AllowAnonymous(); + Summary(s => s.Summary = "Crée un nouveau cours (statut Brouillon par défaut)"); + } + + public override async Task HandleAsync(CreateCourseDto req, CancellationToken ct) + { + var creatorExists = await db.Users.AnyAsync(u => u.Id == req.CreatorId, ct); + if (!creatorExists) + { + AddError(r => r.CreatorId, "L'utilisateur créateur n'existe pas."); + await SendErrorsAsync(404, ct); + return; + } + + var course = mapper.Map(req); + db.Courses.Add(course); + await db.SaveChangesAsync(ct); + + var created = await db.Courses + .Include(c => c.Creator) + .Include(c => c.Topics) + .FirstAsync(c => c.Id == course.Id, ct); + + await SendAsync(mapper.Map(created), 201, ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Courses/DeleteCourseEndpoint.cs b/MetaCourse.Api/Endpoints/Courses/DeleteCourseEndpoint.cs new file mode 100644 index 0000000..bf638a3 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Courses/DeleteCourseEndpoint.cs @@ -0,0 +1,44 @@ +using FastEndpoints; +using MetaCourse.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Courses; + +public class DeleteCourseRequest +{ + public Guid Id { get; set; } +} + +public class DeleteCourseEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Delete("api/courses/{id}"); + AllowAnonymous(); + Summary(s => s.Summary = "Supprime un cours (uniquement si brouillon sans inscriptions)"); + } + + public override async Task HandleAsync(DeleteCourseRequest req, CancellationToken ct) + { + var course = await db.Courses + .Include(c => c.UserCourses) + .FirstOrDefaultAsync(c => c.Id == req.Id, ct); + + if (course is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (course.UserCourses.Any()) + { + AddError("Impossible de supprimer un cours auquel des utilisateurs sont inscrits."); + await SendErrorsAsync(409, ct); + return; + } + + db.Courses.Remove(course); + await db.SaveChangesAsync(ct); + await SendNoContentAsync(ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Courses/GetCourseEndpoint.cs b/MetaCourse.Api/Endpoints/Courses/GetCourseEndpoint.cs new file mode 100644 index 0000000..442491c --- /dev/null +++ b/MetaCourse.Api/Endpoints/Courses/GetCourseEndpoint.cs @@ -0,0 +1,41 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Courses; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Courses; + +public class GetCourseRequest +{ + public Guid Id { get; set; } +} + +public class GetCourseEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Get("api/courses/{id}"); + AllowAnonymous(); + Summary(s => s.Summary = "Récupère le détail d'un cours avec ses sujets et ressources"); + } + + public override async Task HandleAsync(GetCourseRequest req, CancellationToken ct) + { + var course = await db.Courses + .AsNoTracking() + .Include(c => c.Creator) + .Include(c => c.Topics.OrderBy(t => t.Position)) + .ThenInclude(t => t.TopicResources.OrderBy(tr => tr.Position)) + .ThenInclude(tr => tr.Resource) + .FirstOrDefaultAsync(c => c.Id == req.Id, ct); + + if (course is null) + { + await SendNotFoundAsync(ct); + return; + } + + await SendOkAsync(mapper.Map(course), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Courses/GetCoursesEndpoint.cs b/MetaCourse.Api/Endpoints/Courses/GetCoursesEndpoint.cs new file mode 100644 index 0000000..e1f303f --- /dev/null +++ b/MetaCourse.Api/Endpoints/Courses/GetCoursesEndpoint.cs @@ -0,0 +1,47 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Courses; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Courses; + +public class GetCoursesRequest +{ + public string? Search { get; set; } +} + +public class GetCoursesEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint> +{ + public override void Configure() + { + Get("api/courses"); + AllowAnonymous(); + Summary(s => + { + s.Summary = "Liste les cours publiés"; + s.Description = "Retourne tous les cours avec le statut Publié, avec recherche optionnelle."; + }); + } + + public override async Task HandleAsync(GetCoursesRequest req, CancellationToken ct) + { + var query = db.Courses + .AsNoTracking() + .Include(c => c.Creator) + .Include(c => c.Topics) + .Where(c => c.Status == Entities.CourseStatus.Published); + + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var search = req.Search.ToLower(); + query = query.Where(c => + c.Title.ToLower().Contains(search) || + c.Description.ToLower().Contains(search) || + c.Creator.Name.ToLower().Contains(search)); + } + + var courses = await query.OrderByDescending(c => c.CreatedAt).ToListAsync(ct); + await SendOkAsync(mapper.Map>(courses), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Courses/GetUserCoursesEndpoint.cs b/MetaCourse.Api/Endpoints/Courses/GetUserCoursesEndpoint.cs new file mode 100644 index 0000000..524961a --- /dev/null +++ b/MetaCourse.Api/Endpoints/Courses/GetUserCoursesEndpoint.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Courses; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Courses; + +public class GetUserCoursesRequest +{ + public Guid UserId { get; set; } +} + +public class GetUserCoursesEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint> +{ + public override void Configure() + { + Get("api/users/{userId}/courses"); + AllowAnonymous(); + Summary(s => s.Summary = "Liste les cours créés par un utilisateur"); + } + + public override async Task HandleAsync(GetUserCoursesRequest req, CancellationToken ct) + { + var courses = await db.Courses + .AsNoTracking() + .Include(c => c.Creator) + .Include(c => c.Topics) + .Where(c => c.CreatorId == req.UserId) + .OrderByDescending(c => c.UpdatedAt) + .ToListAsync(ct); + + await SendOkAsync(mapper.Map>(courses), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Courses/PublishCourseEndpoint.cs b/MetaCourse.Api/Endpoints/Courses/PublishCourseEndpoint.cs new file mode 100644 index 0000000..7aa6a02 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Courses/PublishCourseEndpoint.cs @@ -0,0 +1,60 @@ +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Courses; +using MetaCourse.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Courses; + +public class PublishCourseRequest +{ + public Guid Id { get; set; } +} + +public class PublishCourseEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Patch("api/courses/{id}/publish"); + AllowAnonymous(); + Summary(s => s.Summary = "Publie un cours (le rend visible dans le catalogue)"); + } + + public override async Task HandleAsync(PublishCourseRequest req, CancellationToken ct) + { + var course = await db.Courses + .Include(c => c.Creator) + .Include(c => c.Topics) + .FirstOrDefaultAsync(c => c.Id == req.Id, ct); + + if (course is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!course.Topics.Any()) + { + AddError("Un cours doit avoir au moins un sujet pour être publié."); + await SendErrorsAsync(422, ct); + return; + } + + course.Status = CourseStatus.Published; + course.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + + await SendOkAsync(new GetCourseDto + { + Id = course.Id, + Title = course.Title, + Description = course.Description, + Status = course.Status.ToString(), + CreatorId = course.CreatorId, + CreatorName = course.Creator.Name, + TopicCount = course.Topics.Count, + CreatedAt = course.CreatedAt, + UpdatedAt = course.UpdatedAt + }, ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Courses/UpdateCourseEndpoint.cs b/MetaCourse.Api/Endpoints/Courses/UpdateCourseEndpoint.cs new file mode 100644 index 0000000..9a06ae7 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Courses/UpdateCourseEndpoint.cs @@ -0,0 +1,37 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Courses; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Courses; + +public class UpdateCourseEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Put("api/courses/{id}"); + AllowAnonymous(); + Summary(s => s.Summary = "Met à jour le titre et la description d'un cours"); + } + + public override async Task HandleAsync(UpdateCourseDto req, CancellationToken ct) + { + var course = await db.Courses + .Include(c => c.Creator) + .Include(c => c.Topics) + .FirstOrDefaultAsync(c => c.Id == req.Id, ct); + + if (course is null) + { + await SendNotFoundAsync(ct); + return; + } + + mapper.Map(req, course); + course.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + + await SendOkAsync(mapper.Map(course), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Progress/GetCourseProgressEndpoint.cs b/MetaCourse.Api/Endpoints/Progress/GetCourseProgressEndpoint.cs new file mode 100644 index 0000000..dcae8ce --- /dev/null +++ b/MetaCourse.Api/Endpoints/Progress/GetCourseProgressEndpoint.cs @@ -0,0 +1,66 @@ +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Progress; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Progress; + +public class GetCourseProgressRequest +{ + public Guid CourseId { get; set; } + public Guid UserId { get; set; } +} + +public class GetCourseProgressEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Get("api/courses/{courseId}/progress"); + AllowAnonymous(); + Summary(s => s.Summary = "Retourne la progression d'un utilisateur dans un cours"); + } + + public override async Task HandleAsync(GetCourseProgressRequest req, CancellationToken ct) + { + var course = await db.Courses + .AsNoTracking() + .Include(c => c.Topics) + .ThenInclude(t => t.TopicResources) + .FirstOrDefaultAsync(c => c.Id == req.CourseId, ct); + + if (course is null) + { + await SendNotFoundAsync(ct); + return; + } + + var topicIds = course.Topics.Select(t => t.Id).ToList(); + var resourceIds = course.Topics + .SelectMany(t => t.TopicResources.Select(tr => tr.ResourceId)) + .Distinct() + .ToList(); + + var completedTopics = await db.UserTopicProgresses + .CountAsync(p => p.UserId == req.UserId && topicIds.Contains(p.TopicId) && p.Completed, ct); + + var completedResources = await db.UserResourceProgresses + .CountAsync(p => p.UserId == req.UserId && resourceIds.Contains(p.ResourceId) && p.Completed, ct); + + var totalTopics = topicIds.Count; + var totalResources = resourceIds.Count; + + var percentage = totalTopics == 0 ? 0 : + Math.Round((completedTopics + completedResources) / (double)(totalTopics + totalResources) * 100, 1); + + await SendOkAsync(new CourseProgressDto + { + CourseId = req.CourseId, + UserId = req.UserId, + TotalTopics = totalTopics, + CompletedTopics = completedTopics, + TotalResources = totalResources, + CompletedResources = completedResources, + ProgressPercentage = percentage + }, ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Progress/MarkResourceProgressEndpoint.cs b/MetaCourse.Api/Endpoints/Progress/MarkResourceProgressEndpoint.cs new file mode 100644 index 0000000..a2e9945 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Progress/MarkResourceProgressEndpoint.cs @@ -0,0 +1,42 @@ +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Progress; +using MetaCourse.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Progress; + +public class MarkResourceProgressEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Post("api/resources/{resourceId}/progress"); + AllowAnonymous(); + Summary(s => s.Summary = "Marque une ressource comme terminée ou non terminée pour un utilisateur"); + } + + public override async Task HandleAsync(MarkResourceProgressDto req, CancellationToken ct) + { + var progress = await db.UserResourceProgresses + .FirstOrDefaultAsync(p => p.UserId == req.UserId && p.ResourceId == req.ResourceId, ct); + + if (progress is null) + { + db.UserResourceProgresses.Add(new UserResourceProgress + { + UserId = req.UserId, + ResourceId = req.ResourceId, + Completed = req.Completed, + CompletedAt = req.Completed ? DateTime.UtcNow : null + }); + } + else + { + progress.Completed = req.Completed; + progress.CompletedAt = req.Completed ? DateTime.UtcNow : null; + } + + await db.SaveChangesAsync(ct); + await SendNoContentAsync(ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Progress/MarkTopicProgressEndpoint.cs b/MetaCourse.Api/Endpoints/Progress/MarkTopicProgressEndpoint.cs new file mode 100644 index 0000000..a08b1e2 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Progress/MarkTopicProgressEndpoint.cs @@ -0,0 +1,42 @@ +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Progress; +using MetaCourse.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Progress; + +public class MarkTopicProgressEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Post("api/topics/{topicId}/progress"); + AllowAnonymous(); + Summary(s => s.Summary = "Marque un sujet comme terminé ou non terminé pour un utilisateur"); + } + + public override async Task HandleAsync(MarkTopicProgressDto req, CancellationToken ct) + { + var progress = await db.UserTopicProgresses + .FirstOrDefaultAsync(p => p.UserId == req.UserId && p.TopicId == req.TopicId, ct); + + if (progress is null) + { + db.UserTopicProgresses.Add(new UserTopicProgress + { + UserId = req.UserId, + TopicId = req.TopicId, + Completed = req.Completed, + CompletedAt = req.Completed ? DateTime.UtcNow : null + }); + } + else + { + progress.Completed = req.Completed; + progress.CompletedAt = req.Completed ? DateTime.UtcNow : null; + } + + await db.SaveChangesAsync(ct); + await SendNoContentAsync(ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Resources/CreateResourceEndpoint.cs b/MetaCourse.Api/Endpoints/Resources/CreateResourceEndpoint.cs new file mode 100644 index 0000000..c0d4b1e --- /dev/null +++ b/MetaCourse.Api/Endpoints/Resources/CreateResourceEndpoint.cs @@ -0,0 +1,25 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Resources; +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.Endpoints.Resources; + +public class CreateResourceEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Post("api/resources"); + AllowAnonymous(); + Summary(s => s.Summary = "Crée une nouvelle ressource dans le catalogue"); + } + + public override async Task HandleAsync(CreateResourceDto req, CancellationToken ct) + { + var resource = mapper.Map(req); + db.Resources.Add(resource); + await db.SaveChangesAsync(ct); + await SendAsync(mapper.Map(resource), 201, ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Resources/DeleteResourceEndpoint.cs b/MetaCourse.Api/Endpoints/Resources/DeleteResourceEndpoint.cs new file mode 100644 index 0000000..28cd62e --- /dev/null +++ b/MetaCourse.Api/Endpoints/Resources/DeleteResourceEndpoint.cs @@ -0,0 +1,34 @@ +using FastEndpoints; +using MetaCourse.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Resources; + +public class DeleteResourceRequest +{ + public Guid Id { get; set; } +} + +public class DeleteResourceEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Delete("api/resources/{id}"); + AllowAnonymous(); + Summary(s => s.Summary = "Supprime une ressource (et ses associations aux sujets)"); + } + + public override async Task HandleAsync(DeleteResourceRequest req, CancellationToken ct) + { + var resource = await db.Resources.FirstOrDefaultAsync(r => r.Id == req.Id, ct); + if (resource is null) + { + await SendNotFoundAsync(ct); + return; + } + + db.Resources.Remove(resource); + await db.SaveChangesAsync(ct); + await SendNoContentAsync(ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Resources/GetResourceEndpoint.cs b/MetaCourse.Api/Endpoints/Resources/GetResourceEndpoint.cs new file mode 100644 index 0000000..6c5b2d9 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Resources/GetResourceEndpoint.cs @@ -0,0 +1,33 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Resources; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Resources; + +public class GetResourceRequest +{ + public Guid Id { get; set; } +} + +public class GetResourceEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Get("api/resources/{id}"); + AllowAnonymous(); + Summary(s => s.Summary = "Récupère une ressource par son identifiant"); + } + + public override async Task HandleAsync(GetResourceRequest req, CancellationToken ct) + { + var resource = await db.Resources.AsNoTracking().FirstOrDefaultAsync(r => r.Id == req.Id, ct); + if (resource is null) + { + await SendNotFoundAsync(ct); + return; + } + await SendOkAsync(mapper.Map(resource), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Resources/GetResourcesEndpoint.cs b/MetaCourse.Api/Endpoints/Resources/GetResourcesEndpoint.cs new file mode 100644 index 0000000..eab30f5 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Resources/GetResourcesEndpoint.cs @@ -0,0 +1,27 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Resources; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Resources; + +public class GetResourcesEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("api/resources"); + AllowAnonymous(); + Summary(s => s.Summary = "Liste toutes les ressources du catalogue"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var resources = await db.Resources + .AsNoTracking() + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(ct); + + await SendOkAsync(mapper.Map>(resources), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Resources/UpdateResourceEndpoint.cs b/MetaCourse.Api/Endpoints/Resources/UpdateResourceEndpoint.cs new file mode 100644 index 0000000..8424db1 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Resources/UpdateResourceEndpoint.cs @@ -0,0 +1,31 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Resources; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Resources; + +public class UpdateResourceEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Put("api/resources/{id}"); + AllowAnonymous(); + Summary(s => s.Summary = "Met à jour une ressource"); + } + + public override async Task HandleAsync(UpdateResourceDto req, CancellationToken ct) + { + var resource = await db.Resources.FirstOrDefaultAsync(r => r.Id == req.Id, ct); + if (resource is null) + { + await SendNotFoundAsync(ct); + return; + } + + mapper.Map(req, resource); + await db.SaveChangesAsync(ct); + await SendOkAsync(mapper.Map(resource), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Topics/CreateTopicEndpoint.cs b/MetaCourse.Api/Endpoints/Topics/CreateTopicEndpoint.cs new file mode 100644 index 0000000..c01261a --- /dev/null +++ b/MetaCourse.Api/Endpoints/Topics/CreateTopicEndpoint.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Topics; +using MetaCourse.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Topics; + +public class CreateTopicEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Post("api/topics"); + AllowAnonymous(); + Summary(s => s.Summary = "Ajoute un sujet à un cours"); + } + + public override async Task HandleAsync(CreateTopicDto req, CancellationToken ct) + { + var courseExists = await db.Courses.AnyAsync(c => c.Id == req.CourseId, ct); + if (!courseExists) + { + AddError(r => r.CourseId, "Le cours n'existe pas."); + await SendErrorsAsync(404, ct); + return; + } + + var topic = mapper.Map(req); + db.Topics.Add(topic); + await db.SaveChangesAsync(ct); + + await SendAsync(mapper.Map(topic), 201, ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Topics/DeleteTopicEndpoint.cs b/MetaCourse.Api/Endpoints/Topics/DeleteTopicEndpoint.cs new file mode 100644 index 0000000..c0a8ac4 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Topics/DeleteTopicEndpoint.cs @@ -0,0 +1,34 @@ +using FastEndpoints; +using MetaCourse.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Topics; + +public class DeleteTopicRequest +{ + public Guid Id { get; set; } +} + +public class DeleteTopicEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Delete("api/topics/{id}"); + AllowAnonymous(); + Summary(s => s.Summary = "Supprime un sujet et ses associations de ressources"); + } + + public override async Task HandleAsync(DeleteTopicRequest req, CancellationToken ct) + { + var topic = await db.Topics.FirstOrDefaultAsync(t => t.Id == req.Id, ct); + if (topic is null) + { + await SendNotFoundAsync(ct); + return; + } + + db.Topics.Remove(topic); + await db.SaveChangesAsync(ct); + await SendNoContentAsync(ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Topics/GetTopicEndpoint.cs b/MetaCourse.Api/Endpoints/Topics/GetTopicEndpoint.cs new file mode 100644 index 0000000..b3b6ebb --- /dev/null +++ b/MetaCourse.Api/Endpoints/Topics/GetTopicEndpoint.cs @@ -0,0 +1,39 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Topics; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Topics; + +public class GetTopicRequest +{ + public Guid Id { get; set; } +} + +public class GetTopicEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Get("api/topics/{id}"); + AllowAnonymous(); + Summary(s => s.Summary = "Récupère un sujet avec ses ressources"); + } + + public override async Task HandleAsync(GetTopicRequest req, CancellationToken ct) + { + var topic = await db.Topics + .AsNoTracking() + .Include(t => t.TopicResources.OrderBy(tr => tr.Position)) + .ThenInclude(tr => tr.Resource) + .FirstOrDefaultAsync(t => t.Id == req.Id, ct); + + if (topic is null) + { + await SendNotFoundAsync(ct); + return; + } + + await SendOkAsync(mapper.Map(topic), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Topics/LinkResourceToTopicEndpoint.cs b/MetaCourse.Api/Endpoints/Topics/LinkResourceToTopicEndpoint.cs new file mode 100644 index 0000000..59b7982 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Topics/LinkResourceToTopicEndpoint.cs @@ -0,0 +1,61 @@ +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Topics; + +public class LinkResourceRequest +{ + public Guid TopicId { get; set; } + public Guid ResourceId { get; set; } + public int Position { get; set; } = 1; +} + +public class LinkResourceToTopicEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Post("api/topics/{topicId}/resources/{resourceId}"); + AllowAnonymous(); + Summary(s => s.Summary = "Associe une ressource à un sujet"); + } + + public override async Task HandleAsync(LinkResourceRequest req, CancellationToken ct) + { + var topicExists = await db.Topics.AnyAsync(t => t.Id == req.TopicId, ct); + if (!topicExists) + { + AddError("Le sujet n'existe pas."); + await SendErrorsAsync(404, ct); + return; + } + + var resourceExists = await db.Resources.AnyAsync(r => r.Id == req.ResourceId, ct); + if (!resourceExists) + { + AddError("La ressource n'existe pas."); + await SendErrorsAsync(404, ct); + return; + } + + var alreadyLinked = await db.TopicResources + .AnyAsync(tr => tr.TopicId == req.TopicId && tr.ResourceId == req.ResourceId, ct); + + if (alreadyLinked) + { + AddError("Cette ressource est déjà associée à ce sujet."); + await SendErrorsAsync(409, ct); + return; + } + + db.TopicResources.Add(new TopicResource + { + TopicId = req.TopicId, + ResourceId = req.ResourceId, + Position = req.Position + }); + await db.SaveChangesAsync(ct); + await SendNoContentAsync(ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Topics/UnlinkResourceFromTopicEndpoint.cs b/MetaCourse.Api/Endpoints/Topics/UnlinkResourceFromTopicEndpoint.cs new file mode 100644 index 0000000..08f2e28 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Topics/UnlinkResourceFromTopicEndpoint.cs @@ -0,0 +1,37 @@ +using FastEndpoints; +using MetaCourse.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Topics; + +public class UnlinkResourceRequest +{ + public Guid TopicId { get; set; } + public Guid ResourceId { get; set; } +} + +public class UnlinkResourceFromTopicEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Delete("api/topics/{topicId}/resources/{resourceId}"); + AllowAnonymous(); + Summary(s => s.Summary = "Dissocie une ressource d'un sujet"); + } + + public override async Task HandleAsync(UnlinkResourceRequest req, CancellationToken ct) + { + var link = await db.TopicResources + .FirstOrDefaultAsync(tr => tr.TopicId == req.TopicId && tr.ResourceId == req.ResourceId, ct); + + if (link is null) + { + await SendNotFoundAsync(ct); + return; + } + + db.TopicResources.Remove(link); + await db.SaveChangesAsync(ct); + await SendNoContentAsync(ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Topics/UpdateTopicEndpoint.cs b/MetaCourse.Api/Endpoints/Topics/UpdateTopicEndpoint.cs new file mode 100644 index 0000000..d6d5294 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Topics/UpdateTopicEndpoint.cs @@ -0,0 +1,36 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Topics; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Topics; + +public class UpdateTopicEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Put("api/topics/{id}"); + AllowAnonymous(); + Summary(s => s.Summary = "Met à jour un sujet"); + } + + public override async Task HandleAsync(UpdateTopicDto req, CancellationToken ct) + { + var topic = await db.Topics + .Include(t => t.TopicResources) + .ThenInclude(tr => tr.Resource) + .FirstOrDefaultAsync(t => t.Id == req.Id, ct); + + if (topic is null) + { + await SendNotFoundAsync(ct); + return; + } + + mapper.Map(req, topic); + await db.SaveChangesAsync(ct); + + await SendOkAsync(mapper.Map(topic), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/UserCourses/EnrollEndpoint.cs b/MetaCourse.Api/Endpoints/UserCourses/EnrollEndpoint.cs new file mode 100644 index 0000000..bba4c90 --- /dev/null +++ b/MetaCourse.Api/Endpoints/UserCourses/EnrollEndpoint.cs @@ -0,0 +1,68 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.UserCourses; +using MetaCourse.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.UserCourses; + +public class EnrollEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Post("api/courses/{courseId}/enroll"); + AllowAnonymous(); + Summary(s => s.Summary = "Inscrit un utilisateur à un cours publié"); + } + + public override async Task HandleAsync(EnrollDto req, CancellationToken ct) + { + var course = await db.Courses.FirstOrDefaultAsync(c => c.Id == req.CourseId, ct); + if (course is null) + { + AddError("Le cours n'existe pas."); + await SendErrorsAsync(404, ct); + return; + } + + if (course.Status != CourseStatus.Published) + { + AddError("Impossible de s'inscrire à un cours non publié."); + await SendErrorsAsync(422, ct); + return; + } + + var userExists = await db.Users.AnyAsync(u => u.Id == req.UserId, ct); + if (!userExists) + { + AddError("L'utilisateur n'existe pas."); + await SendErrorsAsync(404, ct); + return; + } + + var alreadyEnrolled = await db.UserCourses + .AnyAsync(uc => uc.UserId == req.UserId && uc.CourseId == req.CourseId, ct); + + if (alreadyEnrolled) + { + AddError("L'utilisateur est déjà inscrit à ce cours."); + await SendErrorsAsync(409, ct); + return; + } + + var enrollment = new UserCourse + { + UserId = req.UserId, + CourseId = req.CourseId + }; + db.UserCourses.Add(enrollment); + await db.SaveChangesAsync(ct); + + var result = await db.UserCourses + .Include(uc => uc.Course) + .FirstAsync(uc => uc.UserId == req.UserId && uc.CourseId == req.CourseId, ct); + + await SendAsync(mapper.Map(result), 201, ct); + } +} diff --git a/MetaCourse.Api/Endpoints/UserCourses/GetUserEnrollmentsEndpoint.cs b/MetaCourse.Api/Endpoints/UserCourses/GetUserEnrollmentsEndpoint.cs new file mode 100644 index 0000000..4e91be5 --- /dev/null +++ b/MetaCourse.Api/Endpoints/UserCourses/GetUserEnrollmentsEndpoint.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.UserCourses; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.UserCourses; + +public class GetUserEnrollmentsRequest +{ + public Guid UserId { get; set; } +} + +public class GetUserEnrollmentsEndpoint(AppDbContext db, AutoMapper.IMapper mapper) + : Endpoint> +{ + public override void Configure() + { + Get("api/users/{userId}/enrollments"); + AllowAnonymous(); + Summary(s => s.Summary = "Liste les cours auxquels un utilisateur est inscrit"); + } + + public override async Task HandleAsync(GetUserEnrollmentsRequest req, CancellationToken ct) + { + var enrollments = await db.UserCourses + .AsNoTracking() + .Include(uc => uc.Course) + .Where(uc => uc.UserId == req.UserId) + .OrderByDescending(uc => uc.EnrolledAt) + .ToListAsync(ct); + + await SendOkAsync(mapper.Map>(enrollments), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Users/GetUserEndpoint.cs b/MetaCourse.Api/Endpoints/Users/GetUserEndpoint.cs new file mode 100644 index 0000000..20661a1 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Users/GetUserEndpoint.cs @@ -0,0 +1,33 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Users; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Users; + +public class GetUserRequest +{ + public Guid Id { get; set; } +} + +public class GetUserEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Get("api/users/{id}"); + AllowAnonymous(); + Summary(s => s.Summary = "Récupère le profil d'un utilisateur"); + } + + public override async Task HandleAsync(GetUserRequest req, CancellationToken ct) + { + var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == req.Id, ct); + if (user is null) + { + await SendNotFoundAsync(ct); + return; + } + await SendOkAsync(mapper.Map(user), ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Users/LoginEndpoint.cs b/MetaCourse.Api/Endpoints/Users/LoginEndpoint.cs new file mode 100644 index 0000000..cc2595b --- /dev/null +++ b/MetaCourse.Api/Endpoints/Users/LoginEndpoint.cs @@ -0,0 +1,39 @@ +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Users; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Users; + +public class LoginEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Post("api/users/login"); + AllowAnonymous(); + Summary(s => + { + s.Summary = "Connexion d'un utilisateur"; + s.Description = "Authentifie l'utilisateur avec email et mot de passe."; + }); + } + + public override async Task HandleAsync(LoginUserDto req, CancellationToken ct) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.Email == req.Email, ct); + + if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) + { + AddError("Email ou mot de passe incorrect."); + await SendErrorsAsync(401, ct); + return; + } + + await SendOkAsync(new LoginResponseDto + { + UserId = user.Id, + Name = user.Name, + Email = user.Email + }, ct); + } +} diff --git a/MetaCourse.Api/Endpoints/Users/RegisterEndpoint.cs b/MetaCourse.Api/Endpoints/Users/RegisterEndpoint.cs new file mode 100644 index 0000000..2609169 --- /dev/null +++ b/MetaCourse.Api/Endpoints/Users/RegisterEndpoint.cs @@ -0,0 +1,41 @@ +using AutoMapper; +using FastEndpoints; +using MetaCourse.Api.Data; +using MetaCourse.Api.DTOs.Users; +using MetaCourse.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MetaCourse.Api.Endpoints.Users; + +public class RegisterEndpoint(AppDbContext db, AutoMapper.IMapper mapper) : Endpoint +{ + public override void Configure() + { + Post("api/users/register"); + AllowAnonymous(); + Summary(s => + { + s.Summary = "Inscription d'un nouvel utilisateur"; + s.Description = "Crée un compte utilisateur avec email unique et mot de passe haché."; + }); + } + + public override async Task HandleAsync(RegisterUserDto req, CancellationToken ct) + { + var emailExists = await db.Users.AnyAsync(u => u.Email == req.Email, ct); + if (emailExists) + { + AddError(r => r.Email, "Cet email est déjà utilisé."); + await SendErrorsAsync(409, ct); + return; + } + + var user = mapper.Map(req); + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password); + + db.Users.Add(user); + await db.SaveChangesAsync(ct); + + await SendAsync(mapper.Map(user), 201, ct); + } +} diff --git a/MetaCourse.Api/Entities/Course.cs b/MetaCourse.Api/Entities/Course.cs new file mode 100644 index 0000000..c2381d0 --- /dev/null +++ b/MetaCourse.Api/Entities/Course.cs @@ -0,0 +1,19 @@ +namespace MetaCourse.Api.Entities; + +public enum CourseStatus { Draft, Published } + +public class Course +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public CourseStatus Status { get; set; } = CourseStatus.Draft; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public Guid CreatorId { get; set; } + public User Creator { get; set; } = null!; + + public ICollection Topics { get; set; } = new List(); + public ICollection UserCourses { get; set; } = new List(); +} diff --git a/MetaCourse.Api/Entities/Resource.cs b/MetaCourse.Api/Entities/Resource.cs new file mode 100644 index 0000000..8b423a2 --- /dev/null +++ b/MetaCourse.Api/Entities/Resource.cs @@ -0,0 +1,15 @@ +namespace MetaCourse.Api.Entities; + +public enum ResourceType { Url, Video, Text, File } + +public class Resource +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public ResourceType Type { get; set; } + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public ICollection TopicResources { get; set; } = new List(); + public ICollection UserProgresses { get; set; } = new List(); +} diff --git a/MetaCourse.Api/Entities/Topic.cs b/MetaCourse.Api/Entities/Topic.cs new file mode 100644 index 0000000..d6ee930 --- /dev/null +++ b/MetaCourse.Api/Entities/Topic.cs @@ -0,0 +1,15 @@ +namespace MetaCourse.Api.Entities; + +public class Topic +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public int Position { get; set; } + + public Guid CourseId { get; set; } + public Course Course { get; set; } = null!; + + public ICollection TopicResources { get; set; } = new List(); + public ICollection UserProgresses { get; set; } = new List(); +} diff --git a/MetaCourse.Api/Entities/TopicResource.cs b/MetaCourse.Api/Entities/TopicResource.cs new file mode 100644 index 0000000..205d686 --- /dev/null +++ b/MetaCourse.Api/Entities/TopicResource.cs @@ -0,0 +1,12 @@ +namespace MetaCourse.Api.Entities; + +public class TopicResource +{ + public Guid TopicId { get; set; } + public Topic Topic { get; set; } = null!; + + public Guid ResourceId { get; set; } + public Resource Resource { get; set; } = null!; + + public int Position { get; set; } +} diff --git a/MetaCourse.Api/Entities/User.cs b/MetaCourse.Api/Entities/User.cs new file mode 100644 index 0000000..65fc9a2 --- /dev/null +++ b/MetaCourse.Api/Entities/User.cs @@ -0,0 +1,15 @@ +namespace MetaCourse.Api.Entities; + +public class User +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public ICollection CreatedCourses { get; set; } = new List(); + public ICollection UserCourses { get; set; } = new List(); + public ICollection TopicProgresses { get; set; } = new List(); + public ICollection ResourceProgresses { get; set; } = new List(); +} diff --git a/MetaCourse.Api/Entities/UserCourse.cs b/MetaCourse.Api/Entities/UserCourse.cs new file mode 100644 index 0000000..a40ac6b --- /dev/null +++ b/MetaCourse.Api/Entities/UserCourse.cs @@ -0,0 +1,13 @@ +namespace MetaCourse.Api.Entities; + +public class UserCourse +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + + public Guid CourseId { get; set; } + public Course Course { get; set; } = null!; + + public DateTime EnrolledAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } +} diff --git a/MetaCourse.Api/Entities/UserResourceProgress.cs b/MetaCourse.Api/Entities/UserResourceProgress.cs new file mode 100644 index 0000000..fe184db --- /dev/null +++ b/MetaCourse.Api/Entities/UserResourceProgress.cs @@ -0,0 +1,13 @@ +namespace MetaCourse.Api.Entities; + +public class UserResourceProgress +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + + public Guid ResourceId { get; set; } + public Resource Resource { get; set; } = null!; + + public bool Completed { get; set; } + public DateTime? CompletedAt { get; set; } +} diff --git a/MetaCourse.Api/Entities/UserTopicProgress.cs b/MetaCourse.Api/Entities/UserTopicProgress.cs new file mode 100644 index 0000000..117a402 --- /dev/null +++ b/MetaCourse.Api/Entities/UserTopicProgress.cs @@ -0,0 +1,13 @@ +namespace MetaCourse.Api.Entities; + +public class UserTopicProgress +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + + public Guid TopicId { get; set; } + public Topic Topic { get; set; } = null!; + + public bool Completed { get; set; } + public DateTime? CompletedAt { get; set; } +} diff --git a/MetaCourse.Api/Mappings/CourseProfile.cs b/MetaCourse.Api/Mappings/CourseProfile.cs new file mode 100644 index 0000000..23d6e2c --- /dev/null +++ b/MetaCourse.Api/Mappings/CourseProfile.cs @@ -0,0 +1,33 @@ +using AutoMapper; +using MetaCourse.Api.DTOs.Courses; +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.Mappings; + +public class CourseProfile : Profile +{ + public CourseProfile() + { + CreateMap() + .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status.ToString())) + .ForMember(dest => dest.CreatorName, opt => opt.MapFrom(src => src.Creator.Name)) + .ForMember(dest => dest.TopicCount, opt => opt.MapFrom(src => src.Topics.Count)); + + CreateMap() + .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status.ToString())) + .ForMember(dest => dest.CreatorName, opt => opt.MapFrom(src => src.Creator.Name)); + + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.Status, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()); + + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.Status, opt => opt.Ignore()) + .ForMember(dest => dest.CreatorId, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()); + } +} diff --git a/MetaCourse.Api/Mappings/ResourceProfile.cs b/MetaCourse.Api/Mappings/ResourceProfile.cs new file mode 100644 index 0000000..eb46c1c --- /dev/null +++ b/MetaCourse.Api/Mappings/ResourceProfile.cs @@ -0,0 +1,22 @@ +using AutoMapper; +using MetaCourse.Api.DTOs.Resources; +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.Mappings; + +public class ResourceProfile : Profile +{ + public ResourceProfile() + { + CreateMap() + .ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type.ToString())); + + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()); + + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()); + } +} diff --git a/MetaCourse.Api/Mappings/TopicProfile.cs b/MetaCourse.Api/Mappings/TopicProfile.cs new file mode 100644 index 0000000..7397d13 --- /dev/null +++ b/MetaCourse.Api/Mappings/TopicProfile.cs @@ -0,0 +1,25 @@ +using AutoMapper; +using MetaCourse.Api.DTOs.Topics; +using MetaCourse.Api.DTOs.Resources; +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.Mappings; + +public class TopicProfile : Profile +{ + public TopicProfile() + { + CreateMap() + .ForMember(dest => dest.Resources, opt => opt.MapFrom( + src => src.TopicResources + .OrderBy(tr => tr.Position) + .Select(tr => tr.Resource))); + + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()); + + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.CourseId, opt => opt.Ignore()); + } +} diff --git a/MetaCourse.Api/Mappings/UserCourseProfile.cs b/MetaCourse.Api/Mappings/UserCourseProfile.cs new file mode 100644 index 0000000..dc974cd --- /dev/null +++ b/MetaCourse.Api/Mappings/UserCourseProfile.cs @@ -0,0 +1,14 @@ +using AutoMapper; +using MetaCourse.Api.DTOs.UserCourses; +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.Mappings; + +public class UserCourseProfile : Profile +{ + public UserCourseProfile() + { + CreateMap() + .ForMember(dest => dest.CourseTitle, opt => opt.MapFrom(src => src.Course.Title)); + } +} diff --git a/MetaCourse.Api/Mappings/UserProfile.cs b/MetaCourse.Api/Mappings/UserProfile.cs new file mode 100644 index 0000000..69dc4c7 --- /dev/null +++ b/MetaCourse.Api/Mappings/UserProfile.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using MetaCourse.Api.DTOs.Users; +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.Mappings; + +public class UserProfile : Profile +{ + public UserProfile() + { + CreateMap(); + CreateMap() + .ForMember(dest => dest.PasswordHash, opt => opt.Ignore()) + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()); + } +} diff --git a/MetaCourse.Api/MetaCourse.Api.csproj b/MetaCourse.Api/MetaCourse.Api.csproj new file mode 100644 index 0000000..f8f50f9 --- /dev/null +++ b/MetaCourse.Api/MetaCourse.Api.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/MetaCourse.Api/MetaCourse.Api.http b/MetaCourse.Api/MetaCourse.Api.http new file mode 100644 index 0000000..8c108ed --- /dev/null +++ b/MetaCourse.Api/MetaCourse.Api.http @@ -0,0 +1,6 @@ +@MetaCourse.Api_HostAddress = http://localhost:5201 + +GET {{MetaCourse.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/MetaCourse.Api/Migrations/20260326141046_InitialCreate.Designer.cs b/MetaCourse.Api/Migrations/20260326141046_InitialCreate.Designer.cs new file mode 100644 index 0000000..a98eda3 --- /dev/null +++ b/MetaCourse.Api/Migrations/20260326141046_InitialCreate.Designer.cs @@ -0,0 +1,476 @@ +// +using System; +using MetaCourse.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MetaCourse.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260326141046_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MetaCourse.Api.Entities.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Courses"); + + b.HasData( + new + { + Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + CreatorId = new Guid("11111111-1111-1111-1111-111111111111"), + Description = "Maîtrisez React, Tailwind CSS et TypeScript pour créer des applications web performantes.", + Status = "Published", + Title = "Développement Web Moderne avec React", + UpdatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + CreatorId = new Guid("22222222-2222-2222-2222-222222222222"), + Description = "Apprenez à construire des APIs REST robustes avec ASP.NET Core et FastEndpoints.", + Status = "Draft", + Title = "API REST avec .NET et FastEndpoints", + UpdatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Resource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Resources"); + + b.HasData( + new + { + Id = new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff"), + Content = "https://youtube.com/watch?v=example1", + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Title = "React en 30 minutes", + Type = "Video" + }, + new + { + Id = new Guid("00000000-0000-0000-aaaa-000000000001"), + Content = "Les hooks permettent d'utiliser le state et d'autres fonctionnalités React dans des composants fonctionnels.", + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Title = "Guide des Hooks", + Type = "Text" + }, + new + { + Id = new Guid("00000000-0000-0000-aaaa-000000000002"), + Content = "https://react.dev", + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Title = "Documentation officielle React", + Type = "Url" + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Topic", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CourseId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Position") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("Topics"); + + b.HasData( + new + { + Id = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), + CourseId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + Description = "Les bases de React : composants, JSX, props.", + Position = 1, + Title = "Introduction à React" + }, + new + { + Id = new Guid("dddddddd-dddd-dddd-dddd-dddddddddddd"), + CourseId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + Description = "useState, useEffect et hooks personnalisés.", + Position = 2, + Title = "Hooks et State" + }, + new + { + Id = new Guid("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), + CourseId = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + Description = "Principes REST et verbes HTTP.", + Position = 1, + Title = "Fondamentaux REST" + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.TopicResource", b => + { + b.Property("TopicId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("Position") + .HasColumnType("int"); + + b.HasKey("TopicId", "ResourceId"); + + b.HasIndex("ResourceId"); + + b.ToTable("TopicResources"); + + b.HasData( + new + { + TopicId = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), + ResourceId = new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff"), + Position = 1 + }, + new + { + TopicId = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), + ResourceId = new Guid("00000000-0000-0000-aaaa-000000000002"), + Position = 2 + }, + new + { + TopicId = new Guid("dddddddd-dddd-dddd-dddd-dddddddddddd"), + ResourceId = new Guid("00000000-0000-0000-aaaa-000000000001"), + Position = 1 + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Email = "alice@metacourse.io", + Name = "Alice Dupont", + PasswordHash = "$2a$11$iVlx6m6E1Nlwe5EuA1m4XeJoWc1P2fgS5i1iwEVo2xYbRNt9gSdFe" + }, + new + { + Id = new Guid("22222222-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Email = "bob@metacourse.io", + Name = "Bob Martin", + PasswordHash = "$2a$11$LIWuKuO3ful3H1AjoRI1n.kmjW9n05alzfOyMCI0iIIO28q5cSOKu" + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserCourse", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("CourseId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("EnrolledAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "CourseId"); + + b.HasIndex("CourseId"); + + b.ToTable("UserCourses"); + + b.HasData( + new + { + UserId = new Guid("22222222-2222-2222-2222-222222222222"), + CourseId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + EnrolledAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserResourceProgress", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("Completed") + .HasColumnType("bit"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "ResourceId"); + + b.HasIndex("ResourceId"); + + b.ToTable("UserResourceProgresses"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserTopicProgress", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier"); + + b.Property("Completed") + .HasColumnType("bit"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "TopicId"); + + b.HasIndex("TopicId"); + + b.ToTable("UserTopicProgresses"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Course", b => + { + b.HasOne("MetaCourse.Api.Entities.User", "Creator") + .WithMany("CreatedCourses") + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Topic", b => + { + b.HasOne("MetaCourse.Api.Entities.Course", "Course") + .WithMany("Topics") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.TopicResource", b => + { + b.HasOne("MetaCourse.Api.Entities.Resource", "Resource") + .WithMany("TopicResources") + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MetaCourse.Api.Entities.Topic", "Topic") + .WithMany("TopicResources") + .HasForeignKey("TopicId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resource"); + + b.Navigation("Topic"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserCourse", b => + { + b.HasOne("MetaCourse.Api.Entities.Course", "Course") + .WithMany("UserCourses") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MetaCourse.Api.Entities.User", "User") + .WithMany("UserCourses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserResourceProgress", b => + { + b.HasOne("MetaCourse.Api.Entities.Resource", "Resource") + .WithMany("UserProgresses") + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MetaCourse.Api.Entities.User", "User") + .WithMany("ResourceProgresses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resource"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserTopicProgress", b => + { + b.HasOne("MetaCourse.Api.Entities.Topic", "Topic") + .WithMany("UserProgresses") + .HasForeignKey("TopicId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MetaCourse.Api.Entities.User", "User") + .WithMany("TopicProgresses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Topic"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Course", b => + { + b.Navigation("Topics"); + + b.Navigation("UserCourses"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Resource", b => + { + b.Navigation("TopicResources"); + + b.Navigation("UserProgresses"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Topic", b => + { + b.Navigation("TopicResources"); + + b.Navigation("UserProgresses"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.User", b => + { + b.Navigation("CreatedCourses"); + + b.Navigation("ResourceProgresses"); + + b.Navigation("TopicProgresses"); + + b.Navigation("UserCourses"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MetaCourse.Api/Migrations/20260326141046_InitialCreate.cs b/MetaCourse.Api/Migrations/20260326141046_InitialCreate.cs new file mode 100644 index 0000000..6589d71 --- /dev/null +++ b/MetaCourse.Api/Migrations/20260326141046_InitialCreate.cs @@ -0,0 +1,311 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace MetaCourse.Api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Resources", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Type = table.Column(type: "nvarchar(max)", nullable: false), + Title = table.Column(type: "nvarchar(max)", nullable: false), + Content = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Resources", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: false), + Email = table.Column(type: "nvarchar(450)", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Courses", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Title = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: false), + Status = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false), + CreatorId = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Courses", x => x.Id); + table.ForeignKey( + name: "FK_Courses_Users_CreatorId", + column: x => x.CreatorId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "UserResourceProgresses", + columns: table => new + { + UserId = table.Column(type: "uniqueidentifier", nullable: false), + ResourceId = table.Column(type: "uniqueidentifier", nullable: false), + Completed = table.Column(type: "bit", nullable: false), + CompletedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserResourceProgresses", x => new { x.UserId, x.ResourceId }); + table.ForeignKey( + name: "FK_UserResourceProgresses_Resources_ResourceId", + column: x => x.ResourceId, + principalTable: "Resources", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserResourceProgresses_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Topics", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Title = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + Position = table.Column(type: "int", nullable: false), + CourseId = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Topics", x => x.Id); + table.ForeignKey( + name: "FK_Topics_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserCourses", + columns: table => new + { + UserId = table.Column(type: "uniqueidentifier", nullable: false), + CourseId = table.Column(type: "uniqueidentifier", nullable: false), + EnrolledAt = table.Column(type: "datetime2", nullable: false), + CompletedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserCourses", x => new { x.UserId, x.CourseId }); + table.ForeignKey( + name: "FK_UserCourses_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserCourses_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TopicResources", + columns: table => new + { + TopicId = table.Column(type: "uniqueidentifier", nullable: false), + ResourceId = table.Column(type: "uniqueidentifier", nullable: false), + Position = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TopicResources", x => new { x.TopicId, x.ResourceId }); + table.ForeignKey( + name: "FK_TopicResources_Resources_ResourceId", + column: x => x.ResourceId, + principalTable: "Resources", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TopicResources_Topics_TopicId", + column: x => x.TopicId, + principalTable: "Topics", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserTopicProgresses", + columns: table => new + { + UserId = table.Column(type: "uniqueidentifier", nullable: false), + TopicId = table.Column(type: "uniqueidentifier", nullable: false), + Completed = table.Column(type: "bit", nullable: false), + CompletedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserTopicProgresses", x => new { x.UserId, x.TopicId }); + table.ForeignKey( + name: "FK_UserTopicProgresses_Topics_TopicId", + column: x => x.TopicId, + principalTable: "Topics", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserTopicProgresses_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "Resources", + columns: new[] { "Id", "Content", "CreatedAt", "Title", "Type" }, + values: new object[,] + { + { new Guid("00000000-0000-0000-aaaa-000000000001"), "Les hooks permettent d'utiliser le state et d'autres fonctionnalités React dans des composants fonctionnels.", new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "Guide des Hooks", "Text" }, + { new Guid("00000000-0000-0000-aaaa-000000000002"), "https://react.dev", new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "Documentation officielle React", "Url" }, + { new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff"), "https://youtube.com/watch?v=example1", new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "React en 30 minutes", "Video" } + }); + + migrationBuilder.InsertData( + table: "Users", + columns: new[] { "Id", "CreatedAt", "Email", "Name", "PasswordHash" }, + values: new object[,] + { + { new Guid("11111111-1111-1111-1111-111111111111"), new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "alice@metacourse.io", "Alice Dupont", "$2a$11$iVlx6m6E1Nlwe5EuA1m4XeJoWc1P2fgS5i1iwEVo2xYbRNt9gSdFe" }, + { new Guid("22222222-2222-2222-2222-222222222222"), new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "bob@metacourse.io", "Bob Martin", "$2a$11$LIWuKuO3ful3H1AjoRI1n.kmjW9n05alzfOyMCI0iIIO28q5cSOKu" } + }); + + migrationBuilder.InsertData( + table: "Courses", + columns: new[] { "Id", "CreatedAt", "CreatorId", "Description", "Status", "Title", "UpdatedAt" }, + values: new object[,] + { + { new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), new Guid("11111111-1111-1111-1111-111111111111"), "Maîtrisez React, Tailwind CSS et TypeScript pour créer des applications web performantes.", "Published", "Développement Web Moderne avec React", new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) }, + { new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), new Guid("22222222-2222-2222-2222-222222222222"), "Apprenez à construire des APIs REST robustes avec ASP.NET Core et FastEndpoints.", "Draft", "API REST avec .NET et FastEndpoints", new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) } + }); + + migrationBuilder.InsertData( + table: "Topics", + columns: new[] { "Id", "CourseId", "Description", "Position", "Title" }, + values: new object[,] + { + { new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), "Les bases de React : composants, JSX, props.", 1, "Introduction à React" }, + { new Guid("dddddddd-dddd-dddd-dddd-dddddddddddd"), new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), "useState, useEffect et hooks personnalisés.", 2, "Hooks et State" }, + { new Guid("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), "Principes REST et verbes HTTP.", 1, "Fondamentaux REST" } + }); + + migrationBuilder.InsertData( + table: "UserCourses", + columns: new[] { "CourseId", "UserId", "CompletedAt", "EnrolledAt" }, + values: new object[] { new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), new Guid("22222222-2222-2222-2222-222222222222"), null, new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) }); + + migrationBuilder.InsertData( + table: "TopicResources", + columns: new[] { "ResourceId", "TopicId", "Position" }, + values: new object[,] + { + { new Guid("00000000-0000-0000-aaaa-000000000002"), new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), 2 }, + { new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff"), new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), 1 }, + { new Guid("00000000-0000-0000-aaaa-000000000001"), new Guid("dddddddd-dddd-dddd-dddd-dddddddddddd"), 1 } + }); + + migrationBuilder.CreateIndex( + name: "IX_Courses_CreatorId", + table: "Courses", + column: "CreatorId"); + + migrationBuilder.CreateIndex( + name: "IX_TopicResources_ResourceId", + table: "TopicResources", + column: "ResourceId"); + + migrationBuilder.CreateIndex( + name: "IX_Topics_CourseId", + table: "Topics", + column: "CourseId"); + + migrationBuilder.CreateIndex( + name: "IX_UserCourses_CourseId", + table: "UserCourses", + column: "CourseId"); + + migrationBuilder.CreateIndex( + name: "IX_UserResourceProgresses_ResourceId", + table: "UserResourceProgresses", + column: "ResourceId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserTopicProgresses_TopicId", + table: "UserTopicProgresses", + column: "TopicId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TopicResources"); + + migrationBuilder.DropTable( + name: "UserCourses"); + + migrationBuilder.DropTable( + name: "UserResourceProgresses"); + + migrationBuilder.DropTable( + name: "UserTopicProgresses"); + + migrationBuilder.DropTable( + name: "Resources"); + + migrationBuilder.DropTable( + name: "Topics"); + + migrationBuilder.DropTable( + name: "Courses"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/MetaCourse.Api/Migrations/AppDbContextModelSnapshot.cs b/MetaCourse.Api/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..94f1a72 --- /dev/null +++ b/MetaCourse.Api/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,473 @@ +// +using System; +using MetaCourse.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MetaCourse.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MetaCourse.Api.Entities.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Courses"); + + b.HasData( + new + { + Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + CreatorId = new Guid("11111111-1111-1111-1111-111111111111"), + Description = "Maîtrisez React, Tailwind CSS et TypeScript pour créer des applications web performantes.", + Status = "Published", + Title = "Développement Web Moderne avec React", + UpdatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + CreatorId = new Guid("22222222-2222-2222-2222-222222222222"), + Description = "Apprenez à construire des APIs REST robustes avec ASP.NET Core et FastEndpoints.", + Status = "Draft", + Title = "API REST avec .NET et FastEndpoints", + UpdatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Resource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Resources"); + + b.HasData( + new + { + Id = new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff"), + Content = "https://youtube.com/watch?v=example1", + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Title = "React en 30 minutes", + Type = "Video" + }, + new + { + Id = new Guid("00000000-0000-0000-aaaa-000000000001"), + Content = "Les hooks permettent d'utiliser le state et d'autres fonctionnalités React dans des composants fonctionnels.", + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Title = "Guide des Hooks", + Type = "Text" + }, + new + { + Id = new Guid("00000000-0000-0000-aaaa-000000000002"), + Content = "https://react.dev", + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Title = "Documentation officielle React", + Type = "Url" + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Topic", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CourseId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Position") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("Topics"); + + b.HasData( + new + { + Id = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), + CourseId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + Description = "Les bases de React : composants, JSX, props.", + Position = 1, + Title = "Introduction à React" + }, + new + { + Id = new Guid("dddddddd-dddd-dddd-dddd-dddddddddddd"), + CourseId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + Description = "useState, useEffect et hooks personnalisés.", + Position = 2, + Title = "Hooks et State" + }, + new + { + Id = new Guid("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), + CourseId = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + Description = "Principes REST et verbes HTTP.", + Position = 1, + Title = "Fondamentaux REST" + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.TopicResource", b => + { + b.Property("TopicId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("Position") + .HasColumnType("int"); + + b.HasKey("TopicId", "ResourceId"); + + b.HasIndex("ResourceId"); + + b.ToTable("TopicResources"); + + b.HasData( + new + { + TopicId = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), + ResourceId = new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff"), + Position = 1 + }, + new + { + TopicId = new Guid("cccccccc-cccc-cccc-cccc-cccccccccccc"), + ResourceId = new Guid("00000000-0000-0000-aaaa-000000000002"), + Position = 2 + }, + new + { + TopicId = new Guid("dddddddd-dddd-dddd-dddd-dddddddddddd"), + ResourceId = new Guid("00000000-0000-0000-aaaa-000000000001"), + Position = 1 + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Email = "alice@metacourse.io", + Name = "Alice Dupont", + PasswordHash = "$2a$11$iVlx6m6E1Nlwe5EuA1m4XeJoWc1P2fgS5i1iwEVo2xYbRNt9gSdFe" + }, + new + { + Id = new Guid("22222222-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Email = "bob@metacourse.io", + Name = "Bob Martin", + PasswordHash = "$2a$11$LIWuKuO3ful3H1AjoRI1n.kmjW9n05alzfOyMCI0iIIO28q5cSOKu" + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserCourse", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("CourseId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("EnrolledAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "CourseId"); + + b.HasIndex("CourseId"); + + b.ToTable("UserCourses"); + + b.HasData( + new + { + UserId = new Guid("22222222-2222-2222-2222-222222222222"), + CourseId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + EnrolledAt = new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserResourceProgress", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("Completed") + .HasColumnType("bit"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "ResourceId"); + + b.HasIndex("ResourceId"); + + b.ToTable("UserResourceProgresses"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserTopicProgress", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("TopicId") + .HasColumnType("uniqueidentifier"); + + b.Property("Completed") + .HasColumnType("bit"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "TopicId"); + + b.HasIndex("TopicId"); + + b.ToTable("UserTopicProgresses"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Course", b => + { + b.HasOne("MetaCourse.Api.Entities.User", "Creator") + .WithMany("CreatedCourses") + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Topic", b => + { + b.HasOne("MetaCourse.Api.Entities.Course", "Course") + .WithMany("Topics") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.TopicResource", b => + { + b.HasOne("MetaCourse.Api.Entities.Resource", "Resource") + .WithMany("TopicResources") + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MetaCourse.Api.Entities.Topic", "Topic") + .WithMany("TopicResources") + .HasForeignKey("TopicId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resource"); + + b.Navigation("Topic"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserCourse", b => + { + b.HasOne("MetaCourse.Api.Entities.Course", "Course") + .WithMany("UserCourses") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MetaCourse.Api.Entities.User", "User") + .WithMany("UserCourses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserResourceProgress", b => + { + b.HasOne("MetaCourse.Api.Entities.Resource", "Resource") + .WithMany("UserProgresses") + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MetaCourse.Api.Entities.User", "User") + .WithMany("ResourceProgresses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resource"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.UserTopicProgress", b => + { + b.HasOne("MetaCourse.Api.Entities.Topic", "Topic") + .WithMany("UserProgresses") + .HasForeignKey("TopicId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MetaCourse.Api.Entities.User", "User") + .WithMany("TopicProgresses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Topic"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Course", b => + { + b.Navigation("Topics"); + + b.Navigation("UserCourses"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Resource", b => + { + b.Navigation("TopicResources"); + + b.Navigation("UserProgresses"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.Topic", b => + { + b.Navigation("TopicResources"); + + b.Navigation("UserProgresses"); + }); + + modelBuilder.Entity("MetaCourse.Api.Entities.User", b => + { + b.Navigation("CreatedCourses"); + + b.Navigation("ResourceProgresses"); + + b.Navigation("TopicProgresses"); + + b.Navigation("UserCourses"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MetaCourse.Api/Program.cs b/MetaCourse.Api/Program.cs new file mode 100644 index 0000000..90027e1 --- /dev/null +++ b/MetaCourse.Api/Program.cs @@ -0,0 +1,60 @@ +using FastEndpoints; +using FastEndpoints.Swagger; +using MetaCourse.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +var builder = WebApplication.CreateBuilder(args); + +// Database +builder.Services.AddDbContext(options => + options.UseSqlServer("Server=romaric-thibault.fr;Database=arsene_MetaCourseV2;User Id=arsene;Password=Onto9-Cage-Afflicted;TrustServerCertificate=true;") + .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))); + +// AutoMapper +builder.Services.AddAutoMapper(typeof(Program).Assembly); + +// FastEndpoints + Swagger +builder.Services.AddFastEndpoints() + .SwaggerDocument(o => + { + o.DocumentSettings = s => + { + s.Title = "MetaCourse API"; + s.Version = "v1"; + s.Description = "API REST pour la plateforme MetaCourse – gestion de cours, sujets, ressources et progression."; + }; + }); + +// CORS (pour les clients front-end) +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); +}); + +var app = builder.Build(); + +app.UseCors("AllowAll"); + +app.UseFastEndpoints(c => +{ + c.Errors.ResponseBuilder = (failures, ctx, statusCode) => + { + return new + { + StatusCode = statusCode, + Errors = failures.Select(f => new { f.PropertyName, f.ErrorMessage }) + }; + }; +}); + +app.UseSwaggerGen(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); +} + +app.Run(); diff --git a/MetaCourse.Api/Properties/launchSettings.json b/MetaCourse.Api/Properties/launchSettings.json new file mode 100644 index 0000000..e125d69 --- /dev/null +++ b/MetaCourse.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5201", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7048;http://localhost:5201", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/MetaCourse.Api/Validators/Courses/CreateCourseDtoValidator.cs b/MetaCourse.Api/Validators/Courses/CreateCourseDtoValidator.cs new file mode 100644 index 0000000..8e75fe6 --- /dev/null +++ b/MetaCourse.Api/Validators/Courses/CreateCourseDtoValidator.cs @@ -0,0 +1,23 @@ +using FastEndpoints; +using FluentValidation; +using MetaCourse.Api.DTOs.Courses; + +namespace MetaCourse.Api.Validators.Courses; + +public class CreateCourseDtoValidator : Validator +{ + public CreateCourseDtoValidator() + { + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Le titre est requis.") + .MinimumLength(3).WithMessage("Le titre doit contenir au moins 3 caractères.") + .MaximumLength(200).WithMessage("Le titre ne peut pas dépasser 200 caractères."); + + RuleFor(x => x.Description) + .NotEmpty().WithMessage("La description est requise.") + .MaximumLength(2000).WithMessage("La description ne peut pas dépasser 2000 caractères."); + + RuleFor(x => x.CreatorId) + .NotEqual(Guid.Empty).WithMessage("L'identifiant du créateur est invalide."); + } +} diff --git a/MetaCourse.Api/Validators/Courses/UpdateCourseDtoValidator.cs b/MetaCourse.Api/Validators/Courses/UpdateCourseDtoValidator.cs new file mode 100644 index 0000000..cf3e1a0 --- /dev/null +++ b/MetaCourse.Api/Validators/Courses/UpdateCourseDtoValidator.cs @@ -0,0 +1,23 @@ +using FastEndpoints; +using FluentValidation; +using MetaCourse.Api.DTOs.Courses; + +namespace MetaCourse.Api.Validators.Courses; + +public class UpdateCourseDtoValidator : Validator +{ + public UpdateCourseDtoValidator() + { + RuleFor(x => x.Id) + .NotEqual(Guid.Empty).WithMessage("L'identifiant est invalide."); + + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Le titre est requis.") + .MinimumLength(3).WithMessage("Le titre doit contenir au moins 3 caractères.") + .MaximumLength(200).WithMessage("Le titre ne peut pas dépasser 200 caractères."); + + RuleFor(x => x.Description) + .NotEmpty().WithMessage("La description est requise.") + .MaximumLength(2000).WithMessage("La description ne peut pas dépasser 2000 caractères."); + } +} diff --git a/MetaCourse.Api/Validators/Resources/CreateResourceDtoValidator.cs b/MetaCourse.Api/Validators/Resources/CreateResourceDtoValidator.cs new file mode 100644 index 0000000..9a39cdb --- /dev/null +++ b/MetaCourse.Api/Validators/Resources/CreateResourceDtoValidator.cs @@ -0,0 +1,30 @@ +using FastEndpoints; +using FluentValidation; +using MetaCourse.Api.DTOs.Resources; +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.Validators.Resources; + +public class CreateResourceDtoValidator : Validator +{ + public CreateResourceDtoValidator() + { + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Le titre est requis.") + .MinimumLength(2).WithMessage("Le titre doit contenir au moins 2 caractères.") + .MaximumLength(200).WithMessage("Le titre ne peut pas dépasser 200 caractères."); + + RuleFor(x => x.Content) + .NotEmpty().WithMessage("Le contenu est requis."); + + RuleFor(x => x.Type) + .IsInEnum().WithMessage("Le type de ressource est invalide."); + + When(x => x.Type == ResourceType.Url || x.Type == ResourceType.Video, () => + { + RuleFor(x => x.Content) + .Must(c => Uri.TryCreate(c, UriKind.Absolute, out _)) + .WithMessage("Le contenu doit être une URL valide pour ce type de ressource."); + }); + } +} diff --git a/MetaCourse.Api/Validators/Resources/UpdateResourceDtoValidator.cs b/MetaCourse.Api/Validators/Resources/UpdateResourceDtoValidator.cs new file mode 100644 index 0000000..273195c --- /dev/null +++ b/MetaCourse.Api/Validators/Resources/UpdateResourceDtoValidator.cs @@ -0,0 +1,33 @@ +using FastEndpoints; +using FluentValidation; +using MetaCourse.Api.DTOs.Resources; +using MetaCourse.Api.Entities; + +namespace MetaCourse.Api.Validators.Resources; + +public class UpdateResourceDtoValidator : Validator +{ + public UpdateResourceDtoValidator() + { + RuleFor(x => x.Id) + .NotEqual(Guid.Empty).WithMessage("L'identifiant est invalide."); + + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Le titre est requis.") + .MinimumLength(2).WithMessage("Le titre doit contenir au moins 2 caractères.") + .MaximumLength(200).WithMessage("Le titre ne peut pas dépasser 200 caractères."); + + RuleFor(x => x.Content) + .NotEmpty().WithMessage("Le contenu est requis."); + + RuleFor(x => x.Type) + .IsInEnum().WithMessage("Le type de ressource est invalide."); + + When(x => x.Type == ResourceType.Url || x.Type == ResourceType.Video, () => + { + RuleFor(x => x.Content) + .Must(c => Uri.TryCreate(c, UriKind.Absolute, out _)) + .WithMessage("Le contenu doit être une URL valide pour ce type de ressource."); + }); + } +} diff --git a/MetaCourse.Api/Validators/Topics/CreateTopicDtoValidator.cs b/MetaCourse.Api/Validators/Topics/CreateTopicDtoValidator.cs new file mode 100644 index 0000000..3157ffd --- /dev/null +++ b/MetaCourse.Api/Validators/Topics/CreateTopicDtoValidator.cs @@ -0,0 +1,28 @@ +using FastEndpoints; +using FluentValidation; +using MetaCourse.Api.DTOs.Topics; + +namespace MetaCourse.Api.Validators.Topics; + +public class CreateTopicDtoValidator : Validator +{ + public CreateTopicDtoValidator() + { + RuleFor(x => x.CourseId) + .NotEqual(Guid.Empty).WithMessage("L'identifiant du cours est invalide."); + + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Le titre est requis.") + .MinimumLength(2).WithMessage("Le titre doit contenir au moins 2 caractères.") + .MaximumLength(200).WithMessage("Le titre ne peut pas dépasser 200 caractères."); + + When(x => x.Description != null, () => + { + RuleFor(x => x.Description) + .MaximumLength(1000).WithMessage("La description ne peut pas dépasser 1000 caractères."); + }); + + RuleFor(x => x.Position) + .GreaterThan(0).WithMessage("La position doit être supérieure à 0."); + } +} diff --git a/MetaCourse.Api/Validators/Topics/UpdateTopicDtoValidator.cs b/MetaCourse.Api/Validators/Topics/UpdateTopicDtoValidator.cs new file mode 100644 index 0000000..53ab878 --- /dev/null +++ b/MetaCourse.Api/Validators/Topics/UpdateTopicDtoValidator.cs @@ -0,0 +1,28 @@ +using FastEndpoints; +using FluentValidation; +using MetaCourse.Api.DTOs.Topics; + +namespace MetaCourse.Api.Validators.Topics; + +public class UpdateTopicDtoValidator : Validator +{ + public UpdateTopicDtoValidator() + { + RuleFor(x => x.Id) + .NotEqual(Guid.Empty).WithMessage("L'identifiant est invalide."); + + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Le titre est requis.") + .MinimumLength(2).WithMessage("Le titre doit contenir au moins 2 caractères.") + .MaximumLength(200).WithMessage("Le titre ne peut pas dépasser 200 caractères."); + + When(x => x.Description != null, () => + { + RuleFor(x => x.Description) + .MaximumLength(1000).WithMessage("La description ne peut pas dépasser 1000 caractères."); + }); + + RuleFor(x => x.Position) + .GreaterThan(0).WithMessage("La position doit être supérieure à 0."); + } +} diff --git a/MetaCourse.Api/Validators/UserCourses/EnrollDtoValidator.cs b/MetaCourse.Api/Validators/UserCourses/EnrollDtoValidator.cs new file mode 100644 index 0000000..4faacee --- /dev/null +++ b/MetaCourse.Api/Validators/UserCourses/EnrollDtoValidator.cs @@ -0,0 +1,17 @@ +using FastEndpoints; +using FluentValidation; +using MetaCourse.Api.DTOs.UserCourses; + +namespace MetaCourse.Api.Validators.UserCourses; + +public class EnrollDtoValidator : Validator +{ + public EnrollDtoValidator() + { + RuleFor(x => x.UserId) + .NotEqual(Guid.Empty).WithMessage("L'identifiant de l'utilisateur est invalide."); + + RuleFor(x => x.CourseId) + .NotEqual(Guid.Empty).WithMessage("L'identifiant du cours est invalide."); + } +} diff --git a/MetaCourse.Api/Validators/Users/LoginUserDtoValidator.cs b/MetaCourse.Api/Validators/Users/LoginUserDtoValidator.cs new file mode 100644 index 0000000..50213ec --- /dev/null +++ b/MetaCourse.Api/Validators/Users/LoginUserDtoValidator.cs @@ -0,0 +1,18 @@ +using FastEndpoints; +using FluentValidation; +using MetaCourse.Api.DTOs.Users; + +namespace MetaCourse.Api.Validators.Users; + +public class LoginUserDtoValidator : Validator +{ + public LoginUserDtoValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("L'email est requis.") + .EmailAddress().WithMessage("Le format de l'email est invalide."); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Le mot de passe est requis."); + } +} diff --git a/MetaCourse.Api/Validators/Users/RegisterUserDtoValidator.cs b/MetaCourse.Api/Validators/Users/RegisterUserDtoValidator.cs new file mode 100644 index 0000000..47dc855 --- /dev/null +++ b/MetaCourse.Api/Validators/Users/RegisterUserDtoValidator.cs @@ -0,0 +1,27 @@ +using FastEndpoints; +using FluentValidation; +using MetaCourse.Api.DTOs.Users; + +namespace MetaCourse.Api.Validators.Users; + +public class RegisterUserDtoValidator : Validator +{ + public RegisterUserDtoValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Le nom est requis.") + .MinimumLength(2).WithMessage("Le nom doit contenir au moins 2 caractères.") + .MaximumLength(100).WithMessage("Le nom ne peut pas dépasser 100 caractères."); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("L'email est requis.") + .EmailAddress().WithMessage("Le format de l'email est invalide.") + .MaximumLength(255).WithMessage("L'email ne peut pas dépasser 255 caractères."); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Le mot de passe est requis.") + .MinimumLength(8).WithMessage("Le mot de passe doit contenir au moins 8 caractères.") + .Matches(@"[A-Z]").WithMessage("Le mot de passe doit contenir au moins une majuscule.") + .Matches(@"[0-9]").WithMessage("Le mot de passe doit contenir au moins un chiffre."); + } +} diff --git a/MetaCourse.Api/appsettings.Development.json b/MetaCourse.Api/appsettings.Development.json new file mode 100644 index 0000000..8176426 --- /dev/null +++ b/MetaCourse.Api/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=MetaCourseDb_Dev;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=true;" + } +} diff --git a/MetaCourse.Api/appsettings.json b/MetaCourse.Api/appsettings.json new file mode 100644 index 0000000..f44cb62 --- /dev/null +++ b/MetaCourse.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Server=romaric-thibault.fr;Database=arsene_MetaCourse;User Id=arsene;Password=Onto9-Cage-Afflicted;TrustServerCertificate=true;" + } +} diff --git a/MetaCourseApi.sln b/MetaCourseApi.sln new file mode 100644 index 0000000..dbdc876 --- /dev/null +++ b/MetaCourseApi.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetaCourse.Api", "MetaCourse.Api\MetaCourse.Api.csproj", "{6B568060-4D99-2D52-11EC-A44F48EFA34B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Debug|x64.ActiveCfg = Debug|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Debug|x64.Build.0 = Debug|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Debug|x86.ActiveCfg = Debug|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Debug|x86.Build.0 = Debug|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Release|Any CPU.Build.0 = Release|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Release|x64.ActiveCfg = Release|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Release|x64.Build.0 = Release|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Release|x86.ActiveCfg = Release|Any CPU + {6B568060-4D99-2D52-11EC-A44F48EFA34B}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A98C0AA4-816E-41B3-8542-28E358D75F73} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b6980c --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# MetaCourseApi \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ea598ca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.9' + +services: + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "80:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ConnectionStrings__DefaultConnection=Server=romaric-thibault.fr;Database=arsene_MetaCourse;User Id=arsene;Password=Onto9-Cage-Afflicted;TrustServerCertificate=true; + restart: unless-stopped diff --git a/frontend-config/api.service.example.ts b/frontend-config/api.service.example.ts new file mode 100644 index 0000000..654a634 --- /dev/null +++ b/frontend-config/api.service.example.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class ApiService { + private baseUrl = environment.apiBaseUrl; + + constructor(private http: HttpClient) {} + + get(path: string) { + return this.http.get(`${this.baseUrl}${path}`); + } + + post(path: string, body: unknown) { + return this.http.post(`${this.baseUrl}${path}`, body); + } + + put(path: string, body: unknown) { + return this.http.put(`${this.baseUrl}${path}`, body); + } + + delete(path: string) { + return this.http.delete(`${this.baseUrl}${path}`); + } +} diff --git a/frontend-config/src/environments/environment.prod.ts b/frontend-config/src/environments/environment.prod.ts new file mode 100644 index 0000000..fa1659c --- /dev/null +++ b/frontend-config/src/environments/environment.prod.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + apiBaseUrl: 'http://romaric-thibault.fr:8080/api' +}; diff --git a/frontend-config/src/environments/environment.ts b/frontend-config/src/environments/environment.ts new file mode 100644 index 0000000..4ab8a52 --- /dev/null +++ b/frontend-config/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiBaseUrl: 'http://localhost:8080/api' +}; diff --git a/frontend-connection-info.txt b/frontend-connection-info.txt new file mode 100644 index 0000000..32dfcc4 --- /dev/null +++ b/frontend-connection-info.txt @@ -0,0 +1,32 @@ +MetaCourse API - Frontend Connection Info +========================================== + +API Base URL (development) +-------------------------- +http://localhost:8080/api + +API Base URL (production) +------------------------- +http://romaric-thibault.fr:8080/api + +Endpoints +--------- +POST /api/users/login +GET /api/courses +POST /api/courses +GET /api/topics +... (see BACKEND_API_DOCS.md for full list) + +Format +------ +- All requests/responses: JSON +- IDs: UUID (string) +- Dates: ISO 8601 + +CORS +---- +All origins are allowed — no extra setup needed on the frontend. + +Notes +----- +- Switch the base URL from localhost to production when building for prod.