From 5c13f837da9442456fad1f7ee2db7cf213e1bb91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ho=C3=A0ng=20Nam?= Date: Sun, 31 May 2026 16:52:15 +0700 Subject: [PATCH 1/5] feat(course): refactor curriculum and insight to use Clean Architecture and DDD - Introduce CurriculumSection Value Object with domain validations - Migrate Course Curriculum from string to List - Use EF Core HasConversion for Curriculum persistence - Update Command/Query Handlers and DTOs to use strongly typed collections - Remove deprecated CourseContentHelper --- .../Commands/Course/CreateCourseHandler.cs | 33 +++++---- .../Commands/Course/UpdateCourseHandler.cs | 41 ++++++----- .../UserCases/CourseContentHelper.cs | 73 ------------------- .../Queries/Course/GetCourseByIdHandler.cs | 7 +- .../Course/GetCourseCurriculumHandler.cs | 7 +- .../Queries/Course/GetCoursesHandler.cs | 7 +- .../Services/Course/Command.cs | 8 +- .../Services/Course/Request.cs | 4 +- .../Src/PIED_LMS.Domain/Entities/Course.cs | 17 ++++- .../Entities/CurriculumSection.cs | 27 +++++++ .../Configurations/CourseConfiguration.cs | 13 ++++ 11 files changed, 111 insertions(+), 126 deletions(-) delete mode 100644 backend/Src/PIED_LMS.Application/UserCases/CourseContentHelper.cs create mode 100644 backend/Src/PIED_LMS.Domain/Entities/CurriculumSection.cs 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..ee68e7d6 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,25 @@ 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); + 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..feea3d85 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,22 +37,7 @@ public async Task> Handle(UpdateCourseCommand request, C return new ServiceResponse(false, validationError); } - var (curriculumJson, curriculumError) = CourseContentHelper.ValidateAndSerializeCurriculum(request.Curriculum); - if (curriculumError is not null) - { - logger.LogWarning("Course update validation failed for course {CourseId}: {ValidationError}", - request.Id, 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 update validation failed for course {CourseId}: {ValidationError}", - request.Id, errorMessage); - return new ServiceResponse(false, errorMessage); - } // Subtask 6.3: Implement thumbnail update logic var newThumbnailPath = course.ThumbnailPath; @@ -113,8 +98,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,10 +114,28 @@ 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; + 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); + } + else + { + course.SetCurriculum(null); + } + 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); + } + // Update course in repository repository.Update(course); await unitOfWork.CommitAsync(cancellationToken); 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..efc8ec17 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() ?? 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..23d3d78f 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() ?? 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..4e9e59ba 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() ?? 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.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..5a2a3f3c --- /dev/null +++ b/backend/Src/PIED_LMS.Domain/Entities/CurriculumSection.cs @@ -0,0 +1,27 @@ +namespace PIED_LMS.Domain.Entities; + +public class CurriculumSection +{ + public string Title { get; private set; } + public string Summary { get; private set; } + public List Content { get; private set; } + + // Required for EF Core serialization/deserialization or default instantiation + private CurriculumSection() { } + + 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 = content; + } +} 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) From 108018b424990716b09182bab189f083afe01c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ho=C3=A0ng=20Nam?= Date: Sun, 31 May 2026 17:58:39 +0700 Subject: [PATCH 2/5] fix(course): prevent orphaned thumbnails on domain validation failure --- .../Commands/Course/CreateCourseHandler.cs | 14 ++++++++++++++ .../Commands/Course/UpdateCourseHandler.cs | 14 ++++++++++++++ 2 files changed, 28 insertions(+) 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 ee68e7d6..7c51fac1 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/CreateCourseHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/CreateCourseHandler.cs @@ -93,6 +93,20 @@ public async Task> Handle(CreateCourseCommand request, Can 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); } 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 feea3d85..77ec39a7 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/UpdateCourseHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/UpdateCourseHandler.cs @@ -133,6 +133,20 @@ public async Task> Handle(UpdateCourseCommand request, C catch (ArgumentException ex) { logger.LogWarning("Course update validation failed for course {CourseId}: {ValidationError}", request.Id, ex.Message); + + if (!string.IsNullOrEmpty(newThumbnailPath) && newThumbnailPath != course.ThumbnailPath) + { + try + { + await fileStorageService.DeleteFileAsync(newThumbnailPath, cancellationToken); + logger.LogInformation("Deleted newly uploaded thumbnail {ThumbnailPath} after validation failure", newThumbnailPath); + } + catch (Exception deleteEx) + { + logger.LogError(deleteEx, "Failed to delete orphaned thumbnail {ThumbnailPath}", newThumbnailPath); + } + } + return new ServiceResponse(false, ex.Message); } From 93e5c5dfa3e6dba5e3e65289d617cfb1f1c1c9b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ho=C3=A0ng=20Nam?= Date: Sun, 31 May 2026 17:59:47 +0700 Subject: [PATCH 3/5] fix(course): preserve curriculum on update when payload provides null --- .../UserCases/Commands/Course/UpdateCourseHandler.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 77ec39a7..b8cd2a28 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/UpdateCourseHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/UpdateCourseHandler.cs @@ -124,10 +124,7 @@ public async Task> Handle(UpdateCourseCommand request, C new Domain.Entities.CurriculumSection(c.Title, c.Summary, c.Content)).ToList(); course.SetCurriculum(domainCurriculum); } - else - { - course.SetCurriculum(null); - } + course.SetInsight(request.Insight); } catch (ArgumentException ex) From c1dce60d26909fdd6ddd06282cd1319eb6a6e208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ho=C3=A0ng=20Nam?= Date: Sun, 31 May 2026 18:02:11 +0700 Subject: [PATCH 4/5] fix(course): move domain validation before side effects in update handler --- .../Commands/Course/UpdateCourseHandler.cs | 87 ++++++++----------- 1 file changed, 37 insertions(+), 50 deletions(-) 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 b8cd2a28..11092896 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/UpdateCourseHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Commands/Course/UpdateCourseHandler.cs @@ -39,6 +39,43 @@ public async Task> Handle(UpdateCourseCommand request, C + // 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; + } + + // Validate Domain rules (Curriculum and Insight) before any side-effects + 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 update validation failed for course {CourseId}: {ValidationError}", request.Id, ex.Message); + return new ServiceResponse(false, ex.Message); + } + // Subtask 6.3: Implement thumbnail update logic var newThumbnailPath = course.ThumbnailPath; if (request.ThumbnailFile is not null) @@ -70,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}'"); @@ -116,37 +134,6 @@ public async Task> Handle(UpdateCourseCommand request, C course.Value = request.Value; course.UpdatedAt = 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 update validation failed for course {CourseId}: {ValidationError}", request.Id, ex.Message); - - if (!string.IsNullOrEmpty(newThumbnailPath) && newThumbnailPath != course.ThumbnailPath) - { - try - { - await fileStorageService.DeleteFileAsync(newThumbnailPath, cancellationToken); - logger.LogInformation("Deleted newly uploaded thumbnail {ThumbnailPath} after validation failure", newThumbnailPath); - } - catch (Exception deleteEx) - { - logger.LogError(deleteEx, "Failed to delete orphaned thumbnail {ThumbnailPath}", newThumbnailPath); - } - } - - return new ServiceResponse(false, ex.Message); - } - // Update course in repository repository.Update(course); await unitOfWork.CommitAsync(cancellationToken); From 2026c06e7d915d1b2a8b529409dd6a4ccfdc61fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ho=C3=A0ng=20Nam?= Date: Sun, 31 May 2026 18:09:01 +0700 Subject: [PATCH 5/5] fix(course): make CurriculumSection Content immutable to enforce DDD encapsulation --- .../UserCases/Queries/Course/GetCourseByIdHandler.cs | 2 +- .../Queries/Course/GetCourseCurriculumHandler.cs | 2 +- .../UserCases/Queries/Course/GetCoursesHandler.cs | 2 +- .../UserCases/Queries/GetUserByIdHandler.cs | 2 +- .../Src/PIED_LMS.Domain/Entities/CurriculumSection.cs | 11 ++++++++--- 5 files changed, 12 insertions(+), 7 deletions(-) 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 efc8ec17..dddd34c1 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseByIdHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseByIdHandler.cs @@ -109,7 +109,7 @@ private async Task MapToCourseDto(Domain.Entities.Course course, Canc )).ToList(); var curriculumDto = course.Curriculum?.Select(c => - new CurriculumSectionDto(c.Title, c.Summary, c.Content)).ToList() ?? new List(); + new CurriculumSectionDto(c.Title, c.Summary, c.Content.ToList())).ToList() ?? new List(); return new CourseDto( course.Id, 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 23d3d78f..96327abd 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseCurriculumHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCourseCurriculumHandler.cs @@ -29,7 +29,7 @@ public async Task>> Handle( } var curriculumDto = course.Curriculum?.Select(c => - new CurriculumSectionDto(c.Title, c.Summary, c.Content)).ToList() ?? new List(); + new CurriculumSectionDto(c.Title, c.Summary, c.Content.ToList())).ToList() ?? new List(); return new ServiceResponse>( true, 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 4e9e59ba..71ade96d 100644 --- a/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCoursesHandler.cs +++ b/backend/Src/PIED_LMS.Application/UserCases/Queries/Course/GetCoursesHandler.cs @@ -120,7 +120,7 @@ private async Task MapToCourseDto(Domain.Entities.Course course, Canc )).ToList(); var curriculumDto = course.Curriculum?.Select(c => - new CurriculumSectionDto(c.Title, c.Summary, c.Content)).ToList() ?? new List(); + new CurriculumSectionDto(c.Title, c.Summary, c.Content.ToList())).ToList() ?? new List(); return new CourseDto( course.Id, 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.Domain/Entities/CurriculumSection.cs b/backend/Src/PIED_LMS.Domain/Entities/CurriculumSection.cs index 5a2a3f3c..2862e3de 100644 --- a/backend/Src/PIED_LMS.Domain/Entities/CurriculumSection.cs +++ b/backend/Src/PIED_LMS.Domain/Entities/CurriculumSection.cs @@ -4,10 +4,15 @@ public class CurriculumSection { public string Title { get; private set; } public string Summary { get; private set; } - public List Content { get; private set; } + public IReadOnlyList Content { get; private set; } // Required for EF Core serialization/deserialization or default instantiation - private CurriculumSection() { } + private CurriculumSection() + { + Title = null!; + Summary = null!; + Content = null!; + } public CurriculumSection(string title, string summary, List content) { @@ -22,6 +27,6 @@ public CurriculumSection(string title, string summary, List content) Title = title; Summary = summary; - Content = content; + Content = new List(content).AsReadOnly(); } }