diff --git a/Api/Controllers/ReviewAggregateController.cs b/Api/Controllers/ReviewAggregateController.cs deleted file mode 100644 index a801930bfc2d108aceca9f253665c6ea6947099c..0000000000000000000000000000000000000000 --- a/Api/Controllers/ReviewAggregateController.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Api.Models; -using DAL.Data; -using DAL.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace Api.Controllers -{ - [ApiController] - [Route("/[controller]")] - public class ReviewAggregateController : Controller - { - private readonly RestaurantDBContext _context; - - public ReviewAggregateController(RestaurantDBContext context) - { - _context = context; - } - - [HttpGet] - [Route("{restaurantId:guid}")] - public async Task<IActionResult> GetAggregateForRestaurant([FromRoute] Guid restaurantId) - { - var aggregate = await _context.ReviewAggregate.FindAsync(restaurantId); - if (aggregate is null || aggregate.Restaurant!.DeletedAt is not null) - { - return NotFound(); - } - - return Ok(Converter.ToReviewAggregateModel(aggregate)); - } - - [HttpGet] - public async Task<IActionResult> GetAggregates([FromQuery] string orderBy = "", - [FromQuery] int limit = 0, [FromQuery] int offset = 0) - { - var query = _context.ReviewAggregate.Include(r => r.Restaurant); - - var baseAggregatesQuery = orderBy.ToLower() switch - { - "food" => query.OrderBy(r => r.FoodRating), - "food_desc" => query.OrderByDescending(r => r.FoodRating), - "environment" => query.OrderBy(r => r.EnvironmentRating), - "environment_desc" => query.OrderByDescending(r => r.EnvironmentRating), - "service" => query.OrderBy(r => r.ServiceRating), - "service_desc" => query.OrderByDescending(r => r.ServiceRating), - "all" => query.OrderBy(r => r.FoodRating) - .ThenBy(r => r.EnvironmentRating) - .ThenBy(r => r.ServiceRating), - _ => query.OrderByDescending(r => r.FoodRating) - .ThenByDescending(r => r.EnvironmentRating) - .ThenByDescending(r => r.ServiceRating) - }; - - var aggregates = limit > 0 - ? await baseAggregatesQuery.Skip(offset).Take(limit).ToListAsync() - : await baseAggregatesQuery.Skip(offset).ToListAsync(); - - return Ok(aggregates - .Where(r => r.Restaurant!.DeletedAt is null) - .Select(Converter.ToReviewAggregateModel) - .ToList()); - } - - // The other operations do not make much sense here. This entity is tied to restaurant. - - /** - * This is merely a Proof of Concept call for recaculation of review aggregates. - * When you add a new review, call this with the restaurant ID, and you will be able to access - * an updated / new review aggregate for that restaurant. - * **/ - [HttpPost] - [Route("{restaurantId:guid}")] - public async Task<IActionResult> RecalculateAggregates(Guid restaurantId) - { - var reviews = await _context.Reviews - .Where(r => r.RestaurantId == restaurantId && r.DeletedAt == null) - .ToListAsync(); - - var aggregate = await _context.ReviewAggregate.FindAsync(restaurantId); - if (aggregate == null) - { - aggregate = new ReviewAggregate - { - RestaurantId = restaurantId, - ServiceRating = 1, - FoodRating = 1, - EnvironmentRating = 1 - }; - await _context.AddAsync(aggregate); - } - - var avg = reviews.GroupBy(r => r.RestaurantId) - .Select(group => new ReviewAggregate - { - RestaurantId = group.Key, - FoodRating = (uint)Math.Round(group.Average(item => item.FoodRating)), - ServiceRating = (uint)Math.Round(group.Average(item => item.ServiceRating)), - EnvironmentRating = (uint)Math.Round(group.Average(item => item.EnvironmentRating)) - }) - .ToList(); - - if (avg.Count == 0) - { - return NotFound("No reviews avaialble."); - } - - aggregate.ServiceRating = avg[0].ServiceRating; - aggregate.FoodRating = avg[0].FoodRating; - aggregate.EnvironmentRating = avg[0].EnvironmentRating; - - _context.ReviewAggregate.Update(aggregate); - await _context.SaveChangesAsync(); - - return Ok(); - } - } -} diff --git a/Api/Controllers/ReviewAggregateResultController.cs b/Api/Controllers/ReviewAggregateResultController.cs new file mode 100644 index 0000000000000000000000000000000000000000..eb4cfb07efd33cbe0e62172bb155e6a172808b47 --- /dev/null +++ b/Api/Controllers/ReviewAggregateResultController.cs @@ -0,0 +1,51 @@ +using BusinessLayer.Services.ReviewAggregateService; +using Microsoft.AspNetCore.Mvc; + +namespace Api.Controllers +{ + [ApiController] + [Route("/[controller]")] + public class ReviewAggregateResultController : Controller + { + private readonly IReviewAggregateResultService _service; + + public ReviewAggregateResultController(IReviewAggregateResultService service) + { + _service = service; + } + + [HttpGet] + [Route("{restaurantId:guid}")] + public async Task<IActionResult> GetAggregateForRestaurant([FromRoute] Guid restaurantId) + { + var aggregate = await _service.GetAggregateByIdAsync(restaurantId); + return aggregate is null + ? NotFound("Data not found, try again later.") + : Ok(aggregate); + } + + [HttpGet] + public async Task<IActionResult> GetAggregates([FromQuery] string orderBy = "", + [FromQuery] int limit = 0, [FromQuery] int offset = 0) + { + return Ok(await _service.GetAggregatesAsync(orderBy, limit, offset, false)); + } + + // The other operations do not make much sense here. This entity is tied to restaurant. + + /** + * This is merely a Proof of Concept call for recaculation of review aggregates. + * When you add a new review, call this with the restaurant ID, and you will be able to access + * an updated / new review aggregate for that restaurant. + * **/ + [HttpPost] + [Route("{restaurantId:guid}")] + public async Task<IActionResult> RecalculateAggregates(Guid restaurantId) + { + + return await _service.RecalculateAggregateAsync(restaurantId, true) + ? Ok() + : NotFound("No reviews exist for restaurant, unable to calculate aggregate."); + } + } +} diff --git a/Api/Controllers/ReviewCommentController.cs b/Api/Controllers/ReviewCommentController.cs index 1a3e9bced3356457a668332f9219bf57bafe3f9b..85e05b2fd97be7bdc195d1be3ef8ea36a67800d0 100644 --- a/Api/Controllers/ReviewCommentController.cs +++ b/Api/Controllers/ReviewCommentController.cs @@ -1,9 +1,7 @@ -using Api.Models; -using Api.Models.ReviewComment; -using DAL.Data; -using DAL.Models; +using BusinessLayer.DTOs.ReviewComment; +using BusinessLayer.Services.ReviewCommentService; +using BusinessLayer.Utils.Filters; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; namespace Api.Controllers { @@ -11,115 +9,50 @@ namespace Api.Controllers [Route("/[controller]")] public class ReviewCommentController : Controller { - private readonly RestaurantDBContext _context; + private readonly IReviewCommentService _service; - public ReviewCommentController(RestaurantDBContext context) + public ReviewCommentController(IReviewCommentService service) { - _context = context; + _service = service; } [HttpGet] [Route("{commentId:guid}")] public async Task<IActionResult> GetReviewCommentById([FromRoute] Guid commentId) { - var comment = await _context.ReviewComments - .Include(r => r.Poster) - .FirstOrDefaultAsync(r => r.Id == commentId - && r.DeletedAt == null - && r.Poster!.DeletedAt == null); + var comment = await _service.GetByIdAsync(commentId); - if (comment is null) - { - return NotFound(); - } - - if (comment.ParentCommentId is not null && comment.ParentComment!.DeletedAt is not null) - { - return NotFound(); - } - - return Ok(Converter.ToReviewCommentModel(comment)); + return comment is null + ? NotFound("Comment does not exist.") + : Ok(comment); } [HttpGet] public async Task<IActionResult> GetReviewComments([FromQuery] ReviewCommentFilter filter, [FromQuery] int limit = 0, [FromQuery] int offset = 0) { - var filterFunc = Converter.ToReviewCommentFilterFunc(filter); - - // There isn't many meaningful ways to order this at the moment, so just get newest first. - var commentsQuery = _context.ReviewComments - .Include(r => r.Poster).Where(filterFunc) - .OrderByDescending(r => r.CreatedAt).Skip(offset); - - var comments = limit > 0 - ? await commentsQuery.Take(limit).ToListAsync() - : await commentsQuery.ToListAsync(); - - return Ok(comments.Select(Converter.ToReviewCommentModel).ToList()); + return Ok(await _service.GetCommentsAsync(filter, limit, offset)); } [HttpPost] - public async Task<IActionResult> CreateReviewComment([FromBody] ReviewCommentCreateModel data) + public async Task<IActionResult> CreateReviewComment([FromBody] ReviewCommentCreateDTO data) { - var poster = await _context.Users.FindAsync(data.PosterId); - - if (poster is null || poster.DeletedAt is not null) - { - return NotFound("The user posting the comment does not exist."); - } - - var review = await _context.Reviews.FindAsync(data.ReviewId); - - if (review is null || review.DeletedAt is not null) - { - return NotFound("The review this comment is being posted for does not exist."); - } - - if (data.ParentCommentId is not null) - { - var parent = await _context.ReviewComments.FindAsync(data.ParentCommentId); - - if (parent is null || parent.DeletedAt is not null) - { - return NotFound("The parent comment does not exist."); - } - } + var comment = await _service.CreateCommentAsync(data); - var comment = new ReviewComment - { - Id = Guid.NewGuid(), - PosterId = data.PosterId, - ReviewId = data.ReviewId, - Content = data.Content, - ParentCommentId = data.ParentCommentId, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - await _context.ReviewComments.AddAsync(comment); - await _context.SaveChangesAsync(); - - return CreatedAtAction(nameof(CreateReviewComment), Converter.ToReviewCommentModel(comment)); + return comment is null + ? NotFound("The review or the poster of the comment was not found.") + : CreatedAtAction(nameof(CreateReviewComment), comment); } [HttpPatch] [Route("{commentId:guid}")] - public async Task<IActionResult> UpdateReviewComment([FromRoute] Guid commentId, [FromBody] ReviewCommentUpdateModel data) + public async Task<IActionResult> UpdateReviewComment([FromRoute] Guid commentId, [FromBody] ReviewCommentUpdateDTO data) { - var comment = await _context.ReviewComments.FindAsync(commentId); - - if (comment is null || comment.DeletedAt is not null) - { - return NotFound("The edited comment does not exist."); - } - - comment.Content = data.Content; - _context.ReviewComments.Update(comment); - - await _context.SaveChangesAsync(); + var comment = await _service.UpdateCommentAsync(commentId, data); - return Ok(comment); + return comment is null + ? NotFound("The comment does not exist.") + : Ok(comment); } /** @@ -129,20 +62,9 @@ namespace Api.Controllers [Route("{commentId:guid}")] public async Task<IActionResult> DeleteReviewComment([FromRoute] Guid commentId) { - var comment = await _context.ReviewComments.FindAsync(commentId); - - if (comment is null) - { - return NotFound("The comment that is to be deleted was not found."); - } - - comment.UpdatedAt = DateTime.UtcNow; - comment.DeletedAt = DateTime.UtcNow; - - _context.Update(comment); - await _context.SaveChangesAsync(); - - return Ok(); + return await _service.DeleteCommentAsync(commentId) + ? Ok() + : NotFound("Comment not found."); } } } diff --git a/Api/Controllers/ReviewController.cs b/Api/Controllers/ReviewController.cs index 928f8ec3d6c2bc855b765473cea7ec6ca38f06fa..3601ab96baa0bdb4fdc57817b8c77fbbafa38f8a 100644 --- a/Api/Controllers/ReviewController.cs +++ b/Api/Controllers/ReviewController.cs @@ -1,9 +1,9 @@ -using Api.Models; -using Api.Models.Review; -using DAL.Data; -using DAL.Models; +using Api.Models.Review; +using BusinessLayer.DTOs.Review; +using BusinessLayer.Services.ReviewService; +using BusinessLayer.Utils.Filters; +using BusinessLayer.Utils.Ordering; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; namespace Api.Controllers { @@ -11,107 +11,48 @@ namespace Api.Controllers [ApiController] public class ReviewController : Controller { - private readonly RestaurantDBContext _context; - - public ReviewController(RestaurantDBContext context) + private readonly IReviewService _service; + public ReviewController(IReviewService service) { - _context = context; + _service = service; + } [HttpGet] [Route("{reviewId:guid}")] public async Task<IActionResult> GetReviewById([FromRoute] Guid reviewId) { - // Comments are to be loaded separately. - var review = await _context.Reviews.Include(r => r.Poster) - .SingleOrDefaultAsync(r => r.Id == reviewId); - - if (review is null || review.DeletedAt is not null - || review.Poster!.DeletedAt is not null) + var review = await _service.GetReviewAsync(reviewId); + if (review is null || review.DeletedAt is not null) { return NotFound(); } - return Ok(Converter.ToReviewModel(review)); + return Ok(review); } [HttpGet] public async Task<IActionResult> GetReviews([FromQuery] ReviewFilter filter, - [FromQuery] string orderBy = "Id", [FromQuery] int limit = 0, + [FromQuery] ReviewOrdering orderBy, [FromQuery] int limit = 0, [FromQuery] int offset = 0) { - var filterFunc = Converter.ToReviewFilterFunc(filter); - - var reviewsQuery = _context.Reviews - .Include(r => r.Poster).Where(filterFunc); - - /* - * It would've been nicer to use an Enum to represent these values, but in the query string, - * that translates into passing a number representing the enum value, which is not human-friendly. - */ - reviewsQuery = orderBy.ToLower() switch - { - "service" => reviewsQuery.OrderBy(r => r.ServiceRating), - "service_desc" => reviewsQuery.OrderByDescending(r => r.ServiceRating), - "environment" => reviewsQuery.OrderBy(r => r.EnvironmentRating), - "environment_desc" => reviewsQuery.OrderByDescending(r => r.EnvironmentRating), - "food" => reviewsQuery.OrderBy(r => r.FoodRating), - "food_desc" => reviewsQuery.OrderByDescending(r => r.FoodRating), - _ => reviewsQuery.OrderByDescending(r => r.CreatedAt) // To keep the newest first - }; - - reviewsQuery = reviewsQuery.Skip(offset); - - var reviews = limit > 0 - ? await reviewsQuery.Take(limit).ToListAsync() - : await reviewsQuery.ToListAsync(); - - return Ok(reviews.Select(Converter.ToReviewModel).ToList()); + return Ok(await _service.GetReviewsAsync(filter, orderBy, limit, offset)); } [HttpPost] - public async Task<IActionResult> CreateReview([FromBody] ReviewCreateModel data) + public async Task<IActionResult> CreateReview([FromBody] ReviewCreateDTO data) { - var user = await _context.Users.FindAsync(data.PosterId); - - if (user is null || user.DeletedAt is not null) - { - return NotFound("User does not exist!"); - } - - var restaurant = await _context.Restaurants.FindAsync(data.RestaurantId); - - if (restaurant is null || restaurant.DeletedAt is not null) - { - return NotFound("Restaurant does not exist!"); - } - - var review = new Review - { - Id = Guid.NewGuid(), - PosterId = data.PosterId, - RestaurantId = data.RestaurantId, - Content = data.Content, - FoodRating = data.FoodRating, - ServiceRating = data.ServiceRating, - EnvironmentRating = data.EnvironmentRating, - ServesFreeWater = data.ServesFreeWater, - TimeSpent = data.TimeSpent, - LeftTip = data.LeftTip, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - - await _context.Reviews.AddAsync(review); - await _context.SaveChangesAsync(); + var review = await _service.CreateReviewAsync(data); - return CreatedAtAction(nameof(CreateReview), Converter.ToReviewModel(review)); + return review is null + ? NotFound("User or Restaurant does not exist.") + : CreatedAtAction(nameof(CreateReview), review); } [HttpPatch] [Route("{reviewId:guid}")] public async Task<IActionResult> UpdateReview([FromRoute] Guid reviewId, - [FromBody] ReviewUpdateModel data) + [FromBody] ReviewUpdateDTO data) { if (UpdateEmpty(data)) { @@ -123,49 +64,25 @@ namespace Api.Controllers return BadRequest("Review content cannot be empty."); } - var review = await _context.Reviews.FindAsync(reviewId); - - if (review is null || review.DeletedAt is not null) - { - return NotFound("The edited review does not exist."); - } - - review.Content = data.Content ?? review.Content; - review.FoodRating = data.FoodRating ?? review.FoodRating; - review.ServiceRating = data.ServiceRating ?? review.ServiceRating; - review.EnvironmentRating = data.EnvironmentRating ?? review.EnvironmentRating; - review.ServesFreeWater = data.ServesFreeWater ?? review.ServesFreeWater; - review.TimeSpent = data.TimeSpent ?? review.TimeSpent; - review.LeftTip = data.LeftTip ?? review.LeftTip; - review.UpdatedAt = DateTime.UtcNow; - - _context.Reviews.Update(review); - await _context.SaveChangesAsync(); + var review = await _service.UpdateReviewAsync(reviewId, data); - return Ok(Converter.ToReviewModel(review)); + return review is null + ? NotFound("Review does not exist.") + : Ok(review); } [HttpDelete] [Route("{reviewId:guid}")] public async Task<IActionResult> DeleteReview([FromRoute] Guid reviewId) { - var review = await _context.Reviews.FindAsync(reviewId); - - if (review is null) - { - return NotFound(); - } - - review.UpdatedAt = DateTime.UtcNow; - review.DeletedAt = DateTime.UtcNow; - - _context.Reviews.Update(review); - await _context.SaveChangesAsync(); + var deleted = await _service.DeleteReviewByIdAsync(reviewId); - return Ok(); + return deleted + ? Ok() + : NotFound("Review does not exist."); } - private static bool UpdateEmpty(ReviewUpdateModel data) + private static bool UpdateEmpty(ReviewUpdateDTO data) { return data.Content is null && data.FoodRating is null && data.ServiceRating is null && data.EnvironmentRating is null diff --git a/Api/Models/Converter.cs b/Api/Models/Converter.cs index 122058ae6f692ace62f4e604f29c84905094d891..6716ee8fa1dd07b3ef35c7ab127bd4fa13c13198 100644 --- a/Api/Models/Converter.cs +++ b/Api/Models/Converter.cs @@ -77,7 +77,7 @@ namespace Api.Models }; } - public static ReviewAggregateModel ToReviewAggregateModel(DAL.Models.ReviewAggregate aggregate) + public static ReviewAggregateModel ToReviewAggregateModel(DAL.Models.ReviewAggregateResult aggregate) { return new ReviewAggregateModel { @@ -88,15 +88,6 @@ namespace Api.Models }; } - public static Expression<Func<DAL.Models.ReviewComment, bool>> ToReviewCommentFilterFunc(ReviewCommentFilter filter) - { - return c => (filter.PosterId == null || c.PosterId == filter.PosterId) - && (filter.ReviewId == null || c.ReviewId == filter.ReviewId) - && (filter.ParentCommentId == null || c.ParentCommentId == filter.ParentCommentId) - && c.DeletedAt == null && (c.ParentComment == null || c.ParentComment.DeletedAt == null) - && (c.Poster == null || c.Poster!.DeletedAt == null); - } - public static Expression<Func<DAL.Models.User, bool>> ToUserFilterFunc(UserFilter filter) { return u => (filter.NameLike == null || u.Name.Contains(filter.NameLike)) @@ -104,19 +95,6 @@ namespace Api.Models && u.DeletedAt == null; } - public static Expression<Func<DAL.Models.Review, bool>> ToReviewFilterFunc(ReviewFilter filter) - { - return r => (filter.PosterId == null || r.PosterId == filter.PosterId) - && (filter.RestaurantId == null || r.RestaurantId == filter.RestaurantId) - && (filter.FoodGreaterEqual == null || r.FoodRating >= filter.FoodGreaterEqual) - && (filter.FoodLessEqual == null || r.FoodRating <= filter.FoodLessEqual) - && (filter.EnvGreaterEqual == null || r.EnvironmentRating >= filter.EnvGreaterEqual) - && (filter.EnvLessEqual == null || r.EnvironmentRating <= filter.EnvLessEqual) - && (filter.ServiceGreaterEqual == null || r.ServiceRating >= filter.ServiceGreaterEqual) - && (filter.ServiceLessEqual == null || r.ServiceRating <= filter.ServiceLessEqual) - && r.DeletedAt == null && ( r.Poster != null || r.Poster!.DeletedAt == null ); - } - // EVENT public static EventModel ToEventModel(DAL.Models.Event eventEntity) { diff --git a/Api/Models/Review/ReviewFilter.cs b/Api/Models/Review/ReviewFilter.cs deleted file mode 100644 index bfa444c165e8542d7a0c6f6f6719bb99c993d552..0000000000000000000000000000000000000000 --- a/Api/Models/Review/ReviewFilter.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Api.Models.Review -{ - public class ReviewFilter - { - public Guid? PosterId { get; set; } - public Guid? RestaurantId { get; set; } - public uint? FoodGreaterEqual { get; set; } - public uint? FoodLessEqual { get; set; } - public uint? EnvGreaterEqual { get; set; } - public uint? EnvLessEqual { get; set; } - public uint? ServiceGreaterEqual { get; set; } - public uint? ServiceLessEqual { get; set; } - } -} diff --git a/Api/Models/ReviewComment/ReviewCommentFilter.cs b/Api/Models/ReviewComment/ReviewCommentFilter.cs deleted file mode 100644 index 7fb6f3a08eb38a7467455064d2f194b3e8348b3b..0000000000000000000000000000000000000000 --- a/Api/Models/ReviewComment/ReviewCommentFilter.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Linq.Expressions; - -namespace Api.Models.ReviewComment -{ - public class ReviewCommentFilter - { - public Guid? PosterId { get; set; } - public Guid? ReviewId { get; set; } - public Guid? ParentCommentId { get; set; } - } -} diff --git a/Api/Program.cs b/Api/Program.cs index 73a05465e199a6629d6bbbf813da9c70fbd7c5ce..533a2c658ee090cf3c9b500c1733e70765760f0f 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -1,4 +1,7 @@ using Api.Middleware; +using BusinessLayer.Services.ReviewAggregateService; +using BusinessLayer.Services.ReviewCommentService; +using BusinessLayer.Services.ReviewService; using DAL.Data; using Microsoft.EntityFrameworkCore; using Microsoft.OpenApi.Models; @@ -20,6 +23,10 @@ builder.Services.AddDbContextFactory<RestaurantDBContext>(options => builder.Configuration.GetConnectionString("MSSQL")) ); +builder.Services.AddScoped<IReviewService, ReviewService>(); +builder.Services.AddScoped<IReviewAggregateResultService, ReviewAggregateResultService>(); +builder.Services.AddScoped<IReviewCommentService, ReviewCommentService>(); + builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle diff --git a/BusinessLayer/BusinessLayer.csproj b/BusinessLayer/BusinessLayer.csproj index 5e913cad62d18bd446d738f7121fb94f0de03c87..df1db77d82baa1baa1b752cdb4aca04252d59b8e 100644 --- a/BusinessLayer/BusinessLayer.csproj +++ b/BusinessLayer/BusinessLayer.csproj @@ -11,9 +11,7 @@ </ItemGroup> <ItemGroup> - <Folder Include="DTOs\" /> <Folder Include="Mapper\" /> - <Folder Include="Services\" /> </ItemGroup> <ItemGroup> diff --git a/BusinessLayer/DTOs/Restaurant/RestaurantDTO.cs b/BusinessLayer/DTOs/Restaurant/RestaurantDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..c758de38b802dc304cf7e430082393cad14ab126 --- /dev/null +++ b/BusinessLayer/DTOs/Restaurant/RestaurantDTO.cs @@ -0,0 +1,9 @@ +namespace BusinessLayer.DTOs.Restaurant +{ + // Stub DTO just for the necessary calls, reimplement + // when Restaurant BL is done. + public record RestaurantDTO + { + public required string Name { get; set; } + } +} diff --git a/BusinessLayer/DTOs/Review/ReviewCreateDTO.cs b/BusinessLayer/DTOs/Review/ReviewCreateDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..13d8769dfde9500b4944193a5c7e95019f1aa8ef --- /dev/null +++ b/BusinessLayer/DTOs/Review/ReviewCreateDTO.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Api.Models.Review +{ + public record ReviewCreateDTO + { + [Required] + public Guid PosterId { get; set; } + [Required] + public Guid RestaurantId { get; set; } + [Required] + [MaxLength(3600)] + public required string Content { get; set; } + [Required] + [Range(1, 10)] + public uint FoodRating { get; set; } + [Required] + [Range(1, 10)] + public uint ServiceRating { get; set; } + [Required] + [Range(1, 10)] + public uint EnvironmentRating { get; set; } + public bool? ServesFreeWater { get; set; } + public float? TimeSpent { get; set; } + public bool? LeftTip { get; set; } + } +} diff --git a/BusinessLayer/DTOs/Review/ReviewDTO.cs b/BusinessLayer/DTOs/Review/ReviewDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..225c13dad27aac7d257a06dcc72ec5b2ad973ef8 --- /dev/null +++ b/BusinessLayer/DTOs/Review/ReviewDTO.cs @@ -0,0 +1,17 @@ +using BusinessLayer.DTOs.Restaurant; +using BusinessLayer.DTOs.User; + +namespace BusinessLayer.DTOs.Review +{ + public record ReviewDTO + { + public Guid Id { get; set; } + public UserDTO? Poster { get; set; } + public uint FoodRating { get; set; } + public uint ServiceRating { get; set; } + public uint EnvironmentRating { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public DateTime? DeletedAt { get; set; } + } +} diff --git a/BusinessLayer/DTOs/Review/ReviewDetailDTO.cs b/BusinessLayer/DTOs/Review/ReviewDetailDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..d976265e59a8decc3978a1d8ead7de3ab23f2904 --- /dev/null +++ b/BusinessLayer/DTOs/Review/ReviewDetailDTO.cs @@ -0,0 +1,22 @@ +using BusinessLayer.DTOs.Restaurant; +using BusinessLayer.DTOs.User; + +namespace BusinessLayer.DTOs.Review +{ + public class ReviewDetailDTO + { + public Guid Id { get; set; } + public UserDTO? Poster { get; set; } + public RestaurantDTO? Restaurant { get; set; } + public required string Content { get; set; } + public uint FoodRating { get; set; } + public uint ServiceRating { get; set; } + public uint EnvironmentRating { get; set; } + public bool? ServesFreeWater { get; set; } + public float? TimeSpent { get; set; } + public bool? LeftTip { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public DateTime? DeletedAt { get; set; } + } +} diff --git a/BusinessLayer/DTOs/Review/ReviewUpdateDTO.cs b/BusinessLayer/DTOs/Review/ReviewUpdateDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..9f168cb5ceb48b17b4cd45e62ffd03e0f5d459eb --- /dev/null +++ b/BusinessLayer/DTOs/Review/ReviewUpdateDTO.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace BusinessLayer.DTOs.Review +{ + public record ReviewUpdateDTO + { + [MaxLength(3600)] + public string? Content { get; set; } + [Range(1, 10)] + public uint? FoodRating { get; set; } + [Range(1, 10)] + public uint? ServiceRating { get; set; } + [Range(1, 10)] + public uint? EnvironmentRating { get; set; } + public bool? ServesFreeWater { get; set; } + public float? TimeSpent { get; set; } + public DateTime? VisitedAt { get; set; } + public bool? LeftTip { get; set; } + } +} diff --git a/BusinessLayer/DTOs/ReviewAggregate/ReviewAggregateDTO.cs b/BusinessLayer/DTOs/ReviewAggregate/ReviewAggregateDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..ec77b9a800ffaeb19ef5622802ab76a0d3892de9 --- /dev/null +++ b/BusinessLayer/DTOs/ReviewAggregate/ReviewAggregateDTO.cs @@ -0,0 +1,13 @@ +using BusinessLayer.DTOs.Restaurant; + +namespace BusinessLayer.DTOs.ReviewAggregate +{ + public class ReviewAggregateDTO + { + public Guid RestaurantId { get; set; } + public RestaurantDTO? Restaurant { get; set; } + public uint FoodRating { get; set; } + public uint ServiceRating { get; set; } + public uint EnvironmentRating { get; set; } + } +} diff --git a/BusinessLayer/DTOs/ReviewComment/ReviewCommentCreateDTO.cs b/BusinessLayer/DTOs/ReviewComment/ReviewCommentCreateDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..c57d8eb2f8c5f259ae012ea998242a36ae8008fd --- /dev/null +++ b/BusinessLayer/DTOs/ReviewComment/ReviewCommentCreateDTO.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace BusinessLayer.DTOs.ReviewComment +{ + public class ReviewCommentCreateDTO + { + [Required] + public Guid PosterId { get; set; } + [Required] + public Guid ReviewId { get; set; } + public Guid? ParentCommentId { get; set; } + [Required] + [MaxLength(1800)] + public string Content { get; set; } + } +} diff --git a/BusinessLayer/DTOs/ReviewComment/ReviewCommentDTO.cs b/BusinessLayer/DTOs/ReviewComment/ReviewCommentDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..eed881c847b9c05800884376322d153258fcc015 --- /dev/null +++ b/BusinessLayer/DTOs/ReviewComment/ReviewCommentDTO.cs @@ -0,0 +1,19 @@ +using BusinessLayer.DTOs.Review; +using BusinessLayer.DTOs.User; + +namespace BusinessLayer.DTOs.ReviewComment +{ + public class ReviewCommentDTO + { + public Guid Id { get; set; } + public UserDTO? Poster { get; set; } + public Guid ReviewId { get; set; } + public ReviewDTO? Review { get; set; } + public Guid? ParentCommentId { get; set; } + public List<ReviewCommentDTO> ChildComments { get; set; } = []; + public required string Content { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public DateTime? DeletedAt { get; set; } + } +} diff --git a/BusinessLayer/DTOs/ReviewComment/ReviewCommentUpdateDTO.cs b/BusinessLayer/DTOs/ReviewComment/ReviewCommentUpdateDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..6af02e9f53138bdec063ced429fcd9b816c8b4fa --- /dev/null +++ b/BusinessLayer/DTOs/ReviewComment/ReviewCommentUpdateDTO.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace BusinessLayer.DTOs.ReviewComment +{ + public class ReviewCommentUpdateDTO + { + [Required] + [MaxLength(1800)] + public required string Content { get; set; } + } +} diff --git a/BusinessLayer/DTOs/User/UserDTO.cs b/BusinessLayer/DTOs/User/UserDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..50d96e462fe7f1e41191bd2cf59d9f21defe1d65 --- /dev/null +++ b/BusinessLayer/DTOs/User/UserDTO.cs @@ -0,0 +1,8 @@ +namespace BusinessLayer.DTOs.User +{ + // STUB DTO, reimplement later + public record UserDTO + { + public required string Name { get; set; } + } +} diff --git a/BusinessLayer/Services/BaseService.cs b/BusinessLayer/Services/BaseService.cs new file mode 100644 index 0000000000000000000000000000000000000000..c2586f4fafc1b2678ff627e534e6f2ded891535b --- /dev/null +++ b/BusinessLayer/Services/BaseService.cs @@ -0,0 +1,23 @@ + +using DAL.Data; + +namespace BusinessLayer.Services +{ + public class BaseService : IBaseService + { + private readonly RestaurantDBContext _dbContext; + + public BaseService(RestaurantDBContext dbContext) + { + _dbContext = dbContext; + } + + public async Task SaveAsync(bool save) + { + if (save) + { + await _dbContext.SaveChangesAsync(); + } + } + } +} diff --git a/BusinessLayer/Services/IBaseService.cs b/BusinessLayer/Services/IBaseService.cs new file mode 100644 index 0000000000000000000000000000000000000000..d1833638506a8629e2c6e25ff423da704edb7134 --- /dev/null +++ b/BusinessLayer/Services/IBaseService.cs @@ -0,0 +1,7 @@ +namespace BusinessLayer.Services +{ + public interface IBaseService + { + public Task SaveAsync(bool save); + } +} diff --git a/BusinessLayer/Services/ReviewAggregateResultService/IReviewAggregateResultService.cs b/BusinessLayer/Services/ReviewAggregateResultService/IReviewAggregateResultService.cs new file mode 100644 index 0000000000000000000000000000000000000000..892d4d9781c63b24f7efb04a38120bbb1d040341 --- /dev/null +++ b/BusinessLayer/Services/ReviewAggregateResultService/IReviewAggregateResultService.cs @@ -0,0 +1,19 @@ +using BusinessLayer.DTOs.ReviewAggregate; + +namespace BusinessLayer.Services.ReviewAggregateService +{ + public interface IReviewAggregateResultService : IBaseService + { + public Task<bool> DoesAggregateExist(params Guid[] ids); + + public Task<List<ReviewAggregateDTO>> GetAggregatesAsync( + string orderBy = "", + int limit = 0, + int offset = 0, + bool includeRestaurant = false); + + public Task<ReviewAggregateDTO?> GetAggregateByIdAsync(Guid id, bool includeRestaurant = false); + + public Task<bool> RecalculateAggregateAsync(Guid id, bool save = true); + } +} diff --git a/BusinessLayer/Services/ReviewAggregateResultService/ReviewAggregateResultService.cs b/BusinessLayer/Services/ReviewAggregateResultService/ReviewAggregateResultService.cs new file mode 100644 index 0000000000000000000000000000000000000000..134c17fb4c625f2f5a4bce1e843c1a0d92a25a86 --- /dev/null +++ b/BusinessLayer/Services/ReviewAggregateResultService/ReviewAggregateResultService.cs @@ -0,0 +1,119 @@ +using BusinessLayer.DTOs.ReviewAggregate; +using DAL.Data; +using DAL.Models; +using Mapster; +using Microsoft.EntityFrameworkCore; + +namespace BusinessLayer.Services.ReviewAggregateService +{ + public class ReviewAggregateResultService : BaseService, IReviewAggregateResultService + { + private readonly RestaurantDBContext _dbContext; + public ReviewAggregateResultService(RestaurantDBContext dbContext) : base(dbContext) + { + _dbContext = dbContext; + } + + public async Task<bool> DoesAggregateExist(params Guid[] ids) + { + return await _dbContext.ReviewAggregateResults.AnyAsync(r => ids.Contains(r.RestaurantId)); + } + + public async Task<ReviewAggregateDTO?> GetAggregateByIdAsync(Guid id, bool includeRestaurant = false) + { + IQueryable<ReviewAggregateResult> query = _dbContext.ReviewAggregateResults; + + if (includeRestaurant) + { + query = query.Include(r => r.Restaurant); + } + + var aggregate = await query.FirstOrDefaultAsync(r => r.RestaurantId == id); + + if (aggregate == null) + { + return null; + } + return aggregate.Adapt<ReviewAggregateDTO>(); + } + + public async Task<List<ReviewAggregateDTO>> GetAggregatesAsync(string orderBy = "", int limit = 0, int offset = 0, bool includeRestaurant = false) + { + IQueryable<ReviewAggregateResult> query = _dbContext.ReviewAggregateResults; + if (includeRestaurant) + { + query = query.Include(r => r.Restaurant); + } + + query = orderBy.ToLower() switch + { + "food" => query.OrderBy(r => r.FoodRating), + "food_desc" => query.OrderByDescending(r => r.FoodRating), + "environment" => query.OrderBy(r => r.EnvironmentRating), + "environment_desc" => query.OrderByDescending(r => r.EnvironmentRating), + "service" => query.OrderBy(r => r.ServiceRating), + "service_desc" => query.OrderByDescending(r => r.ServiceRating), + "all" => query.OrderBy(r => r.FoodRating) + .ThenBy(r => r.EnvironmentRating) + .ThenBy(r => r.ServiceRating), + _ => query.OrderByDescending(r => r.FoodRating) + .ThenByDescending(r => r.EnvironmentRating) + .ThenByDescending(r => r.ServiceRating) + }; + + query = query.Skip(offset); + if (limit > 0) + { + query = query.Take(limit); + } + + return await query.Select(r => r.Adapt<ReviewAggregateDTO>()).ToListAsync(); + } + + /** + * DO NOT TEST THIS METHOD, I will likely replace it with a stored database procedure. + */ + public async Task<bool> RecalculateAggregateAsync(Guid id, bool save = true) + { + var reviews = await _dbContext.Reviews + .Where(r => r.RestaurantId == id && r.DeletedAt == null) + .ToListAsync(); + + var aggregate = await _dbContext.ReviewAggregateResults.FindAsync(id); + if (aggregate == null) + { + aggregate = new ReviewAggregateResult + { + RestaurantId = id, + ServiceRating = 1, + FoodRating = 1, + EnvironmentRating = 1 + }; + await _dbContext.AddAsync(aggregate); + } + + var avg = reviews.GroupBy(r => r.RestaurantId) + .Select(group => new ReviewAggregateResult + { + RestaurantId = group.Key, + FoodRating = (uint)Math.Round(group.Average(item => item.FoodRating)), + ServiceRating = (uint)Math.Round(group.Average(item => item.ServiceRating)), + EnvironmentRating = (uint)Math.Round(group.Average(item => item.EnvironmentRating)) + }) + .ToList(); + + if (avg.Count == 0) + { + return false; + } + + aggregate.ServiceRating = avg[0].ServiceRating; + aggregate.FoodRating = avg[0].FoodRating; + aggregate.EnvironmentRating = avg[0].EnvironmentRating; + + _dbContext.ReviewAggregateResults.Update(aggregate); + await SaveAsync(save); + return true; + } + } +} diff --git a/BusinessLayer/Services/ReviewCommentService/IReviewCommentService.cs b/BusinessLayer/Services/ReviewCommentService/IReviewCommentService.cs new file mode 100644 index 0000000000000000000000000000000000000000..e0f80c36fd4391e488cb36d3a318823d80771b6c --- /dev/null +++ b/BusinessLayer/Services/ReviewCommentService/IReviewCommentService.cs @@ -0,0 +1,31 @@ +using BusinessLayer.DTOs.ReviewComment; +using BusinessLayer.Utils.Filters; + +namespace BusinessLayer.Services.ReviewCommentService +{ + public interface IReviewCommentService : IBaseService + { + public Task<bool> DoesCommentExistAsync(params Guid[] ids); + + public Task<ReviewCommentDTO?> GetByIdAsync( + Guid id, + bool includeUser = true, + bool includeReview = false, + bool includeChildren = false); + + public Task<List<ReviewCommentDTO>> GetCommentsAsync( + ReviewCommentFilter filter, + int limit = 0, + int offset = 0, + Guid[]? ids = null, + bool includeUser = true, + bool includeReview = false, + bool includeChildren = false); + + public Task<ReviewCommentDTO?> CreateCommentAsync(ReviewCommentCreateDTO data, bool save = true); + + public Task<ReviewCommentDTO?> UpdateCommentAsync(Guid id, ReviewCommentUpdateDTO data, bool save = true); + + public Task<bool> DeleteCommentAsync(Guid id, bool save = true); + } +} diff --git a/BusinessLayer/Services/ReviewCommentService/ReviewCommentService.cs b/BusinessLayer/Services/ReviewCommentService/ReviewCommentService.cs new file mode 100644 index 0000000000000000000000000000000000000000..fa3d0b779154bea7efb00d0fc4a41977fccb5cbc --- /dev/null +++ b/BusinessLayer/Services/ReviewCommentService/ReviewCommentService.cs @@ -0,0 +1,149 @@ +using BusinessLayer.DTOs.Review; +using BusinessLayer.DTOs.ReviewComment; +using BusinessLayer.Utils.Filters; +using BusinessLayer.Utils.Pagination; +using DAL.Data; +using DAL.Models; +using Mapster; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.Design; + +namespace BusinessLayer.Services.ReviewCommentService +{ + public class ReviewCommentService : BaseService, IReviewCommentService + { + private readonly RestaurantDBContext _dbContext; + + public ReviewCommentService(RestaurantDBContext dbContext) : base(dbContext) + { + _dbContext = dbContext; + } + + public async Task<ReviewCommentDTO?> CreateCommentAsync(ReviewCommentCreateDTO data, bool save = true) + { + var poster = await _dbContext.Users.FindAsync(data.PosterId); + + if (poster is null || poster.DeletedAt is not null) + { + return null; + } + + var review = await _dbContext.Reviews.FindAsync(data.ReviewId); + + if (review is null || review.DeletedAt is not null) + { + return null; + } + + if (data.ParentCommentId is not null) + { + var parent = await _dbContext.ReviewComments.FindAsync(data.ParentCommentId); + + if (parent is null || parent.DeletedAt is not null) + { + return null; + } + } + + var comment = data.Adapt<ReviewComment>(); + comment.Id = Guid.NewGuid(); + comment.CreatedAt = DateTime.UtcNow; + comment.UpdatedAt = DateTime.UtcNow; + await _dbContext.ReviewComments.AddAsync(comment); + await SaveAsync(save); + return comment.Adapt<ReviewCommentDTO?>(); + } + + public async Task<bool> DeleteCommentAsync(Guid id, bool save = true) + { + var comment = await _dbContext.ReviewComments.FindAsync(id); + + if (comment is null || comment.DeletedAt is not null) + { + return false; + } + + comment.UpdatedAt = DateTime.UtcNow; + comment.DeletedAt = DateTime.UtcNow; + + _dbContext.Update(comment); + await SaveAsync(save); + return true; + } + + public async Task<bool> DoesCommentExistAsync(params Guid[] ids) + { + return await _dbContext.ReviewComments.AnyAsync(r => ids.Contains(r.Id) && r.DeletedAt == null); + } + + public async Task<ReviewCommentDTO?> GetByIdAsync(Guid id, bool includeUser = true, bool includeReview = false, bool includeChildren = false) + { + var query = BuildQuery(includeUser, includeReview, includeChildren); + + var result = await query.SingleOrDefaultAsync(r => r.Id == id && r.DeletedAt == null); + if (result == null + || (includeUser && result.Poster.DeletedAt != null) + || (includeReview && result.Review.DeletedAt != null)) + { + return null; + } + + return result.Adapt<ReviewCommentDTO?>(); + } + + public async Task<List<ReviewCommentDTO>> GetCommentsAsync(ReviewCommentFilter filter, + int limit = 0, + int offset = 0, + Guid[]? ids = null, + bool includeUser = true, + bool includeReview = false, + bool includeChildren = false) + { + IQueryable<ReviewComment> query = BuildQuery(includeUser, includeReview, includeChildren); + + return await query + .Where(filter.ComposeFilterFunction(ids)) + .OrderByDescending(r => r.CreatedAt) + .ApplyPagination(limit, offset) + .Select(r => r.Adapt<ReviewCommentDTO>()).ToListAsync(); + } + + public async Task<ReviewCommentDTO?> UpdateCommentAsync(Guid id, ReviewCommentUpdateDTO data, bool save = true) + { + var comment = await _dbContext.ReviewComments.FindAsync(id); + + if (comment is null || comment.DeletedAt is not null) + { + return null; + } + + comment.Content = data.Content; + _dbContext.ReviewComments.Update(comment); + + await SaveAsync(save); + return comment.Adapt<ReviewCommentDTO?>(); + } + + private IQueryable<ReviewComment> BuildQuery(bool includeUser, bool includeReview, bool includeChildren) + { + IQueryable<ReviewComment> query = _dbContext.ReviewComments; + + if (includeUser) + { + query = query.Include(r => r.Poster); + } + + if (includeReview) + { + query = query.Include(r => r.Review); + } + + if (includeChildren) + { + query = query.Include(r => r.ChildComments); + } + + return query; + } + } +} diff --git a/BusinessLayer/Services/ReviewService/IReviewService.cs b/BusinessLayer/Services/ReviewService/IReviewService.cs new file mode 100644 index 0000000000000000000000000000000000000000..ca25281dabbb86280ae1421ef7d55f63ba8f9c2f --- /dev/null +++ b/BusinessLayer/Services/ReviewService/IReviewService.cs @@ -0,0 +1,32 @@ +using Api.Models.Review; +using BusinessLayer.DTOs.Review; +using BusinessLayer.Utils.Filters; +using BusinessLayer.Utils.Ordering; + +namespace BusinessLayer.Services.ReviewService +{ + public interface IReviewService : IBaseService + { + public Task<bool> DoesReviewExistAsync(params Guid[] ids); + + public Task<List<ReviewDTO>> GetReviewsAsync( + ReviewFilter filter, + ReviewOrdering orderBy, + int limit = 0, + int offset = 0, + Guid[]? ids = null, + bool includeUser = true); + + public Task<ReviewDetailDTO?> GetReviewAsync( + Guid id, + bool includeUser = true, + bool includeRestaurant = true, + bool IncludeComments = true); + + public Task<ReviewDetailDTO?> CreateReviewAsync(ReviewCreateDTO data, bool save = true); + + public Task<ReviewDetailDTO?> UpdateReviewAsync(Guid id, ReviewUpdateDTO data, bool save = true); + + public Task<bool> DeleteReviewByIdAsync(Guid id, bool save = true); + } +} diff --git a/BusinessLayer/Services/ReviewService/ReviewService.cs b/BusinessLayer/Services/ReviewService/ReviewService.cs new file mode 100644 index 0000000000000000000000000000000000000000..f2b2290cfdd56215ed138b5867279cdd13ad186c --- /dev/null +++ b/BusinessLayer/Services/ReviewService/ReviewService.cs @@ -0,0 +1,150 @@ +using Api.Models.Review; +using BusinessLayer.DTOs.Review; +using BusinessLayer.Utils.Filters; +using BusinessLayer.Utils.Ordering; +using BusinessLayer.Utils.Pagination; +using DAL.Data; +using DAL.Models; +using Mapster; +using Microsoft.EntityFrameworkCore; + +namespace BusinessLayer.Services.ReviewService +{ + public class ReviewService : BaseService, IReviewService + { + private readonly RestaurantDBContext _dbContext; + + public ReviewService(RestaurantDBContext dbContext) : base(dbContext) + { + _dbContext = dbContext; + } + + public async Task<ReviewDetailDTO?> CreateReviewAsync(ReviewCreateDTO data, bool save = true) + { + + var user = await _dbContext.Users.FindAsync(data.PosterId); + + if (user is null || user.DeletedAt is not null) + { + return null; + } + + var restaurant = await _dbContext.Restaurants.FindAsync(data.RestaurantId); + + if (restaurant is null || restaurant.DeletedAt is not null) + { + return null; + } + + var review = data.Adapt<Review>(); + review.Id = Guid.NewGuid(); + review.CreatedAt = DateTime.UtcNow; + review.UpdatedAt = DateTime.UtcNow; + + await _dbContext.Reviews.AddAsync(review); + await SaveAsync(save); + + return review.Adapt<ReviewDetailDTO>(); + } + + public async Task<bool> DeleteReviewByIdAsync(Guid id, bool save = true) + { + var review = await _dbContext.Reviews.FindAsync(id); + + if (review is null) + { + return false; + } + + review.UpdatedAt = DateTime.UtcNow; + review.DeletedAt = DateTime.UtcNow; + + _dbContext.Reviews.Update(review); + await SaveAsync(save); + return true; + } + + public async Task<bool> DoesReviewExistAsync(params Guid[] ids) + { + return await _dbContext.Reviews.AnyAsync(r => ids.Contains(r.Id) && r.DeletedAt == null); + } + + public async Task<ReviewDetailDTO?> GetReviewAsync(Guid id, bool includeUser = true, bool includeRestaurant = true, bool IncludeComments = true) + { + var query = CreateQuery(includeUser, includeRestaurant, IncludeComments); + var review = await query.SingleOrDefaultAsync(r => r.Id == id); + + if (review is null || review.DeletedAt is not null) + { + return null; + } + + return review.Adapt<ReviewDetailDTO>(); + } + + public async Task<List<ReviewDTO>> GetReviewsAsync(ReviewFilter filter, + ReviewOrdering orderBy, + int limit = 0, + int offset = 0, + Guid[]? ids = null, + bool includeUser = true) + { + var reviewsQuery = CreateQuery(includeUser, false, false); + + return await reviewsQuery + .Where(filter.ComposeFilterFunction(ids)) + .ApplyPagination(limit, offset) + .ApplyOrdering(orderBy) + .Select(review => review.Adapt<ReviewDTO>()) + .ToListAsync(); + } + + public async Task<ReviewDetailDTO?> UpdateReviewAsync(Guid id, ReviewUpdateDTO data, bool save = true) + { + var review = await _dbContext.Reviews.FindAsync(id); + + if (review is null || review.DeletedAt is not null) + { + return null; + } + + review.Content = data.Content ?? review.Content; + review.FoodRating = data.FoodRating ?? review.FoodRating; + review.ServiceRating = data.ServiceRating ?? review.ServiceRating; + review.EnvironmentRating = data.EnvironmentRating ?? review.EnvironmentRating; + review.ServesFreeWater = data.ServesFreeWater ?? review.ServesFreeWater; + review.TimeSpent = data.TimeSpent ?? review.TimeSpent; + review.LeftTip = data.LeftTip ?? review.LeftTip; + review.UpdatedAt = DateTime.UtcNow; + + _dbContext.Reviews.Update(review); + await SaveAsync(save); + + return review.Adapt<ReviewDetailDTO>(); + } + + private IQueryable<Review> CreateQuery(bool includeUser, bool includeRestaurant, bool includeComments) + { + IQueryable<Review> query = _dbContext.Reviews; + + if (includeUser) + { + query = query.Include(r => r.Poster); + } + + if (includeRestaurant) + { + query = query.Include(r => r.Restaurant); + } + + if (includeComments) + { + query = query.Include(r => r.Comments); + } + + return query; + } + + + } +} diff --git a/BusinessLayer/Utils/Filters/ReviewCommentFilter.cs b/BusinessLayer/Utils/Filters/ReviewCommentFilter.cs new file mode 100644 index 0000000000000000000000000000000000000000..9511b4e92e08a88989c15aa6e7d8ba703ea109fa --- /dev/null +++ b/BusinessLayer/Utils/Filters/ReviewCommentFilter.cs @@ -0,0 +1,21 @@ +using DAL.Models; +using System.Linq.Expressions; + +namespace BusinessLayer.Utils.Filters +{ + public class ReviewCommentFilter + { + public Guid? PosterId { get; set; } + public Guid? ReviewId { get; set; } + public Guid? ParentCommentId { get; set; } + + public Expression<Func<ReviewComment, bool>> ComposeFilterFunction(Guid[]? ids) + { + return r => (ids == null || ids.Contains(r.Id)) + && (PosterId == null || r.PosterId == PosterId) + && (ReviewId == null || r.ReviewId == ReviewId) + && (ParentCommentId == null || r.ParentCommentId == ParentCommentId) + && r.DeletedAt == null; + } + } +} diff --git a/BusinessLayer/Utils/Filters/ReviewFilter.cs b/BusinessLayer/Utils/Filters/ReviewFilter.cs new file mode 100644 index 0000000000000000000000000000000000000000..6fdef9e94c7edf48fc85fa9f4e9428f7c2ea084f --- /dev/null +++ b/BusinessLayer/Utils/Filters/ReviewFilter.cs @@ -0,0 +1,32 @@ +using DAL.Models; +using System.Linq.Expressions; + +namespace BusinessLayer.Utils.Filters +{ + public class ReviewFilter + { + public Guid? PosterId { get; set; } + public Guid? RestaurantId { get; set; } + + public uint? FoodGreaterEqual { get; set; } + public uint? FoodLessEqual { get; set; } + public uint? EnvGreaterEqual { get; set; } + public uint? EnvLessEqual { get; set; } + public uint? ServiceGreaterEqual { get; set; } + public uint? ServiceLessEqual { get; set; } + + public Expression<Func<Review, bool>> ComposeFilterFunction(Guid[]? ids) + { + return r => (ids == null || ids.Contains(r.Id)) + && (PosterId == null || r.PosterId == PosterId) + && (RestaurantId == null || r.RestaurantId == RestaurantId) + && (FoodGreaterEqual == null || r.FoodRating >= FoodGreaterEqual) + && (FoodLessEqual == null || r.FoodRating <= FoodLessEqual) + && (EnvGreaterEqual == null || r.EnvironmentRating >= EnvGreaterEqual) + && (EnvLessEqual == null || r.EnvironmentRating <= EnvLessEqual) + && (ServiceGreaterEqual == null || r.ServiceRating >= ServiceGreaterEqual) + && (ServiceLessEqual == null || r.ServiceRating <= ServiceLessEqual) + && r.DeletedAt == null; + } + } +} diff --git a/BusinessLayer/Utils/Ordering/ReviewOrdering.cs b/BusinessLayer/Utils/Ordering/ReviewOrdering.cs new file mode 100644 index 0000000000000000000000000000000000000000..bfa8695aefcf4352004163ab72dbaf8662195fb0 --- /dev/null +++ b/BusinessLayer/Utils/Ordering/ReviewOrdering.cs @@ -0,0 +1,16 @@ +namespace BusinessLayer.Utils.Ordering +{ + public enum ReviewOrdering + { + Service, + ServiceDesc, + Environment, + EnvironmentDesc, + Food, + FoodDesc, + CreatedAt, + CreatedAtDesc, + UpdatedAt, + UpdatedAtDesc, + } +} diff --git a/BusinessLayer/Utils/Ordering/ReviewOrderingManager.cs b/BusinessLayer/Utils/Ordering/ReviewOrderingManager.cs new file mode 100644 index 0000000000000000000000000000000000000000..bd87524c9d9fe8d27cbf49e39cdade5c8807c1dc --- /dev/null +++ b/BusinessLayer/Utils/Ordering/ReviewOrderingManager.cs @@ -0,0 +1,25 @@ +using DAL.Models; + +namespace BusinessLayer.Utils.Ordering +{ + public static class ReviewOrderingManager + { + public static IOrderedQueryable<Review> ApplyOrdering(this IQueryable<Review> query, ReviewOrdering orderBy) + { + + return orderBy switch + { + ReviewOrdering.Service => query.OrderBy(r => r.ServiceRating), + ReviewOrdering.ServiceDesc => query.OrderByDescending(r => r.ServiceRating), + ReviewOrdering.Environment => query.OrderBy(r => r.EnvironmentRating), + ReviewOrdering.EnvironmentDesc => query.OrderByDescending(r => r.EnvironmentRating), + ReviewOrdering.Food => query.OrderBy(r => r.FoodRating), + ReviewOrdering.FoodDesc => query.OrderByDescending(r => r.FoodRating), + ReviewOrdering.UpdatedAt => query.OrderBy(r => r.UpdatedAt), + ReviewOrdering.UpdatedAtDesc => query.OrderByDescending(r => r.UpdatedAt), + ReviewOrdering.CreatedAt => query.OrderBy(r => r.CreatedAt), + _ => query.OrderByDescending(r => r.CreatedAt) + }; + } + } +} diff --git a/BusinessLayer/Utils/Pagination/Pagination.cs b/BusinessLayer/Utils/Pagination/Pagination.cs new file mode 100644 index 0000000000000000000000000000000000000000..a04cfd3cce790004489559007cea370133fc80bf --- /dev/null +++ b/BusinessLayer/Utils/Pagination/Pagination.cs @@ -0,0 +1,17 @@ +namespace BusinessLayer.Utils.Pagination +{ + public static class Pagination + { + public static IQueryable<T> ApplyPagination<T>(this IQueryable<T> query, int limit, int offset) + { + query = query.Skip(offset); + + if (limit > 0) + { + query = query.Take(limit); + } + + return query; + } + } +} diff --git a/DAL/DAL.csproj b/DAL/DAL.csproj index cc2375d7ea50675d0cf88a3e7d37de42fa70d8f0..767c3ca4ff77640a5dc14f45c5dcc041b1a22c74 100644 --- a/DAL/DAL.csproj +++ b/DAL/DAL.csproj @@ -14,6 +14,10 @@ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" /> + </ItemGroup> + + <ItemGroup> + <Folder Include="Migrations\" /> </ItemGroup> </Project> diff --git a/DAL/Data/DataInitializer.cs b/DAL/Data/DataInitializer.cs index 0eb8e14153ba325dcf9edaf29f607c672f40ec78..1306f40ea80bb7971cde1486c5c843ebff09d500 100644 --- a/DAL/Data/DataInitializer.cs +++ b/DAL/Data/DataInitializer.cs @@ -19,7 +19,7 @@ namespace DAL.Data var reviewAggregates = reviews .GroupBy(r => r.RestaurantId) - .Select(group => new ReviewAggregate + .Select(group => new ReviewAggregateResult { RestaurantId = group.Key, FoodRating = (uint)Math.Round(group.Average(item => item.FoodRating)), @@ -68,7 +68,7 @@ namespace DAL.Data modelBuilder.Entity<Location>() .HasData(locations); - modelBuilder.Entity<ReviewAggregate>() + modelBuilder.Entity<ReviewAggregateResult>() .HasData(reviewAggregates); } diff --git a/DAL/Data/RestaurantDBContext.cs b/DAL/Data/RestaurantDBContext.cs index 38272d60cb0c9668dc130bb6c4fc689fb5e0b5fe..1b380f37c940f637b155146245aa3087a223e370 100644 --- a/DAL/Data/RestaurantDBContext.cs +++ b/DAL/Data/RestaurantDBContext.cs @@ -8,7 +8,7 @@ namespace DAL.Data public DbSet<User> Users { get; set; } public DbSet<Review> Reviews { get; set; } public DbSet<ReviewComment> ReviewComments { get; set; } - public DbSet<ReviewAggregate> ReviewAggregate { get; set; } + public DbSet<ReviewAggregateResult> ReviewAggregateResults { get; set; } public DbSet<Event> Events { get; set; } public DbSet<EventComment> EventComments { get; set; } public DbSet<EventParticipant> EventParticipants { get; set; } diff --git a/DAL/Migrations/20241023151817_Initial-Migration.Designer.cs b/DAL/Migrations/20241104212906_UpdatedSchema.Designer.cs similarity index 98% rename from DAL/Migrations/20241023151817_Initial-Migration.Designer.cs rename to DAL/Migrations/20241104212906_UpdatedSchema.Designer.cs index f02cc159f6bb2d4311eb18042496f4576ab61d10..e53071371fa558ca8ffe75941775cd6048efe256 100644 --- a/DAL/Migrations/20241023151817_Initial-Migration.Designer.cs +++ b/DAL/Migrations/20241104212906_UpdatedSchema.Designer.cs @@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace DAL.Migrations { [DbContext(typeof(RestaurantDBContext))] - [Migration("20241023151817_Initial-Migration")] - partial class InitialMigration + [Migration("20241104212906_UpdatedSchema")] + partial class UpdatedSchema { /// <inheritdoc /> protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -21,9 +21,6 @@ namespace DAL.Migrations #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "8.0.10") - .HasAnnotation("Proxies:ChangeTracking", false) - .HasAnnotation("Proxies:CheckEquality", false) - .HasAnnotation("Proxies:LazyLoading", true) .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -438,7 +435,7 @@ namespace DAL.Migrations }); }); - modelBuilder.Entity("DAL.Models.ReviewAggregate", b => + modelBuilder.Entity("DAL.Models.ReviewAggregateResult", b => { b.Property<Guid>("RestaurantId") .HasColumnType("uniqueidentifier"); @@ -454,7 +451,7 @@ namespace DAL.Migrations b.HasKey("RestaurantId"); - b.ToTable("ReviewAggregate"); + b.ToTable("ReviewAggregateResults"); b.HasData( new @@ -727,11 +724,11 @@ namespace DAL.Migrations b.Navigation("Restaurant"); }); - modelBuilder.Entity("DAL.Models.ReviewAggregate", b => + modelBuilder.Entity("DAL.Models.ReviewAggregateResult", b => { b.HasOne("DAL.Models.Restaurant", "Restaurant") .WithOne("ReviewAggregate") - .HasForeignKey("DAL.Models.ReviewAggregate", "RestaurantId") + .HasForeignKey("DAL.Models.ReviewAggregateResult", "RestaurantId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); diff --git a/DAL/Migrations/20241023151817_Initial-Migration.cs b/DAL/Migrations/20241104212906_UpdatedSchema.cs similarity index 98% rename from DAL/Migrations/20241023151817_Initial-Migration.cs rename to DAL/Migrations/20241104212906_UpdatedSchema.cs index 2d397e3979109141bcf400318579e2f470ce0079..db701bf0633cbe7bc1126162f9a80bdca433f525 100644 --- a/DAL/Migrations/20241023151817_Initial-Migration.cs +++ b/DAL/Migrations/20241104212906_UpdatedSchema.cs @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace DAL.Migrations { /// <inheritdoc /> - public partial class InitialMigration : Migration + public partial class UpdatedSchema : Migration { /// <inheritdoc /> protected override void Up(MigrationBuilder migrationBuilder) @@ -99,7 +99,7 @@ namespace DAL.Migrations }); migrationBuilder.CreateTable( - name: "ReviewAggregate", + name: "ReviewAggregateResults", columns: table => new { RestaurantId = table.Column<Guid>(type: "uniqueidentifier", nullable: false), @@ -109,9 +109,9 @@ namespace DAL.Migrations }, constraints: table => { - table.PrimaryKey("PK_ReviewAggregate", x => x.RestaurantId); + table.PrimaryKey("PK_ReviewAggregateResults", x => x.RestaurantId); table.ForeignKey( - name: "FK_ReviewAggregate_Restaurants_RestaurantId", + name: "FK_ReviewAggregateResults_Restaurants_RestaurantId", column: x => x.RestaurantId, principalTable: "Restaurants", principalColumn: "Id", @@ -324,7 +324,7 @@ namespace DAL.Migrations }); migrationBuilder.InsertData( - table: "ReviewAggregate", + table: "ReviewAggregateResults", columns: new[] { "RestaurantId", "EnvironmentRating", "FoodRating", "ServiceRating" }, values: new object[,] { @@ -459,7 +459,7 @@ namespace DAL.Migrations name: "RestaurantUser"); migrationBuilder.DropTable( - name: "ReviewAggregate"); + name: "ReviewAggregateResults"); migrationBuilder.DropTable( name: "ReviewComments"); diff --git a/DAL/Migrations/RestaurantDBContextModelSnapshot.cs b/DAL/Migrations/RestaurantDBContextModelSnapshot.cs index d2f5393171aa05595080b3277de46b1c533fe8b8..fd7ff15aff56b9b086161e34a7ae85e4efb0a594 100644 --- a/DAL/Migrations/RestaurantDBContextModelSnapshot.cs +++ b/DAL/Migrations/RestaurantDBContextModelSnapshot.cs @@ -18,9 +18,6 @@ namespace DAL.Migrations #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "8.0.10") - .HasAnnotation("Proxies:ChangeTracking", false) - .HasAnnotation("Proxies:CheckEquality", false) - .HasAnnotation("Proxies:LazyLoading", true) .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -435,7 +432,7 @@ namespace DAL.Migrations }); }); - modelBuilder.Entity("DAL.Models.ReviewAggregate", b => + modelBuilder.Entity("DAL.Models.ReviewAggregateResult", b => { b.Property<Guid>("RestaurantId") .HasColumnType("uniqueidentifier"); @@ -451,7 +448,7 @@ namespace DAL.Migrations b.HasKey("RestaurantId"); - b.ToTable("ReviewAggregate"); + b.ToTable("ReviewAggregateResults"); b.HasData( new @@ -724,11 +721,11 @@ namespace DAL.Migrations b.Navigation("Restaurant"); }); - modelBuilder.Entity("DAL.Models.ReviewAggregate", b => + modelBuilder.Entity("DAL.Models.ReviewAggregateResult", b => { b.HasOne("DAL.Models.Restaurant", "Restaurant") .WithOne("ReviewAggregate") - .HasForeignKey("DAL.Models.ReviewAggregate", "RestaurantId") + .HasForeignKey("DAL.Models.ReviewAggregateResult", "RestaurantId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); diff --git a/DAL/Models/Restaurant.cs b/DAL/Models/Restaurant.cs index 065dd4fa4cce3a55a01518df6c7f8e61a02526e8..435fc07c426ab17bab171a82dd3f541602975995 100644 --- a/DAL/Models/Restaurant.cs +++ b/DAL/Models/Restaurant.cs @@ -11,7 +11,7 @@ public class Restaurant : BaseEntity public virtual ICollection<Review> Reviews { get; set; } = []; public virtual ICollection<Event> Events { get; set; } = []; - public virtual ReviewAggregate? ReviewAggregate { get; set; } + public virtual ReviewAggregateResult? ReviewAggregate { get; set; } [Required] [MaxLength(Limits.ShortTextLength)] diff --git a/DAL/Models/ReviewAggregate.cs b/DAL/Models/ReviewAggregateResult.cs similarity index 94% rename from DAL/Models/ReviewAggregate.cs rename to DAL/Models/ReviewAggregateResult.cs index 2b274496eee7c8f053e3a71cbaf20688f9ea0087..bbb11cdd214464475382e009b6fcb70f157df65f 100644 --- a/DAL/Models/ReviewAggregate.cs +++ b/DAL/Models/ReviewAggregateResult.cs @@ -4,7 +4,7 @@ using System.ComponentModel.DataAnnotations.Schema; namespace DAL.Models { - public class ReviewAggregate + public class ReviewAggregateResult { [Key] public Guid RestaurantId { get; set; }