diff --git a/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/CreateCourseHandler.cs b/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/CreateCourseHandler.cs index 9325698a..7c51fac1 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/CreateCourseHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/CreateCourseHandler.cs @@ -1,7 +1,7 @@ using PIED_LMS.Contract.Abstractions.Storage; using PIED_LMS.Contract.Services.Course; using PIED_LMS.Contract.Services.Identity; -using PIED_LMS.Application.UserCases; + using PIED_LMS.Domain.Abstractions; namespace PIED_LMS.Application.UserCases.Commands.Course; @@ -35,20 +35,7 @@ public async Task> Handle(CreateCourseCommand request, Can return new ServiceResponse(false, errorMessage); } - var (curriculumJson, curriculumError) = CourseContentHelper.ValidateAndSerializeCurriculum(request.Curriculum); - if (curriculumError is not null) - { - logger.LogWarning("Course creation validation failed: {ValidationError}", curriculumError); - return new ServiceResponse(false, curriculumError); - } - var insight = CourseContentHelper.ValidateAndNormalizeInsight(request.Insight); - if (insight is null) - { - const string errorMessage = "Insight is required and cannot be empty."; - logger.LogWarning("Course creation validation failed: {ValidationError}", errorMessage); - return new ServiceResponse(false, errorMessage); - } // Subtask 5.4: Implement file upload and course creation string? thumbnailPath = null; @@ -90,11 +77,39 @@ public async Task> Handle(CreateCourseCommand request, Can Seats = request.Seats, Price = request.Price, Value = request.Value, - Curriculum = curriculumJson, - Insight = insight, CreatedAt = DateTime.UtcNow }; + try + { + if (request.Curriculum != null) + { + var domainCurriculum = request.Curriculum.Select(c => + new Domain.Entities.CurriculumSection(c.Title, c.Summary, c.Content)).ToList(); + course.SetCurriculum(domainCurriculum); + } + course.SetInsight(request.Insight); + } + catch (ArgumentException ex) + { + logger.LogWarning("Course creation validation failed: {ValidationError}", ex.Message); + + if (!string.IsNullOrEmpty(thumbnailPath)) + { + try + { + await fileStorageService.DeleteFileAsync(thumbnailPath, cancellationToken); + logger.LogInformation("Deleted orphaned thumbnail {ThumbnailPath} after validation failure", thumbnailPath); + } + catch (Exception deleteEx) + { + logger.LogError(deleteEx, "Failed to delete orphaned thumbnail {ThumbnailPath}", thumbnailPath); + } + } + + return new ServiceResponse(false, ex.Message); + } + // Add course to repository var repository = unitOfWork.Repository(); await repository.AddAsync(course, cancellationToken); diff --git a/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/UpdateCourseHandler.cs b/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/UpdateCourseHandler.cs index c495165d..11092896 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/UpdateCourseHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/UpdateCourseHandler.cs @@ -1,7 +1,7 @@ using PIED_LMS.Contract.Abstractions.Storage; using PIED_LMS.Contract.Services.Course; using PIED_LMS.Contract.Services.Identity; -using PIED_LMS.Application.UserCases; + using PIED_LMS.Domain.Abstractions; namespace PIED_LMS.Application.UserCases.Commands.Course; @@ -37,21 +37,43 @@ public async Task> Handle(UpdateCourseCommand request, C return new ServiceResponse(false, validationError); } - var (curriculumJson, curriculumError) = CourseContentHelper.ValidateAndSerializeCurriculum(request.Curriculum); - if (curriculumError is not null) + + + // Validate new slug uniqueness if slug changed + var newSlug = course.Slug; + if (!string.IsNullOrWhiteSpace(request.Slug) && request.Slug != course.Slug) { - logger.LogWarning("Course update validation failed for course {CourseId}: {ValidationError}", - request.Id, curriculumError); - return new ServiceResponse(false, curriculumError); + var slugToValidate = request.Slug.ToLowerInvariant().Trim(); + var slugExists = await repository.AnyAsync( + c => c.Slug == slugToValidate && c.Id != request.Id, + cancellationToken); + + if (slugExists) + { + logger.LogWarning("Course update failed for course {CourseId}: Slug '{Slug}' already exists", + request.Id, slugToValidate); + return new ServiceResponse(false, "Slug already exists. Please provide a unique slug."); + } + + newSlug = slugToValidate; } - var insight = CourseContentHelper.ValidateAndNormalizeInsight(request.Insight); - if (insight is null) + // Validate Domain rules (Curriculum and Insight) before any side-effects + try { - const string errorMessage = "Insight is required and cannot be empty."; - logger.LogWarning("Course update validation failed for course {CourseId}: {ValidationError}", - request.Id, errorMessage); - return new ServiceResponse(false, errorMessage); + if (request.Curriculum != null) + { + var domainCurriculum = request.Curriculum.Select(c => + new Domain.Entities.CurriculumSection(c.Title, c.Summary, c.Content)).ToList(); + course.SetCurriculum(domainCurriculum); + } + + course.SetInsight(request.Insight); + } + catch (ArgumentException ex) + { + logger.LogWarning("Course update validation failed for course {CourseId}: {ValidationError}", request.Id, ex.Message); + return new ServiceResponse(false, ex.Message); } // Subtask 6.3: Implement thumbnail update logic @@ -85,25 +107,6 @@ public async Task> Handle(UpdateCourseCommand request, C } // Subtask 6.4: Implement course update and commit - // Validate new slug uniqueness if slug changed - var newSlug = course.Slug; - if (!string.IsNullOrWhiteSpace(request.Slug) && request.Slug != course.Slug) - { - var slugToValidate = request.Slug.ToLowerInvariant().Trim(); - var slugExists = await repository.AnyAsync( - c => c.Slug == slugToValidate && c.Id != request.Id, - cancellationToken); - - if (slugExists) - { - logger.LogWarning("Course update failed for course {CourseId}: Slug '{Slug}' already exists", - request.Id, slugToValidate); - return new ServiceResponse(false, "Slug already exists. Please provide a unique slug."); - } - - newSlug = slugToValidate; - } - // Track changed properties for logging var changedProperties = new List(); if (course.Title != request.Title) changedProperties.Add($"Title: '{course.Title}' -> '{request.Title}'"); @@ -113,8 +116,6 @@ public async Task> Handle(UpdateCourseCommand request, C if (course.Status != request.Status) changedProperties.Add($"Status: {course.Status} -> {request.Status}"); if (course.Slug != newSlug) changedProperties.Add($"Slug: '{course.Slug}' -> '{newSlug}'"); if (course.ThumbnailPath != newThumbnailPath) changedProperties.Add("ThumbnailPath"); - if (course.Curriculum != curriculumJson) changedProperties.Add("Curriculum"); - if (course.Insight != insight) changedProperties.Add("Insight"); // Update course properties course.Title = request.Title; @@ -131,8 +132,6 @@ public async Task> Handle(UpdateCourseCommand request, C course.Seats = request.Seats; course.Price = request.Price; course.Value = request.Value; - course.Curriculum = curriculumJson; - course.Insight = insight; course.UpdatedAt = DateTime.UtcNow; // Update course in repository diff --git a/backend/Src/PIED_LMS.Application/UserCases/CourseContentHelper.cs b/backend/Src/PIED_LMS.Application/UserCases/CourseContentHelper.cs deleted file mode 100644 index 309edc1b..00000000 --- a/backend/Src/PIED_LMS.Application/UserCases/CourseContentHelper.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Text.Json; -using PIED_LMS.Contract.Services.Course; - -namespace PIED_LMS.Application.UserCases; - -public static class CourseContentHelper -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - public static (string? CurriculumJson, string? Error) ValidateAndSerializeCurriculum(string? curriculum) - { - if (string.IsNullOrWhiteSpace(curriculum)) - return (null, "Curriculum is required and cannot be empty."); - - List sections; - try - { - sections = JsonSerializer.Deserialize>(curriculum, JsonOptions) - ?? []; - } - catch (JsonException) - { - return (null, "Curriculum must be a valid JSON array of sections."); - } - - for (var i = 0; i < sections.Count; i++) - { - var section = sections[i]; - if (string.IsNullOrWhiteSpace(section.Title)) - return (null, $"Curriculum section {i + 1} must have a title."); - - if (string.IsNullOrWhiteSpace(section.Summary)) - return (null, $"Curriculum section {i + 1} must have a summary."); - - if (section.Content is null || section.Content.Count == 0 || - section.Content.All(string.IsNullOrWhiteSpace)) - return (null, $"Curriculum section {i + 1} must have at least one content item."); - } - - return (JsonSerializer.Serialize(sections, JsonOptions), null); - } - - public static string? ValidateAndNormalizeInsight(string? insight) - { - if (string.IsNullOrWhiteSpace(insight)) - return null; - - return insight.Trim(); - } - - public static List DeserializeCurriculum( - string? curriculum, - ILogger logger, - Guid courseId) - { - if (string.IsNullOrWhiteSpace(curriculum)) - return []; - - try - { - return JsonSerializer.Deserialize>(curriculum, JsonOptions) - ?? []; - } - catch (JsonException ex) - { - logger.LogError(ex, "Failed to parse curriculum JSON for course {CourseId}", courseId); - return []; - } - } -} diff --git a/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseByIdHandler.cs b/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseByIdHandler.cs index f8fd6586..dddd34c1 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseByIdHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseByIdHandler.cs @@ -2,7 +2,7 @@ using PIED_LMS.Contract.Abstractions.Storage; using PIED_LMS.Contract.Services.Course; using PIED_LMS.Contract.Services.Identity; -using PIED_LMS.Application.UserCases; + using PIED_LMS.Domain.Abstractions; namespace PIED_LMS.Application.UserCases.Queries.Course; @@ -108,7 +108,8 @@ private async Task MapToCourseDto(Domain.Entities.Course course, Canc t.ProfilePictureUrl )).ToList(); - var curriculum = CourseContentHelper.DeserializeCurriculum(course.Curriculum, logger, course.Id); + var curriculumDto = course.Curriculum?.Select(c => + new CurriculumSectionDto(c.Title, c.Summary, c.Content.ToList())).ToList() ?? new List(); return new CourseDto( course.Id, @@ -127,7 +128,7 @@ private async Task MapToCourseDto(Domain.Entities.Course course, Canc course.CreatedAt, course.UpdatedAt, course.Value, - curriculum, + curriculumDto, course.Insight ?? string.Empty ); } diff --git a/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseCurriculumHandler.cs b/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseCurriculumHandler.cs index 5fbe146b..96327abd 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseCurriculumHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseCurriculumHandler.cs @@ -1,6 +1,6 @@ using PIED_LMS.Contract.Services.Course; using PIED_LMS.Contract.Services.Identity; -using PIED_LMS.Application.UserCases; + using PIED_LMS.Domain.Abstractions; namespace PIED_LMS.Application.UserCases.Queries.Course; @@ -28,12 +28,13 @@ public async Task>> Handle( ); } - var curriculum = CourseContentHelper.DeserializeCurriculum(course.Curriculum, logger, course.Id); + var curriculumDto = course.Curriculum?.Select(c => + new CurriculumSectionDto(c.Title, c.Summary, c.Content.ToList())).ToList() ?? new List(); return new ServiceResponse>( true, "Curriculum retrieved successfully", - curriculum + curriculumDto ); } catch (Exception ex) diff --git a/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCoursesHandler.cs b/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCoursesHandler.cs index d75f5d7d..71ade96d 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCoursesHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCoursesHandler.cs @@ -2,7 +2,7 @@ using PIED_LMS.Contract.Abstractions.Storage; using PIED_LMS.Contract.Services.Course; using PIED_LMS.Contract.Services.Identity; -using PIED_LMS.Application.UserCases; + using PIED_LMS.Domain.Abstractions; namespace PIED_LMS.Application.UserCases.Queries.Course; @@ -119,7 +119,8 @@ private async Task MapToCourseDto(Domain.Entities.Course course, Canc t.ProfilePictureUrl )).ToList(); - var curriculum = CourseContentHelper.DeserializeCurriculum(course.Curriculum, logger, course.Id); + var curriculumDto = course.Curriculum?.Select(c => + new CurriculumSectionDto(c.Title, c.Summary, c.Content.ToList())).ToList() ?? new List(); return new CourseDto( course.Id, @@ -138,7 +139,7 @@ private async Task MapToCourseDto(Domain.Entities.Course course, Canc course.CreatedAt, course.UpdatedAt, course.Value, - curriculum, + curriculumDto, course.Insight ?? string.Empty ); } diff --git a/backend/Src/PIED_LMS.Application/UserCases/Queries/GetUserByIdHandler.cs b/backend/Src/PIED_LMS.Application/UserCases/Queries/GetUserByIdHandler.cs index a72bfd5d..55395b6d 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Queries/GetUserByIdHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Queries/GetUserByIdHandler.cs @@ -1,6 +1,6 @@ using PIED_LMS.Contract.Abstractions.Storage; -using PIED_LMS.Contract.Abstractions.Storage; using PIED_LMS.Contract.Services.Identity; +using PIED_LMS.Domain.Abstractions; using PIED_LMS.Domain.Entities; namespace PIED_LMS.Application.UserCases.Queries; diff --git a/backend/Src/PIED_LMS.Contract/Services/Course/Command.cs b/backend/Src/PIED_LMS.Contract/Services/Course/Command.cs index fbdc1876..6dfdcbc7 100644 --- a/backend/Src/PIED_LMS.Contract/Services/Course/Command.cs +++ b/backend/Src/PIED_LMS.Contract/Services/Course/Command.cs @@ -19,8 +19,8 @@ public record CreateCourseCommand( string? Seats, string? Price, int Value, - string? Curriculum, - string? Insight + List? Curriculum, + string Insight ) : IRequest>; // Update Course Command @@ -38,8 +38,8 @@ public record UpdateCourseCommand( string? Seats, string? Price, int Value, - string? Curriculum, - string? Insight + List? Curriculum, + string Insight ) : IRequest>; // Delete Course Command diff --git a/backend/Src/PIED_LMS.Contract/Services/Course/Request.cs b/backend/Src/PIED_LMS.Contract/Services/Course/Request.cs index 803fd7fe..e11474e8 100644 --- a/backend/Src/PIED_LMS.Contract/Services/Course/Request.cs +++ b/backend/Src/PIED_LMS.Contract/Services/Course/Request.cs @@ -18,7 +18,7 @@ public sealed record CreateCourseRequest( [FromForm(Name = "seats")] string? Seats, [FromForm(Name = "price")] string? Price, [FromForm(Name = "value")] int Value, - [FromForm(Name = "curriculum")] string? Curriculum, + [FromForm(Name = "curriculum")] List? Curriculum, [FromForm(Name = "insight")] [Required(ErrorMessage = "Insight is required and cannot be empty.")] string Insight @@ -37,7 +37,7 @@ public sealed record UpdateCourseRequest( [FromForm(Name = "seats")] string? Seats, [FromForm(Name = "price")] string? Price, [FromForm(Name = "value")] int Value, - [FromForm(Name = "curriculum")] string? Curriculum, + [FromForm(Name = "curriculum")] List? Curriculum, [FromForm(Name = "insight")] [Required(ErrorMessage = "Insight is required and cannot be empty.")] string Insight diff --git a/backend/Src/PIED_LMS.Domain/Entities/Course.cs b/backend/Src/PIED_LMS.Domain/Entities/Course.cs index f628d831..9d363f9c 100644 --- a/backend/Src/PIED_LMS.Domain/Entities/Course.cs +++ b/backend/Src/PIED_LMS.Domain/Entities/Course.cs @@ -21,8 +21,8 @@ public class Course public string? Price { get; set; } public int Value { get; set; } - public string? Curriculum { get; set; } - public string? Insight { get; set; } + public List Curriculum { get; private set; } = new(); + public string? Insight { get; private set; } public int MaxCapacity { get; set; } public int CurrentEnrollment { get; set; } @@ -34,4 +34,17 @@ public class Course // Self-referencing many-to-many for prerequisites public ICollection PrerequisiteCourses { get; set; } = new List(); public ICollection PrerequisiteFor { get; set; } = new List(); + + public void SetCurriculum(List? curriculum) + { + Curriculum = curriculum ?? new List(); + } + + public void SetInsight(string insight) + { + if (string.IsNullOrWhiteSpace(insight)) + throw new ArgumentException("Insight is required and cannot be empty."); + + Insight = insight.Trim(); + } } diff --git a/backend/Src/PIED_LMS.Domain/Entities/CurriculumSection.cs b/backend/Src/PIED_LMS.Domain/Entities/CurriculumSection.cs new file mode 100644 index 00000000..2862e3de --- /dev/null +++ b/backend/Src/PIED_LMS.Domain/Entities/CurriculumSection.cs @@ -0,0 +1,32 @@ +namespace PIED_LMS.Domain.Entities; + +public class CurriculumSection +{ + public string Title { get; private set; } + public string Summary { get; private set; } + public IReadOnlyList Content { get; private set; } + + // Required for EF Core serialization/deserialization or default instantiation + private CurriculumSection() + { + Title = null!; + Summary = null!; + Content = null!; + } + + public CurriculumSection(string title, string summary, List content) + { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Curriculum section must have a title."); + + if (string.IsNullOrWhiteSpace(summary)) + throw new ArgumentException("Curriculum section must have a summary."); + + if (content == null || content.Count == 0 || content.All(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Curriculum section must have at least one valid content item."); + + Title = title; + Summary = summary; + Content = new List(content).AsReadOnly(); + } +} diff --git a/backend/Src/PIED_LMS.Persistence/Configurations/CourseConfiguration.cs b/backend/Src/PIED_LMS.Persistence/Configurations/CourseConfiguration.cs index 39d47dd2..ba0bed84 100644 --- a/backend/Src/PIED_LMS.Persistence/Configurations/CourseConfiguration.cs +++ b/backend/Src/PIED_LMS.Persistence/Configurations/CourseConfiguration.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using PIED_LMS.Domain.Entities; namespace PIED_LMS.Persistence.Configurations; @@ -54,6 +55,18 @@ public void Configure(EntityTypeBuilder builder) .IsRequired() .HasDefaultValue(0); + var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + builder.Property(c => c.Curriculum) + .HasConversion( + v => JsonSerializer.Serialize(v, jsonOptions), + v => string.IsNullOrWhiteSpace(v) + ? new List() + : JsonSerializer.Deserialize>(v, jsonOptions) ?? new List() + ); + + builder.Property(c => c.Insight) + .IsRequired(false); // Can be configured as needed + // Many-to-many relationship for Prerequisites builder.HasMany(c => c.PrerequisiteCourses) .WithMany(c => c.PrerequisiteFor)