feat(application): add CQRS application layer
Commands: - CreateStudent, UpdateStudent, DeleteStudent - EnrollStudent, UnenrollStudent Queries: - GetStudents, GetStudentById, GetStudentsPaged - GetSubjects, GetAvailableSubjects - GetProfessors - GetClassmates DTOs: - StudentDto, SubjectDto, ProfessorDto, EnrollmentDtos Validation: - FluentValidation with ValidationBehavior pipeline - EnrollStudentValidator for input validation Uses MediatR for command/query dispatching
This commit is contained in:
parent
2d6c08e14a
commit
68e420fdf2
|
|
@ -0,0 +1,20 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentValidation" Version="*" />
|
||||||
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="*" />
|
||||||
|
<PackageReference Include="MediatR" Version="*" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace Application.Common.Behaviors;
|
||||||
|
|
||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||||
|
: IPipelineBehavior<TRequest, TResponse>
|
||||||
|
where TRequest : notnull
|
||||||
|
{
|
||||||
|
public async Task<TResponse> Handle(
|
||||||
|
TRequest request,
|
||||||
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!validators.Any())
|
||||||
|
return await next();
|
||||||
|
|
||||||
|
var context = new ValidationContext<TRequest>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<EnrollmentDomainService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<EnrollmentDto>;
|
||||||
|
|
||||||
|
public class EnrollStudentHandler(
|
||||||
|
IStudentRepository studentRepository,
|
||||||
|
ISubjectRepository subjectRepository,
|
||||||
|
IEnrollmentRepository enrollmentRepository,
|
||||||
|
EnrollmentDomainService enrollmentService,
|
||||||
|
IUnitOfWork unitOfWork)
|
||||||
|
: IRequestHandler<EnrollStudentCommand, EnrollmentDto>
|
||||||
|
{
|
||||||
|
public async Task<EnrollmentDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
namespace Application.Enrollments.Commands;
|
||||||
|
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public class EnrollStudentValidator : AbstractValidator<EnrollStudentCommand>
|
||||||
|
{
|
||||||
|
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<UnenrollStudentCommand>
|
||||||
|
{
|
||||||
|
public UnenrollStudentValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.EnrollmentId)
|
||||||
|
.GreaterThan(0).WithMessage("Invalid enrollment ID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
namespace Application.Enrollments.Commands;
|
||||||
|
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Domain.Ports.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
public record UnenrollStudentCommand(int EnrollmentId) : IRequest<bool>;
|
||||||
|
|
||||||
|
public class UnenrollStudentHandler(
|
||||||
|
IEnrollmentRepository enrollmentRepository,
|
||||||
|
IStudentRepository studentRepository,
|
||||||
|
IUnitOfWork unitOfWork)
|
||||||
|
: IRequestHandler<UnenrollStudentCommand, bool>
|
||||||
|
{
|
||||||
|
public async Task<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace Application.Enrollments.DTOs;
|
||||||
|
|
||||||
|
public record EnrollStudentRequest(int StudentId, int SubjectId);
|
||||||
|
|
||||||
|
public record ClassmatesBySubjectDto(
|
||||||
|
int SubjectId,
|
||||||
|
string SubjectName,
|
||||||
|
IReadOnlyList<ClassmateDto> Classmates);
|
||||||
|
|
||||||
|
public record ClassmateDto(int StudentId, string Name);
|
||||||
|
|
@ -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<IReadOnlyList<ClassmatesBySubjectDto>>;
|
||||||
|
|
||||||
|
public class GetClassmatesHandler(
|
||||||
|
IStudentRepository studentRepository,
|
||||||
|
IEnrollmentRepository enrollmentRepository)
|
||||||
|
: IRequestHandler<GetClassmatesQuery, IReadOnlyList<ClassmatesBySubjectDto>>
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<ClassmatesBySubjectDto>> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace Application.Professors.DTOs;
|
||||||
|
|
||||||
|
public record ProfessorDto(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<ProfessorSubjectDto> Subjects);
|
||||||
|
|
||||||
|
public record ProfessorSubjectDto(int Id, string Name, int Credits);
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace Application.Professors.Queries;
|
||||||
|
|
||||||
|
using Application.Professors.DTOs;
|
||||||
|
using Domain.Ports.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
public record GetProfessorsQuery : IRequest<IReadOnlyList<ProfessorDto>>;
|
||||||
|
|
||||||
|
public class GetProfessorsHandler(IProfessorRepository professorRepository)
|
||||||
|
: IRequestHandler<GetProfessorsQuery, IReadOnlyList<ProfessorDto>>
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<ProfessorDto>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<StudentDto>;
|
||||||
|
|
||||||
|
public class CreateStudentHandler(
|
||||||
|
IStudentRepository studentRepository,
|
||||||
|
IUnitOfWork unitOfWork)
|
||||||
|
: IRequestHandler<CreateStudentCommand, StudentDto>
|
||||||
|
{
|
||||||
|
public async Task<StudentDto> 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, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
namespace Application.Students.Commands;
|
||||||
|
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Domain.Ports.Repositories;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
public partial class CreateStudentValidator : AbstractValidator<CreateStudentCommand>
|
||||||
|
{
|
||||||
|
[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<UpdateStudentCommand>
|
||||||
|
{
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
namespace Application.Students.Commands;
|
||||||
|
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Domain.Ports.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
public record DeleteStudentCommand(int Id) : IRequest<bool>;
|
||||||
|
|
||||||
|
public class DeleteStudentHandler(
|
||||||
|
IStudentRepository studentRepository,
|
||||||
|
IUnitOfWork unitOfWork)
|
||||||
|
: IRequestHandler<DeleteStudentCommand, bool>
|
||||||
|
{
|
||||||
|
public async Task<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<StudentDto>;
|
||||||
|
|
||||||
|
public class UpdateStudentHandler(
|
||||||
|
IStudentRepository studentRepository,
|
||||||
|
IUnitOfWork unitOfWork)
|
||||||
|
: IRequestHandler<UpdateStudentCommand, StudentDto>
|
||||||
|
{
|
||||||
|
public async Task<StudentDto> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace Application.Students.DTOs;
|
||||||
|
|
||||||
|
public record StudentDto(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string Email,
|
||||||
|
int TotalCredits,
|
||||||
|
IReadOnlyList<EnrollmentDto> 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<T>(
|
||||||
|
IReadOnlyList<T> Items,
|
||||||
|
int? NextCursor,
|
||||||
|
int TotalCount,
|
||||||
|
bool HasNextPage);
|
||||||
|
|
||||||
|
public record StudentPagedDto(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string Email,
|
||||||
|
int TotalCredits);
|
||||||
|
|
@ -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<StudentDto>;
|
||||||
|
|
||||||
|
public class GetStudentByIdHandler(IStudentRepository studentRepository)
|
||||||
|
: IRequestHandler<GetStudentByIdQuery, StudentDto>
|
||||||
|
{
|
||||||
|
public async Task<StudentDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<PagedResult<StudentPagedDto>>;
|
||||||
|
|
||||||
|
public class GetStudentsPagedHandler(IStudentRepository studentRepository)
|
||||||
|
: IRequestHandler<GetStudentsPagedQuery, PagedResult<StudentPagedDto>>
|
||||||
|
{
|
||||||
|
private const int MaxPageSize = 50;
|
||||||
|
|
||||||
|
public async Task<PagedResult<StudentPagedDto>> 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<StudentPagedDto>(
|
||||||
|
items,
|
||||||
|
nextCursor,
|
||||||
|
totalCount,
|
||||||
|
nextCursor.HasValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<IReadOnlyList<StudentDto>>;
|
||||||
|
|
||||||
|
public class GetStudentsHandler(IStudentRepository studentRepository)
|
||||||
|
: IRequestHandler<GetStudentsQuery, IReadOnlyList<StudentDto>>
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<StudentDto>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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<IReadOnlyList<AvailableSubjectDto>>;
|
||||||
|
|
||||||
|
public class GetAvailableSubjectsHandler(
|
||||||
|
IStudentRepository studentRepository,
|
||||||
|
ISubjectRepository subjectRepository)
|
||||||
|
: IRequestHandler<GetAvailableSubjectsQuery, IReadOnlyList<AvailableSubjectDto>>
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<AvailableSubjectDto>> 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<int> enrolledSubjectIds,
|
||||||
|
HashSet<int> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace Application.Subjects.Queries;
|
||||||
|
|
||||||
|
using Application.Subjects.DTOs;
|
||||||
|
using Domain.Ports.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
public record GetSubjectsQuery : IRequest<IReadOnlyList<SubjectDto>>;
|
||||||
|
|
||||||
|
public class GetSubjectsHandler(ISubjectRepository subjectRepository)
|
||||||
|
: IRequestHandler<GetSubjectsQuery, IReadOnlyList<SubjectDto>>
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<SubjectDto>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue