diff --git a/src/backend/Application/Application.csproj b/src/backend/Application/Application.csproj new file mode 100644 index 0000000..f1c4b91 --- /dev/null +++ b/src/backend/Application/Application.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/backend/Application/Common/Behaviors/ValidationBehavior.cs b/src/backend/Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..7edad0b --- /dev/null +++ b/src/backend/Application/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,33 @@ +namespace Application.Common.Behaviors; + +using FluentValidation; +using MediatR; + +public class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken ct) + { + if (!validators.Any()) + return await next(); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, ct))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + throw new ValidationException(failures); + + return await next(); + } +} diff --git a/src/backend/Application/DependencyInjection.cs b/src/backend/Application/DependencyInjection.cs new file mode 100644 index 0000000..2278412 --- /dev/null +++ b/src/backend/Application/DependencyInjection.cs @@ -0,0 +1,22 @@ +namespace Application; + +using Application.Common.Behaviors; +using Domain.Services; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + var assembly = typeof(DependencyInjection).Assembly; + + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly)); + services.AddValidatorsFromAssembly(assembly); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + services.AddScoped(); + + return services; + } +} diff --git a/src/backend/Application/Enrollments/Commands/EnrollStudentCommand.cs b/src/backend/Application/Enrollments/Commands/EnrollStudentCommand.cs new file mode 100644 index 0000000..d1672fb --- /dev/null +++ b/src/backend/Application/Enrollments/Commands/EnrollStudentCommand.cs @@ -0,0 +1,39 @@ +namespace Application.Enrollments.Commands; + +using Application.Students.DTOs; +using Domain.Exceptions; +using Domain.Ports.Repositories; +using Domain.Services; +using MediatR; + +public record EnrollStudentCommand(int StudentId, int SubjectId) : IRequest; + +public class EnrollStudentHandler( + IStudentRepository studentRepository, + ISubjectRepository subjectRepository, + IEnrollmentRepository enrollmentRepository, + EnrollmentDomainService enrollmentService, + IUnitOfWork unitOfWork) + : IRequestHandler +{ + public async Task Handle(EnrollStudentCommand request, CancellationToken ct) + { + var student = await studentRepository.GetByIdWithEnrollmentsAsync(request.StudentId, ct) + ?? throw new StudentNotFoundException(request.StudentId); + + var subject = await subjectRepository.GetByIdWithProfessorAsync(request.SubjectId, ct) + ?? throw new SubjectNotFoundException(request.SubjectId); + + var enrollment = enrollmentService.CreateEnrollment(student, subject); + enrollmentRepository.Add(enrollment); + await unitOfWork.SaveChangesAsync(ct); + + return new EnrollmentDto( + enrollment.Id, + subject.Id, + subject.Name, + subject.Credits, + subject.Professor?.Name ?? "", + enrollment.EnrolledAt); + } +} diff --git a/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs b/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs new file mode 100644 index 0000000..3fac178 --- /dev/null +++ b/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs @@ -0,0 +1,24 @@ +namespace Application.Enrollments.Commands; + +using FluentValidation; + +public class EnrollStudentValidator : AbstractValidator +{ + public EnrollStudentValidator() + { + RuleFor(x => x.StudentId) + .GreaterThan(0).WithMessage("Invalid student ID"); + + RuleFor(x => x.SubjectId) + .GreaterThan(0).WithMessage("Invalid subject ID"); + } +} + +public class UnenrollStudentValidator : AbstractValidator +{ + public UnenrollStudentValidator() + { + RuleFor(x => x.EnrollmentId) + .GreaterThan(0).WithMessage("Invalid enrollment ID"); + } +} diff --git a/src/backend/Application/Enrollments/Commands/UnenrollStudentCommand.cs b/src/backend/Application/Enrollments/Commands/UnenrollStudentCommand.cs new file mode 100644 index 0000000..a52d1a0 --- /dev/null +++ b/src/backend/Application/Enrollments/Commands/UnenrollStudentCommand.cs @@ -0,0 +1,28 @@ +namespace Application.Enrollments.Commands; + +using Domain.Exceptions; +using Domain.Ports.Repositories; +using MediatR; + +public record UnenrollStudentCommand(int EnrollmentId) : IRequest; + +public class UnenrollStudentHandler( + IEnrollmentRepository enrollmentRepository, + IStudentRepository studentRepository, + IUnitOfWork unitOfWork) + : IRequestHandler +{ + public async Task Handle(UnenrollStudentCommand request, CancellationToken ct) + { + var enrollment = await enrollmentRepository.GetByIdAsync(request.EnrollmentId, ct) + ?? throw new EnrollmentNotFoundException(request.EnrollmentId); + + var student = await studentRepository.GetByIdWithEnrollmentsAsync(enrollment.StudentId, ct); + student?.RemoveEnrollment(enrollment); + + enrollmentRepository.Delete(enrollment); + await unitOfWork.SaveChangesAsync(ct); + + return true; + } +} diff --git a/src/backend/Application/Enrollments/DTOs/EnrollmentDtos.cs b/src/backend/Application/Enrollments/DTOs/EnrollmentDtos.cs new file mode 100644 index 0000000..48df6be --- /dev/null +++ b/src/backend/Application/Enrollments/DTOs/EnrollmentDtos.cs @@ -0,0 +1,10 @@ +namespace Application.Enrollments.DTOs; + +public record EnrollStudentRequest(int StudentId, int SubjectId); + +public record ClassmatesBySubjectDto( + int SubjectId, + string SubjectName, + IReadOnlyList Classmates); + +public record ClassmateDto(int StudentId, string Name); diff --git a/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs b/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs new file mode 100644 index 0000000..1b7ae3f --- /dev/null +++ b/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs @@ -0,0 +1,38 @@ +namespace Application.Enrollments.Queries; + +using Application.Enrollments.DTOs; +using Domain.Exceptions; +using Domain.Ports.Repositories; +using MediatR; + +public record GetClassmatesQuery(int StudentId) : IRequest>; + +public class GetClassmatesHandler( + IStudentRepository studentRepository, + IEnrollmentRepository enrollmentRepository) + : IRequestHandler> +{ + public async Task> Handle( + GetClassmatesQuery request, + CancellationToken ct) + { + var student = await studentRepository.GetByIdWithEnrollmentsAsync(request.StudentId, ct) + ?? throw new StudentNotFoundException(request.StudentId); + + if (student.Enrollments.Count == 0) + return []; + + // Single batch query instead of N queries (eliminates N+1) + var subjectIds = student.Enrollments.Select(e => e.SubjectId); + var classmatesBySubject = await enrollmentRepository + .GetClassmatesBatchAsync(request.StudentId, subjectIds, ct); + + return student.Enrollments.Select(enrollment => new ClassmatesBySubjectDto( + enrollment.SubjectId, + enrollment.Subject?.Name ?? "", + classmatesBySubject.TryGetValue(enrollment.SubjectId, out var classmates) + ? classmates.Select(c => new ClassmateDto(c.Id, c.Name)).ToList() + : [] + )).ToList(); + } +} diff --git a/src/backend/Application/Professors/DTOs/ProfessorDto.cs b/src/backend/Application/Professors/DTOs/ProfessorDto.cs new file mode 100644 index 0000000..aa61fcb --- /dev/null +++ b/src/backend/Application/Professors/DTOs/ProfessorDto.cs @@ -0,0 +1,8 @@ +namespace Application.Professors.DTOs; + +public record ProfessorDto( + int Id, + string Name, + IReadOnlyList Subjects); + +public record ProfessorSubjectDto(int Id, string Name, int Credits); diff --git a/src/backend/Application/Professors/Queries/GetProfessorsQuery.cs b/src/backend/Application/Professors/Queries/GetProfessorsQuery.cs new file mode 100644 index 0000000..076882d --- /dev/null +++ b/src/backend/Application/Professors/Queries/GetProfessorsQuery.cs @@ -0,0 +1,22 @@ +namespace Application.Professors.Queries; + +using Application.Professors.DTOs; +using Domain.Ports.Repositories; +using MediatR; + +public record GetProfessorsQuery : IRequest>; + +public class GetProfessorsHandler(IProfessorRepository professorRepository) + : IRequestHandler> +{ + public async Task> Handle(GetProfessorsQuery request, CancellationToken ct) + { + // Direct projection - only SELECT needed columns + return await professorRepository.GetAllProjectedAsync( + p => new ProfessorDto( + p.Id, + p.Name, + p.Subjects.Select(s => new ProfessorSubjectDto(s.Id, s.Name, s.Credits)).ToList() + ), ct); + } +} diff --git a/src/backend/Application/Students/Commands/CreateStudentCommand.cs b/src/backend/Application/Students/Commands/CreateStudentCommand.cs new file mode 100644 index 0000000..f0d68f8 --- /dev/null +++ b/src/backend/Application/Students/Commands/CreateStudentCommand.cs @@ -0,0 +1,26 @@ +namespace Application.Students.Commands; + +using Application.Students.DTOs; +using Domain.Entities; +using Domain.Ports.Repositories; +using Domain.ValueObjects; +using MediatR; + +public record CreateStudentCommand(string Name, string Email) : IRequest; + +public class CreateStudentHandler( + IStudentRepository studentRepository, + IUnitOfWork unitOfWork) + : IRequestHandler +{ + public async Task Handle(CreateStudentCommand request, CancellationToken ct) + { + var email = Email.Create(request.Email); + var student = new Student(request.Name, email); + + studentRepository.Add(student); + await unitOfWork.SaveChangesAsync(ct); + + return new StudentDto(student.Id, student.Name, student.Email.Value, 0, []); + } +} diff --git a/src/backend/Application/Students/Commands/CreateStudentValidator.cs b/src/backend/Application/Students/Commands/CreateStudentValidator.cs new file mode 100644 index 0000000..e371de4 --- /dev/null +++ b/src/backend/Application/Students/Commands/CreateStudentValidator.cs @@ -0,0 +1,70 @@ +namespace Application.Students.Commands; + +using System.Text.RegularExpressions; +using Domain.Ports.Repositories; +using FluentValidation; + +public partial class CreateStudentValidator : AbstractValidator +{ + [GeneratedRegex(@"^[\p{L}\s\-'\.]+$", RegexOptions.Compiled)] + private static partial Regex SafeNamePattern(); + + [GeneratedRegex(@"<[^>]*>|javascript:|on\w+=", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex DangerousPattern(); + + public CreateStudentValidator(IStudentRepository studentRepository) + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MinimumLength(3).WithMessage("Name must be at least 3 characters") + .MaximumLength(100).WithMessage("Name must not exceed 100 characters") + .Matches(SafeNamePattern()).WithMessage("Name contains invalid characters") + .Must(NotContainDangerousContent).WithMessage("Name contains prohibited content"); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .MaximumLength(254).WithMessage("Email must not exceed 254 characters") + .EmailAddress().WithMessage("Invalid email format") + .Must(NotContainDangerousContent).WithMessage("Email contains prohibited content") + .MustAsync(async (email, ct) => + !await studentRepository.EmailExistsAsync(email, null, ct)) + .WithMessage("Email already exists"); + } + + private static bool NotContainDangerousContent(string? value) => + string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value); +} + +public partial class UpdateStudentValidator : AbstractValidator +{ + [GeneratedRegex(@"^[\p{L}\s\-'\.]+$", RegexOptions.Compiled)] + private static partial Regex SafeNamePattern(); + + [GeneratedRegex(@"<[^>]*>|javascript:|on\w+=", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex DangerousPattern(); + + public UpdateStudentValidator(IStudentRepository studentRepository) + { + RuleFor(x => x.Id) + .GreaterThan(0).WithMessage("Invalid student ID"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MinimumLength(3).WithMessage("Name must be at least 3 characters") + .MaximumLength(100).WithMessage("Name must not exceed 100 characters") + .Matches(SafeNamePattern()).WithMessage("Name contains invalid characters") + .Must(NotContainDangerousContent).WithMessage("Name contains prohibited content"); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .MaximumLength(254).WithMessage("Email must not exceed 254 characters") + .EmailAddress().WithMessage("Invalid email format") + .Must(NotContainDangerousContent).WithMessage("Email contains prohibited content") + .MustAsync(async (command, email, ct) => + !await studentRepository.EmailExistsAsync(email, command.Id, ct)) + .WithMessage("Email already exists"); + } + + private static bool NotContainDangerousContent(string? value) => + string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value); +} diff --git a/src/backend/Application/Students/Commands/DeleteStudentCommand.cs b/src/backend/Application/Students/Commands/DeleteStudentCommand.cs new file mode 100644 index 0000000..ab4a7c9 --- /dev/null +++ b/src/backend/Application/Students/Commands/DeleteStudentCommand.cs @@ -0,0 +1,24 @@ +namespace Application.Students.Commands; + +using Domain.Exceptions; +using Domain.Ports.Repositories; +using MediatR; + +public record DeleteStudentCommand(int Id) : IRequest; + +public class DeleteStudentHandler( + IStudentRepository studentRepository, + IUnitOfWork unitOfWork) + : IRequestHandler +{ + public async Task Handle(DeleteStudentCommand request, CancellationToken ct) + { + var student = await studentRepository.GetByIdAsync(request.Id, ct) + ?? throw new StudentNotFoundException(request.Id); + + studentRepository.Delete(student); + await unitOfWork.SaveChangesAsync(ct); + + return true; + } +} diff --git a/src/backend/Application/Students/Commands/UpdateStudentCommand.cs b/src/backend/Application/Students/Commands/UpdateStudentCommand.cs new file mode 100644 index 0000000..9823473 --- /dev/null +++ b/src/backend/Application/Students/Commands/UpdateStudentCommand.cs @@ -0,0 +1,41 @@ +namespace Application.Students.Commands; + +using Application.Students.DTOs; +using Domain.Exceptions; +using Domain.Ports.Repositories; +using Domain.ValueObjects; +using MediatR; + +public record UpdateStudentCommand(int Id, string Name, string Email) : IRequest; + +public class UpdateStudentHandler( + IStudentRepository studentRepository, + IUnitOfWork unitOfWork) + : IRequestHandler +{ + public async Task Handle(UpdateStudentCommand request, CancellationToken ct) + { + var student = await studentRepository.GetByIdWithEnrollmentsAsync(request.Id, ct) + ?? throw new StudentNotFoundException(request.Id); + + student.UpdateName(request.Name); + student.UpdateEmail(Email.Create(request.Email)); + + studentRepository.Update(student); + await unitOfWork.SaveChangesAsync(ct); + + return new StudentDto( + student.Id, + student.Name, + student.Email.Value, + student.TotalCredits, + student.Enrollments.Select(e => new EnrollmentDto( + e.Id, + e.SubjectId, + e.Subject?.Name ?? "", + e.Subject?.Credits ?? 3, + e.Subject?.Professor?.Name ?? "", + e.EnrolledAt)).ToList() + ); + } +} diff --git a/src/backend/Application/Students/DTOs/StudentDto.cs b/src/backend/Application/Students/DTOs/StudentDto.cs new file mode 100644 index 0000000..3090252 --- /dev/null +++ b/src/backend/Application/Students/DTOs/StudentDto.cs @@ -0,0 +1,33 @@ +namespace Application.Students.DTOs; + +public record StudentDto( + int Id, + string Name, + string Email, + int TotalCredits, + IReadOnlyList Enrollments); + +public record EnrollmentDto( + int Id, + int SubjectId, + string SubjectName, + int Credits, + string ProfessorName, + DateTime EnrolledAt); + +public record CreateStudentRequest(string Name, string Email); + +public record UpdateStudentRequest(string Name, string Email); + +// Pagination DTOs +public record PagedResult( + IReadOnlyList Items, + int? NextCursor, + int TotalCount, + bool HasNextPage); + +public record StudentPagedDto( + int Id, + string Name, + string Email, + int TotalCredits); diff --git a/src/backend/Application/Students/Queries/GetStudentByIdQuery.cs b/src/backend/Application/Students/Queries/GetStudentByIdQuery.cs new file mode 100644 index 0000000..b0496d4 --- /dev/null +++ b/src/backend/Application/Students/Queries/GetStudentByIdQuery.cs @@ -0,0 +1,35 @@ +namespace Application.Students.Queries; + +using Application.Students.DTOs; +using Domain.Entities; +using Domain.Exceptions; +using Domain.Ports.Repositories; +using MediatR; + +public record GetStudentByIdQuery(int Id) : IRequest; + +public class GetStudentByIdHandler(IStudentRepository studentRepository) + : IRequestHandler +{ + public async Task Handle(GetStudentByIdQuery request, CancellationToken ct) + { + // Direct projection - only SELECT needed columns + var dto = await studentRepository.GetByIdProjectedAsync( + request.Id, + s => new StudentDto( + s.Id, + s.Name, + s.Email.Value, + s.Enrollments.Sum(e => e.Subject != null ? e.Subject.Credits : Subject.CreditsPerSubject), + s.Enrollments.Select(e => new EnrollmentDto( + e.Id, + e.SubjectId, + e.Subject != null ? e.Subject.Name : "", + e.Subject != null ? e.Subject.Credits : Subject.CreditsPerSubject, + e.Subject != null && e.Subject.Professor != null ? e.Subject.Professor.Name : "", + e.EnrolledAt)).ToList() + ), ct); + + return dto ?? throw new StudentNotFoundException(request.Id); + } +} diff --git a/src/backend/Application/Students/Queries/GetStudentsPagedQuery.cs b/src/backend/Application/Students/Queries/GetStudentsPagedQuery.cs new file mode 100644 index 0000000..9404441 --- /dev/null +++ b/src/backend/Application/Students/Queries/GetStudentsPagedQuery.cs @@ -0,0 +1,39 @@ +namespace Application.Students.Queries; + +using Application.Students.DTOs; +using Domain.Entities; +using Domain.Ports.Repositories; +using MediatR; + +public record GetStudentsPagedQuery(int? AfterCursor = null, int PageSize = 10) + : IRequest>; + +public class GetStudentsPagedHandler(IStudentRepository studentRepository) + : IRequestHandler> +{ + private const int MaxPageSize = 50; + + public async Task> Handle( + GetStudentsPagedQuery request, + CancellationToken ct) + { + var pageSize = Math.Min(request.PageSize, MaxPageSize); + + var (items, nextCursor, totalCount) = await studentRepository.GetPagedProjectedAsync( + s => new StudentPagedDto( + s.Id, + s.Name, + s.Email.Value, + s.Enrollments.Sum(e => e.Subject != null ? e.Subject.Credits : Subject.CreditsPerSubject) + ), + request.AfterCursor, + pageSize, + ct); + + return new PagedResult( + items, + nextCursor, + totalCount, + nextCursor.HasValue); + } +} diff --git a/src/backend/Application/Students/Queries/GetStudentsQuery.cs b/src/backend/Application/Students/Queries/GetStudentsQuery.cs new file mode 100644 index 0000000..b4908fb --- /dev/null +++ b/src/backend/Application/Students/Queries/GetStudentsQuery.cs @@ -0,0 +1,33 @@ +namespace Application.Students.Queries; + +using Application.Students.DTOs; +using Domain.Entities; +using Domain.Ports.Repositories; +using MediatR; + +public record GetStudentsQuery : IRequest>; + +public class GetStudentsHandler(IStudentRepository studentRepository) + : IRequestHandler> +{ + public async Task> Handle( + GetStudentsQuery request, + CancellationToken ct) + { + // Direct projection - only SELECT needed columns, no entity materialization + return await studentRepository.GetAllProjectedAsync( + s => new StudentDto( + s.Id, + s.Name, + s.Email.Value, + s.Enrollments.Sum(e => e.Subject != null ? e.Subject.Credits : Subject.CreditsPerSubject), + s.Enrollments.Select(e => new EnrollmentDto( + e.Id, + e.SubjectId, + e.Subject != null ? e.Subject.Name : "", + e.Subject != null ? e.Subject.Credits : Subject.CreditsPerSubject, + e.Subject != null && e.Subject.Professor != null ? e.Subject.Professor.Name : "", + e.EnrolledAt)).ToList() + ), ct); + } +} diff --git a/src/backend/Application/Subjects/DTOs/SubjectDto.cs b/src/backend/Application/Subjects/DTOs/SubjectDto.cs new file mode 100644 index 0000000..7db1330 --- /dev/null +++ b/src/backend/Application/Subjects/DTOs/SubjectDto.cs @@ -0,0 +1,17 @@ +namespace Application.Subjects.DTOs; + +public record SubjectDto( + int Id, + string Name, + int Credits, + int ProfessorId, + string ProfessorName); + +public record AvailableSubjectDto( + int Id, + string Name, + int Credits, + int ProfessorId, + string ProfessorName, + bool IsAvailable, + string? UnavailableReason); diff --git a/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs new file mode 100644 index 0000000..19fa1dc --- /dev/null +++ b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs @@ -0,0 +1,62 @@ +namespace Application.Subjects.Queries; + +using Application.Subjects.DTOs; +using Domain.Entities; +using Domain.Exceptions; +using Domain.Ports.Repositories; +using MediatR; + +public record GetAvailableSubjectsQuery(int StudentId) : IRequest>; + +public class GetAvailableSubjectsHandler( + IStudentRepository studentRepository, + ISubjectRepository subjectRepository) + : IRequestHandler> +{ + public async Task> Handle( + GetAvailableSubjectsQuery request, + CancellationToken ct) + { + var student = await studentRepository.GetByIdWithEnrollmentsAsync(request.StudentId, ct) + ?? throw new StudentNotFoundException(request.StudentId); + + var allSubjects = await subjectRepository.GetAllWithProfessorsAsync(ct); + + var enrolledSubjectIds = student.Enrollments.Select(e => e.SubjectId).ToHashSet(); + var enrolledProfessorIds = student.Enrollments + .Select(e => e.Subject?.ProfessorId ?? 0) + .Where(id => id > 0) + .ToHashSet(); + + return allSubjects.Select(s => + { + var (isAvailable, reason) = GetAvailability(s, student, enrolledSubjectIds, enrolledProfessorIds); + return new AvailableSubjectDto( + s.Id, + s.Name, + s.Credits, + s.ProfessorId, + s.Professor?.Name ?? "", + isAvailable, + reason); + }).ToList(); + } + + private static (bool IsAvailable, string? Reason) GetAvailability( + Subject subject, + Student student, + HashSet enrolledSubjectIds, + HashSet enrolledProfessorIds) + { + if (enrolledSubjectIds.Contains(subject.Id)) + return (false, "Already enrolled"); + + if (!student.CanEnroll()) + return (false, "Maximum 3 subjects reached"); + + if (enrolledProfessorIds.Contains(subject.ProfessorId)) + return (false, "Already have a subject with this professor"); + + return (true, null); + } +} diff --git a/src/backend/Application/Subjects/Queries/GetSubjectsQuery.cs b/src/backend/Application/Subjects/Queries/GetSubjectsQuery.cs new file mode 100644 index 0000000..06ac4c9 --- /dev/null +++ b/src/backend/Application/Subjects/Queries/GetSubjectsQuery.cs @@ -0,0 +1,23 @@ +namespace Application.Subjects.Queries; + +using Application.Subjects.DTOs; +using Domain.Ports.Repositories; +using MediatR; + +public record GetSubjectsQuery : IRequest>; + +public class GetSubjectsHandler(ISubjectRepository subjectRepository) + : IRequestHandler> +{ + public async Task> Handle(GetSubjectsQuery request, CancellationToken ct) + { + // Direct projection - only SELECT needed columns + return await subjectRepository.GetAllProjectedAsync( + s => new SubjectDto( + s.Id, + s.Name, + s.Credits, + s.ProfessorId, + s.Professor != null ? s.Professor.Name : ""), ct); + } +}