diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..149c441 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,27 @@ +name: .NET CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release --verbosity normal \ No newline at end of file diff --git a/Library.API/Controllers/AuthController.cs b/Library.API/Controllers/AuthController.cs new file mode 100644 index 0000000..7e884d9 --- /dev/null +++ b/Library.API/Controllers/AuthController.cs @@ -0,0 +1,68 @@ +using Library.Application.Dtos; +using Library.Application.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Library.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("register")] + public async Task Register([FromBody] RegisterRequest request) + { + try + { + var result = await _authService.RegisterAsync(request); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Message = ex.Message }); + } + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + try + { + var result = await _authService.LoginAsync(request); + return Ok(result); + } + catch (UnauthorizedAccessException ex) + { + return Unauthorized(new { Message = ex.Message }); + } + } + + [HttpPost("refresh")] + public async Task Refresh([FromBody] RefreshRequest request) + { + try + { + var result = await _authService.RefreshAsync(request); + return Ok(result); + } + catch (UnauthorizedAccessException ex) + { + return Unauthorized(new { Message = ex.Message }); + } + } + + [HttpPost("revoke")] + [Authorize] + public async Task Revoke([FromBody] RefreshRequest request) + { + await _authService.RevokeAsync(request.RefreshToken); + return NoContent(); + } +} \ No newline at end of file diff --git a/Library.API/Controllers/BooksController.cs b/Library.API/Controllers/BooksController.cs index 8dc4f96..9e688ea 100644 --- a/Library.API/Controllers/BooksController.cs +++ b/Library.API/Controllers/BooksController.cs @@ -1,54 +1,66 @@ using Library.Application.Dtos; +using Library.Application.Interfaces; using Library.Applictation.Dtos; -using Library.Applictation.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Library.API.Controllers +namespace Library.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class BooksController : ControllerBase { - [Route("api/[controller]")] - [ApiController] - public class BooksController : ControllerBase + private readonly IBookService _bookService; + + public BooksController(IBookService bookService) + { + _bookService = bookService; + } + + [HttpGet] + public async Task GetBooks([FromQuery] BookQueryParameters parameters) + { + var result = await _bookService.GetBooksAsync(parameters); + return Ok(result); + } + + [HttpGet("{id}")] + public async Task GetById(Guid id) + { + var book = await _bookService.GetBookByIdAsync(id); + if (book == null) return NotFound(); + return Ok(book); + } + + [HttpPost] + [Authorize(Roles = "Admin,Librarian")] + public async Task Create([FromBody] CreateBookRequest request) + { + var created = await _bookService.CreateBookAsync(request); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + [HttpPut("{id}")] + [Authorize(Roles = "Admin,Librarian")] + public async Task Update(Guid id, [FromBody] UpdateBookRequest request) + { + var updated = await _bookService.UpdateBookAsync(id, request); + return Ok(updated); + } + + [HttpDelete("{id}")] + [Authorize(Roles = "Admin")] + public async Task Delete(Guid id) + { + await _bookService.DeleteBookAsync(id); + return NoContent(); // 204 — успешно, тела нет + } + + [HttpPost("{id}/restore")] + [Authorize(Roles = "Admin")] + public async Task Restore(Guid id) { - private readonly IBookService _bookService; - - public BooksController(IBookService bookService) - { - _bookService = bookService; - } - - [HttpGet] - public async Task GetAll() - { - var books = await _bookService.GetAllBooksAsync(); - return Ok(books); - } - - [HttpGet("{id}")] - public async Task GetById(Guid id) - { - var book = await _bookService.GetBookByIdAsync(id); - - if (book == null) - { - return NotFound(); - } - - return Ok(book); - } - - [HttpPost] - public async Task Create([FromBody] CreateBookRequest request) - { - var created = await _bookService.CreateBookAsync(request); - - return CreatedAtAction(nameof(GetById), new {id = created.Id}, created); - } - - [HttpGet] - public async Task GetBooks([FromQuery] BookQueryParameters parameters) - { - var result = await _bookService.GetBooksAsync(parameters); - return Ok(result); - } + await _bookService.RestoreBookAsync(id); + return NoContent(); } -} +} \ No newline at end of file diff --git a/Library.API/Controllers/LoansController.cs b/Library.API/Controllers/LoansController.cs new file mode 100644 index 0000000..9c47a76 --- /dev/null +++ b/Library.API/Controllers/LoansController.cs @@ -0,0 +1,70 @@ +using Library.Application.Dtos; +using Library.Application.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Library.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class LoansController : ControllerBase +{ + private readonly ILoanService _loanService; + + public LoansController(ILoanService loanService) + { + _loanService = loanService; + } + + [HttpGet] + [Authorize(Roles = "Admin,Librarian")] + public async Task GetAll() + { + var loans = await _loanService.GetAllLoansAsync(); + return Ok(loans); + } + + [HttpGet("user/{userId}")] + public async Task GetUserLoans(Guid userId) + { + var loans = await _loanService.GetUserLoansAsync(userId); + return Ok(loans); + } + + [HttpPost("borrow")] + public async Task Borrow([FromBody] BorrowRequest request) + { + try + { + var result = await _loanService.BorrowBookAsync(request); + return CreatedAtAction(nameof(GetUserLoans), new { userId = request.UserId }, result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Message = ex.Message }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { Message = ex.Message }); + } + } + + [HttpPost("return")] + public async Task Return([FromBody] ReturnRequest request) + { + try + { + var result = await _loanService.ReturnBookAsync(request); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Message = ex.Message }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { Message = ex.Message }); + } + } +} \ No newline at end of file diff --git a/Library.API/Controllers/UserController.cs b/Library.API/Controllers/UserController.cs new file mode 100644 index 0000000..8e96847 --- /dev/null +++ b/Library.API/Controllers/UserController.cs @@ -0,0 +1,90 @@ +using Library.Application.Dtos; +using Library.Applictation.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Library.API.Controllers +{ + [ApiController] + [Route("api/[controller]")] + [Authorize] + public class UserController : ControllerBase + { + private readonly IUserService _userService; + + public UserController(IUserService userService) + { + _userService = userService; + } + + [HttpGet] + [Authorize(Roles = "Admin, Librarian")] + public async Task GetAll() + { + return Ok(await _userService.GetAllUsersAsync()); + } + + [HttpGet("{id}")] + public async Task GetById(Guid id) + { + var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var isStaff = User.IsInRole("Admin") || User.IsInRole("Librarian"); + + if (!isStaff && currentUserId != id.ToString()) + { + return Forbid(); + } + + return Ok(await _userService.GetUserByIdAsync(id)); + } + + [HttpPut("{id}")] + public async Task Update(Guid id, [FromBody] UpdateUserRequest request) + { + var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var isStaff = User.IsInRole("Admin") || User.IsInRole("Librarian"); + + if (!isStaff && currentUserId == id.ToString()) + { + return Forbid(); + } + + return Ok(await _userService.UpdateUserAsync(id, request)); + } + + [HttpPost("{id}/change-password")] + public async Task ChangePassword(Guid id, [FromBody] ChangePasswordRequest request) + { + var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (currentUserId != id.ToString()) + return Forbid(); // только сам пользователь + + await _userService.ChangePasswordAsync(id, request); + return NoContent(); + } + + [HttpPatch("{id}/role")] + [Authorize(Roles = "Admin")] + public async Task UpdateRole(Guid id, [FromBody] UpdateUserRoleRequest request) + { + return Ok(await _userService.UpdateRoleAsync(id, request)); + } + + [HttpDelete("{id}")] + [Authorize(Roles = "Admin")] + public async Task Delete(Guid id) + { + await _userService.DeleteUserAsync(id); + return NoContent(); + } + + [HttpPost("{id}/restore")] + [Authorize(Roles = "Admin")] + public async Task Restore(Guid id) + { + await _userService.RestoreUserAsync(id); + return NoContent(); + } + } +} diff --git a/Library.API/Library.API.csproj b/Library.API/Library.API.csproj index e4c693c..37c4ff2 100644 --- a/Library.API/Library.API.csproj +++ b/Library.API/Library.API.csproj @@ -8,6 +8,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Library.API/Program.cs b/Library.API/Program.cs index fb80bfb..213dc5d 100644 --- a/Library.API/Program.cs +++ b/Library.API/Program.cs @@ -1,11 +1,16 @@ - +using FluentValidation; +using Library.API.Middleware; +using Library.Application.Interfaces; using Library.Applictation.Interfaces; using Library.Applictation.Validators; using Library.Infrastructure.Data; using Library.Infrastructure.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; -using FluentValidation; -using Library.API.Middleware; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Security.Claims; +using System.Text; namespace Library.API { @@ -20,13 +25,71 @@ public static void Main(string[] args) // Ñòðîêà ïîäêëþ÷åíèÿ áóäåò ëåæàòü â appsettings.json builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); - builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddValidatorsFromAssemblyContaining(); - builder.Services.AddValidatorsFromAssembly(typeof(CreateBookRequestValidator).Assembly); + var jwtSettings = builder.Configuration.GetSection("JwtSettings"); + var secretKey = jwtSettings["SecretKey"]!; + + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings["Issuer"], + ValidAudience = jwtSettings["Audience"], + NameClaimType = ClaimTypes.Name, + RoleClaimType = ClaimTypes.Role, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + ClockSkew = TimeSpan.Zero // áåç äåôîëòíûõ 5 ìèíóò çàäåðæêè + }; + }); + + builder.Services.AddAuthorization(); builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi - builder.Services.AddSwaggerGen(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new OpenApiInfo { Title = "Library API", Version = "v1" }); + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Ââåäèòå JWT-òîêåí" + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); + }); var app = builder.Build(); @@ -37,10 +100,13 @@ public static void Main(string[] args) app.UseSwaggerUI(); } - app.UseHttpsRedirection(); - + app.UseAuthentication(); app.UseAuthorization(); + app.UseDeveloperExceptionPage(); // ïîêàæåò äåòàëè îøèáêè ïðÿìî â áðàóçåðå + + app.UseHttpsRedirection(); + app.UseMiddleware(); app.MapControllers(); diff --git a/Library.API/Properties/launchSettings.json b/Library.API/Properties/launchSettings.json index fec597c..f76b2d4 100644 --- a/Library.API/Properties/launchSettings.json +++ b/Library.API/Properties/launchSettings.json @@ -1,23 +1,36 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", +{ "profiles": { "http": { "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5114", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - } + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5114" }, "https": { "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7237;http://localhost:5114", + "applicationUrl": "https://localhost:7237;http://localhost:5114" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } + }, + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:62415/", + "sslPort": 44307 + } } -} +} \ No newline at end of file diff --git a/Library.API/appsettings.json b/Library.API/appsettings.json index 62c1f4e..e8a0079 100644 --- a/Library.API/appsettings.json +++ b/Library.API/appsettings.json @@ -7,6 +7,12 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "DefaultConnection": "Server=localhost;Database=LibraryDb;User Id=sa;Password=YourPassword;TrustServerCertificate=True;" + "DefaultConnection": "Server=itsql.elektro.ru;Database=test;Integrated Security=true;TrustServerCertificate=True;" + }, + "JwtSettings": { + "SecretKey": "super_secret_key_minimum_32_characters_long_1234567890", + "Issuer": "LibraryAPI", + "Audience": "LibraryAPIClient", + "ExpiryMinutes": 60 } } diff --git a/Library.Applictation/Dtos/AuthResponse.cs b/Library.Applictation/Dtos/AuthResponse.cs new file mode 100644 index 0000000..d4f6c14 --- /dev/null +++ b/Library.Applictation/Dtos/AuthResponse.cs @@ -0,0 +1,11 @@ +namespace Library.Application.Dtos; + +public class AuthResponse +{ + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string Email { get; set; } + public string FullName { get; set; } + public string Role { get; set; } + public DateTime AccessTokenExpiresAt { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/BookLoanDto.cs b/Library.Applictation/Dtos/BookLoanDto.cs new file mode 100644 index 0000000..771d6ba --- /dev/null +++ b/Library.Applictation/Dtos/BookLoanDto.cs @@ -0,0 +1,13 @@ +namespace Library.Application.Dtos; + +public class BookLoanDto +{ + public Guid Id { get; set; } + public string UserName { get; set; } + public string BookTitle { get; set; } + public DateTime BorrowDate { get; set; } + public DateTime DueDate { get; set; } + public DateTime? ReturnDate { get; set; } + public decimal? Fine { get; set; } + public bool IsActive => ReturnDate == null; +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/BorrowRequest.cs b/Library.Applictation/Dtos/BorrowRequest.cs new file mode 100644 index 0000000..6f8c9aa --- /dev/null +++ b/Library.Applictation/Dtos/BorrowRequest.cs @@ -0,0 +1,8 @@ +namespace Library.Application.Dtos; + +public class BorrowRequest +{ + public Guid UserId { get; set; } + public Guid BookId { get; set; } + public int? LoanDays { get; set; } // опционально, по умолчанию 14 +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/ChangePasswordRequest.cs b/Library.Applictation/Dtos/ChangePasswordRequest.cs new file mode 100644 index 0000000..5bf4c73 --- /dev/null +++ b/Library.Applictation/Dtos/ChangePasswordRequest.cs @@ -0,0 +1,7 @@ +namespace Library.Application.Dtos; + +public class ChangePasswordRequest +{ + public string CurrentPassword { get; set; } + public string NewPassword { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/LoginRequest.cs b/Library.Applictation/Dtos/LoginRequest.cs new file mode 100644 index 0000000..5ca7784 --- /dev/null +++ b/Library.Applictation/Dtos/LoginRequest.cs @@ -0,0 +1,7 @@ +namespace Library.Application.Dtos; + +public class LoginRequest +{ + public string Email { get; set; } + public string Password { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/RefreshRequest.cs b/Library.Applictation/Dtos/RefreshRequest.cs new file mode 100644 index 0000000..88a90b8 --- /dev/null +++ b/Library.Applictation/Dtos/RefreshRequest.cs @@ -0,0 +1,6 @@ +namespace Library.Application.Dtos; + +public class RefreshRequest +{ + public string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/RegisterRequest.cs b/Library.Applictation/Dtos/RegisterRequest.cs new file mode 100644 index 0000000..3fc317f --- /dev/null +++ b/Library.Applictation/Dtos/RegisterRequest.cs @@ -0,0 +1,8 @@ +namespace Library.Application.Dtos; + +public class RegisterRequest +{ + public string FullName { get; set; } + public string Email { get; set; } + public string Password { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/ReturnRequest.cs b/Library.Applictation/Dtos/ReturnRequest.cs new file mode 100644 index 0000000..51318a0 --- /dev/null +++ b/Library.Applictation/Dtos/ReturnRequest.cs @@ -0,0 +1,6 @@ +namespace Library.Application.Dtos; + +public class ReturnRequest +{ + public Guid LoanId { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/UpdateBookRequest.cs b/Library.Applictation/Dtos/UpdateBookRequest.cs new file mode 100644 index 0000000..8cfafbf --- /dev/null +++ b/Library.Applictation/Dtos/UpdateBookRequest.cs @@ -0,0 +1,10 @@ +namespace Library.Application.Dtos; + +public class UpdateBookRequest +{ + public string Title { get; set; } + public string ISBN { get; set; } + public string Genre { get; set; } + public int PublicationYear { get; set; } + public int TotalCopies { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/UpdateUserRequest.cs b/Library.Applictation/Dtos/UpdateUserRequest.cs new file mode 100644 index 0000000..a7d5b44 --- /dev/null +++ b/Library.Applictation/Dtos/UpdateUserRequest.cs @@ -0,0 +1,6 @@ +namespace Library.Application.Dtos; + +public class UpdateUserRequest +{ + public string FullName { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/UpdateUserRoleRequest.cs b/Library.Applictation/Dtos/UpdateUserRoleRequest.cs new file mode 100644 index 0000000..be016c0 --- /dev/null +++ b/Library.Applictation/Dtos/UpdateUserRoleRequest.cs @@ -0,0 +1,6 @@ +namespace Library.Application.Dtos; + +public class UpdateUserRoleRequest +{ + public string Role { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/UserDto.cs b/Library.Applictation/Dtos/UserDto.cs new file mode 100644 index 0000000..beb2d48 --- /dev/null +++ b/Library.Applictation/Dtos/UserDto.cs @@ -0,0 +1,12 @@ +namespace Library.Applictation.Dtos +{ + public class UserDto + { + public Guid Id { get; set; } + public string FullName { get; set; } + public string Email { get; set; } + public string Role { get; set; } + public DateTime RegistrationDate { get; set; } + public int ActiveLoansCount { get; set; } + } +} diff --git a/Library.Applictation/Interfaces/IAuthService.cs b/Library.Applictation/Interfaces/IAuthService.cs new file mode 100644 index 0000000..9e538d5 --- /dev/null +++ b/Library.Applictation/Interfaces/IAuthService.cs @@ -0,0 +1,11 @@ +using Library.Application.Dtos; + +namespace Library.Application.Interfaces; + +public interface IAuthService +{ + Task RegisterAsync(RegisterRequest request); + Task LoginAsync(LoginRequest request); + Task RefreshAsync(RefreshRequest request); + Task RevokeAsync(string refreshToken); +} \ No newline at end of file diff --git a/Library.Applictation/Interfaces/IBookService.cs b/Library.Applictation/Interfaces/IBookService.cs index c220220..651e62e 100644 --- a/Library.Applictation/Interfaces/IBookService.cs +++ b/Library.Applictation/Interfaces/IBookService.cs @@ -1,13 +1,14 @@ using Library.Application.Dtos; using Library.Applictation.Dtos; -namespace Library.Applictation.Interfaces +namespace Library.Application.Interfaces; + +public interface IBookService { - public interface IBookService - { - Task> GetAllBooksAsync(); - Task GetBookByIdAsync(Guid id); - Task CreateBookAsync(CreateBookRequest request); - Task> GetBooksAsync(BookQueryParameters parameters); - } -} + Task> GetBooksAsync(BookQueryParameters parameters); + Task GetBookByIdAsync(Guid id); + Task CreateBookAsync(CreateBookRequest request); + Task UpdateBookAsync(Guid id, UpdateBookRequest request); + Task DeleteBookAsync(Guid id); + Task RestoreBookAsync(Guid id); +} \ No newline at end of file diff --git a/Library.Applictation/Interfaces/ILoanService.cs b/Library.Applictation/Interfaces/ILoanService.cs new file mode 100644 index 0000000..fadea4d --- /dev/null +++ b/Library.Applictation/Interfaces/ILoanService.cs @@ -0,0 +1,11 @@ +using Library.Application.Dtos; + +namespace Library.Application.Interfaces; + +public interface ILoanService +{ + Task> GetAllLoansAsync(); + Task BorrowBookAsync(BorrowRequest request); + Task ReturnBookAsync(ReturnRequest request); + Task> GetUserLoansAsync(Guid userId); +} \ No newline at end of file diff --git a/Library.Applictation/Interfaces/IUserService.cs b/Library.Applictation/Interfaces/IUserService.cs new file mode 100644 index 0000000..c0a6fad --- /dev/null +++ b/Library.Applictation/Interfaces/IUserService.cs @@ -0,0 +1,16 @@ +using Library.Application.Dtos; +using Library.Applictation.Dtos; + +namespace Library.Applictation.Interfaces +{ + public interface IUserService + { + Task> GetAllUsersAsync(); + Task GetUserByIdAsync(Guid id); + Task UpdateUserAsync(Guid id, UpdateUserRequest request); + Task ChangePasswordAsync(Guid id, ChangePasswordRequest request); + Task UpdateRoleAsync (Guid id, UpdateUserRoleRequest request); + Task DeleteUserAsync (Guid id); + Task RestoreUserAsync (Guid id); + } +} diff --git a/Library.Applictation/Library.Applictation.csproj b/Library.Applictation/Library.Applictation.csproj index daaf19a..5981ae0 100644 --- a/Library.Applictation/Library.Applictation.csproj +++ b/Library.Applictation/Library.Applictation.csproj @@ -8,6 +8,8 @@ + + diff --git a/Library.Domain/Entities/Book.cs b/Library.Domain/Entities/Book.cs index 31f9b3f..21c98a1 100644 --- a/Library.Domain/Entities/Book.cs +++ b/Library.Domain/Entities/Book.cs @@ -9,6 +9,8 @@ public class Book public int PublicationYear { get; private set; } public int TotalCopies { get; private set; } // Всего экземпляров в библиотеке public int AvailableCopies { get; private set; } // Сколько свободно сейчас + public bool IsDeleted { get; private set; } + public DateTime? DeletedAt { get; private set; } private Book() { } // Для EF @@ -58,4 +60,47 @@ public void ReturnCopy() AvailableCopies++; } + + public void UpdateDetails(string title, string isbn, string genre, int year, int totalCopies) + { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Название не может быть пустым", nameof(title)); + if (totalCopies < AvailableCopies) + throw new InvalidOperationException( + "Нельзя уменьшить общее количество ниже уже выданных экземпляров"); + + var borrowed = TotalCopies - AvailableCopies; + if (totalCopies < borrowed) + throw new InvalidOperationException( + $"Нельзя установить общее количество {totalCopies}: " + + $"у читателей уже {borrowed} экземпляров"); + + Title = title; + ISBN = isbn; + Genre = genre; + PublicationYear = year; + + AvailableCopies += totalCopies - TotalCopies; + TotalCopies = totalCopies; + } + + public void MarkAsDeleted() + { + if (IsDeleted) + throw new InvalidOperationException("Книга уже удалена"); + if (AvailableCopies < TotalCopies) + throw new InvalidOperationException( + "Нельзя удалить книгу, пока не возвращены все выданные экземпляры"); + + IsDeleted = true; + DeletedAt = DateTime.UtcNow; + } + + public void Restore() + { + if (!IsDeleted) + throw new InvalidOperationException("Книга не была удалена"); + IsDeleted = false; + DeletedAt = null; + } } \ No newline at end of file diff --git a/Library.Domain/Entities/BookLoan.cs b/Library.Domain/Entities/BookLoan.cs new file mode 100644 index 0000000..685320e --- /dev/null +++ b/Library.Domain/Entities/BookLoan.cs @@ -0,0 +1,63 @@ +namespace Library.Domain.Entities; + +public class BookLoan +{ + public Guid Id { get; private set; } + public Guid UserId { get; private set; } + public User User { get; private set; } + + public Guid BookId { get; private set; } + public Book Book { get; private set; } + + public DateTime BorrowDate { get; private set; } + public DateTime DueDate { get; private set; } // плановая дата возврата + public DateTime? ReturnDate { get; private set; } // фактическая дата возврата (null, если не возвращена) + public decimal? Fine { get; private set; } // штраф (если есть) + + private BookLoan() { } + + public static BookLoan Create(User user, Book book, int loanDays = 14) + { + if (user == null) throw new ArgumentNullException(nameof(user)); + if (book == null) throw new ArgumentNullException(nameof(book)); + if (book.AvailableCopies <= 0) + throw new InvalidOperationException("Нет доступных экземпляров книги"); + if (!user.CanBorrowMore()) + throw new InvalidOperationException("Пользователь уже взял максимум книг (5)"); + + book.BorrowCopy(); + + return new BookLoan + { + Id = Guid.NewGuid(), + User = user, + UserId = user.Id, + Book = book, + BookId = book.Id, + BorrowDate = DateTime.UtcNow, + DueDate = DateTime.UtcNow.AddDays(loanDays) + }; + } + + // Метод возврата книги с расчётом штрафа + public void Return(decimal? finePerDay = null) + { + if (ReturnDate.HasValue) + throw new InvalidOperationException("Книга уже возвращена"); + + ReturnDate = DateTime.UtcNow; + Book.ReturnCopy(); + + if (ReturnDate > DueDate) + { + // Считаем каждый НАЧАТЫЙ день просрочки + var daysOverdue = (int)Math.Ceiling((ReturnDate.Value - DueDate).TotalDays); + var rate = finePerDay ?? 10m; + Fine = daysOverdue * rate; + } + else + { + Fine = 0; + } + } +} \ No newline at end of file diff --git a/Library.Domain/Entities/RefreshToken.cs b/Library.Domain/Entities/RefreshToken.cs new file mode 100644 index 0000000..996c567 --- /dev/null +++ b/Library.Domain/Entities/RefreshToken.cs @@ -0,0 +1,44 @@ +namespace Library.Domain.Entities; + +public class RefreshToken +{ + public Guid Id { get; private set; } + public string Token { get; private set; } // сам токен (случайная строка) + public Guid UserId { get; private set; } + public User User { get; private set; } + public DateTime ExpiresAt { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? RevokedAt { get; private set; } // null = активен + + private RefreshToken() { } + + public static RefreshToken Create(Guid userId, int daysValid = 7) + { + return new RefreshToken + { + Id = Guid.NewGuid(), + Token = GenerateSecureToken(), + UserId = userId, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(daysValid) + }; + } + + public bool IsActive => RevokedAt == null && ExpiresAt > DateTime.UtcNow; + + public void Revoke() + { + if (RevokedAt != null) + throw new InvalidOperationException("Токен уже отозван"); + RevokedAt = DateTime.UtcNow; + } + + private static string GenerateSecureToken() + { + // 64 байта случайных данных → 88 символов Base64 + var randomBytes = new byte[64]; + using var rng = System.Security.Cryptography.RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + return Convert.ToBase64String(randomBytes); + } +} \ No newline at end of file diff --git a/Library.Domain/Entities/User.cs b/Library.Domain/Entities/User.cs new file mode 100644 index 0000000..fa45ed6 --- /dev/null +++ b/Library.Domain/Entities/User.cs @@ -0,0 +1,120 @@ +using System.Text.RegularExpressions; + +namespace Library.Domain.Entities +{ + public class User + { + public Guid Id { get; private set; } + public string FullName { get; private set; } + public string Email { get; private set; } + public string PasswordHash { get; private set; } + public string Role { get; private set; } + public DateTime RegistrationDate { get; private set; } + public bool IsDeleted { get; private set; } + public DateTime? DeletedAt { get; private set; } + + //навигационное совйство для истории выдач + private readonly List _loans = new(); + public IReadOnlyCollection Loans => _loans.AsReadOnly(); + + private User() { } + + public static User Create(string fullName, string email, string passwordHash, string role = "User") + { + if (string.IsNullOrWhiteSpace(fullName)) + { + throw new ArgumentException("Имя не может быть пустым", nameof(fullName)); + } + + if (!IsValidEmail(email)) + { + throw new ArgumentException("Неверный формат email", nameof(email)); + } + + if (string.IsNullOrWhiteSpace(passwordHash)) + { + throw new ArgumentException("Пароль не может быть пустым", nameof(passwordHash)); + } + + return new User + { + Id = Guid.NewGuid(), + FullName = fullName, + Email = email.ToLowerInvariant(), // нормализуем, чтобы избежать дублей типа Ivan@ vs ivan@ + PasswordHash = passwordHash, + Role = role, + RegistrationDate = DateTime.UtcNow + }; + } + + private static bool IsValidEmail(string email) + { + //простая валидация, можно использовать Regex + return email.Contains('@') && email.Contains('.'); + } + + public bool CanBorrowMore() + { + return Loans.Count(l => l.ReturnDate == null) < 5; + } + + public void UpdateProfile(string fullName) + { + if (string.IsNullOrWhiteSpace(fullName)) + { + throw new ArgumentException("Имя не может быть пустым", nameof(fullName)); + } + + FullName = fullName; + } + + public void ChangePassword(string newPasswordHash) + { + if (string.IsNullOrWhiteSpace(newPasswordHash)) + { + throw new ArgumentException("Хеш пароля не может быть пустым", nameof(newPasswordHash)); + } + + PasswordHash = newPasswordHash; + } + + public void UpdateRole(string newRole) + { + var allowed = new[] { "User", "Librarian", "Admin" }; + + if (!allowed.Contains(newRole)) + { + throw new ArgumentException($"Роль должна быть одной из: {string.Join(", ", allowed)}"); + } + + Role = newRole; + } + + public void MarkAsDeleted() + { + if (IsDeleted) + { + throw new InvalidOperationException("Пользователь уже удалён"); + } + + if (Loans.Any(l => l.ReturnDate == null)) + { + throw new InvalidOperationException("Нельзя удалить пользователя с активными выдачами"); + } + + IsDeleted = true; + DeletedAt = DateTime.UtcNow; + } + + public void Restore() + { + if(!IsDeleted) + { + throw new InvalidOperationException("Пользователь не был удалён"); + } + + IsDeleted = false; + DeletedAt = null; + } + } +} diff --git a/Library.Infrastructure/Data/AppDbContext.cs b/Library.Infrastructure/Data/AppDbContext.cs index 9b1c15b..cb48cfa 100644 --- a/Library.Infrastructure/Data/AppDbContext.cs +++ b/Library.Infrastructure/Data/AppDbContext.cs @@ -8,6 +8,9 @@ public class AppDbContext : DbContext public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Books { get; set; } + public DbSet Users { get; set; } + public DbSet Loans { get; set; } + public DbSet RefreshTokens { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -33,6 +36,56 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(b => b.Genre) .HasMaxLength(100); + + entity.HasQueryFilter(b => !b.IsDeleted); + + entity.Property(b => b.IsDeleted).HasDefaultValue(false); + }); + + // Настройка User + modelBuilder.Entity(entity => + { + entity.ToTable("Users"); + entity.HasKey(u => u.Id); + entity.Property(u => u.FullName).IsRequired().HasMaxLength(100); + entity.Property(u => u.Email).IsRequired().HasMaxLength(100); + entity.HasIndex(u => u.Email).IsUnique(); + entity.HasQueryFilter(u => !u.IsDeleted); + entity.Property(u => u.IsDeleted).HasDefaultValue(false); + }); + + // Настройка BookLoan + modelBuilder.Entity(entity => + { + entity.ToTable("BookLoans"); + entity.HasKey(l => l.Id); + + entity.Property(l => l.Fine).HasPrecision(18, 2); + + // Связь с User (один ко многим) + entity.HasOne(l => l.User) + .WithMany(u => u.Loans) + .HasForeignKey(l => l.UserId) + .OnDelete(DeleteBehavior.Restrict); + + // Связь с Book (один ко многим) + entity.HasOne(l => l.Book) + .WithMany() // у Book пока нет коллекции, можем добавить позже + .HasForeignKey(l => l.BookId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("RefreshTokens"); + entity.HasKey(t => t.Id); + entity.Property(t => t.Token).IsRequired().HasMaxLength(200); + entity.HasIndex(t => t.Token).IsUnique(); + + entity.HasOne(t => t.User) + .WithMany() + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); }); } } \ No newline at end of file diff --git a/Library.Infrastructure/Library.Infrastructure.csproj b/Library.Infrastructure/Library.Infrastructure.csproj index 2107bea..c936dd9 100644 --- a/Library.Infrastructure/Library.Infrastructure.csproj +++ b/Library.Infrastructure/Library.Infrastructure.csproj @@ -7,6 +7,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.Designer.cs b/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.Designer.cs new file mode 100644 index 0000000..4a68d54 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.Designer.cs @@ -0,0 +1,153 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930032133_AddUserAndLoan")] + partial class AddUserAndLoan + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.cs b/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.cs new file mode 100644 index 0000000..6e590c5 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.cs @@ -0,0 +1,110 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + /// + public partial class AddUserAndLoan : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Books", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Title = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + ISBN = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + Genre = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + PublicationYear = table.Column(type: "int", nullable: false), + TotalCopies = table.Column(type: "int", nullable: false), + AvailableCopies = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Books", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + FullName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Email = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + RegistrationDate = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "BookLoans", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + BookId = table.Column(type: "uniqueidentifier", nullable: false), + BorrowDate = table.Column(type: "datetime2", nullable: false), + DueDate = table.Column(type: "datetime2", nullable: false), + ReturnDate = table.Column(type: "datetime2", nullable: true), + Fine = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BookLoans", x => x.Id); + table.ForeignKey( + name: "FK_BookLoans_Books_BookId", + column: x => x.BookId, + principalTable: "Books", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_BookLoans_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_BookLoans_BookId", + table: "BookLoans", + column: "BookId"); + + migrationBuilder.CreateIndex( + name: "IX_BookLoans_UserId", + table: "BookLoans", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Books_ISBN", + table: "Books", + column: "ISBN", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BookLoans"); + + migrationBuilder.DropTable( + name: "Books"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.Designer.cs b/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.Designer.cs new file mode 100644 index 0000000..abfd2ec --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.Designer.cs @@ -0,0 +1,161 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930034549_AddPasswordAndRoleToUser")] + partial class AddPasswordAndRoleToUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.cs b/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.cs new file mode 100644 index 0000000..a4e2660 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + /// + public partial class AddPasswordAndRoleToUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordHash", + table: "Users", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Role", + table: "Users", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordHash", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Role", + table: "Users"); + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.Designer.cs b/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.Designer.cs new file mode 100644 index 0000000..b67fc46 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.Designer.cs @@ -0,0 +1,205 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930040529_AddRefreshTokens")] + partial class AddRefreshTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.cs b/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.cs new file mode 100644 index 0000000..ea8ac63 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + /// + public partial class AddRefreshTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Token = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + RevokedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_Token", + table: "RefreshTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.Designer.cs b/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.Designer.cs new file mode 100644 index 0000000..40535d9 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.Designer.cs @@ -0,0 +1,213 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930041417_AddSoftDeleteToBook")] + partial class AddSoftDeleteToBook + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.cs b/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.cs new file mode 100644 index 0000000..b8e8e6f --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + /// + public partial class AddSoftDeleteToBook : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "Books", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Books", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "Books"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Books"); + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.Designer.cs b/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.Designer.cs new file mode 100644 index 0000000..1781578 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.Designer.cs @@ -0,0 +1,221 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930043425_AddSoftDeleteToUser")] + partial class AddSoftDeleteToUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.cs b/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.cs new file mode 100644 index 0000000..16bbb5e --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + /// + public partial class AddSoftDeleteToUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "Users", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Users", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "Users"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Users"); + } + } +} diff --git a/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..a30e781 --- /dev/null +++ b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,218 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Services/AuthService.cs b/Library.Infrastructure/Services/AuthService.cs new file mode 100644 index 0000000..dbb9194 --- /dev/null +++ b/Library.Infrastructure/Services/AuthService.cs @@ -0,0 +1,125 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Library.Application.Dtos; +using Library.Application.Interfaces; +using Library.Domain.Entities; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Library.Infrastructure.Services; + +public class AuthService : IAuthService +{ + private readonly AppDbContext _context; + private readonly IConfiguration _configuration; + + public AuthService(AppDbContext context, IConfiguration configuration) + { + _context = context; + _configuration = configuration; + } + + public async Task RegisterAsync(RegisterRequest request) + { + var exists = await _context.Users.AnyAsync(u => u.Email == request.Email.ToLowerInvariant()); + if (exists) + throw new InvalidOperationException("Пользователь с таким email уже зарегистрирован"); + + var passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); + var user = User.Create(request.FullName, request.Email, passwordHash, role: "User"); + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + return await GenerateAuthResponseAsync(user); + } + + public async Task LoginAsync(LoginRequest request) + { + var user = await _context.Users + .FirstOrDefaultAsync(u => u.Email == request.Email.ToLowerInvariant()); + + if (user == null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) + throw new UnauthorizedAccessException("Неверный email или пароль"); + + return await GenerateAuthResponseAsync(user); + } + + public async Task RefreshAsync(RefreshRequest request) + { + var storedToken = await _context.RefreshTokens + .Include(t => t.User) + .FirstOrDefaultAsync(t => t.Token == request.RefreshToken); + + if (storedToken == null || !storedToken.IsActive) + throw new UnauthorizedAccessException("Недействительный или истёкший refresh-токен"); + + // Отзываем старый токен (rotation) — так безопаснее + storedToken.Revoke(); + await _context.SaveChangesAsync(); + + // Генерируем новую пару + return await GenerateAuthResponseAsync(storedToken.User); + } + + public async Task RevokeAsync(string refreshToken) + { + var token = await _context.RefreshTokens + .FirstOrDefaultAsync(t => t.Token == refreshToken); + + if (token == null || !token.IsActive) + return; // тихо игнорируем — не выдаём, что токена не было + + token.Revoke(); + await _context.SaveChangesAsync(); + } + + private async Task GenerateAuthResponseAsync(User user) + { + var jwtSettings = _configuration.GetSection("JwtSettings"); + var secretKey = jwtSettings["SecretKey"]!; + var issuer = jwtSettings["Issuer"]!; + var audience = jwtSettings["Audience"]!; + var expiryMinutes = int.Parse(jwtSettings["ExpiryMinutes"] ?? "15"); + + var accessTokenExpiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(ClaimTypes.Name, user.FullName), + new Claim(ClaimTypes.Role, user.Role), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: accessTokenExpiresAt, + signingCredentials: credentials + ); + + // Создаём refresh-токен и сохраняем в БД + var refreshToken = RefreshToken.Create(user.Id); + _context.RefreshTokens.Add(refreshToken); + await _context.SaveChangesAsync(); + + return new AuthResponse + { + AccessToken = new JwtSecurityTokenHandler().WriteToken(token), + RefreshToken = refreshToken.Token, + Email = user.Email, + FullName = user.FullName, + Role = user.Role, + AccessTokenExpiresAt = accessTokenExpiresAt + }; + } +} \ No newline at end of file diff --git a/Library.Infrastructure/Services/BookService.cs b/Library.Infrastructure/Services/BookService.cs index e41c8b5..75bad34 100644 --- a/Library.Infrastructure/Services/BookService.cs +++ b/Library.Infrastructure/Services/BookService.cs @@ -1,7 +1,7 @@ using FluentValidation; using Library.Application.Dtos; +using Library.Application.Interfaces; using Library.Applictation.Dtos; -using Library.Applictation.Interfaces; using Library.Domain.Entities; using Library.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -152,5 +152,67 @@ public async Task> GetBooksAsync(BookQueryParameters parame PageSize = parameters.PageSize }; } + + public async Task UpdateBookAsync(Guid id, UpdateBookRequest request) + { + var book = await _context.Books.FindAsync(id); + if (book == null) + throw new KeyNotFoundException("Книга не найдена"); + + // Проверяем уникальность ISBN (кроме самой обновляемой книги) + var isbnTaken = await _context.Books + .AnyAsync(b => b.ISBN == request.ISBN && b.Id != id); + if (isbnTaken) + throw new InvalidOperationException("Книга с таким ISBN уже существует"); + + book.UpdateDetails( + request.Title, + request.ISBN, + request.Genre, + request.PublicationYear, + request.TotalCopies + ); + + await _context.SaveChangesAsync(); + return MapToDto(book); + } + + public async Task DeleteBookAsync(Guid id) + { + var book = await _context.Books.FindAsync(id); + if (book == null) + throw new KeyNotFoundException("Книга не найдена"); + + book.MarkAsDeleted(); + await _context.SaveChangesAsync(); + } + + public async Task RestoreBookAsync(Guid id) + { + // Ищем среди удалённых — обходим query filter + var book = await _context.Books + .IgnoreQueryFilters() + .FirstOrDefaultAsync(b => b.Id == id); + + if (book == null) + throw new KeyNotFoundException("Книга не найдена"); + if (!book.IsDeleted) + throw new InvalidOperationException("Книга не была удалена"); + + book.Restore(); + await _context.SaveChangesAsync(); + } + + // Вспомогательный метод (вынесите, если ещё не сделали) + private static BookDto MapToDto(Book book) => new() + { + Id = book.Id, + Title = book.Title, + ISBN = book.ISBN, + Genre = book.Genre, + PublicationYear = book.PublicationYear, + TotalCopies = book.TotalCopies, + AvailableCopies = book.AvailableCopies + }; } } diff --git a/Library.Infrastructure/Services/LoanService.cs b/Library.Infrastructure/Services/LoanService.cs new file mode 100644 index 0000000..4b7eb27 --- /dev/null +++ b/Library.Infrastructure/Services/LoanService.cs @@ -0,0 +1,95 @@ +using Library.Application.Dtos; +using Library.Application.Interfaces; +using Library.Domain.Entities; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace Library.Infrastructure.Services; + +public class LoanService : ILoanService +{ + private readonly AppDbContext _context; + + public LoanService(AppDbContext context) + { + _context = context; + } + + public async Task> GetAllLoansAsync() + { + var loans = await _context.Loans + .Include(l => l.User) + .Include(l => l.Book) + .ToListAsync(); + + return loans.Select(MapToDto); + } + + public async Task> GetUserLoansAsync(Guid userId) + { + var loans = await _context.Loans + .Include(l => l.User) + .Include(l => l.Book) + .Where(l => l.UserId == userId) + .ToListAsync(); + + return loans.Select(MapToDto); + } + + public async Task BorrowBookAsync(BorrowRequest request) + { + // Получаем пользователя и книгу с проверкой существования + var user = await _context.Users.FindAsync(request.UserId); + if (user == null) + throw new KeyNotFoundException("Пользователь не найден"); + + var book = await _context.Books.FindAsync(request.BookId); + if (book == null) + throw new KeyNotFoundException("Книга не найдена"); + + // Создаём запись о выдаче через фабричный метод (вся логика внутри) + var loanDays = request.LoanDays ?? 14; + var loan = BookLoan.Create(user, book, loanDays); + + // Уменьшаем количество доступных копий (это делает BookLoan.Create? + // Нет, мы вызываем BorrowCopy вручную, так как Create только создаёт запись, + // а уменьшение копий — это отдельная операция. + + _context.Loans.Add(loan); + await _context.SaveChangesAsync(); + + return MapToDto(loan); + } + + public async Task ReturnBookAsync(ReturnRequest request) + { + var loan = await _context.Loans + .Include(l => l.User) + .Include(l => l.Book) + .FirstOrDefaultAsync(l => l.Id == request.LoanId); + + if (loan == null) + throw new KeyNotFoundException("Запись о выдаче не найдена"); + + // Если уже возвращена — ошибка (обработается внутри метода Return) + loan.Return(); // вызываем метод, который увеличит AvailableCopies и рассчитает штраф + + await _context.SaveChangesAsync(); + + return MapToDto(loan); + } + + private static BookLoanDto MapToDto(BookLoan loan) + { + return new BookLoanDto + { + Id = loan.Id, + UserName = loan.User?.FullName ?? "Unknown", + BookTitle = loan.Book?.Title ?? "Unknown", + BorrowDate = loan.BorrowDate, + DueDate = loan.DueDate, + ReturnDate = loan.ReturnDate, + Fine = loan.Fine + }; + } +} \ No newline at end of file diff --git a/Library.Infrastructure/Services/UserService.cs b/Library.Infrastructure/Services/UserService.cs new file mode 100644 index 0000000..1dadc96 --- /dev/null +++ b/Library.Infrastructure/Services/UserService.cs @@ -0,0 +1,136 @@ +using Library.Application.Dtos; +using Library.Application.Interfaces; +using Library.Applictation.Dtos; +using Library.Applictation.Interfaces; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace Library.Infrastructure.Services +{ + public class UserService : IUserService + { + private readonly AppDbContext _context; + + public UserService(AppDbContext context) + { + _context = context; + } + + public async Task ChangePasswordAsync(Guid id, ChangePasswordRequest request) + { + var user = await _context.Users + .Include(u => u.Loans) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + throw new InvalidOperationException("Пользователь не найден"); + } + + if(!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash)) + { + throw new UnauthorizedAccessException("Текущий пароль указан неверно"); + } + + var newHash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword); + user.ChangePassword(newHash); + + await _context.SaveChangesAsync(); + } + + public async Task DeleteUserAsync(Guid id) + { + var user = await _context.Users + .Include(u => u.Loans) + .FirstOrDefaultAsync (u => u.Id == id); + + if (user == null) + { + throw new KeyNotFoundException("Пользователь не найден"); + } + + user.MarkAsDeleted(); + await _context.SaveChangesAsync(); + } + + public async Task> GetAllUsersAsync() + { + var users = await _context.Users + .Include(u => u.Loans) + .ToListAsync(); + return users.Select(MapToDto); + } + + public async Task GetUserByIdAsync(Guid id) + { + var user = await _context.Users + .Include(u => u.Loans) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + throw new KeyNotFoundException("Пользователь не найден"); + } + + return MapToDto(user); + } + + public async Task RestoreUserAsync(Guid id) + { + var user = await _context.Users + .IgnoreQueryFilters() + .Include(u => u.Loans) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + throw new KeyNotFoundException("Пользователь не найден"); + } + + user.Restore(); + await _context.SaveChangesAsync(); + } + + public async Task UpdateRoleAsync(Guid id, UpdateUserRoleRequest request) + { + var user = await _context.Users + .Include (u => u.Loans) + .FirstOrDefaultAsync (u => u.Id == id); + + if(user == null) + { + throw new KeyNotFoundException("Пользователь не найден"); + } + + user.UpdateRole(request.Role); + await _context.SaveChangesAsync(); + return MapToDto(user); + } + + public async Task UpdateUserAsync(Guid id, UpdateUserRequest request) + { + var user = await _context.Users + .Include (u => u.Loans) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + throw new KeyNotFoundException("Пользователь не найден"); + } + + user.UpdateProfile(request.FullName); + await _context.SaveChangesAsync(); + return MapToDto(user); + } + + private static UserDto MapToDto(Domain.Entities.User user) => new() + { + Id = user.Id, + FullName = user.FullName, + Email = user.Email, + Role = user.Role, + RegistrationDate = user.RegistrationDate, + ActiveLoansCount = user.Loans.Count(l => l.ReturnDate == null) + }; + } +} diff --git a/Library.Tests/Domain/BookLoanTests.cs b/Library.Tests/Domain/BookLoanTests.cs new file mode 100644 index 0000000..6c02812 --- /dev/null +++ b/Library.Tests/Domain/BookLoanTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using Library.Domain.Entities; + +namespace Library.Tests.Domain; + +public class BookLoanTests +{ + private static (User user, Book book) CreateValidPair(int copies = 3) + { + var user = User.Create("Иван", "ivan@test.com", "hash"); + var book = Book.Create("Война и мир", "isbn", "Роман", 1869, copies); + return (user, book); + } + + [Fact] + public void Create_WithValidData_ShouldSetDueDate() + { + var (user, book) = CreateValidPair(); + + var loan = BookLoan.Create(user, book, loanDays: 14); + + loan.BorrowDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); + loan.DueDate.Should().BeCloseTo(DateTime.UtcNow.AddDays(14), TimeSpan.FromSeconds(2)); + loan.ReturnDate.Should().BeNull(); + loan.Fine.Should().BeNull(); + } + + [Fact] + public void Return_WithoutOverdue_ShouldSetZeroFine() + { + var (user, book) = CreateValidPair(); + var loan = BookLoan.Create(user, book, loanDays: 14); + + loan.Return(); + + loan.ReturnDate.Should().NotBeNull(); + loan.Fine.Should().Be(0); + book.AvailableCopies.Should().Be(3); // не забыл вернуть копию + } + + [Fact] + public void Return_WithOverdue_ShouldCalculateFine() + { + var (user, book) = CreateValidPair(); + var loan = BookLoan.Create(user, book, loanDays: 0); // просрочка сразу + + // Ждём немного, чтобы ReturnDate > DueDate + Thread.Sleep(1100); + + loan.Return(finePerDay: 10m); + + loan.Fine.Should().BeGreaterThan(0); + book.AvailableCopies.Should().Be(3); + } + + [Fact] + public void Return_Twice_ShouldThrow() + { + var (user, book) = CreateValidPair(); + var loan = BookLoan.Create(user, book); + loan.Return(); + + var act = () => loan.Return(); + act.Should().Throw() + .WithMessage("*уже возвращена*"); + } +} \ No newline at end of file diff --git a/Library.Tests/Domain/BookTests.cs b/Library.Tests/Domain/BookTests.cs new file mode 100644 index 0000000..cac294a --- /dev/null +++ b/Library.Tests/Domain/BookTests.cs @@ -0,0 +1,138 @@ +using FluentAssertions; +using Library.Domain.Entities; + +namespace Library.Tests.Domain; + +public class BookTests +{ + [Fact] + public void Create_WithValidData_ShouldReturnBook() + { + // Act + var book = Book.Create("Война и мир", "978-5-17-118636-4", "Роман", 1869, 5); + + // Assert + book.Title.Should().Be("Война и мир"); + book.TotalCopies.Should().Be(5); + book.AvailableCopies.Should().Be(5); // все доступны изначально + book.IsDeleted.Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Create_WithEmptyTitle_ShouldThrow(string title) + { + var act = () => Book.Create(title, "isbn", "genre", 2000, 1); + act.Should().Throw(); + } + + [Fact] + public void Create_WithZeroCopies_ShouldThrow() + { + var act = () => Book.Create("Title", "isbn", "genre", 2000, 0); + act.Should().Throw(); + } + + [Fact] + public void BorrowCopy_WhenAvailable_ShouldDecrementAvailable() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + + book.BorrowCopy(); + + book.AvailableCopies.Should().Be(2); + book.TotalCopies.Should().Be(3); // Total не меняется + } + + [Fact] + public void BorrowCopy_WhenNoAvailable_ShouldThrow() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 1); + book.BorrowCopy(); // теперь 0 + + var act = () => book.BorrowCopy(); + act.Should().Throw() + .WithMessage("*доступных экземпляров*"); + } + + [Fact] + public void ReturnCopy_ShouldIncrementAvailable() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + book.BorrowCopy(); + + book.ReturnCopy(); + + book.AvailableCopies.Should().Be(3); + } + + [Fact] + public void ReturnCopy_WhenAllReturned_ShouldThrow() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + // ничего не брали, но пытаемся вернуть + + var act = () => book.ReturnCopy(); + act.Should().Throw(); + } + + [Fact] + public void UpdateDetails_WithNewTotalCopies_ShouldRecomputeAvailable() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + book.BorrowCopy(); // Available = 2 + + book.UpdateDetails("New Title", "new-isbn", "new-genre", 2001, 5); + + book.TotalCopies.Should().Be(5); + book.AvailableCopies.Should().Be(4); // 2 + (5 - 3) + } + + [Fact] + public void UpdateDetails_WhenNewTotalLowerThanBorrowed_ShouldThrow() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 5); + book.BorrowCopy(); + book.BorrowCopy(); + book.BorrowCopy(); + book.BorrowCopy(); // Available = 1, borrowed = 4 + + var act = () => book.UpdateDetails("T", "i", "g", 2000, 2); // 2 < 4 + act.Should().Throw(); + } + + [Fact] + public void MarkAsDeleted_WhenBooksOut_ShouldThrow() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + book.BorrowCopy(); + + var act = () => book.MarkAsDeleted(); + act.Should().Throw(); + } + + [Fact] + public void MarkAsDeleted_WhenAllReturned_ShouldSetFlag() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + + book.MarkAsDeleted(); + + book.IsDeleted.Should().BeTrue(); + book.DeletedAt.Should().NotBeNull(); + } + + [Fact] + public void Restore_AfterDelete_ShouldClearFlag() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + book.MarkAsDeleted(); + + book.Restore(); + + book.IsDeleted.Should().BeFalse(); + book.DeletedAt.Should().BeNull(); + } +} \ No newline at end of file diff --git a/Library.Tests/Library.Tests.csproj b/Library.Tests/Library.Tests.csproj new file mode 100644 index 0000000..16a07bc --- /dev/null +++ b/Library.Tests/Library.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/Library.sln b/Library.sln index dc13dda..dee27b4 100644 --- a/Library.sln +++ b/Library.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.37502.11 d17.14 +VisualStudioVersion = 17.14.37502.11 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library.API", "Library.API\Library.API.csproj", "{D21279C6-32E9-44D5-87B8-D15C61866E23}" EndProject @@ -11,28 +11,78 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library.Applictation", "Lib EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library.Infrastructure", "Library.Infrastructure\Library.Infrastructure.csproj", "{0D228A6D-F6F4-4AD5-BA67-E260E9448710}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library.Tests", "Library.Tests\Library.Tests.csproj", "{7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|x64.ActiveCfg = Debug|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|x64.Build.0 = Debug|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|x86.ActiveCfg = Debug|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|x86.Build.0 = Debug|Any CPU {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|Any CPU.ActiveCfg = Release|Any CPU {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|Any CPU.Build.0 = Release|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|x64.ActiveCfg = Release|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|x64.Build.0 = Release|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|x86.ActiveCfg = Release|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|x86.Build.0 = Release|Any CPU {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|x64.ActiveCfg = Debug|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|x64.Build.0 = Debug|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|x86.ActiveCfg = Debug|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|x86.Build.0 = Debug|Any CPU {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|Any CPU.ActiveCfg = Release|Any CPU {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|Any CPU.Build.0 = Release|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|x64.ActiveCfg = Release|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|x64.Build.0 = Release|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|x86.ActiveCfg = Release|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|x86.Build.0 = Release|Any CPU {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|x64.ActiveCfg = Debug|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|x64.Build.0 = Debug|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|x86.ActiveCfg = Debug|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|x86.Build.0 = Debug|Any CPU {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|Any CPU.ActiveCfg = Release|Any CPU {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|Any CPU.Build.0 = Release|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|x64.ActiveCfg = Release|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|x64.Build.0 = Release|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|x86.ActiveCfg = Release|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|x86.Build.0 = Release|Any CPU {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|x64.ActiveCfg = Debug|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|x64.Build.0 = Debug|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|x86.ActiveCfg = Debug|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|x86.Build.0 = Debug|Any CPU {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|Any CPU.ActiveCfg = Release|Any CPU {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|Any CPU.Build.0 = Release|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|x64.ActiveCfg = Release|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|x64.Build.0 = Release|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|x86.ActiveCfg = Release|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|x86.Build.0 = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|x64.ActiveCfg = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|x64.Build.0 = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|x86.ActiveCfg = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|x86.Build.0 = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|Any CPU.Build.0 = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|x64.ActiveCfg = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|x64.Build.0 = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|x86.ActiveCfg = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE