From b94ecd00099074d0ba00f4a2880797139c4b1726 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 14:21:37 +0000 Subject: [PATCH 1/4] Initial plan From ec29280e323df360066c13e9a516cbbafc468cdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 14:33:04 +0000 Subject: [PATCH 2/4] Add waitlist feature implementation with domain entities, services, and API endpoints Co-authored-by: Hemavathi15sg <224925058+Hemavathi15sg@users.noreply.github.com> --- .../Controllers/WaitlistController.cs | 288 ++++++++++++++++++ api/CourseRegistration.API/Program.cs | 3 + .../DTOs/CourseDtos.cs | 25 ++ .../DTOs/WaitlistDtos.cs | 111 +++++++ .../Interfaces/INotificationService.cs | 29 ++ .../Interfaces/IWaitlistService.cs | 59 ++++ .../Mappings/MappingProfile.cs | 43 ++- .../Services/NotificationService.cs | 78 +++++ .../Services/RegistrationService.cs | 22 +- .../Services/WaitlistService.cs | 285 +++++++++++++++++ .../Entities/Course.cs | 17 ++ .../Entities/Student.cs | 5 + .../Entities/WaitlistEntry.cs | 76 +++++ .../Enums/NotificationType.cs | 22 ++ .../Interfaces/IUnitOfWork.cs | 5 + .../Interfaces/IWaitlistRepository.cs | 44 +++ .../Data/CourseRegistrationDbContext.cs | 45 +++ .../Repositories/UnitOfWork.cs | 13 + .../Repositories/WaitlistRepository.cs | 105 +++++++ 19 files changed, 1269 insertions(+), 6 deletions(-) create mode 100644 api/CourseRegistration.API/Controllers/WaitlistController.cs create mode 100644 api/CourseRegistration.Application/DTOs/WaitlistDtos.cs create mode 100644 api/CourseRegistration.Application/Interfaces/INotificationService.cs create mode 100644 api/CourseRegistration.Application/Interfaces/IWaitlistService.cs create mode 100644 api/CourseRegistration.Application/Services/NotificationService.cs create mode 100644 api/CourseRegistration.Application/Services/WaitlistService.cs create mode 100644 api/CourseRegistration.Domain/Entities/WaitlistEntry.cs create mode 100644 api/CourseRegistration.Domain/Enums/NotificationType.cs create mode 100644 api/CourseRegistration.Domain/Interfaces/IWaitlistRepository.cs create mode 100644 api/CourseRegistration.Infrastructure/Repositories/WaitlistRepository.cs diff --git a/api/CourseRegistration.API/Controllers/WaitlistController.cs b/api/CourseRegistration.API/Controllers/WaitlistController.cs new file mode 100644 index 0000000..5763375 --- /dev/null +++ b/api/CourseRegistration.API/Controllers/WaitlistController.cs @@ -0,0 +1,288 @@ +using Microsoft.AspNetCore.Mvc; +using CourseRegistration.Application.DTOs; +using CourseRegistration.Application.Interfaces; + +namespace CourseRegistration.API.Controllers; + +/// +/// Controller for waitlist management operations +/// +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class WaitlistController : ControllerBase +{ + private readonly IWaitlistService _waitlistService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the WaitlistController + /// + public WaitlistController(IWaitlistService waitlistService, ILogger logger) + { + _waitlistService = waitlistService ?? throw new ArgumentNullException(nameof(waitlistService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Adds a student to a course waitlist + /// + /// Waitlist entry creation data + /// Created waitlist entry + [HttpPost] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status400BadRequest)] + public async Task>> JoinWaitlist([FromBody] CreateWaitlistEntryDto createWaitlistEntryDto) + { + _logger.LogInformation("Student {StudentId} attempting to join waitlist for course {CourseId}", + createWaitlistEntryDto.StudentId, createWaitlistEntryDto.CourseId); + + try + { + var waitlistEntry = await _waitlistService.JoinWaitlistAsync(createWaitlistEntryDto); + return CreatedAtAction( + nameof(GetWaitlistEntry), + new { id = waitlistEntry.WaitlistEntryId }, + new ApiResponseDto + { + Success = true, + Message = $"Successfully joined waitlist at position {waitlistEntry.Position}", + Data = waitlistEntry + }); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning("Failed to join waitlist: {Message}", ex.Message); + return BadRequest(new ApiResponseDto + { + Success = false, + Message = ex.Message + }); + } + } + + /// + /// Gets a specific waitlist entry by ID + /// + /// Waitlist entry ID + /// Waitlist entry details + [HttpGet("{id}")] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status404NotFound)] + public async Task>> GetWaitlistEntry(Guid id) + { + _logger.LogInformation("Getting waitlist entry {Id}", id); + + var waitlistEntry = await _waitlistService.GetWaitlistEntryAsync(id); + + if (waitlistEntry == null) + { + return NotFound(new ApiResponseDto + { + Success = false, + Message = "Waitlist entry not found" + }); + } + + return Ok(new ApiResponseDto + { + Success = true, + Data = waitlistEntry + }); + } + + /// + /// Gets active waitlist entries for a specific course + /// + /// Course ID + /// List of waitlist entries for the course + [HttpGet("course/{courseId}")] + [ProducesResponseType(typeof(ApiResponseDto>), StatusCodes.Status200OK)] + public async Task>>> GetCourseWaitlist(Guid courseId) + { + _logger.LogInformation("Getting waitlist for course {CourseId}", courseId); + + var waitlistEntries = await _waitlistService.GetCourseWaitlistAsync(courseId); + + return Ok(new ApiResponseDto> + { + Success = true, + Data = waitlistEntries, + Message = $"Found {waitlistEntries.Count()} entries in waitlist" + }); + } + + /// + /// Gets active waitlist entries for a specific student + /// + /// Student ID + /// List of waitlist entries for the student + [HttpGet("student/{studentId}")] + [ProducesResponseType(typeof(ApiResponseDto>), StatusCodes.Status200OK)] + public async Task>>> GetStudentWaitlists(Guid studentId) + { + _logger.LogInformation("Getting waitlists for student {StudentId}", studentId); + + var waitlistEntries = await _waitlistService.GetStudentWaitlistsAsync(studentId); + + return Ok(new ApiResponseDto> + { + Success = true, + Data = waitlistEntries, + Message = $"Student is on {waitlistEntries.Count()} waitlist(s)" + }); + } + + /// + /// Removes a student from a waitlist + /// + /// Waitlist entry ID + /// Success status + [HttpDelete("{id}")] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status404NotFound)] + public async Task>> LeaveWaitlist(Guid id) + { + _logger.LogInformation("Attempting to remove waitlist entry {Id}", id); + + var result = await _waitlistService.LeaveWaitlistAsync(id); + + if (!result) + { + return NotFound(new ApiResponseDto + { + Success = false, + Message = "Waitlist entry not found or already inactive" + }); + } + + return Ok(new ApiResponseDto + { + Success = true, + Message = "Successfully left the waitlist" + }); + } + + /// + /// Updates a waitlist entry (admin function) + /// + /// Waitlist entry ID + /// Update data + /// Updated waitlist entry + [HttpPatch("{id}")] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status400BadRequest)] + public async Task>> UpdateWaitlistEntry( + Guid id, + [FromBody] UpdateWaitlistEntryDto updateDto) + { + _logger.LogInformation("Updating waitlist entry {Id}", id); + + try + { + var updatedEntry = await _waitlistService.UpdateWaitlistEntryAsync(id, updateDto); + + if (updatedEntry == null) + { + return NotFound(new ApiResponseDto + { + Success = false, + Message = "Waitlist entry not found" + }); + } + + return Ok(new ApiResponseDto + { + Success = true, + Message = "Waitlist entry updated successfully", + Data = updatedEntry + }); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning("Failed to update waitlist entry: {Message}", ex.Message); + return BadRequest(new ApiResponseDto + { + Success = false, + Message = ex.Message + }); + } + } + + /// + /// Clears the entire waitlist for a course (admin function) + /// + /// Course ID + /// Success status + [HttpDelete("course/{courseId}/clear")] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status200OK)] + public async Task>> ClearWaitlist(Guid courseId) + { + _logger.LogInformation("Clearing waitlist for course {CourseId}", courseId); + + await _waitlistService.ClearWaitlistAsync(courseId); + + return Ok(new ApiResponseDto + { + Success = true, + Message = "Waitlist cleared successfully" + }); + } + + /// + /// Notifies the next student on the waitlist (admin/system function) + /// + /// Course ID + /// Success status + [HttpPost("course/{courseId}/notify-next")] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status200OK)] + public async Task>> NotifyNextStudent(Guid courseId) + { + _logger.LogInformation("Notifying next student on waitlist for course {CourseId}", courseId); + + await _waitlistService.NotifyNextStudentAsync(courseId); + + return Ok(new ApiResponseDto + { + Success = true, + Message = "Next student notified successfully" + }); + } + + /// + /// Reorders waitlist entries for a course (admin function) + /// + /// Course ID + /// Dictionary of waitlist entry IDs and their new positions + /// Success status + [HttpPut("course/{courseId}/reorder")] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponseDto), StatusCodes.Status400BadRequest)] + public async Task>> ReorderWaitlist( + Guid courseId, + [FromBody] Dictionary newPositions) + { + _logger.LogInformation("Reordering waitlist for course {CourseId}", courseId); + + try + { + await _waitlistService.ReorderWaitlistAsync(courseId, newPositions); + + return Ok(new ApiResponseDto + { + Success = true, + Message = "Waitlist reordered successfully" + }); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning("Failed to reorder waitlist: {Message}", ex.Message); + return BadRequest(new ApiResponseDto + { + Success = false, + Message = ex.Message + }); + } + } +} diff --git a/api/CourseRegistration.API/Program.cs b/api/CourseRegistration.API/Program.cs index 2c174da..c15a0aa 100644 --- a/api/CourseRegistration.API/Program.cs +++ b/api/CourseRegistration.API/Program.cs @@ -60,11 +60,14 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); // Register services builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); // Add CORS policy for development diff --git a/api/CourseRegistration.Application/DTOs/CourseDtos.cs b/api/CourseRegistration.Application/DTOs/CourseDtos.cs index 661dedc..6d6a0f9 100644 --- a/api/CourseRegistration.Application/DTOs/CourseDtos.cs +++ b/api/CourseRegistration.Application/DTOs/CourseDtos.cs @@ -34,6 +34,11 @@ public class CreateCourseDto /// Course schedule /// public string Schedule { get; set; } = string.Empty; + + /// + /// Maximum number of students allowed + /// + public int MaxEnrollment { get; set; } = 30; } /// @@ -70,6 +75,11 @@ public class UpdateCourseDto /// Course schedule /// public string Schedule { get; set; } = string.Empty; + + /// + /// Maximum number of students allowed + /// + public int MaxEnrollment { get; set; } = 30; } /// @@ -117,6 +127,21 @@ public class CourseDto /// public int CurrentEnrollment { get; set; } + /// + /// Maximum enrollment capacity + /// + public int MaxEnrollment { get; set; } + + /// + /// Indicates if the course is full + /// + public bool IsFull { get; set; } + + /// + /// Number of students on the waitlist + /// + public int WaitlistCount { get; set; } + /// /// Indicates if the course is active /// diff --git a/api/CourseRegistration.Application/DTOs/WaitlistDtos.cs b/api/CourseRegistration.Application/DTOs/WaitlistDtos.cs new file mode 100644 index 0000000..1ae1078 --- /dev/null +++ b/api/CourseRegistration.Application/DTOs/WaitlistDtos.cs @@ -0,0 +1,111 @@ +using CourseRegistration.Domain.Enums; + +namespace CourseRegistration.Application.DTOs; + +/// +/// Data transfer object for creating a waitlist entry +/// +public class CreateWaitlistEntryDto +{ + /// + /// Student ID + /// + public Guid StudentId { get; set; } + + /// + /// Course ID + /// + public Guid CourseId { get; set; } + + /// + /// Notification preference + /// + public NotificationType NotificationPreference { get; set; } = NotificationType.Email; +} + +/// +/// Data transfer object for waitlist entry response +/// +public class WaitlistEntryDto +{ + /// + /// Waitlist entry ID + /// + public Guid WaitlistEntryId { get; set; } + + /// + /// Student ID + /// + public Guid StudentId { get; set; } + + /// + /// Student's full name + /// + public string StudentName { get; set; } = string.Empty; + + /// + /// Student's email + /// + public string StudentEmail { get; set; } = string.Empty; + + /// + /// Course ID + /// + public Guid CourseId { get; set; } + + /// + /// Course name + /// + public string CourseName { get; set; } = string.Empty; + + /// + /// Position in the waitlist + /// + public int Position { get; set; } + + /// + /// Date and time when joined the waitlist + /// + public DateTime JoinedAt { get; set; } + + /// + /// Notification preference + /// + public NotificationType NotificationPreference { get; set; } + + /// + /// Indicates if still active on waitlist + /// + public bool IsActive { get; set; } + + /// + /// Date and time when notified + /// + public DateTime? NotifiedAt { get; set; } + + /// + /// Notes about the waitlist entry + /// + public string? Notes { get; set; } +} + +/// +/// Data transfer object for updating waitlist entry +/// +public class UpdateWaitlistEntryDto +{ + /// + /// New position in the waitlist (for admin reordering) + /// + public int? Position { get; set; } + + /// + /// Notification preference + /// + public NotificationType? NotificationPreference { get; set; } + + /// + /// Notes about the waitlist entry + /// + public string? Notes { get; set; } +} diff --git a/api/CourseRegistration.Application/Interfaces/INotificationService.cs b/api/CourseRegistration.Application/Interfaces/INotificationService.cs new file mode 100644 index 0000000..55bdafb --- /dev/null +++ b/api/CourseRegistration.Application/Interfaces/INotificationService.cs @@ -0,0 +1,29 @@ +using CourseRegistration.Domain.Enums; + +namespace CourseRegistration.Application.Interfaces; + +/// +/// Service interface for sending notifications +/// +public interface INotificationService +{ + /// + /// Sends a notification to a student about an available spot + /// + Task SendWaitlistNotificationAsync( + string studentEmail, + string studentName, + string courseName, + int position, + NotificationType notificationType); + + /// + /// Sends an email notification + /// + Task SendEmailAsync(string toEmail, string subject, string body); + + /// + /// Sends an in-app notification (placeholder for future implementation) + /// + Task SendInAppNotificationAsync(Guid studentId, string message); +} diff --git a/api/CourseRegistration.Application/Interfaces/IWaitlistService.cs b/api/CourseRegistration.Application/Interfaces/IWaitlistService.cs new file mode 100644 index 0000000..aecaaa1 --- /dev/null +++ b/api/CourseRegistration.Application/Interfaces/IWaitlistService.cs @@ -0,0 +1,59 @@ +using CourseRegistration.Application.DTOs; + +namespace CourseRegistration.Application.Interfaces; + +/// +/// Service interface for waitlist operations +/// +public interface IWaitlistService +{ + /// + /// Adds a student to a course waitlist + /// + Task JoinWaitlistAsync(CreateWaitlistEntryDto createWaitlistEntryDto); + + /// + /// Removes a student from a course waitlist + /// + Task LeaveWaitlistAsync(Guid waitlistEntryId); + + /// + /// Gets active waitlist entries for a course + /// + Task> GetCourseWaitlistAsync(Guid courseId); + + /// + /// Gets a student's active waitlist entries + /// + Task> GetStudentWaitlistsAsync(Guid studentId); + + /// + /// Gets a specific waitlist entry by ID + /// + Task GetWaitlistEntryAsync(Guid waitlistEntryId); + + /// + /// Updates a waitlist entry (admin function for reordering or updating preferences) + /// + Task UpdateWaitlistEntryAsync(Guid waitlistEntryId, UpdateWaitlistEntryDto updateDto); + + /// + /// Notifies the next student on the waitlist when a spot becomes available + /// + Task NotifyNextStudentAsync(Guid courseId); + + /// + /// Clears the entire waitlist for a course (admin function) + /// + Task ClearWaitlistAsync(Guid courseId); + + /// + /// Reorders waitlist entries (admin function) + /// + Task ReorderWaitlistAsync(Guid courseId, Dictionary newPositions); + + /// + /// Checks if a student is on the waitlist for a course + /// + Task IsStudentOnWaitlistAsync(Guid studentId, Guid courseId); +} diff --git a/api/CourseRegistration.Application/Mappings/MappingProfile.cs b/api/CourseRegistration.Application/Mappings/MappingProfile.cs index 10be3ee..9d138a7 100644 --- a/api/CourseRegistration.Application/Mappings/MappingProfile.cs +++ b/api/CourseRegistration.Application/Mappings/MappingProfile.cs @@ -21,32 +21,38 @@ public MappingProfile() .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) .ForMember(dest => dest.IsActive, opt => opt.Ignore()) - .ForMember(dest => dest.Registrations, opt => opt.Ignore()); + .ForMember(dest => dest.Registrations, opt => opt.Ignore()) + .ForMember(dest => dest.WaitlistEntries, opt => opt.Ignore()); CreateMap() .ForMember(dest => dest.StudentId, opt => opt.Ignore()) .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) .ForMember(dest => dest.IsActive, opt => opt.Ignore()) - .ForMember(dest => dest.Registrations, opt => opt.Ignore()); + .ForMember(dest => dest.Registrations, opt => opt.Ignore()) + .ForMember(dest => dest.WaitlistEntries, opt => opt.Ignore()); // Course mappings CreateMap() - .ForMember(dest => dest.CurrentEnrollment, opt => opt.MapFrom(src => src.CurrentEnrollment)); + .ForMember(dest => dest.CurrentEnrollment, opt => opt.MapFrom(src => src.CurrentEnrollment)) + .ForMember(dest => dest.IsFull, opt => opt.MapFrom(src => src.IsFull)) + .ForMember(dest => dest.WaitlistCount, opt => opt.MapFrom(src => src.WaitlistEntries.Count(w => w.IsActive))); CreateMap() .ForMember(dest => dest.CourseId, opt => opt.Ignore()) .ForMember(dest => dest.IsActive, opt => opt.Ignore()) .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) - .ForMember(dest => dest.Registrations, opt => opt.Ignore()); + .ForMember(dest => dest.Registrations, opt => opt.Ignore()) + .ForMember(dest => dest.WaitlistEntries, opt => opt.Ignore()); CreateMap() .ForMember(dest => dest.CourseId, opt => opt.Ignore()) .ForMember(dest => dest.IsActive, opt => opt.Ignore()) .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) - .ForMember(dest => dest.Registrations, opt => opt.Ignore()); + .ForMember(dest => dest.Registrations, opt => opt.Ignore()) + .ForMember(dest => dest.WaitlistEntries, opt => opt.Ignore()); // Registration mappings CreateMap() @@ -69,5 +75,32 @@ public MappingProfile() .ForMember(dest => dest.RegistrationDate, opt => opt.Ignore()) .ForMember(dest => dest.Student, opt => opt.Ignore()) .ForMember(dest => dest.Course, opt => opt.Ignore()); + + // Waitlist mappings + CreateMap() + .ForMember(dest => dest.StudentName, opt => opt.MapFrom(src => src.Student.FullName)) + .ForMember(dest => dest.StudentEmail, opt => opt.MapFrom(src => src.Student.Email)) + .ForMember(dest => dest.CourseName, opt => opt.MapFrom(src => src.Course.CourseName)); + + CreateMap() + .ForMember(dest => dest.WaitlistEntryId, opt => opt.Ignore()) + .ForMember(dest => dest.JoinedAt, opt => opt.Ignore()) + .ForMember(dest => dest.Position, opt => opt.Ignore()) + .ForMember(dest => dest.IsActive, opt => opt.Ignore()) + .ForMember(dest => dest.NotifiedAt, opt => opt.Ignore()) + .ForMember(dest => dest.Notes, opt => opt.Ignore()) + .ForMember(dest => dest.Student, opt => opt.Ignore()) + .ForMember(dest => dest.Course, opt => opt.Ignore()); + + CreateMap() + .ForMember(dest => dest.WaitlistEntryId, opt => opt.Ignore()) + .ForMember(dest => dest.StudentId, opt => opt.Ignore()) + .ForMember(dest => dest.CourseId, opt => opt.Ignore()) + .ForMember(dest => dest.JoinedAt, opt => opt.Ignore()) + .ForMember(dest => dest.IsActive, opt => opt.Ignore()) + .ForMember(dest => dest.NotifiedAt, opt => opt.Ignore()) + .ForMember(dest => dest.Student, opt => opt.Ignore()) + .ForMember(dest => dest.Course, opt => opt.Ignore()) + .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); } } \ No newline at end of file diff --git a/api/CourseRegistration.Application/Services/NotificationService.cs b/api/CourseRegistration.Application/Services/NotificationService.cs new file mode 100644 index 0000000..92ec73d --- /dev/null +++ b/api/CourseRegistration.Application/Services/NotificationService.cs @@ -0,0 +1,78 @@ +using CourseRegistration.Application.Interfaces; +using CourseRegistration.Domain.Enums; +using Microsoft.Extensions.Logging; + +namespace CourseRegistration.Application.Services; + +/// +/// Service implementation for sending notifications +/// +public class NotificationService : INotificationService +{ + private readonly ILogger _logger; + + public NotificationService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Sends a notification to a student about an available spot + /// + public async Task SendWaitlistNotificationAsync( + string studentEmail, + string studentName, + string courseName, + int position, + NotificationType notificationType) + { + var subject = $"Course Waitlist Update - {courseName}"; + var body = $"Dear {studentName},\n\n" + + $"A spot has become available in the course '{courseName}'.\n" + + $"You are currently in position {position} on the waitlist.\n\n" + + $"Please log in to your account to register for the course.\n\n" + + $"Best regards,\n" + + $"Course Registration System"; + + switch (notificationType) + { + case NotificationType.Email: + await SendEmailAsync(studentEmail, subject, body); + break; + case NotificationType.InApp: + // For now, just log - in-app notifications would require additional infrastructure + _logger.LogInformation($"In-app notification would be sent to {studentEmail}: {subject}"); + break; + case NotificationType.Both: + await SendEmailAsync(studentEmail, subject, body); + _logger.LogInformation($"In-app notification would be sent to {studentEmail}: {subject}"); + break; + } + } + + /// + /// Sends an email notification (simulated for now) + /// + public async Task SendEmailAsync(string toEmail, string subject, string body) + { + // In a real implementation, this would integrate with an email service like SendGrid, AWS SES, etc. + // For now, we'll just log the email + _logger.LogInformation($"EMAIL NOTIFICATION\n" + + $"To: {toEmail}\n" + + $"Subject: {subject}\n" + + $"Body: {body}"); + + await Task.CompletedTask; + } + + /// + /// Sends an in-app notification (placeholder for future implementation) + /// + public async Task SendInAppNotificationAsync(Guid studentId, string message) + { + // Placeholder for future implementation + // This would typically store a notification in a database or send via SignalR/WebSocket + _logger.LogInformation($"In-app notification for student {studentId}: {message}"); + await Task.CompletedTask; + } +} diff --git a/api/CourseRegistration.Application/Services/RegistrationService.cs b/api/CourseRegistration.Application/Services/RegistrationService.cs index eaf3e6b..6c71e18 100644 --- a/api/CourseRegistration.Application/Services/RegistrationService.cs +++ b/api/CourseRegistration.Application/Services/RegistrationService.cs @@ -14,14 +14,16 @@ public class RegistrationService : IRegistrationService { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; + private readonly IWaitlistService _waitlistService; /// /// Initializes a new instance of the RegistrationService /// - public RegistrationService(IUnitOfWork unitOfWork, IMapper mapper) + public RegistrationService(IUnitOfWork unitOfWork, IMapper mapper, IWaitlistService waitlistService) { _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + _waitlistService = waitlistService ?? throw new ArgumentNullException(nameof(waitlistService)); } /// @@ -113,6 +115,14 @@ public async Task CreateRegistrationAsync(CreateRegistrationDto throw new InvalidOperationException("Student is already registered for this course."); } + // Check if course is full + if (course.IsFull) + { + throw new InvalidOperationException( + $"Course is full (max enrollment: {course.MaxEnrollment}). " + + "Please join the waitlist instead."); + } + var registration = _mapper.Map(createRegistrationDto); registration.Status = RegistrationStatus.Pending; registration.RegistrationDate = DateTime.UtcNow; @@ -171,9 +181,19 @@ public async Task CancelRegistrationAsync(Guid id) throw new InvalidOperationException("Cannot cancel a completed registration."); } + var courseId = registration.CourseId; + var wasConfirmed = registration.Status == RegistrationStatus.Confirmed; + registration.Status = RegistrationStatus.Cancelled; _unitOfWork.Registrations.Update(registration); await _unitOfWork.SaveChangesAsync(); + + // If a confirmed registration was cancelled, notify the next person on the waitlist + if (wasConfirmed) + { + await _waitlistService.NotifyNextStudentAsync(courseId); + } + return true; } diff --git a/api/CourseRegistration.Application/Services/WaitlistService.cs b/api/CourseRegistration.Application/Services/WaitlistService.cs new file mode 100644 index 0000000..fdd3b5d --- /dev/null +++ b/api/CourseRegistration.Application/Services/WaitlistService.cs @@ -0,0 +1,285 @@ +using AutoMapper; +using CourseRegistration.Application.DTOs; +using CourseRegistration.Application.Interfaces; +using CourseRegistration.Domain.Entities; +using CourseRegistration.Domain.Interfaces; + +namespace CourseRegistration.Application.Services; + +/// +/// Service implementation for waitlist operations +/// +public class WaitlistService : IWaitlistService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + private readonly INotificationService _notificationService; + + /// + /// Initializes a new instance of the WaitlistService + /// + public WaitlistService( + IUnitOfWork unitOfWork, + IMapper mapper, + INotificationService notificationService) + { + _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); + } + + /// + /// Adds a student to a course waitlist + /// + public async Task JoinWaitlistAsync(CreateWaitlistEntryDto createWaitlistEntryDto) + { + // Validate student exists + var student = await _unitOfWork.Students.GetByIdAsync(createWaitlistEntryDto.StudentId); + if (student == null) + { + throw new InvalidOperationException("Student not found."); + } + + // Validate course exists + var course = await _unitOfWork.Courses.GetByIdAsync(createWaitlistEntryDto.CourseId); + if (course == null) + { + throw new InvalidOperationException("Course not found."); + } + + // Check if course is active + if (!course.IsActive) + { + throw new InvalidOperationException("Cannot join waitlist for an inactive course."); + } + + // Check if student is already registered for this course + var isRegistered = await _unitOfWork.Registrations.IsStudentRegisteredForCourseAsync( + createWaitlistEntryDto.StudentId, createWaitlistEntryDto.CourseId); + if (isRegistered) + { + throw new InvalidOperationException("Student is already registered for this course."); + } + + // Check if student is already on the waitlist + var isOnWaitlist = await _unitOfWork.Waitlists.IsStudentOnWaitlistAsync( + createWaitlistEntryDto.StudentId, createWaitlistEntryDto.CourseId); + if (isOnWaitlist) + { + throw new InvalidOperationException("Student is already on the waitlist for this course."); + } + + // Get next position in the waitlist + var nextPosition = await _unitOfWork.Waitlists.GetNextPositionAsync(createWaitlistEntryDto.CourseId); + + // Create waitlist entry + var waitlistEntry = _mapper.Map(createWaitlistEntryDto); + waitlistEntry.Position = nextPosition; + waitlistEntry.JoinedAt = DateTime.UtcNow; + waitlistEntry.IsActive = true; + + await _unitOfWork.Waitlists.AddAsync(waitlistEntry); + await _unitOfWork.SaveChangesAsync(); + + // Get the waitlist entry with related entities + var savedEntry = await _unitOfWork.Waitlists.GetWithDetailsAsync(waitlistEntry.WaitlistEntryId); + return _mapper.Map(savedEntry); + } + + /// + /// Removes a student from a course waitlist + /// + public async Task LeaveWaitlistAsync(Guid waitlistEntryId) + { + var waitlistEntry = await _unitOfWork.Waitlists.GetByIdAsync(waitlistEntryId); + if (waitlistEntry == null || !waitlistEntry.IsActive) + { + return false; + } + + var courseId = waitlistEntry.CourseId; + var position = waitlistEntry.Position; + + // Mark as inactive instead of deleting + waitlistEntry.IsActive = false; + _unitOfWork.Waitlists.Update(waitlistEntry); + await _unitOfWork.SaveChangesAsync(); + + // Reorder remaining waitlist entries + await _unitOfWork.Waitlists.ReorderWaitlistAsync(courseId, position); + + return true; + } + + /// + /// Gets active waitlist entries for a course + /// + public async Task> GetCourseWaitlistAsync(Guid courseId) + { + var waitlistEntries = await _unitOfWork.Waitlists.GetActiveWaitlistForCourseAsync(courseId); + return _mapper.Map>(waitlistEntries); + } + + /// + /// Gets a student's active waitlist entries + /// + public async Task> GetStudentWaitlistsAsync(Guid studentId) + { + var waitlistEntries = await _unitOfWork.Waitlists.GetStudentActiveWaitlistEntriesAsync(studentId); + return _mapper.Map>(waitlistEntries); + } + + /// + /// Gets a specific waitlist entry by ID + /// + public async Task GetWaitlistEntryAsync(Guid waitlistEntryId) + { + var waitlistEntry = await _unitOfWork.Waitlists.GetWithDetailsAsync(waitlistEntryId); + return waitlistEntry != null ? _mapper.Map(waitlistEntry) : null; + } + + /// + /// Updates a waitlist entry (admin function) + /// + public async Task UpdateWaitlistEntryAsync(Guid waitlistEntryId, UpdateWaitlistEntryDto updateDto) + { + var existingEntry = await _unitOfWork.Waitlists.GetWithDetailsAsync(waitlistEntryId); + if (existingEntry == null) + { + return null; + } + + // Update only the fields that are provided + if (updateDto.NotificationPreference.HasValue) + { + existingEntry.NotificationPreference = updateDto.NotificationPreference.Value; + } + + if (updateDto.Notes != null) + { + existingEntry.Notes = updateDto.Notes; + } + + // Handle position change if provided (admin reordering) + if (updateDto.Position.HasValue && updateDto.Position.Value != existingEntry.Position) + { + var oldPosition = existingEntry.Position; + var newPosition = updateDto.Position.Value; + + // Get all active waitlist entries for the course + var allEntries = await _unitOfWork.Waitlists.GetActiveWaitlistForCourseAsync(existingEntry.CourseId); + var entriesList = allEntries.ToList(); + + // Validate new position + if (newPosition < 1 || newPosition > entriesList.Count) + { + throw new InvalidOperationException("Invalid position specified."); + } + + // Reorder entries + if (newPosition < oldPosition) + { + // Moving up in the list + foreach (var entry in entriesList.Where(e => e.Position >= newPosition && e.Position < oldPosition)) + { + entry.Position++; + } + } + else + { + // Moving down in the list + foreach (var entry in entriesList.Where(e => e.Position > oldPosition && e.Position <= newPosition)) + { + entry.Position--; + } + } + + existingEntry.Position = newPosition; + } + + _unitOfWork.Waitlists.Update(existingEntry); + await _unitOfWork.SaveChangesAsync(); + + return _mapper.Map(existingEntry); + } + + /// + /// Notifies the next student on the waitlist when a spot becomes available + /// + public async Task NotifyNextStudentAsync(Guid courseId) + { + var waitlistEntries = await _unitOfWork.Waitlists.GetActiveWaitlistForCourseAsync(courseId); + var nextEntry = waitlistEntries.OrderBy(w => w.Position).FirstOrDefault(); + + if (nextEntry != null) + { + // Send notification based on preference + await _notificationService.SendWaitlistNotificationAsync( + nextEntry.Student.Email, + nextEntry.Student.FullName, + nextEntry.Course.CourseName, + nextEntry.Position, + nextEntry.NotificationPreference); + + // Mark as notified + nextEntry.NotifiedAt = DateTime.UtcNow; + _unitOfWork.Waitlists.Update(nextEntry); + await _unitOfWork.SaveChangesAsync(); + } + } + + /// + /// Clears the entire waitlist for a course (admin function) + /// + public async Task ClearWaitlistAsync(Guid courseId) + { + var waitlistEntries = await _unitOfWork.Waitlists.GetActiveWaitlistForCourseAsync(courseId); + + foreach (var entry in waitlistEntries) + { + entry.IsActive = false; + } + + await _unitOfWork.SaveChangesAsync(); + return true; + } + + /// + /// Reorders waitlist entries (admin function) + /// + public async Task ReorderWaitlistAsync(Guid courseId, Dictionary newPositions) + { + var waitlistEntries = await _unitOfWork.Waitlists.GetActiveWaitlistForCourseAsync(courseId); + var entriesList = waitlistEntries.ToList(); + + // Validate that all positions are unique and sequential + var positions = newPositions.Values.OrderBy(p => p).ToList(); + for (int i = 0; i < positions.Count; i++) + { + if (positions[i] != i + 1) + { + throw new InvalidOperationException("Positions must be sequential starting from 1."); + } + } + + // Update positions + foreach (var entry in entriesList) + { + if (newPositions.TryGetValue(entry.WaitlistEntryId, out int newPosition)) + { + entry.Position = newPosition; + } + } + + await _unitOfWork.SaveChangesAsync(); + return true; + } + + /// + /// Checks if a student is on the waitlist for a course + /// + public async Task IsStudentOnWaitlistAsync(Guid studentId, Guid courseId) + { + return await _unitOfWork.Waitlists.IsStudentOnWaitlistAsync(studentId, courseId); + } +} diff --git a/api/CourseRegistration.Domain/Entities/Course.cs b/api/CourseRegistration.Domain/Entities/Course.cs index dc3f941..9867c11 100644 --- a/api/CourseRegistration.Domain/Entities/Course.cs +++ b/api/CourseRegistration.Domain/Entities/Course.cs @@ -53,6 +53,12 @@ public class Course [MaxLength(100)] public string Schedule { get; set; } = string.Empty; + /// + /// Maximum number of students allowed to enroll in the course + /// + [Required] + public int MaxEnrollment { get; set; } = 30; + /// /// Indicates if the course is active /// @@ -73,9 +79,20 @@ public class Course /// public virtual ICollection Registrations { get; set; } = new List(); + /// + /// Navigation property for course waitlist entries + /// + public virtual ICollection WaitlistEntries { get; set; } = new List(); + /// /// Computed property for current enrollment count /// [NotMapped] public int CurrentEnrollment => Registrations?.Count(r => r.Status == Enums.RegistrationStatus.Confirmed) ?? 0; + + /// + /// Computed property to check if course is full + /// + [NotMapped] + public bool IsFull => CurrentEnrollment >= MaxEnrollment; } \ No newline at end of file diff --git a/api/CourseRegistration.Domain/Entities/Student.cs b/api/CourseRegistration.Domain/Entities/Student.cs index ee4a5b1..1737c8e 100644 --- a/api/CourseRegistration.Domain/Entities/Student.cs +++ b/api/CourseRegistration.Domain/Entities/Student.cs @@ -69,6 +69,11 @@ public class Student /// public virtual ICollection Registrations { get; set; } = new List(); + /// + /// Navigation property for student's waitlist entries + /// + public virtual ICollection WaitlistEntries { get; set; } = new List(); + /// /// Computed property for student's full name /// diff --git a/api/CourseRegistration.Domain/Entities/WaitlistEntry.cs b/api/CourseRegistration.Domain/Entities/WaitlistEntry.cs new file mode 100644 index 0000000..b5e9bc5 --- /dev/null +++ b/api/CourseRegistration.Domain/Entities/WaitlistEntry.cs @@ -0,0 +1,76 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using CourseRegistration.Domain.Enums; + +namespace CourseRegistration.Domain.Entities; + +/// +/// Represents a student's entry on a course waitlist +/// +public class WaitlistEntry +{ + /// + /// Unique identifier for the waitlist entry + /// + [Key] + public Guid WaitlistEntryId { get; set; } = Guid.NewGuid(); + + /// + /// Foreign key to the student + /// + [Required] + public Guid StudentId { get; set; } + + /// + /// Foreign key to the course + /// + [Required] + public Guid CourseId { get; set; } + + /// + /// Date and time when the student joined the waitlist + /// + [Required] + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; + + /// + /// Position in the waitlist (1-based) + /// + [Required] + public int Position { get; set; } + + /// + /// Student's notification preference + /// + [Required] + public NotificationType NotificationPreference { get; set; } = NotificationType.Email; + + /// + /// Indicates if the student is still active on the waitlist + /// + [Required] + public bool IsActive { get; set; } = true; + + /// + /// Date and time when the student was notified about an available spot + /// + public DateTime? NotifiedAt { get; set; } + + /// + /// Notes about the waitlist entry (e.g., admin comments) + /// + [MaxLength(500)] + public string? Notes { get; set; } + + /// + /// Navigation property to the student + /// + [ForeignKey(nameof(StudentId))] + public virtual Student Student { get; set; } = null!; + + /// + /// Navigation property to the course + /// + [ForeignKey(nameof(CourseId))] + public virtual Course Course { get; set; } = null!; +} diff --git a/api/CourseRegistration.Domain/Enums/NotificationType.cs b/api/CourseRegistration.Domain/Enums/NotificationType.cs new file mode 100644 index 0000000..9edea25 --- /dev/null +++ b/api/CourseRegistration.Domain/Enums/NotificationType.cs @@ -0,0 +1,22 @@ +namespace CourseRegistration.Domain.Enums; + +/// +/// Represents the type of notification preference for waitlist +/// +public enum NotificationType +{ + /// + /// Email notification + /// + Email = 0, + + /// + /// In-app notification + /// + InApp = 1, + + /// + /// Both email and in-app notification + /// + Both = 2 +} diff --git a/api/CourseRegistration.Domain/Interfaces/IUnitOfWork.cs b/api/CourseRegistration.Domain/Interfaces/IUnitOfWork.cs index c56d8f4..476e553 100644 --- a/api/CourseRegistration.Domain/Interfaces/IUnitOfWork.cs +++ b/api/CourseRegistration.Domain/Interfaces/IUnitOfWork.cs @@ -20,6 +20,11 @@ public interface IUnitOfWork : IDisposable /// IRegistrationRepository Registrations { get; } + /// + /// Waitlist repository + /// + IWaitlistRepository Waitlists { get; } + /// /// Saves all changes made in this unit of work asynchronously /// diff --git a/api/CourseRegistration.Domain/Interfaces/IWaitlistRepository.cs b/api/CourseRegistration.Domain/Interfaces/IWaitlistRepository.cs new file mode 100644 index 0000000..812fe8d --- /dev/null +++ b/api/CourseRegistration.Domain/Interfaces/IWaitlistRepository.cs @@ -0,0 +1,44 @@ +using CourseRegistration.Domain.Entities; + +namespace CourseRegistration.Domain.Interfaces; + +/// +/// Repository interface for WaitlistEntry operations +/// +public interface IWaitlistRepository : IRepository +{ + /// + /// Gets active waitlist entries for a specific course, ordered by position + /// + Task> GetActiveWaitlistForCourseAsync(Guid courseId); + + /// + /// Gets a student's active waitlist entry for a specific course + /// + Task GetActiveWaitlistEntryAsync(Guid studentId, Guid courseId); + + /// + /// Gets all active waitlist entries for a student + /// + Task> GetStudentActiveWaitlistEntriesAsync(Guid studentId); + + /// + /// Checks if a student is on the active waitlist for a course + /// + Task IsStudentOnWaitlistAsync(Guid studentId, Guid courseId); + + /// + /// Gets the next available position for a course waitlist + /// + Task GetNextPositionAsync(Guid courseId); + + /// + /// Gets waitlist entry with student and course details + /// + Task GetWithDetailsAsync(Guid waitlistEntryId); + + /// + /// Reorders waitlist positions after a student is removed + /// + Task ReorderWaitlistAsync(Guid courseId, int removedPosition); +} diff --git a/api/CourseRegistration.Infrastructure/Data/CourseRegistrationDbContext.cs b/api/CourseRegistration.Infrastructure/Data/CourseRegistrationDbContext.cs index 7491da8..62ff885 100644 --- a/api/CourseRegistration.Infrastructure/Data/CourseRegistrationDbContext.cs +++ b/api/CourseRegistration.Infrastructure/Data/CourseRegistrationDbContext.cs @@ -32,6 +32,11 @@ public CourseRegistrationDbContext(DbContextOptions /// public DbSet Registrations { get; set; } = null!; + /// + /// WaitlistEntries DbSet + /// + public DbSet WaitlistEntries { get; set; } = null!; + /// /// Configures the model relationships and constraints /// @@ -58,6 +63,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(r => r.Student) .HasForeignKey(r => r.StudentId) .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(s => s.WaitlistEntries) + .WithOne(w => w.Student) + .HasForeignKey(w => w.StudentId) + .OnDelete(DeleteBehavior.Cascade); }); // Configure Course entity @@ -70,6 +80,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(c => c.StartDate).IsRequired(); entity.Property(c => c.EndDate).IsRequired(); entity.Property(c => c.Schedule).IsRequired().HasMaxLength(100); + entity.Property(c => c.MaxEnrollment).IsRequired(); entity.Property(c => c.IsActive).IsRequired(); entity.Property(c => c.CreatedAt).IsRequired(); entity.Property(c => c.UpdatedAt).IsRequired(); @@ -79,6 +90,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(r => r.Course) .HasForeignKey(r => r.CourseId) .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(c => c.WaitlistEntries) + .WithOne(w => w.Course) + .HasForeignKey(w => w.CourseId) + .OnDelete(DeleteBehavior.Cascade); }); // Configure Registration entity @@ -108,6 +124,35 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(r => r.CourseId) .OnDelete(DeleteBehavior.Cascade); }); + + // Configure WaitlistEntry entity + modelBuilder.Entity(entity => + { + entity.HasKey(w => w.WaitlistEntryId); + entity.Property(w => w.StudentId).IsRequired(); + entity.Property(w => w.CourseId).IsRequired(); + entity.Property(w => w.JoinedAt).IsRequired(); + entity.Property(w => w.Position).IsRequired(); + entity.Property(w => w.NotificationPreference).IsRequired() + .HasConversion(); + entity.Property(w => w.IsActive).IsRequired(); + entity.Property(w => w.Notes).HasMaxLength(500); + + // Create index for querying active waitlist entries + // Note: Only one active waitlist entry per student per course should exist + entity.HasIndex(w => new { w.StudentId, w.CourseId, w.IsActive }); + + // Configure relationships + entity.HasOne(w => w.Student) + .WithMany(s => s.WaitlistEntries) + .HasForeignKey(w => w.StudentId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(w => w.Course) + .WithMany(c => c.WaitlistEntries) + .HasForeignKey(w => w.CourseId) + .OnDelete(DeleteBehavior.Cascade); + }); } /// diff --git a/api/CourseRegistration.Infrastructure/Repositories/UnitOfWork.cs b/api/CourseRegistration.Infrastructure/Repositories/UnitOfWork.cs index 63a4e8d..e57fa0e 100644 --- a/api/CourseRegistration.Infrastructure/Repositories/UnitOfWork.cs +++ b/api/CourseRegistration.Infrastructure/Repositories/UnitOfWork.cs @@ -18,6 +18,7 @@ public class UnitOfWork : IUnitOfWork private IStudentRepository? _students; private ICourseRepository? _courses; private IRegistrationRepository? _registrations; + private IWaitlistRepository? _waitlists; /// /// Initializes a new instance of the UnitOfWork @@ -64,6 +65,18 @@ public IRegistrationRepository Registrations } } + /// + /// Waitlist repository + /// + public IWaitlistRepository Waitlists + { + get + { + _waitlists ??= new WaitlistRepository(_context); + return _waitlists; + } + } + /// /// Saves all changes made in this unit of work asynchronously /// diff --git a/api/CourseRegistration.Infrastructure/Repositories/WaitlistRepository.cs b/api/CourseRegistration.Infrastructure/Repositories/WaitlistRepository.cs new file mode 100644 index 0000000..e814304 --- /dev/null +++ b/api/CourseRegistration.Infrastructure/Repositories/WaitlistRepository.cs @@ -0,0 +1,105 @@ +using CourseRegistration.Domain.Entities; +using CourseRegistration.Domain.Interfaces; +using CourseRegistration.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace CourseRegistration.Infrastructure.Repositories; + +/// +/// Repository implementation for WaitlistEntry operations +/// +public class WaitlistRepository : Repository, IWaitlistRepository +{ + /// + /// Initializes a new instance of the WaitlistRepository + /// + public WaitlistRepository(CourseRegistrationDbContext context) : base(context) + { + } + + /// + /// Gets active waitlist entries for a specific course, ordered by position + /// + public async Task> GetActiveWaitlistForCourseAsync(Guid courseId) + { + return await _context.WaitlistEntries + .Include(w => w.Student) + .Include(w => w.Course) + .Where(w => w.CourseId == courseId && w.IsActive) + .OrderBy(w => w.Position) + .ToListAsync(); + } + + /// + /// Gets a student's active waitlist entry for a specific course + /// + public async Task GetActiveWaitlistEntryAsync(Guid studentId, Guid courseId) + { + return await _context.WaitlistEntries + .Include(w => w.Student) + .Include(w => w.Course) + .FirstOrDefaultAsync(w => w.StudentId == studentId && w.CourseId == courseId && w.IsActive); + } + + /// + /// Gets all active waitlist entries for a student + /// + public async Task> GetStudentActiveWaitlistEntriesAsync(Guid studentId) + { + return await _context.WaitlistEntries + .Include(w => w.Student) + .Include(w => w.Course) + .Where(w => w.StudentId == studentId && w.IsActive) + .OrderBy(w => w.JoinedAt) + .ToListAsync(); + } + + /// + /// Checks if a student is on the active waitlist for a course + /// + public async Task IsStudentOnWaitlistAsync(Guid studentId, Guid courseId) + { + return await _context.WaitlistEntries + .AnyAsync(w => w.StudentId == studentId && w.CourseId == courseId && w.IsActive); + } + + /// + /// Gets the next available position for a course waitlist + /// + public async Task GetNextPositionAsync(Guid courseId) + { + var maxPosition = await _context.WaitlistEntries + .Where(w => w.CourseId == courseId && w.IsActive) + .MaxAsync(w => (int?)w.Position); + + return (maxPosition ?? 0) + 1; + } + + /// + /// Gets waitlist entry with student and course details + /// + public async Task GetWithDetailsAsync(Guid waitlistEntryId) + { + return await _context.WaitlistEntries + .Include(w => w.Student) + .Include(w => w.Course) + .FirstOrDefaultAsync(w => w.WaitlistEntryId == waitlistEntryId); + } + + /// + /// Reorders waitlist positions after a student is removed + /// + public async Task ReorderWaitlistAsync(Guid courseId, int removedPosition) + { + var entriesToUpdate = await _context.WaitlistEntries + .Where(w => w.CourseId == courseId && w.IsActive && w.Position > removedPosition) + .ToListAsync(); + + foreach (var entry in entriesToUpdate) + { + entry.Position--; + } + + await _context.SaveChangesAsync(); + } +} From d41e0789225acb8bb5e6e44eddb4541875bfd395 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 14:39:34 +0000 Subject: [PATCH 3/4] Update CourseRepository to include navigation properties for enrollment tracking Co-authored-by: Hemavathi15sg <224925058+Hemavathi15sg@users.noreply.github.com> --- .../Repositories/CourseRepository.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/CourseRegistration.Infrastructure/Repositories/CourseRepository.cs b/api/CourseRegistration.Infrastructure/Repositories/CourseRepository.cs index 985a879..90a7b3c 100644 --- a/api/CourseRegistration.Infrastructure/Repositories/CourseRepository.cs +++ b/api/CourseRegistration.Infrastructure/Repositories/CourseRepository.cs @@ -98,7 +98,7 @@ public async Task> GetCoursesByInstructorAsync(string instru } /// - /// Override GetPagedAsync to only return active courses + /// Override GetPagedAsync to only return active courses with registrations /// public override async Task> GetPagedAsync(int page, int pageSize) { @@ -107,6 +107,8 @@ public override async Task> GetPagedAsync(int page, int page if (pageSize > 100) pageSize = 100; return await _dbSet + .Include(c => c.Registrations) + .Include(c => c.WaitlistEntries) .Where(c => c.IsActive) .OrderBy(c => c.CourseName) .Skip((page - 1) * pageSize) @@ -115,11 +117,13 @@ public override async Task> GetPagedAsync(int page, int page } /// - /// Override GetByIdAsync to only return active courses + /// Override GetByIdAsync to only return active courses with registrations /// public override async Task GetByIdAsync(Guid id) { return await _dbSet + .Include(c => c.Registrations) + .Include(c => c.WaitlistEntries) .Where(c => c.IsActive) .FirstOrDefaultAsync(c => c.CourseId == id); } From 1a6fa61d69b61efc24e36bfd3e6b0f7bdc050b1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 14:43:05 +0000 Subject: [PATCH 4/4] Address code review feedback: improve validation and performance Co-authored-by: Hemavathi15sg <224925058+Hemavathi15sg@users.noreply.github.com> --- .../Services/NotificationService.cs | 8 ++++---- .../Services/WaitlistService.cs | 20 +++++++++++++++---- .../Repositories/WaitlistRepository.cs | 11 +++++++--- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/api/CourseRegistration.Application/Services/NotificationService.cs b/api/CourseRegistration.Application/Services/NotificationService.cs index 92ec73d..3715f1a 100644 --- a/api/CourseRegistration.Application/Services/NotificationService.cs +++ b/api/CourseRegistration.Application/Services/NotificationService.cs @@ -53,7 +53,7 @@ public async Task SendWaitlistNotificationAsync( /// /// Sends an email notification (simulated for now) /// - public async Task SendEmailAsync(string toEmail, string subject, string body) + public Task SendEmailAsync(string toEmail, string subject, string body) { // In a real implementation, this would integrate with an email service like SendGrid, AWS SES, etc. // For now, we'll just log the email @@ -62,17 +62,17 @@ public async Task SendEmailAsync(string toEmail, string subject, string body) $"Subject: {subject}\n" + $"Body: {body}"); - await Task.CompletedTask; + return Task.CompletedTask; } /// /// Sends an in-app notification (placeholder for future implementation) /// - public async Task SendInAppNotificationAsync(Guid studentId, string message) + public Task SendInAppNotificationAsync(Guid studentId, string message) { // Placeholder for future implementation // This would typically store a notification in a database or send via SignalR/WebSocket _logger.LogInformation($"In-app notification for student {studentId}: {message}"); - await Task.CompletedTask; + return Task.CompletedTask; } } diff --git a/api/CourseRegistration.Application/Services/WaitlistService.cs b/api/CourseRegistration.Application/Services/WaitlistService.cs index fdd3b5d..d8d75f2 100644 --- a/api/CourseRegistration.Application/Services/WaitlistService.cs +++ b/api/CourseRegistration.Application/Services/WaitlistService.cs @@ -252,6 +252,21 @@ public async Task ReorderWaitlistAsync(Guid courseId, Dictionary !newPositions.ContainsKey(e.WaitlistEntryId)).ToList(); + if (missingEntries.Any()) + { + throw new InvalidOperationException( + $"Missing position assignments for {missingEntries.Count} waitlist entry/entries."); + } + // Validate that all positions are unique and sequential var positions = newPositions.Values.OrderBy(p => p).ToList(); for (int i = 0; i < positions.Count; i++) @@ -265,10 +280,7 @@ public async Task ReorderWaitlistAsync(Guid courseId, Dictionary IsStudentOnWaitlistAsync(Guid studentId, Guid courseId) /// public async Task GetNextPositionAsync(Guid courseId) { - var maxPosition = await _context.WaitlistEntries + var activeEntries = await _context.WaitlistEntries .Where(w => w.CourseId == courseId && w.IsActive) - .MaxAsync(w => (int?)w.Position); + .ToListAsync(); + + if (!activeEntries.Any()) + { + return 1; + } - return (maxPosition ?? 0) + 1; + return activeEntries.Max(w => w.Position) + 1; } ///