diff --git a/Api/Controllers/BooksController.cs b/Api/Controllers/BooksController.cs index 8decf21..f6edd37 100644 --- a/Api/Controllers/BooksController.cs +++ b/Api/Controllers/BooksController.cs @@ -21,18 +21,16 @@ namespace Api.Controllers; [Route("api/books")] public class BooksController : Controller { - private readonly CreateBookCommand _createBookCommand; private readonly DeleteBookCommand _deleteBookCommand; private readonly GetAllBooksQuery _getAllBooksQuery; private readonly GetBookByIdQuery _getBookByIdQuery; - private readonly GetBookByCopyIdQuery _getBookByCopyIdQuery; + private readonly IGetBookByCopyIdQuery _getBookByCopyIdQuery; private readonly GetBookCopyReadingHistoryByCopyIdQuery _getBookCopyReadingHistoryByCopyIdQuery; private readonly GetBookHistoryByIdQuery _getBookHistoryByIdQuery; private readonly IGetKnowledgeAreasQuery _getKnowledgeAreasQuery; private readonly BookCopyValidatorQuery _bookCopyValidatorQuery; private readonly SoftDeleteBookCommand _softDeleteBookCommand; private readonly EditBookCommand _editBookCommand; - private readonly ReturnBookCommand _returnBookCommand; private readonly TakeBookService _takeBookService; private readonly IInnerCircleHttpClient _client; @@ -42,16 +40,14 @@ public class BooksController : Controller public BooksController( GetAllBooksQuery getAllBooksQuery, GetBookByIdQuery getBookByIdQuery, - GetBookByCopyIdQuery getBookByCopyIdQuery, + IGetBookByCopyIdQuery getBookByCopyIdQuery, GetBookCopyReadingHistoryByCopyIdQuery getBookCopyReadingHistoryByCopyIdQuery, GetBookHistoryByIdQuery getBookHistoryByIdQuery, BookCopyValidatorQuery bookCopyValidatorQuery, IGetKnowledgeAreasQuery getKnowledgeAreasQuery, - CreateBookCommand createBookCommand, EditBookCommand editBookCommand, DeleteBookCommand deleteBookCommand, SoftDeleteBookCommand softDeleteBookCommand, - ReturnBookCommand returnBookCommand, TakeBookService takeBookService, IInnerCircleHttpClient client ) @@ -63,11 +59,9 @@ IInnerCircleHttpClient client _getBookHistoryByIdQuery = getBookHistoryByIdQuery; _bookCopyValidatorQuery = bookCopyValidatorQuery; _getKnowledgeAreasQuery = getKnowledgeAreasQuery; - _createBookCommand = createBookCommand; _editBookCommand = editBookCommand; _deleteBookCommand = deleteBookCommand; _softDeleteBookCommand = softDeleteBookCommand; - _returnBookCommand = returnBookCommand; _takeBookService = takeBookService; _client = client; } @@ -146,9 +140,9 @@ public async Task> GetBookByIdAsync([Required][ [HttpGet("copy/{id}")] public async Task> GetBookByCopyIdAsync([Required][FromRoute] long id, [Required][FromQuery] string secretKey) { - var bookId = await _getBookByCopyIdQuery.GetBookIdByCopyIdAsync(id, User.GetTenantId()); + var book = await _getBookByCopyIdQuery.GetByCopyIdAsync(id, User.GetTenantId()); - if (bookId == 0) + if (book == null) { return NotFound(new { @@ -166,7 +160,7 @@ public async Task> GetBookByCopyIdAsync([Requir }); } - return await GetBookResponseAsync(bookId); + return await GetBookResponseAsync(book.Id); } /// @@ -202,6 +196,19 @@ public async Task> GetBookCopiesByIdAsync([ }; } + /// + /// Get book feedback by book id + /// + [RequiresPermission(UserClaimsProvider.CanViewBooks)] + [HttpGet("feedback/{bookId}")] + public Task GetBookFeedbackAsync( + [Required][FromRoute] long bookId, + [FromServices] GetBookFeedbackHandler getBookFeedbackHandler + ) + { + return getBookFeedbackHandler.HandleAsync(bookId, User.GetTenantId()); + } + /// /// Adds book /// @@ -262,18 +269,14 @@ public async Task TakeBookAsync([Required][FromBody] TakeBookRequ /// [RequiresPermission(UserClaimsProvider.CanViewBooks)] [HttpPost("return")] - public async Task ReturnBookAsync([Required][FromBody] ReturnBookRequest returnBookRequest) + public async Task ReturnBookAsync( + [Required][FromBody] ReturnBookRequest returnBookRequest, + [FromServices] ReturnBookHandler returnBookHandler + ) { var employee = await _client.GetEmployeeAsync(User.GetCorporateEmail()); - var returnBookCommandParams = new ReturnBookCommandParams - { - BookCopyId = returnBookRequest.BookCopyId, - ProgressOfReading = (ProgressOfReading)Enum.Parse(typeof(ProgressOfReading), returnBookRequest.ProgressOfReading), - ActualReturnedAtUtc = DateTime.UtcNow - }; - - await _returnBookCommand.ReturnAsync(returnBookCommandParams, employee, User.GetTenantId()); + await returnBookHandler.HandleAsync(returnBookRequest, employee, User.GetTenantId()); } /// diff --git a/Api/Controllers/Handlers/GetBookFeedbackHandler.cs b/Api/Controllers/Handlers/GetBookFeedbackHandler.cs new file mode 100644 index 0000000..95b4a47 --- /dev/null +++ b/Api/Controllers/Handlers/GetBookFeedbackHandler.cs @@ -0,0 +1,51 @@ +using Api.Responses; +using Application; +using Application.Queries; + +namespace Api.Controllers.Handlers; + +public class GetBookFeedbackHandler +{ + private readonly IInnerCircleHttpClient _client; + + private readonly GetBookFeedbackQuery _getBookFeedbackQuery; + + public GetBookFeedbackHandler( + IInnerCircleHttpClient client, + GetBookFeedbackQuery getBookFeedbackQuery + ) + { + _client = client; + _getBookFeedbackQuery = getBookFeedbackQuery; + } + + public async Task HandleAsync(long bookId, long tenantId) + { + var bookFeedback = await _getBookFeedbackQuery.GetAsync(bookId, tenantId); + + var employeesIds = bookFeedback + .Select(x => x.EmployeeId) + .ToList(); + + var employeesByIds = await _client.GetEmployeesByIdsAsync(employeesIds); + + return new GetBookFeedbackResponse + { + BookFeedback = bookFeedback + .Select(x => + { + return new BookFeedbackDto + { + Id = x.Id, + EmployeeFullName = employeesByIds.FirstOrDefault(employee => employee.EmployeeId == x.EmployeeId).FullName, + LeftFeedbackAtUtc = x.LeftFeedbackAtUtc, + ProgressOfReading = x.ProgressOfReading.ToString(), + Rating = x.Rating, + Advantages = x.Advantages, + Disadvantages = x.Disadvantages + }; + }) + .ToList(), + }; + } +} diff --git a/Api/Controllers/Handlers/ReturnBookHandler.cs b/Api/Controllers/Handlers/ReturnBookHandler.cs new file mode 100644 index 0000000..ef3d9d6 --- /dev/null +++ b/Api/Controllers/Handlers/ReturnBookHandler.cs @@ -0,0 +1,45 @@ +using Api.Requests; +using Application.Commands; +using Application.Queries; +using Core; +using Core.Entities; + +namespace Api.Controllers.Handlers; + +public class ReturnBookHandler +{ + private readonly IGetBookByCopyIdQuery _getBookByCopyIdQuery; + private readonly ReturnBookCommand _returnBookCommand; + + public ReturnBookHandler( + IGetBookByCopyIdQuery getBookByCopyIdQuery, + ReturnBookCommand returnBookCommand + ) + { + _getBookByCopyIdQuery = getBookByCopyIdQuery; + _returnBookCommand = returnBookCommand; + } + + public async Task HandleAsync(ReturnBookRequest returnBookRequest, Employee employee, long tenantId) + { + var book = await _getBookByCopyIdQuery.GetByCopyIdAsync(returnBookRequest.BookCopyId, tenantId); + + if (book == null) + { + throw new ArgumentException($"Book copy with id {returnBookRequest.BookCopyId} not found"); + } + + var returnBookCommandParams = new ReturnBookCommandParams + { + BookCopyId = returnBookRequest.BookCopyId, + BookId = book.Id, + ProgressOfReading = (ProgressOfReading)Enum.Parse(typeof(ProgressOfReading), returnBookRequest.ProgressOfReading), + ActualReturnedAtUtc = DateTime.UtcNow, + Rating = returnBookRequest.Rating, + Advantages = returnBookRequest.Advantages, + Disadvantages = returnBookRequest.Disadvantages, + }; + + await _returnBookCommand.ReturnAsync(returnBookCommandParams, employee, tenantId); + } +} diff --git a/Api/Controllers/Handlers/ReturnBookHandlerTests.cs b/Api/Controllers/Handlers/ReturnBookHandlerTests.cs new file mode 100644 index 0000000..f94ec37 --- /dev/null +++ b/Api/Controllers/Handlers/ReturnBookHandlerTests.cs @@ -0,0 +1,45 @@ +using Api.Controllers.Handlers; +using Api.Requests; +using Application.Queries; +using Core.Entities; +using Moq; +using Xunit; + +namespace Application.Commands; + +public class ReturnBookHandlerTests +{ + private const long TENANT_ID = 1; + + private readonly ReturnBookHandler _handler; + + public ReturnBookHandlerTests() + { + var getBookByCopyIdQueryMock = new Mock(); + + getBookByCopyIdQueryMock + .Setup(x => x.GetByCopyIdAsync(1, TENANT_ID)) + .ReturnsAsync(new Book + { + Id = 1, + TenantId = TENANT_ID + }); + + _handler = new ReturnBookHandler(getBookByCopyIdQueryMock.Object, null); + } + + [Fact] + public async Task HandleAsyncWithNonExistedBookCopyId_ShouldThrowException() + { + var createBookRequest = new ReturnBookRequest + { + BookCopyId = 999 + }; + + var exception = await Assert.ThrowsAsync( + async () => await _handler.HandleAsync(createBookRequest, null, TENANT_ID) + ); + + Assert.Equal($"Book copy with id {createBookRequest.BookCopyId} not found", exception.Message); + } +} diff --git a/Api/DependencyInjection.cs b/Api/DependencyInjection.cs index cf29506..53e013c 100644 --- a/Api/DependencyInjection.cs +++ b/Api/DependencyInjection.cs @@ -23,8 +23,10 @@ public static void AddApplication(this IServiceCollection services, IConfigurati services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -32,6 +34,7 @@ public static void AddApplication(this IServiceCollection services, IConfigurati services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); } diff --git a/Api/Requests/ReturnBookRequest.cs b/Api/Requests/ReturnBookRequest.cs index 58ed02b..ac44a37 100644 --- a/Api/Requests/ReturnBookRequest.cs +++ b/Api/Requests/ReturnBookRequest.cs @@ -9,4 +9,11 @@ public class ReturnBookRequest [Required] public string ProgressOfReading { get; set; } + + [Range(1, 5)] + public int? Rating { get; set; } + + public string? Advantages { get; set; } + + public string? Disadvantages { get; set; } } diff --git a/Api/Responses/GetBookFeedbackResponse.cs b/Api/Responses/GetBookFeedbackResponse.cs new file mode 100644 index 0000000..54ae62d --- /dev/null +++ b/Api/Responses/GetBookFeedbackResponse.cs @@ -0,0 +1,23 @@ +namespace Api.Responses; + +public class GetBookFeedbackResponse +{ + public List BookFeedback { get; set; } +} + +public class BookFeedbackDto +{ + public long Id { get; set; } + + public string EmployeeFullName { get; set; } + + public DateTime LeftFeedbackAtUtc { get; set; } + + public string ProgressOfReading { get; set; } + + public int? Rating { get; set; } + + public string? Advantages { get; set; } + + public string? Disadvantages { get; set; } +} diff --git a/Application/AppDbContext.cs b/Application/AppDbContext.cs index 6fe9705..99f0b4c 100644 --- a/Application/AppDbContext.cs +++ b/Application/AppDbContext.cs @@ -21,6 +21,8 @@ public AppDbContext() public virtual DbSet KnowledgeAreas { get; set; } + public virtual DbSet BookFeedback { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly); diff --git a/Application/Commands/ReturnBookCommand.cs b/Application/Commands/ReturnBookCommand.cs index 79c2397..9725881 100644 --- a/Application/Commands/ReturnBookCommand.cs +++ b/Application/Commands/ReturnBookCommand.cs @@ -8,9 +8,17 @@ public class ReturnBookCommandParams { public long BookCopyId { get; set; } + public long BookId { get; set; } + public ProgressOfReading ProgressOfReading { get; set; } public DateTime ActualReturnedAtUtc { get; set; } + + public int? Rating { get; set; } + + public string? Advantages { get; set; } + + public string? Disadvantages { get; set; } } public class ReturnBookCommand @@ -38,7 +46,25 @@ long tenantId bookCopyReadingHistory.ActualReturnedAtUtc = returnBookCommandParams.ActualReturnedAtUtc; bookCopyReadingHistory.ProgressOfReading = returnBookCommandParams.ProgressOfReading; - _context.BooksCopiesReadingHistory.Update(bookCopyReadingHistory); + if (returnBookCommandParams.ProgressOfReading == ProgressOfReading.ReadPartially || + returnBookCommandParams.ProgressOfReading == ProgressOfReading.ReadEntirely + ) + { + var bookFeedback = new BookFeedback + { + TenantId = tenantId, + BookId = returnBookCommandParams.BookId, + EmployeeId = employee.Id, + LeftFeedbackAtUtc = DateTime.UtcNow, + ProgressOfReading = returnBookCommandParams.ProgressOfReading, + Rating = returnBookCommandParams.Rating, + Advantages = returnBookCommandParams.Advantages, + Disadvantages = returnBookCommandParams.Disadvantages + }; + + await _context.BookFeedback.AddAsync(bookFeedback); + } + await _context.SaveChangesAsync(); } } diff --git a/Application/Commands/ReturnBookCommandTests.cs b/Application/Commands/ReturnBookCommandTests.cs index e82f86c..4e3af92 100644 --- a/Application/Commands/ReturnBookCommandTests.cs +++ b/Application/Commands/ReturnBookCommandTests.cs @@ -61,4 +61,81 @@ public async Task ReturnAsync_ShouldCompleteBookCopyReadingHistory() Assert.Equal(returnBookRequest.ActualReturnedAtUtc, completedCopyReadingHistory.ActualReturnedAtUtc); Assert.Equal(returnBookRequest.ProgressOfReading, completedCopyReadingHistory.ProgressOfReading); } + + [Fact] + public async Task ReturnAsync_ShouldNotAddFeedbackIfBookCopyWasOvertaken() + { + var employee = new Employee + { + Id = 2 + }; + + var bookCopyReadingHistory = new BookCopyReadingHistory + { + Id = 2, + BookCopyId = 2, + ReaderEmployeeId = employee.Id, + TenantId = TENANT_ID + }; + + _context + .BooksCopiesReadingHistory + .Add(bookCopyReadingHistory); + + await _context.SaveChangesAsync(); + + var returnBookRequest = new ReturnBookCommandParams + { + BookCopyId = 2, + ProgressOfReading = ProgressOfReading.Unknown, + ActualReturnedAtUtc = DateTime.UtcNow + }; + + await _command.ReturnAsync(returnBookRequest, employee, TENANT_ID); + + var bookFeedback = await _context + .BookFeedback + .SingleOrDefaultAsync(x => x.EmployeeId == employee.Id); + + Assert.Null(bookFeedback); + } + + + [Fact] + public async Task ReturnAsync_ShouldNotAddFeedbackIfBookCopyWasReturnedWithNotReadAtAllStatus() + { + var employee = new Employee + { + Id = 3 + }; + + var bookCopyReadingHistory = new BookCopyReadingHistory + { + Id = 3, + BookCopyId = 3, + ReaderEmployeeId = employee.Id, + TenantId = TENANT_ID + }; + + _context + .BooksCopiesReadingHistory + .Add(bookCopyReadingHistory); + + await _context.SaveChangesAsync(); + + var returnBookRequest = new ReturnBookCommandParams + { + BookCopyId = 3, + ProgressOfReading = ProgressOfReading.NotReadAtAll, + ActualReturnedAtUtc = DateTime.UtcNow + }; + + await _command.ReturnAsync(returnBookRequest, employee, TENANT_ID); + + var bookFeedback = await _context + .BookFeedback + .SingleOrDefaultAsync(x => x.EmployeeId == employee.Id); + + Assert.Null(bookFeedback); + } } diff --git a/Application/Migrations/20260401050622_AddBookFeedback.Designer.cs b/Application/Migrations/20260401050622_AddBookFeedback.Designer.cs new file mode 100644 index 0000000..c48151b --- /dev/null +++ b/Application/Migrations/20260401050622_AddBookFeedback.Designer.cs @@ -0,0 +1,343 @@ +// +using System; +using Application; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Application.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260401050622_AddBookFeedback")] + partial class AddBookFeedback + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BookKnowledgeArea", b => + { + b.Property("BooksId") + .HasColumnType("bigint"); + + b.Property("KnowledgeAreasId") + .HasColumnType("bigint"); + + b.HasKey("BooksId", "KnowledgeAreasId"); + + b.HasIndex("KnowledgeAreasId"); + + b.ToTable("BookKnowledgeArea"); + }); + + modelBuilder.Entity("Core.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Annotation") + .IsRequired() + .HasColumnType("text"); + + b.Property("Authors") + .IsRequired() + .HasColumnType("text"); + + b.Property("CoverUrl") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Language") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("Core.Entities.BookCopy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BookId") + .HasColumnType("bigint"); + + b.Property("SecretKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.ToTable("BooksCopies"); + }); + + modelBuilder.Entity("Core.Entities.BookCopyReadingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualReturnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("BookCopyId") + .HasColumnType("bigint"); + + b.Property("ProgressOfReading") + .HasColumnType("text"); + + b.Property("ReaderEmployeeId") + .HasColumnType("bigint"); + + b.Property("ScheduledReturnDate") + .HasColumnType("date"); + + b.Property("TakenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookCopyId"); + + b.ToTable("BooksCopiesReadingHistory"); + }); + + modelBuilder.Entity("Core.Entities.BookFeedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Advantages") + .HasColumnType("text"); + + b.Property("BookId") + .HasColumnType("bigint"); + + b.Property("Disadvantages") + .HasColumnType("text"); + + b.Property("EmployeeId") + .HasColumnType("bigint"); + + b.Property("LeftFeedbackAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProgressOfReading") + .HasColumnType("integer"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.ToTable("BookFeedback"); + }); + + modelBuilder.Entity("Core.Entities.KnowledgeArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("KnowledgeAreas"); + + b.HasData( + new + { + Id = 1L, + Name = "Frontend" + }, + new + { + Id = 2L, + Name = "Backend" + }, + new + { + Id = 3L, + Name = "ML" + }, + new + { + Id = 4L, + Name = "DevOps" + }, + new + { + Id = 5L, + Name = "QA" + }, + new + { + Id = 6L, + Name = "Design" + }, + new + { + Id = 7L, + Name = "Business and Management" + }, + new + { + Id = 8L, + Name = "Embedded" + }, + new + { + Id = 9L, + Name = "GameDev" + }, + new + { + Id = 10L, + Name = "Marketing" + }, + new + { + Id = 11L, + Name = "Information Security" + }, + new + { + Id = 12L, + Name = "Psychology" + }, + new + { + Id = 13L, + Name = "Copywriting and Editing" + }, + new + { + Id = 14L, + Name = "Languages" + }, + new + { + Id = 15L, + Name = "Computer Science" + }, + new + { + Id = 16L, + Name = "Architecture" + }); + }); + + modelBuilder.Entity("BookKnowledgeArea", b => + { + b.HasOne("Core.Entities.Book", null) + .WithMany() + .HasForeignKey("BooksId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.KnowledgeArea", null) + .WithMany() + .HasForeignKey("KnowledgeAreasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.BookCopy", b => + { + b.HasOne("Core.Entities.Book", "Book") + .WithMany("Copies") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("Core.Entities.BookCopyReadingHistory", b => + { + b.HasOne("Core.Entities.BookCopy", "BookCopy") + .WithMany("ReadingHistoryList") + .HasForeignKey("BookCopyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BookCopy"); + }); + + modelBuilder.Entity("Core.Entities.BookFeedback", b => + { + b.HasOne("Core.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("Core.Entities.Book", b => + { + b.Navigation("Copies"); + }); + + modelBuilder.Entity("Core.Entities.BookCopy", b => + { + b.Navigation("ReadingHistoryList"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Application/Migrations/20260401050622_AddBookFeedback.cs b/Application/Migrations/20260401050622_AddBookFeedback.cs new file mode 100644 index 0000000..145730d --- /dev/null +++ b/Application/Migrations/20260401050622_AddBookFeedback.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Application.Migrations +{ + /// + public partial class AddBookFeedback : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BookFeedback", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TenantId = table.Column(type: "bigint", nullable: false), + BookId = table.Column(type: "bigint", nullable: false), + EmployeeId = table.Column(type: "bigint", nullable: false), + LeftFeedbackAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ProgressOfReading = table.Column(type: "integer", nullable: false), + Rating = table.Column(type: "integer", nullable: true), + Advantages = table.Column(type: "text", nullable: true), + Disadvantages = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BookFeedback", x => x.Id); + table.ForeignKey( + name: "FK_BookFeedback_Books_BookId", + column: x => x.BookId, + principalTable: "Books", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BookFeedback_BookId", + table: "BookFeedback", + column: "BookId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BookFeedback"); + } + } +} diff --git a/Application/Migrations/AppDbContextModelSnapshot.cs b/Application/Migrations/AppDbContextModelSnapshot.cs index 94a9fcd..a0dbedd 100644 --- a/Application/Migrations/AppDbContextModelSnapshot.cs +++ b/Application/Migrations/AppDbContextModelSnapshot.cs @@ -139,6 +139,45 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BooksCopiesReadingHistory"); }); + modelBuilder.Entity("Core.Entities.BookFeedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Advantages") + .HasColumnType("text"); + + b.Property("BookId") + .HasColumnType("bigint"); + + b.Property("Disadvantages") + .HasColumnType("text"); + + b.Property("EmployeeId") + .HasColumnType("bigint"); + + b.Property("LeftFeedbackAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProgressOfReading") + .HasColumnType("integer"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.ToTable("BookFeedback"); + }); + modelBuilder.Entity("Core.Entities.KnowledgeArea", b => { b.Property("Id") @@ -275,6 +314,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("BookCopy"); }); + modelBuilder.Entity("Core.Entities.BookFeedback", b => + { + b.HasOne("Core.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + }); + modelBuilder.Entity("Core.Entities.Book", b => { b.Navigation("Copies"); diff --git a/Application/Queries/GetBookByCopyIdQuery.cs b/Application/Queries/GetBookByCopyIdQuery.cs index 079c9ed..88ef38f 100644 --- a/Application/Queries/GetBookByCopyIdQuery.cs +++ b/Application/Queries/GetBookByCopyIdQuery.cs @@ -1,8 +1,14 @@ +using Core.Entities; using Microsoft.EntityFrameworkCore; namespace Application.Queries; -public class GetBookByCopyIdQuery +public interface IGetBookByCopyIdQuery +{ + Task GetByCopyIdAsync(long copyId, long tenantId); +} + +public class GetBookByCopyIdQuery : IGetBookByCopyIdQuery { private readonly AppDbContext _context; @@ -11,13 +17,13 @@ public GetBookByCopyIdQuery(AppDbContext context) _context = context; } - public Task GetBookIdByCopyIdAsync(long copyId, long tenantId) + public Task GetByCopyIdAsync(long copyId, long tenantId) { return _context .BooksCopies .Where(x => x.TenantId == tenantId) .Where(x => x.Id == copyId) - .Select(x => x.BookId) - .FirstOrDefaultAsync(); + .Select(x => x.Book) + .SingleOrDefaultAsync(); } } diff --git a/Application/Queries/GetBookByCopyIdQueryTests.cs b/Application/Queries/GetBookByCopyIdQueryTests.cs index 4147aa4..e6ada9a 100644 --- a/Application/Queries/GetBookByCopyIdQueryTests.cs +++ b/Application/Queries/GetBookByCopyIdQueryTests.cs @@ -21,14 +21,32 @@ public GetBookByCopyIdQueryTests() } [Fact] - public async Task GetBookIdByCopyIdAsync_ShouldReturnBookId_WhenCopyExists() + public async Task GetBookByCopyIdAsync_ShouldReturnBook_WhenCopyExists() { - var bookId = 1; + var book = new Book + { + Id = 1, + TenantId = TENANT_ID, + Title = "Test Book", + Annotation = "Test annotation", + Authors = new List() + { + new Author() + { + FullName = "Test Author" + } + }, + Language = Language.en, + CoverUrl = "http://test-images.com/img404.png" + }; + + _context.Books.Add(book); + await _context.SaveChangesAsync(); var bookCopy = new BookCopy { Id = 4, - BookId = bookId, + BookId = book.Id, TenantId = TENANT_ID, SecretKey = "abcd" }; @@ -36,8 +54,8 @@ public async Task GetBookIdByCopyIdAsync_ShouldReturnBookId_WhenCopyExists() _context.BooksCopies.Add(bookCopy); await _context.SaveChangesAsync(); - var result = await _query.GetBookIdByCopyIdAsync(bookCopy.Id, TENANT_ID); + var result = await _query.GetByCopyIdAsync(bookCopy.Id, TENANT_ID); - Assert.Equal(bookId, result); + Assert.Equal(book, result); } } diff --git a/Application/Queries/GetBookFeedbackQuery.cs b/Application/Queries/GetBookFeedbackQuery.cs new file mode 100644 index 0000000..e856a65 --- /dev/null +++ b/Application/Queries/GetBookFeedbackQuery.cs @@ -0,0 +1,26 @@ +using Core.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Application.Queries; + + +public class GetBookFeedbackQuery +{ + private readonly AppDbContext _context; + + public GetBookFeedbackQuery(AppDbContext context) + { + _context = context; + } + + public Task> GetAsync(long bookId, long tenantId) + { + return _context + .BookFeedback + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .Where(x => x.BookId == bookId) + .Where(x => x.Book.DeletedAtUtc == null) + .ToListAsync(); + } +} diff --git a/Application/Queries/GetBookFeedbackQueryTests.cs b/Application/Queries/GetBookFeedbackQueryTests.cs new file mode 100644 index 0000000..68bba58 --- /dev/null +++ b/Application/Queries/GetBookFeedbackQueryTests.cs @@ -0,0 +1,105 @@ +using Application; +using Application.Queries; +using Core.Entities; +using Microsoft.EntityFrameworkCore; +using Xunit; + +public class GetBookFeedbackQueryTests +{ + private const long TENANT_ID = 1; + private readonly AppDbContext _context; + private readonly GetBookFeedbackQuery _query; + + public GetBookFeedbackQueryTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase("GetBookFeedbackQueryDatabase") + .Options; + + _context = new AppDbContext(options); + _query = new GetBookFeedbackQuery(_context); + } + + [Fact] + public async Task GetAnotherTenantsBookFeedback_ShouldNotGetAnotherTenantsBookFeedback() + { + var book = new Book + { + Id = 1, + TenantId = TENANT_ID, + Title = "Test Book", + Annotation = "Test annotation", + Authors = new List() + { + new Author() + { + FullName = "Test Author" + } + }, + Language = Language.en, + CoverUrl = "http://test-images.com/img404.png" + }; + + var bookFeedback = new BookFeedback + { + Id = 1, + BookId = book.Id, + Book = book, + TenantId = TENANT_ID + }; + + await _context.BookFeedback.AddAsync(bookFeedback); + await _context.SaveChangesAsync(); + + var result = await _query.GetAsync(bookFeedback.BookId, 777); + + Assert.DoesNotContain(result, x => x.Id == bookFeedback.Id); + } + + [Fact] + public async Task GetFeedbackForDeletedBook_ShouldNotGetFeedbackForDeletedBook() + { + var book = new Book + { + Id = 2, + TenantId = TENANT_ID, + Title = "Test Book", + Annotation = "Test annotation", + Authors = new List() + { + new Author() + { + FullName = "Test Author" + } + }, + Language = Language.en, + CoverUrl = "http://test-images.com/img404.png", + DeletedAtUtc = DateTime.UtcNow + }; + + var bookFeedback = new BookFeedback + { + Id = 2, + BookId = book.Id, + Book = book, + TenantId = TENANT_ID + }; + + await _context.BookFeedback.AddAsync(bookFeedback); + await _context.SaveChangesAsync(); + + var result = await _query.GetAsync(bookFeedback.BookId, TENANT_ID); + + Assert.DoesNotContain(result, x => x.Id == bookFeedback.Id); + } + + [Fact] + public async Task GetBookFeedbackForNonExistingBook_ShouldNotGetFeedbackForNonExistingBook() + { + var nonExistentBookId = 999; + + var result = await _query.GetAsync(nonExistentBookId, TENANT_ID); + + Assert.DoesNotContain(result, x => x.Id == nonExistentBookId); + } +} diff --git a/Application/Services/TakeBookService.cs b/Application/Services/TakeBookService.cs index ddf2028..ab446ed 100644 --- a/Application/Services/TakeBookService.cs +++ b/Application/Services/TakeBookService.cs @@ -22,7 +22,8 @@ public TakeBookService(AppDbContext context) public async Task TakeAsync( TakeBookCommandParams takeBookCommandParams, ReturnBookCommandParams returnBookCommandParams, - Employee employee, long tenantId, + Employee employee, + long tenantId, BookCopyReadingHistory? activeReading ) { diff --git a/Application/Services/TakeBookServiceTests.cs b/Application/Services/TakeBookServiceTests.cs index 1bab1e7..cfafaaa 100644 --- a/Application/Services/TakeBookServiceTests.cs +++ b/Application/Services/TakeBookServiceTests.cs @@ -41,7 +41,7 @@ public async Task DisposeAsync() // Todo: move to karate test? [Fact] - public async Task TakeAsync_WhenBookIsAlreadyTaken_ShouldReturnPreviousAndAddNew() + public async Task TakeAsync_WhenBookWasTakenAndNotReturned_ShouldReassignBookCopyToNewReader() { var book = new Book { diff --git a/Core/Entities/BookFeedback.cs b/Core/Entities/BookFeedback.cs new file mode 100644 index 0000000..7a8c6f1 --- /dev/null +++ b/Core/Entities/BookFeedback.cs @@ -0,0 +1,24 @@ +namespace Core.Entities; + +public class BookFeedback +{ + public long Id { get; set; } + + public long TenantId { get; set; } + + public long BookId { get; set; } + + public Book Book { get; set; } + + public long EmployeeId { get; set; } + + public DateTime LeftFeedbackAtUtc { get; set; } + + public ProgressOfReading ProgressOfReading { get; set; } + + public int? Rating { get; set; } + + public string? Advantages { get; set; } + + public string? Disadvantages { get; set; } +} diff --git a/README.md b/README.md index d5dc82e..7f8b382 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ When you have made all the changes and are ready to add new migration, you need 1. You need to run db in docker, using command ``` -docker compose --profile db-only up -d +docker compose --profile DbOnly up -d ``` ### for MacOS diff --git a/e2e/take-book-flow-e2e.feature b/e2e/take-and-return-book-flow-e2e.feature similarity index 81% rename from e2e/take-book-flow-e2e.feature rename to e2e/take-and-return-book-flow-e2e.feature index 99ed344..9f26e7c 100644 --- a/e2e/take-book-flow-e2e.feature +++ b/e2e/take-and-return-book-flow-e2e.feature @@ -37,7 +37,7 @@ Scenario: Take and return book flow When method GET Then status 200 - * def firstKnowledgeAreaId = response.knowledgeAreas[0].id + * def firstKnowledgeAreaId = response.knowledgeAreas[0].id # Create a new book * def randomName = 'Test-book-' + Math.random() @@ -98,6 +98,10 @@ Scenario: Take and return book flow And assert response.employeesWhoReadNow[0].bookCopyId == bookCopyId * def readerFullName = response.employeesWhoReadNow[0].fullName + * def progressOfReading = 'ReadEntirely' + * def rating = 5 + * def advantages = "Good book" + * def disadvantages = "Long book" # Return book copy And path '/return' @@ -105,7 +109,10 @@ Scenario: Take and return book flow """ { "bookCopyId": '#(bookCopyId)', - "progressOfReading": 'ReadEntirely' + "progressOfReading": '#(progressOfReading)', + "rating": '#(rating)', + "advantages": '#(advantages)', + "disadvantages": '#(disadvantages)', } """ When method POST @@ -117,6 +124,16 @@ Scenario: Take and return book flow Then status 200 And assert response.employeesWhoReadNow.length == 0 + # Check that book has feedback + And path '/feedback', newBookId + When method GET + Then status 200 + And assert response.bookFeedback[0].employeeFullName == readerFullName + And assert response.bookFeedback[0].progressOfReading == progressOfReading + And assert response.bookFeedback[0].rating == rating + And assert response.bookFeedback[0].advantages == advantages + And assert response.bookFeedback[0].disadvantages == disadvantages + # Check book history And path '/history', newBookId And param page = 1