diff --git a/.assets/initaws/Dockerfile b/.assets/initaws/Dockerfile new file mode 100644 index 0000000..8484c65 --- /dev/null +++ b/.assets/initaws/Dockerfile @@ -0,0 +1,5 @@ +FROM amazon/aws-cli + +COPY ./init.sh . + +ENTRYPOINT ["/bin/sh", "init.sh"] \ No newline at end of file diff --git a/.assets/initaws/init.sh b/.assets/initaws/init.sh new file mode 100644 index 0000000..ff9fabe --- /dev/null +++ b/.assets/initaws/init.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +echo '{"Version": "2012-10-17", "Statement":[{"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::*"}]}' >> policy.json + +aws dynamodb create-table \ + --table-name order \ + --attribute-definitions AttributeName=id,AttributeType=S \ + --key-schema AttributeName=id,KeyType=HASH \ + --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ + --table-class STANDARD \ + --endpoint-url ${NO_SQL_CONNECTION_STRING} || true + +aws s3api create-bucket \ + --bucket ${AWS_S3_BUCKET_NAME} \ + --endpoint-url ${AWS_S3_SERVICE_URL} || true + +aws s3api put-bucket-policy \ + --bucket ${AWS_S3_BUCKET_NAME} \ + --endpoint-url ${AWS_S3_SERVICE_URL} \ + --policy file://policy.json || true \ No newline at end of file diff --git a/.assets/initsql/nimbleflow.init.sql b/.assets/initsql/nimbleflow.init.sql new file mode 100644 index 0000000..e7f78f7 --- /dev/null +++ b/.assets/initsql/nimbleflow.init.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS category +( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(32) UNIQUE NOT NULL, + color_theme INT NULL, + category_icon INT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE NULL +); + +CREATE TABLE IF NOT EXISTS product +( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(64) UNIQUE NOT NULL, + description VARCHAR(512) NULL, + price DECIMAL NOT NULL, + image_url TEXT NULL, + is_favorite BOOLEAN NOT NULL DEFAULT FALSE, + category_id uuid NOT NULL REFERENCES category (id), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE NULL +); + +CREATE TABLE IF NOT EXISTS "table" +( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + accountable VARCHAR(256) NOT NULL, + is_fully_paid BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE NULL +); \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..15ed8c3 --- /dev/null +++ b/.env @@ -0,0 +1,15 @@ +SQL_CONNECTION_STRING="Server=nimbleflow.sql.db;Port=5432;Database=postgres;User Id=postgres;Password=postgres123" +NO_SQL_CONNECTION_STRING=http://nimbleflow.nosql.db:8000 + +HUB_SERVER_URL=http://nimbleflow.aspnetcore.hub:10505 + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=us-east-1 +AWS_S3_SERVICE_URL=http://192.168.15.131:10502 +AWS_S3_BUCKET_NAME=nimbleflow +AWS_ECR_ALIAS=change.me.if.needed + +CONTAINER_REGISTRY=public.ecr.aws/${AWS_ECR_ALIAS} + +ASPNETCORE_ENVIRONMENT=Development \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9d03094..6188d4a 100644 --- a/.gitignore +++ b/.gitignore @@ -394,6 +394,7 @@ FodyWeavers.xsd *.msp # JetBrains Rider +.idea/* *.sln.iml ### Rider ### @@ -486,4 +487,8 @@ fabric.properties ### VisualStudioCode Patch ### # Ignore all local history of files .history -.ionide \ No newline at end of file +.ionide + +SpringBoot/docker +SpringBoot/target +.idea \ No newline at end of file diff --git a/AspDotNetCore/NimbleFlow.sln b/AspDotNetCore/NimbleFlow.sln new file mode 100755 index 0000000..2330e12 --- /dev/null +++ b/AspDotNetCore/NimbleFlow.sln @@ -0,0 +1,43 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.32328.378 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NimbleFlow.Api", "Src\NimbleFlow.Api\NimbleFlow.Api.csproj", "{344048D3-F0F1-4F93-891F-BAC7A5611314}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NimbleFlow.Contracts", "Src\NimbleFlow.Contracts\NimbleFlow.Contracts.csproj", "{EA149509-39C5-4771-8E00-01FBFA46A72D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NimbleFlow.Data", "Src\NimbleFlow.Data\NimbleFlow.Data.csproj", "{DF6DC2BE-972F-4BC3-B352-A9261EE8D13F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NimbleFlow.Tests", "Src\NimbleFlow.Tests\NimbleFlow.Tests.csproj", "{6846F7C0-A994-486B-91D3-D8C200BC5F2C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {344048D3-F0F1-4F93-891F-BAC7A5611314}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {344048D3-F0F1-4F93-891F-BAC7A5611314}.Debug|Any CPU.Build.0 = Debug|Any CPU + {344048D3-F0F1-4F93-891F-BAC7A5611314}.Release|Any CPU.ActiveCfg = Release|Any CPU + {344048D3-F0F1-4F93-891F-BAC7A5611314}.Release|Any CPU.Build.0 = Release|Any CPU + {EA149509-39C5-4771-8E00-01FBFA46A72D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA149509-39C5-4771-8E00-01FBFA46A72D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA149509-39C5-4771-8E00-01FBFA46A72D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA149509-39C5-4771-8E00-01FBFA46A72D}.Release|Any CPU.Build.0 = Release|Any CPU + {DF6DC2BE-972F-4BC3-B352-A9261EE8D13F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF6DC2BE-972F-4BC3-B352-A9261EE8D13F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF6DC2BE-972F-4BC3-B352-A9261EE8D13F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF6DC2BE-972F-4BC3-B352-A9261EE8D13F}.Release|Any CPU.Build.0 = Release|Any CPU + {6846F7C0-A994-486B-91D3-D8C200BC5F2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6846F7C0-A994-486B-91D3-D8C200BC5F2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6846F7C0-A994-486B-91D3-D8C200BC5F2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6846F7C0-A994-486B-91D3-D8C200BC5F2C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CE70867E-D1DF-48C0-A1C5-4B6B67F0FDA9} + EndGlobalSection +EndGlobal diff --git a/AspDotNetCore/Src/NimbleFlow.Api/.config/dotnet-tools.json b/AspDotNetCore/Src/NimbleFlow.Api/.config/dotnet-tools.json new file mode 100644 index 0000000..6b93cca --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "7.0.3", + "commands": [ + "dotnet-ef" + ] + } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/.dockerignore b/AspDotNetCore/Src/NimbleFlow.Api/.dockerignore new file mode 100644 index 0000000..a79cbcd --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/.dockerignore @@ -0,0 +1,29 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +#**/*[Tt]est* +#**/*[Tt]est*/* +#*[Tt]est* +#*[Tt]est*/* +LICENSE +README.md \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/ConfigurationExtensions/ConfigureSwaggerOptions.cs b/AspDotNetCore/Src/NimbleFlow.Api/ConfigurationExtensions/ConfigureSwaggerOptions.cs new file mode 100644 index 0000000..70dfcee --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/ConfigurationExtensions/ConfigureSwaggerOptions.cs @@ -0,0 +1,13 @@ +using NimbleFlow.Api.Options; + +namespace NimbleFlow.Api.ConfigurationExtensions; + +public static partial class ConfigurationExtensions +{ + public static void ConfigureSwaggerOptions(this IConfiguration configuration) + { + var swaggerOptions = new SwaggerOptions(); + configuration.Bind(SwaggerOptions.OptionsName, swaggerOptions); + SwaggerOptions.ConfigureInstance(swaggerOptions); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Controllers/CategoryController.cs b/AspDotNetCore/Src/NimbleFlow.Api/Controllers/CategoryController.cs new file mode 100644 index 0000000..10d19ee --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Controllers/CategoryController.cs @@ -0,0 +1,201 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using NimbleFlow.Api.Services; +using NimbleFlow.Contracts.DTOs; +using NimbleFlow.Contracts.DTOs.Categories; +using NimbleFlow.Data.Partials.DTOs; + +namespace NimbleFlow.Api.Controllers; + +[ApiController] +[Route("api/v1/[controller]")] +public class CategoryController : ControllerBase +{ + private const int MaxTitleLength = 32; + private readonly CategoryService _categoryService; + private readonly CategoryHubService? _hubService; + + public CategoryController(CategoryService categoryService, CategoryHubService? hubService) + { + _categoryService = categoryService; + _hubService = hubService; + } + + /// Creates a category + /// + /// Bad Request + /// Conflict + /// Internal Server Error + [HttpPost] + [ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)] + public async Task CreateCategory([FromBody] CreateCategoryDto requestBody) + { + if (string.IsNullOrWhiteSpace(requestBody.Title)) + return BadRequest($"{nameof(requestBody.Title)} must not be null or composed by white spaces only"); + + if (requestBody.Title.Length > MaxTitleLength) + return BadRequest($"{nameof(requestBody.Title)} must be under {MaxTitleLength + 1} characters"); + + if (requestBody.ColorTheme < 0) + return BadRequest($"{nameof(requestBody.ColorTheme)} must be positive"); + + if (requestBody.CategoryIcon < 0) + return BadRequest($"{nameof(requestBody.CategoryIcon)} must be positive"); + + var (responseStatus, response) = await _categoryService.Create(requestBody); + switch (responseStatus) + { + case HttpStatusCode.Created when response is not null: + { + if (_hubService is not null) + await _hubService.PublishCategoryCreatedAsync(response); + return Created(string.Empty, response); + } + case HttpStatusCode.Conflict: + return Conflict(); + default: + return Problem(); + } + } + + /// Gets all categories paginated + /// + /// + /// + /// No Content + [HttpGet] + [ProducesResponseType(typeof(PaginatedDto), StatusCodes.Status200OK)] + public async Task GetAllCategoriesPaginated( + [FromQuery] int page = 0, + [FromQuery] int limit = 12, + [FromQuery] bool includeDeleted = false + ) + { + var (totalAmount, categories) = await _categoryService.GetAllPaginated(page, limit, includeDeleted); + if (totalAmount == 0) + return NoContent(); + + var response = new PaginatedDto(totalAmount, categories); + return Ok(response); + } + + /// Gets categories by ids + /// + /// + /// Not Found + [HttpGet("by-ids")] + [ProducesResponseType(typeof(CategoryDto[]), StatusCodes.Status200OK)] + public async Task GetCategoriesByIds( + [FromBody] Guid[] categoriesIds, + [FromQuery] bool includeDeleted = false + ) + { + var response = await _categoryService.GetManyById(categoriesIds, includeDeleted); + if (!response.Any()) + return NotFound(); + + return Ok(response); + } + + /// Gets a category by id + /// + /// Not Found + [HttpGet("{categoryId:guid}")] + [ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)] + public async Task GetCategoryById([FromRoute] Guid categoryId) + { + var response = await _categoryService.GetById(categoryId); + if (response is null) + return NotFound(); + + return Ok(response); + } + + /// Updates a category by id + /// + /// + /// Ok + /// Not Modified + /// Bad Request + /// Not Found + /// Conflict + /// Internal Server Error + [HttpPut("{categoryId:guid}")] + public async Task UpdateCategoryById( + [FromRoute] Guid categoryId, + [FromBody] UpdateCategoryDto requestBody + ) + { + if (string.IsNullOrWhiteSpace(requestBody.Title)) + return BadRequest($"{nameof(requestBody.Title)} must not be null or composed by white spaces only"); + + if (requestBody.Title.Length > MaxTitleLength) + return BadRequest($"{nameof(requestBody.Title)} length must be under {MaxTitleLength + 1} characters"); + + if (requestBody.ColorTheme < 0) + return BadRequest($"{nameof(requestBody.ColorTheme)} must be positive"); + + if (requestBody.CategoryIcon < 0) + return BadRequest($"{nameof(requestBody.CategoryIcon)} must be positive"); + + var (responseStatus, response) = await _categoryService.UpdateCategoryById(categoryId, requestBody); + switch (responseStatus) + { + case HttpStatusCode.OK: + { + if (_hubService is not null && response is not null) + await _hubService.PublishCategoryUpdatedAsync(response); + return Ok(); + } + case HttpStatusCode.NotModified: + return StatusCode((int)HttpStatusCode.NotModified); + case HttpStatusCode.NotFound: + return NotFound(); + case HttpStatusCode.Conflict: + return Conflict(); + default: + return Problem(); + } + } + + /// Deletes categories by ids + /// + /// Ok + /// Not Found + [HttpDelete("by-ids")] + public async Task DeleteCategoriesByIds([FromBody] Guid[] categoriesIds) + { + var response = await _categoryService.DeleteManyByIds(categoriesIds); + if (!response) + return NotFound(); + + if (_hubService is not null) + await _hubService.PublishManyCategoriesDeletedAsync(categoriesIds); + + return Ok(); + } + + /// Deletes a category by id + /// + /// Ok + /// Not Found + /// Internal Server Error + [HttpDelete("{categoryId:guid}")] + public async Task DeleteCategoryById([FromRoute] Guid categoryId) + { + var responseStatus = await _categoryService.DeleteById(categoryId); + switch (responseStatus) + { + case HttpStatusCode.OK: + { + if (_hubService is not null) + await _hubService.PublishCategoryDeletedAsync(categoryId); + return Ok(); + } + case HttpStatusCode.NotFound: + return NotFound(); + default: + return Problem(); + } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Controllers/HealthCheckController.cs b/AspDotNetCore/Src/NimbleFlow.Api/Controllers/HealthCheckController.cs new file mode 100644 index 0000000..6ae54a6 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Controllers/HealthCheckController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace NimbleFlow.Api.Controllers; + +[ApiController] +[Route("[controller]")] +public class HealthCheckController : ControllerBase +{ + /// Checks if service is healthy + /// Ok + [HttpGet] + public Task GetHealthCheckStatus() => Task.FromResult(Ok()); +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Controllers/ProductController.cs b/AspDotNetCore/Src/NimbleFlow.Api/Controllers/ProductController.cs new file mode 100644 index 0000000..2e27f35 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Controllers/ProductController.cs @@ -0,0 +1,215 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using NimbleFlow.Api.Services; +using NimbleFlow.Contracts.DTOs; +using NimbleFlow.Contracts.DTOs.Products; +using NimbleFlow.Data.Partials.DTOs; + +namespace NimbleFlow.Api.Controllers; + +[ApiController] +[Route("api/v1/[controller]")] +public class ProductController : ControllerBase +{ + private const int MaxTitleLength = 64; + private const int MaxDescriptionLength = 512; + private readonly ProductService _productService; + private readonly ProductHubService? _hubService; + + public ProductController(ProductService productService, ProductHubService? hubService) + { + _productService = productService; + _hubService = hubService; + } + + /// Creates a product + /// + /// Bad Request + /// Conflict + /// Internal Server Error + [HttpPost] + [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)] + public async Task CreateProduct([FromBody] CreateProductDto requestBody) + { + if (string.IsNullOrWhiteSpace(requestBody.Title)) + return BadRequest($"{nameof(requestBody.Title)} must not be null or composed by white spaces only"); + + if (requestBody.Title.Length > MaxTitleLength) + return BadRequest($"{nameof(requestBody.Title)} must be under {MaxTitleLength + 1} characters"); + + if (requestBody.Description?.Length > MaxDescriptionLength) + return BadRequest($"{nameof(requestBody.Description)} must be under {MaxDescriptionLength + 1} characters"); + + if (requestBody.Price < 0) + return BadRequest($"{nameof(requestBody.Price)} must not be negative"); + + var (responseStatus, response) = await _productService.Create(requestBody); + switch (responseStatus) + { + case HttpStatusCode.Created when response is not null: + { + if (_hubService is not null) + await _hubService.PublishProductCreatedAsync(response); + return Created(string.Empty, response); + } + case HttpStatusCode.BadRequest: + return BadRequest(); + case HttpStatusCode.Conflict: + return Conflict(); + default: + return Problem(); + } + } + + /// Gets all products paginated or filter all products by category id + /// + /// + /// + /// + /// No Content + [HttpGet] + [ProducesResponseType(typeof(PaginatedDto), StatusCodes.Status200OK)] + public async Task GetAllProductsPaginated( + [FromQuery] int page = 0, + [FromQuery] int limit = 12, + [FromQuery] bool includeDeleted = false, + [FromQuery] Guid? categoryId = null + ) + { + var (totalAmount, products) = categoryId switch + { + not null => await _productService.GetAllProductsPaginatedByCategoryId( + page, + limit, + includeDeleted, + categoryId.Value + ), + _ => await _productService.GetAllPaginated(page, limit, includeDeleted) + }; + if (totalAmount == 0) + return NoContent(); + + var response = new PaginatedDto(totalAmount, products); + return Ok(response); + } + + /// Gets products by ids + /// + /// + /// Not Found + [HttpGet("by-ids")] + [ProducesResponseType(typeof(ProductDto[]), StatusCodes.Status200OK)] + public async Task GetProductsByIds( + [FromBody] Guid[] productsIds, + [FromQuery] bool includeDeleted = false + ) + { + var response = await _productService.GetManyById(productsIds, includeDeleted); + if (!response.Any()) + return NotFound(); + + return Ok(response); + } + + /// Gets a product by id + /// + /// Not Found + [HttpGet("{productId:guid}")] + [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)] + public async Task GetProductById([FromRoute] Guid productId) + { + var response = await _productService.GetById(productId); + if (response is null) + return NotFound(); + + return Ok(response); + } + + /// Updates a product by id + /// + /// + /// Ok + /// Not Modified + /// Bad Request + /// Not Found + /// Conflict + /// Internal Server Error + [HttpPut("{productId:guid}")] + public async Task UpdateProductById( + [FromRoute] Guid productId, + [FromBody] UpdateProductDto requestBody + ) + { + if (requestBody.Title is not null && string.IsNullOrWhiteSpace(requestBody.Title)) + return BadRequest($"{nameof(requestBody.Title)} must not be composed by white spaces only"); + + if (requestBody.Title is not null && requestBody.Title?.Length > MaxTitleLength) + return BadRequest($"{nameof(requestBody.Title)} must be under {MaxTitleLength + 1} characters"); + + if (requestBody.Description?.Length > MaxDescriptionLength) + return BadRequest($"{nameof(requestBody.Description)} must be under {MaxDescriptionLength + 1} characters"); + + if (requestBody.Price < 0) + return BadRequest($"{nameof(requestBody.Price)} must not be negative"); + + var (responseStatus, response) = await _productService.UpdateProductById(productId, requestBody); + switch (responseStatus) + { + case HttpStatusCode.OK: + { + if (_hubService is not null && response is not null) + await _hubService.PublishProductUpdatedAsync(response); + return Ok(); + } + case HttpStatusCode.NotModified: + return StatusCode((int)HttpStatusCode.NotModified); + case HttpStatusCode.NotFound: + return NotFound(); + case HttpStatusCode.Conflict: + return Conflict(); + default: + return Problem(); + } + } + + /// Deletes products by ids + /// + /// Ok + /// Not Found + [HttpDelete("by-ids")] + public async Task DeleteProductsByIds([FromBody] Guid[] productsIds) + { + var response = await _productService.DeleteManyByIds(productsIds); + if (!response) + return NotFound(); + + if (_hubService is not null) + await _hubService.PublishManyProductsDeletedAsync(productsIds); + + return Ok(); + } + + /// Deletes a product by id + /// + /// Ok + /// Not Found + /// Internal Server Error + [HttpDelete("{productId:guid}")] + public async Task DeleteProductById([FromRoute] Guid productId) + { + var responseStatus = await _productService.DeleteById(productId); + switch (responseStatus) + { + case HttpStatusCode.OK: + { + if (_hubService is not null) + await _hubService.PublishProductDeletedAsync(productId); + return Ok(); + } + case HttpStatusCode.NotFound: + return NotFound(); + default: + return Problem(); + } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Controllers/TableController.cs b/AspDotNetCore/Src/NimbleFlow.Api/Controllers/TableController.cs new file mode 100644 index 0000000..6f771df --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Controllers/TableController.cs @@ -0,0 +1,177 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using NimbleFlow.Api.Services; +using NimbleFlow.Contracts.DTOs; +using NimbleFlow.Contracts.DTOs.Tables; +using NimbleFlow.Data.Partials.DTOs; + +namespace NimbleFlow.Api.Controllers; + +[ApiController] +[Route("api/v1/[controller]")] +public class TableController : ControllerBase +{ + private const int MaxAccountableLength = 256; + private readonly TableService _tableService; + private readonly TableHubService? _hubService; + + public TableController(TableService tableService, TableHubService? hubService) + { + _tableService = tableService; + _hubService = hubService; + } + + /// Creates a table + /// + /// Bad Request + /// Conflict + /// Internal Server Error + [HttpPost] + [ProducesResponseType(typeof(TableDto), StatusCodes.Status201Created)] + public async Task CreateTable([FromBody] CreateTableDto requestBody) + { + if (requestBody.Accountable.Length > MaxAccountableLength) + return BadRequest($"{nameof(requestBody.Accountable)} must be under {MaxAccountableLength + 1} characters"); + + var (responseStatus, response) = await _tableService.Create(requestBody); + switch (responseStatus) + { + case HttpStatusCode.Created when response is not null: + { + if (_hubService is not null) + await _hubService.PublishTableCreatedAsync(response); + return Created(string.Empty, response); + } + case HttpStatusCode.Conflict: + return Conflict(); + default: + return Problem(); + } + } + + /// Gets all tables paginated + /// + /// + /// + /// No Content + [HttpGet] + [ProducesResponseType(typeof(PaginatedDto), StatusCodes.Status200OK)] + public async Task GetAllTablesPaginated( + [FromQuery] int page = 0, + [FromQuery] int limit = 12, + [FromQuery] bool includeDeleted = false + ) + { + var (totalAmount, tables) = await _tableService.GetAllPaginated(page, limit, includeDeleted); + if (totalAmount == 0) + return NoContent(); + + var response = new PaginatedDto(totalAmount, tables); + return Ok(response); + } + + /// Gets tables by ids + /// + /// + /// Not Found + [HttpGet("by-ids")] + [ProducesResponseType(typeof(TableDto[]), StatusCodes.Status200OK)] + public async Task GetTablesByIds( + [FromBody] Guid[] tablesIds, + [FromQuery] bool includeDeleted = false + ) + { + var response = await _tableService.GetManyById(tablesIds, includeDeleted); + if (!response.Any()) + return NotFound(); + + return Ok(response); + } + + /// Gets a table by id + /// + /// Not Found + [HttpGet("{tableId:guid}")] + [ProducesResponseType(typeof(TableDto), StatusCodes.Status200OK)] + public async Task GetTableById([FromRoute] Guid tableId) + { + var response = await _tableService.GetById(tableId); + if (response is null) + return NotFound(); + + return Ok(response); + } + + /// Updates a table by id + /// + /// + /// Ok + /// Not Modified + /// Bad Request + /// Not Found + /// Internal Server Error + [HttpPut("{tableId:guid}")] + public async Task UpdateTableById([FromRoute] Guid tableId, [FromBody] UpdateTableDto requestBody) + { + if (requestBody.Accountable?.Length > MaxAccountableLength) + return BadRequest($"{nameof(requestBody.Accountable)} must be under {MaxAccountableLength + 1} characters"); + + var (responseStatus, response) = await _tableService.UpdateTableById(tableId, requestBody); + switch (responseStatus) + { + case HttpStatusCode.OK: + { + if (_hubService is not null && response is not null) + await _hubService.PublishTableUpdatedAsync(response); + return Ok(); + } + case HttpStatusCode.NotModified: + return StatusCode((int)HttpStatusCode.NotModified); + case HttpStatusCode.NotFound: + return NotFound(); + default: + return Problem(); + } + } + + /// Deletes tables by ids + /// + /// Ok + /// Not Found + [HttpDelete("by-ids")] + public async Task DeleteTablesByIds([FromBody] Guid[] tablesIds) + { + var response = await _tableService.DeleteManyByIds(tablesIds); + if (!response) + return NotFound(); + + if (_hubService is not null) + await _hubService.PublishManyTablesDeletedAsync(tablesIds); + + return Ok(); + } + + /// Deletes a table by id + /// + /// Ok + /// Not Found + /// Internal Server Error + [HttpDelete("{tableId:guid}")] + public async Task DeleteTableById([FromRoute] Guid tableId) + { + var responseStatus = await _tableService.DeleteById(tableId); + switch (responseStatus) + { + case HttpStatusCode.OK: + { + if (_hubService is not null) + await _hubService.PublishTableDeletedAsync(tableId); + return Ok(); + } + case HttpStatusCode.NotFound: + return NotFound(); + default: + return Problem(); + } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Controllers/UploadController.cs b/AspDotNetCore/Src/NimbleFlow.Api/Controllers/UploadController.cs new file mode 100644 index 0000000..9a2290a --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Controllers/UploadController.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; +using NimbleFlow.Api.Helpers; +using NimbleFlow.Api.Services; +using NimbleFlow.Contracts.Constants; +using NimbleFlow.Contracts.Enums; + +namespace NimbleFlow.Api.Controllers; + +[ApiController] +[Route("api/v1/[controller]")] +public class UploadController : ControllerBase +{ + /// 10mb in binary bytes + private const int FileSizeLimit = 10485760; + + private readonly Dictionary _acceptedFileSignatures = new() + { + { FileTypeEnum.Jpeg, FileSignatures.Jpeg }, + { FileTypeEnum.Png, FileSignatures.Png } + }; + + private readonly UploadService _uploadService; + + public UploadController(UploadService uploadService) + { + _uploadService = uploadService; + } + + /// Sends a image file to storage, consumes a binary file + /// Bad Request + /// Unsupported Media Type + [HttpPost("image")] + [Consumes("image/jpeg", "image/jpg", "image/png")] + [ProducesResponseType(typeof(string), StatusCodes.Status201Created, MediaTypeNames.Text.Plain)] + public async Task UploadBinaryImage() + { + try + { + await using var fileBuffer = new FileBufferingReadStream( + Request.Body, + FileSizeLimit, + FileSizeLimit, + string.Empty + ); + await using var memoryStream = new MemoryStream(); + await fileBuffer.CopyToAsync(memoryStream); + + var fileBytes = memoryStream.ToArray(); + if (fileBytes.Length == 0) + return BadRequest("no_content"); + + var fileSignatureType = fileBytes.GetFileTypeBySignature(_acceptedFileSignatures); + if (fileSignatureType is FileTypeEnum.Unknown) + return new UnsupportedMediaTypeResult(); + + Request.Headers.TryGetValue(HeaderNames.ContentType, out var contentType); + var (responseStatus, response) = await _uploadService.UploadFileAsync( + memoryStream, + contentType, + fileSignatureType + ); + return responseStatus switch + { + HttpStatusCode.Created => Created(string.Empty, response), + _ => Problem() + }; + } + catch (IOException) + { + return BadRequest("file_is_too_large"); + } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Dockerfile b/AspDotNetCore/Src/NimbleFlow.Api/Dockerfile new file mode 100644 index 0000000..53ae6bc --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build + +ENV PROTOBUF_PROTOC=/usr/bin/protoc +ENV GRPC_PROTOC_PLUGIN=/usr/bin/grpc_csharp_plugin +ENV gRPC_PluginFullPath=/usr/bin/grpc_csharp_plugin +RUN apk add protobuf protobuf-dev grpc grpc-plugins + +WORKDIR /build + +COPY *.sln ./ +COPY ./Src ./Src + +RUN dotnet restore "Src/NimbleFlow.Api/NimbleFlow.Api.csproj" +RUN dotnet publish "Src/NimbleFlow.Api/NimbleFlow.Api.csproj" -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "NimbleFlow.Api.dll"] \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Extensions/StringExtensions.cs b/AspDotNetCore/Src/NimbleFlow.Api/Extensions/StringExtensions.cs new file mode 100644 index 0000000..1e66937 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Extensions/StringExtensions.cs @@ -0,0 +1,19 @@ +namespace NimbleFlow.Api.Extensions; + +public static partial class GeneralExtensions +{ + /// + /// Checks if the comparison between two strings is not null + /// or white space and uses equals with invariant culture and ignore case + /// + /// string value + /// string value + /// comparison between values + public static bool IsNotNullAndNotEquals(this string? firstValue, string? secondValue) + { + if (string.IsNullOrWhiteSpace(firstValue) || string.IsNullOrWhiteSpace(secondValue)) + return false; + + return !firstValue.Equals(secondValue, StringComparison.InvariantCultureIgnoreCase); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Helpers/FilesHelper.cs b/AspDotNetCore/Src/NimbleFlow.Api/Helpers/FilesHelper.cs new file mode 100644 index 0000000..30dae5d --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Helpers/FilesHelper.cs @@ -0,0 +1,24 @@ +using NimbleFlow.Contracts.Enums; + +namespace NimbleFlow.Api.Helpers; + +public static class FilesHelper +{ + public static FileTypeEnum GetFileTypeBySignature( + this byte[] data, + Dictionary fileSignatures + ) + { + foreach (var check in fileSignatures) + { + if (data.Length < check.Value.Length) + continue; + + var slice = data[..check.Value.Length]; + if (slice.SequenceEqual(check.Value)) + return check.Key; + } + + return FileTypeEnum.Unknown; + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/NimbleFlow.Api.csproj b/AspDotNetCore/Src/NimbleFlow.Api/NimbleFlow.Api.csproj new file mode 100644 index 0000000..8e985e3 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/NimbleFlow.Api.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + enable + enable + true + $(NoWarn);1591 + Linux + + + + + + + + + + + + + \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Options/AmazonOptions.cs b/AspDotNetCore/Src/NimbleFlow.Api/Options/AmazonOptions.cs new file mode 100644 index 0000000..49e161a --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Options/AmazonOptions.cs @@ -0,0 +1,8 @@ +using Amazon.Runtime; + +namespace NimbleFlow.Api.Options; + +public class AmazonOptions +{ + public AWSCredentials Credentials { get; set; } = null!; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Options/AmazonS3Options.cs b/AspDotNetCore/Src/NimbleFlow.Api/Options/AmazonS3Options.cs new file mode 100644 index 0000000..0f86ccc --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Options/AmazonS3Options.cs @@ -0,0 +1,21 @@ +using Amazon.S3; + +namespace NimbleFlow.Api.Options; + +public class AmazonS3Options +{ + public AmazonS3Config AmazonS3Config { get; set; } = null!; + public string BucketName { get; set; } = null!; + public bool IsProductionEnvironment { get; set; } + + public void Deconstruct( + out AmazonS3Config amazonS3Config, + out string bucketName, + out bool isProductionEnvironment + ) + { + amazonS3Config = AmazonS3Config; + bucketName = BucketName; + isProductionEnvironment = IsProductionEnvironment; + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Options/Base/BaseInitOptions.cs b/AspDotNetCore/Src/NimbleFlow.Api/Options/Base/BaseInitOptions.cs new file mode 100644 index 0000000..81e6a51 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Options/Base/BaseInitOptions.cs @@ -0,0 +1,9 @@ +namespace NimbleFlow.Api.Options.Base; + +public abstract class BaseInitOptions +{ + public const string OptionsName = ""; + private static T? _instance; + public static void ConfigureInstance(T values) => _instance ??= values; + public static T GetConfiguredInstance() => _instance is null ? throw new NullReferenceException() : _instance; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Options/HubServiceOptions.cs b/AspDotNetCore/Src/NimbleFlow.Api/Options/HubServiceOptions.cs new file mode 100644 index 0000000..1860d5c --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Options/HubServiceOptions.cs @@ -0,0 +1,6 @@ +namespace NimbleFlow.Api.Options; + +public class HubServiceOptions +{ + public string GrpcConnectionUrl { get; set; } = null!; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Options/SwaggerOptions.cs b/AspDotNetCore/Src/NimbleFlow.Api/Options/SwaggerOptions.cs new file mode 100644 index 0000000..8847986 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Options/SwaggerOptions.cs @@ -0,0 +1,14 @@ +using NimbleFlow.Api.Options.Base; + +namespace NimbleFlow.Api.Options; + +public class SwaggerOptions : BaseInitOptions +{ + public new const string OptionsName = "SwaggerOptions"; + public bool IsEnabled { get; init; } = false; + public string Title { get; init; } = null!; + public string Version { get; init; } = null!; + public string Description { get; init; } = null!; + public string LicenseName { get; init; } = null!; + public string LicenseUrl { get; init; } = null!; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Program.cs b/AspDotNetCore/Src/NimbleFlow.Api/Program.cs new file mode 100644 index 0000000..dd6266b --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Program.cs @@ -0,0 +1,27 @@ +using NimbleFlow.Api.ConfigurationExtensions; +using NimbleFlow.Api.ServiceCollectionExtensions; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger(); +builder.Host.UseSerilog(); + +builder.Configuration.ConfigureSwaggerOptions(); +builder.Services.InjectCors(); +builder.Services.InjectOptions(builder.Configuration); +builder.Services.InjectDatabases(builder.Configuration); +builder.Services.InjectRepositories(); +builder.Services.InjectServices(); +builder.Services.AddControllers(); +builder.Services.InjectSwagger(out var enableSwagger); + +var app = builder.Build(); +if (enableSwagger) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Properties/launchSettings.json b/AspDotNetCore/Src/NimbleFlow.Api/Properties/launchSettings.json new file mode 100644 index 0000000..408ede9 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Properties/launchSettings.json @@ -0,0 +1,20 @@ +{ + "profiles": { + "NimbleFlow": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": false, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "SQL_CONNECTION_STRING": "Server=127.0.0.1;Port=10500;Database=postgres;User Id=postgres;Password=postgres123", + "AWS_ACCESS_KEY_ID": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_REGION": "us-east-1", + "AWS_S3_SERVICE_URL": "http://192.168.15.131:10502", + "AWS_S3_BUCKET_NAME": "nimbleflow", + "HUB_SERVER_URL": "http://127.0.0.1:10505" + } + } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Repositories/Base/RepositoryBase.cs b/AspDotNetCore/Src/NimbleFlow.Api/Repositories/Base/RepositoryBase.cs new file mode 100644 index 0000000..5411fff --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Repositories/Base/RepositoryBase.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore; +using NimbleFlow.Data.Partials.Interfaces; + +namespace NimbleFlow.Api.Repositories.Base; + +public abstract class RepositoryBase + where TDbContext : DbContext + where TEntity : class, IIdentifiable, ICreatedAtDeletedAt +{ + private readonly TDbContext _dbContext; + protected readonly DbSet DbEntities; + + protected RepositoryBase(TDbContext dbContext) + { + _dbContext = dbContext; + DbEntities = _dbContext.Set(); + } + + public async Task CreateEntity(TEntity entity) + { + var entityEntry = await DbEntities.AddAsync(entity); + if (await _dbContext.SaveChangesAsync() != 1) + return null; + + return entityEntry.Entity; + } + + public Task<(int totalAmount, TEntity[])> GetAllEntitiesPaginated(int page, int limit, bool includeDeleted) + { + async Task<(int totalAmount, TEntity[])> QueryEntities(IQueryable entities) + { + var totalQuery = await entities.CountAsync(); + var entitiesQuery = await entities + .OrderBy(x => x.CreatedAt) + .Skip(page * limit) + .Take(limit) + .AsNoTracking() + .ToArrayAsync(); + + return (totalQuery, entitiesQuery); + } + + if (includeDeleted) + return QueryEntities(DbEntities); + + return QueryEntities(DbEntities.Where(x => x.DeletedAt == null)); + } + + public Task GetManyEntitiesByIds(Guid[] entityIds, bool includeDeleted) + { + if (includeDeleted) + return DbEntities.Where(x => entityIds.Contains(x.Id)).ToArrayAsync(); + + return DbEntities.Where(x => x.DeletedAt == null && entityIds.Contains(x.Id)).ToArrayAsync(); + } + + public Task GetEntityById(Guid entityId) + => DbEntities.FirstOrDefaultAsync(x => x.Id == entityId); + + public async Task UpdateManyEntities(TEntity[] entities) + { + DbEntities.UpdateRange(entities); + return await _dbContext.SaveChangesAsync() != 0; + } + + public async Task UpdateEntity(TEntity entity) + { + var updatedEntity = _dbContext.Update(entity); + if (await _dbContext.SaveChangesAsync() != 1) + return null; + + return updatedEntity.Entity; + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Repositories/CategoryRepository.cs b/AspDotNetCore/Src/NimbleFlow.Api/Repositories/CategoryRepository.cs new file mode 100644 index 0000000..4f0e44b --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Repositories/CategoryRepository.cs @@ -0,0 +1,12 @@ +using NimbleFlow.Api.Repositories.Base; +using NimbleFlow.Data.Context; +using NimbleFlow.Data.Models; + +namespace NimbleFlow.Api.Repositories; + +public class CategoryRepository : RepositoryBase +{ + public CategoryRepository(NimbleFlowContext dbContext) : base(dbContext) + { + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Repositories/ProductRepository.cs b/AspDotNetCore/Src/NimbleFlow.Api/Repositories/ProductRepository.cs new file mode 100644 index 0000000..ce66072 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Repositories/ProductRepository.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using NimbleFlow.Api.Repositories.Base; +using NimbleFlow.Data.Context; +using NimbleFlow.Data.Models; + +namespace NimbleFlow.Api.Repositories; + +public class ProductRepository : RepositoryBase +{ + public ProductRepository(NimbleFlowContext dbContext) : base(dbContext) + { + } + + public new Task<(int totalAmount, Product[])> GetAllEntitiesPaginated(int page, int limit, bool includeDeleted) + { + async Task<(int totalAmount, Product[])> QueryEntities(IQueryable entities) + { + var totalQuery = await entities.CountAsync(); + var entitiesQuery = await entities + .Include(x => x.Category) + .OrderBy(x => x.CreatedAt) + .Skip(page * limit) + .Take(limit) + .AsNoTracking() + .ToArrayAsync(); + + return (totalQuery, entitiesQuery); + } + + if (includeDeleted) + return QueryEntities(DbEntities); + + return QueryEntities(DbEntities.Where(x => x.DeletedAt == null && x.Category.DeletedAt == null)); + } + + public Task<(int totalAmount, Product[])> GetAllProductsPaginatedByCategoryId( + int page, + int limit, + bool includeDeleted, + Guid categoryId + ) + { + async Task<(int totalAmount, Product[])> QueryEntities(IQueryable entities) + { + var totalQuery = await entities.CountAsync(); + var entitiesQuery = await entities + .Include(x => x.Category) + .OrderBy(x => x.CreatedAt) + .Skip(page * limit) + .Take(limit) + .Where(x => x.CategoryId == categoryId) + .AsNoTracking() + .ToArrayAsync(); + + return (totalQuery, entitiesQuery); + } + + if (includeDeleted) + return QueryEntities(DbEntities); + + return QueryEntities(DbEntities.Where(x => x.DeletedAt == null && x.Category.DeletedAt == null)); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Repositories/TableRepository.cs b/AspDotNetCore/Src/NimbleFlow.Api/Repositories/TableRepository.cs new file mode 100644 index 0000000..2348b5b --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Repositories/TableRepository.cs @@ -0,0 +1,12 @@ +using NimbleFlow.Api.Repositories.Base; +using NimbleFlow.Data.Context; +using NimbleFlow.Data.Models; + +namespace NimbleFlow.Api.Repositories; + +public class TableRepository : RepositoryBase +{ + public TableRepository(NimbleFlowContext dbContext) : base(dbContext) + { + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectCors.cs b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectCors.cs new file mode 100644 index 0000000..bd50bd3 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectCors.cs @@ -0,0 +1,16 @@ +namespace NimbleFlow.Api.ServiceCollectionExtensions; + +public static partial class ServiceCollectionExtensions +{ + public static void InjectCors(this IServiceCollection services) + { + services.AddCors(options => + options.AddDefaultPolicy(policyBuilder => + policyBuilder + .AllowAnyHeader() + .AllowAnyMethod() + .AllowAnyOrigin() + ) + ); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectDatabase.cs b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectDatabase.cs new file mode 100644 index 0000000..c119176 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectDatabase.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using NimbleFlow.Api.Options; +using NimbleFlow.Data.Context; + +namespace NimbleFlow.Api.ServiceCollectionExtensions; + +public static partial class ServiceCollectionExtensions +{ + public static void InjectDatabases(this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(optionsBuilder => + optionsBuilder.UseNpgsql( + configuration["SQL_CONNECTION_STRING"], + builder => builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery) + ) + ); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectOptions.cs b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectOptions.cs new file mode 100644 index 0000000..3afae83 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectOptions.cs @@ -0,0 +1,44 @@ +using Amazon; +using Amazon.Runtime; +using Amazon.S3; +using NimbleFlow.Api.Options; + +namespace NimbleFlow.Api.ServiceCollectionExtensions; + +public static partial class ServiceCollectionExtensions +{ + public static void InjectOptions(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(x => + { + x.GrpcConnectionUrl = configuration["HUB_SERVER_URL"]; + }); + + services.Configure(x => + { + x.Credentials = new BasicAWSCredentials( + configuration["AWS_ACCESS_KEY_ID"], + configuration["AWS_SECRET_ACCESS_KEY"] + ); + }); + + services.Configure(x => + { + var isProduction = configuration["ASPNETCORE_ENVIRONMENT"] is "Production"; + var regionEndpoint = RegionEndpoint.GetBySystemName(configuration["AWS_REGION"]); + + x.AmazonS3Config = isProduction + ? new AmazonS3Config + { + RegionEndpoint = regionEndpoint + } + : new AmazonS3Config + { + ServiceURL = configuration["AWS_S3_SERVICE_URL"], + ForcePathStyle = true + }; + x.BucketName = configuration["AWS_S3_BUCKET_NAME"]; + x.IsProductionEnvironment = isProduction; + }); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectRepositories.cs b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectRepositories.cs new file mode 100644 index 0000000..566badc --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectRepositories.cs @@ -0,0 +1,13 @@ +using NimbleFlow.Api.Repositories; + +namespace NimbleFlow.Api.ServiceCollectionExtensions; + +public static partial class ServiceCollectionExtensions +{ + public static void InjectRepositories(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectServices.cs b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectServices.cs new file mode 100644 index 0000000..3feb3d3 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectServices.cs @@ -0,0 +1,17 @@ +using NimbleFlow.Api.Services; + +namespace NimbleFlow.Api.ServiceCollectionExtensions; + +public static partial class ServiceCollectionExtensions +{ + public static void InjectServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectSwagger.cs b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectSwagger.cs new file mode 100644 index 0000000..ff88230 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/ServiceCollectionExtensions/InjectSwagger.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using Microsoft.OpenApi.Models; +using NimbleFlow.Api.Options; + +namespace NimbleFlow.Api.ServiceCollectionExtensions; + +public static partial class ServiceCollectionExtensions +{ + public static void InjectSwagger(this IServiceCollection services, out bool enableSwagger) + { + var swaggerOptions = SwaggerOptions.GetConfiguredInstance(); + + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", + new OpenApiInfo + { + Title = swaggerOptions.Title, + Version = swaggerOptions.Version, + Description = swaggerOptions.Description, + License = new OpenApiLicense + { + Name = swaggerOptions.LicenseName, + Url = new Uri(swaggerOptions.LicenseUrl) + } + }); + + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + + options.IncludeXmlComments(xmlPath); + }); + + enableSwagger = swaggerOptions.IsEnabled; + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Services/Base/ServiceBase.cs b/AspDotNetCore/Src/NimbleFlow.Api/Services/Base/ServiceBase.cs new file mode 100644 index 0000000..8d4648a --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Services/Base/ServiceBase.cs @@ -0,0 +1,82 @@ +using System.Net; +using Microsoft.EntityFrameworkCore; +using NimbleFlow.Api.Repositories.Base; +using NimbleFlow.Contracts.Interfaces; +using NimbleFlow.Data.Partials.Interfaces; + +namespace NimbleFlow.Api.Services.Base; + +public abstract class ServiceBase + where TCreateDto : IToModel + where TDto : class + where TDbContext : DbContext + where TEntity : class, IIdentifiable, ICreatedAtDeletedAt, IToDto +{ + private readonly RepositoryBase _repository; + + protected ServiceBase(RepositoryBase repository) + { + _repository = repository; + } + + public async Task<(HttpStatusCode, TDto?)> Create(TCreateDto createDto) + { + try + { + var response = await _repository.CreateEntity(createDto.ToModel()); + if (response is null) + return (HttpStatusCode.InternalServerError, null); + + return (HttpStatusCode.Created, response.ToDto()); + } + catch (DbUpdateException) + { + return (HttpStatusCode.Conflict, null); + } + } + + public async Task<(int totalAmount, IEnumerable)> GetAllPaginated(int page, int limit, bool includeDeleted) + { + var (totalAmount, entities) = await _repository.GetAllEntitiesPaginated(page, limit, includeDeleted); + return (totalAmount, entities.Select(x => x.ToDto())); + } + + public async Task> GetManyById(Guid[] entityIds, bool includeDeleted) + { + var entities = await _repository.GetManyEntitiesByIds(entityIds, includeDeleted); + return entities.Select(x => x.ToDto()); + } + + public async Task GetById(Guid entityId) + { + var response = await _repository.GetEntityById(entityId); + return response?.ToDto(); + } + + public async Task DeleteManyByIds(Guid[] entityIds) + { + var entities = await _repository.GetManyEntitiesByIds(entityIds, false); + foreach (var entity in entities) + entity.DeletedAt = DateTime.UtcNow; + + return await _repository.UpdateManyEntities(entities); + } + + public async Task DeleteById(Guid entityId) + { + var entity = await _repository.GetEntityById(entityId); + if (entity is null) + return HttpStatusCode.NotFound; + + if (entity.DeletedAt is not null) + return HttpStatusCode.NotFound; + + entity.DeletedAt = DateTime.UtcNow; + + var updateEntity = await _repository.UpdateEntity(entity); + if (updateEntity is null) + return HttpStatusCode.InternalServerError; + + return HttpStatusCode.OK; + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Services/CategoryHubService.cs b/AspDotNetCore/Src/NimbleFlow.Api/Services/CategoryHubService.cs new file mode 100644 index 0000000..52cf4ab --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Services/CategoryHubService.cs @@ -0,0 +1,64 @@ +using Grpc.Net.Client; +using Microsoft.Extensions.Options; +using NimbleFlow.Api.Options; +using NimbleFlow.Data.Partials.DTOs; +using NimbleFlowHub.Contracts; +using CategoryHubPublisherClient = NimbleFlowHub.Contracts.CategoryHubPublisher.CategoryHubPublisherClient; + +namespace NimbleFlow.Api.Services; + +public class CategoryHubService +{ + private readonly HubServiceOptions _hubServiceOptions; + + public CategoryHubService(IOptions hubServiceOptions) + { + _hubServiceOptions = hubServiceOptions.Value; + } + + public async Task PublishCategoryCreatedAsync(CategoryDto message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new CategoryHubPublisherClient(channel); + _ = await grpcClient.PublishCategoryCreatedAsync(new PublishCategoryValue + { + Id = message.Id.ToString(), + Title = message.Title, + ColorTheme = message.ColorTheme ?? 0, + CategoryIcon = message.CategoryIcon ?? 0 + }); + } + + public async Task PublishCategoryUpdatedAsync(CategoryDto message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new CategoryHubPublisherClient(channel); + _ = await grpcClient.PublishCategoryUpdatedAsync(new PublishCategoryValue + { + Id = message.Id.ToString(), + Title = message.Title, + ColorTheme = message.ColorTheme ?? 0, + CategoryIcon = message.CategoryIcon ?? 0 + }); + } + + public async Task PublishManyCategoriesDeletedAsync(IEnumerable message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new CategoryHubPublisherClient(channel); + _ = await grpcClient.PublishManyCategoriesDeletedAsync(new PublishCategoryIds + { + Ids = { message.Select(x => x.ToString()) } + }); + } + + public async Task PublishCategoryDeletedAsync(Guid message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new CategoryHubPublisherClient(channel); + _ = await grpcClient.PublishCategoryDeletedAsync(new PublishCategoryId + { + Id = message.ToString() + }); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Services/CategoryService.cs b/AspDotNetCore/Src/NimbleFlow.Api/Services/CategoryService.cs new file mode 100644 index 0000000..2cf2765 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Services/CategoryService.cs @@ -0,0 +1,63 @@ +using System.Net; +using Microsoft.EntityFrameworkCore; +using NimbleFlow.Api.Extensions; +using NimbleFlow.Api.Repositories; +using NimbleFlow.Api.Services.Base; +using NimbleFlow.Contracts.DTOs.Categories; +using NimbleFlow.Data.Context; +using NimbleFlow.Data.Models; +using NimbleFlow.Data.Partials.DTOs; + +namespace NimbleFlow.Api.Services; + +public class CategoryService : ServiceBase +{ + private readonly CategoryRepository _categoryRepository; + + public CategoryService(CategoryRepository categoryRepository) : base(categoryRepository) + { + _categoryRepository = categoryRepository; + } + + public async Task<(HttpStatusCode, CategoryDto?)> UpdateCategoryById(Guid categoryId, UpdateCategoryDto categoryDto) + { + var categoryEntity = await _categoryRepository.GetEntityById(categoryId); + if (categoryEntity is null) + return (HttpStatusCode.NotFound, null); + + var shouldUpdate = false; + if (categoryDto.Title.IsNotNullAndNotEquals(categoryEntity.Title)) + { + categoryEntity.Title = categoryDto.Title ?? throw new NullReferenceException(); + shouldUpdate = true; + } + + if (categoryDto.ColorTheme != categoryEntity.ColorTheme) + { + categoryEntity.ColorTheme = categoryDto.ColorTheme; + shouldUpdate = true; + } + + if (categoryDto.CategoryIcon != categoryEntity.CategoryIcon) + { + categoryEntity.CategoryIcon = categoryDto.CategoryIcon; + shouldUpdate = true; + } + + if (!shouldUpdate) + return (HttpStatusCode.NotModified, null); + + try + { + var updatedCategory = await _categoryRepository.UpdateEntity(categoryEntity); + if (updatedCategory is null) + return (HttpStatusCode.NotModified, null); + + return (HttpStatusCode.OK, updatedCategory.ToDto()); + } + catch (DbUpdateException) + { + return (HttpStatusCode.Conflict, null); + } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Services/ProductHubService.cs b/AspDotNetCore/Src/NimbleFlow.Api/Services/ProductHubService.cs new file mode 100644 index 0000000..d2954c2 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Services/ProductHubService.cs @@ -0,0 +1,71 @@ +using System.Globalization; +using Grpc.Net.Client; +using Microsoft.Extensions.Options; +using NimbleFlow.Api.Options; +using NimbleFlow.Data.Partials.DTOs; +using NimbleFlowHub.Contracts; +using ProductHubPublisherClient = NimbleFlowHub.Contracts.ProductHubPublisher.ProductHubPublisherClient; + +namespace NimbleFlow.Api.Services; + +public class ProductHubService +{ + private readonly HubServiceOptions _hubServiceOptions; + + public ProductHubService(IOptions hubServiceOptions) + { + _hubServiceOptions = hubServiceOptions.Value; + } + + public async Task PublishProductCreatedAsync(ProductDto message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new ProductHubPublisherClient(channel); + _ = await grpcClient.PublishProductCreatedAsync(new PublishProductValue + { + Id = message.Id.ToString(), + Title = message.Title, + Description = message.Description ?? string.Empty, + Price = float.Parse(message.Price.ToString(CultureInfo.InvariantCulture)), + ImageUrl = message.ImageUrl ?? string.Empty, + IsFavorite = message.IsFavorite, + CategoryId = message.CategoryId.ToString(), + }); + } + + public async Task PublishProductUpdatedAsync(ProductDto message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new ProductHubPublisherClient(channel); + _ = await grpcClient.PublishProductUpdatedAsync(new PublishProductValue + { + Id = message.Id.ToString(), + Title = message.Title, + Description = message.Description ?? string.Empty, + Price = float.Parse(message.Price.ToString(CultureInfo.InvariantCulture)), + ImageUrl = message.ImageUrl ?? string.Empty, + IsFavorite = message.IsFavorite, + CategoryId = message.CategoryId.ToString(), + }); + } + + public async Task PublishManyProductsDeletedAsync(IEnumerable message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new ProductHubPublisherClient(channel); + _ = await grpcClient.PublishManyProductsDeletedAsync(new PublishProductIds + { + Ids = { message.Select(x => x.ToString()) } + }); + } + + public async Task PublishProductDeletedAsync(Guid message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new ProductHubPublisherClient(channel); + _ = await grpcClient.PublishProductDeletedAsync(new PublishProductId + { + Id = message.ToString() + }); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Services/ProductService.cs b/AspDotNetCore/Src/NimbleFlow.Api/Services/ProductService.cs new file mode 100644 index 0000000..fd6d098 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Services/ProductService.cs @@ -0,0 +1,132 @@ +using System.Net; +using Microsoft.EntityFrameworkCore; +using NimbleFlow.Api.Extensions; +using NimbleFlow.Api.Repositories; +using NimbleFlow.Api.Services.Base; +using NimbleFlow.Contracts.DTOs.Products; +using NimbleFlow.Data.Context; +using NimbleFlow.Data.Models; +using NimbleFlow.Data.Partials.DTOs; + +namespace NimbleFlow.Api.Services; + +public class ProductService : ServiceBase +{ + private readonly ProductRepository _productRepository; + + public ProductService(ProductRepository productRepository) : base(productRepository) + { + _productRepository = productRepository; + } + + public new async Task<(int totalAmount, IEnumerable)> GetAllPaginated( + int page, + int limit, + bool includeDeleted + ) + { + var (totalAmount, entities) = await _productRepository.GetAllEntitiesPaginated(page, limit, includeDeleted); + return (totalAmount, entities.Select(x => x.ToDto())); + } + + public new async Task<(HttpStatusCode, ProductDto?)> Create(CreateProductDto createDto) + { + try + { + var response = await _productRepository.CreateEntity(createDto.ToModel()); + if (response is null) + return (HttpStatusCode.InternalServerError, null); + + return (HttpStatusCode.Created, response.ToDto()); + } + catch (DbUpdateException e) + { + if (e.InnerException?.Message.Contains("FOREIGN", StringComparison.InvariantCultureIgnoreCase) ?? false) + return (HttpStatusCode.BadRequest, null); + + return (HttpStatusCode.Conflict, null); + } + } + + public async Task<(int totalAmount, IEnumerable)> GetAllProductsPaginatedByCategoryId( + int page, + int limit, + bool includeDeleted, + Guid categoryId + ) + { + var (totalAmount, products) = await _productRepository.GetAllProductsPaginatedByCategoryId( + page, + limit, + includeDeleted, + categoryId + ); + return (totalAmount, products.Select(x => x.ToDto())); + } + + public async Task<(HttpStatusCode, ProductDto?)> UpdateProductById(Guid productId, UpdateProductDto productDto) + { + var productEntity = await _productRepository.GetEntityById(productId); + if (productEntity is null) + return (HttpStatusCode.NotFound, null); + + var shouldUpdate = false; + if (productDto.Title.IsNotNullAndNotEquals(productEntity.Title)) + { + productEntity.Title = productDto.Title ?? throw new NullReferenceException(); + shouldUpdate = true; + } + + if (productDto.Description != productEntity.Description + && (productDto.Description is not null && productDto.Description.Trim() != string.Empty + || productDto.Description is null)) + { + productEntity.Description = productDto.Description ?? throw new NullReferenceException(); + shouldUpdate = true; + } + + if (productDto.Price is not null && productDto.Price != productEntity.Price) + { + productEntity.Price = productDto.Price.Value; + shouldUpdate = true; + } + + if (productDto.ImageUrl != productEntity.ImageUrl + && (productDto.ImageUrl is not null && productDto.ImageUrl.Trim() != string.Empty + || productDto.ImageUrl is null)) + { + productEntity.ImageUrl = productDto.ImageUrl; + shouldUpdate = true; + } + + if (productDto.IsFavorite is not null && productDto.IsFavorite != productEntity.IsFavorite) + { + productEntity.IsFavorite = productDto.IsFavorite.Value; + shouldUpdate = true; + } + + if (productDto.CategoryId is not null + && productDto.CategoryId != Guid.Empty + && productDto.CategoryId != productEntity.CategoryId) + { + productEntity.CategoryId = productDto.CategoryId.Value; + shouldUpdate = true; + } + + if (!shouldUpdate) + return (HttpStatusCode.NotModified, null); + + try + { + var updatedProduct = await _productRepository.UpdateEntity(productEntity); + if (updatedProduct is null) + return (HttpStatusCode.NotModified, null); + + return (HttpStatusCode.OK, updatedProduct.ToDto()); + } + catch (DbUpdateException) + { + return (HttpStatusCode.Conflict, null); + } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Services/TableHubService.cs b/AspDotNetCore/Src/NimbleFlow.Api/Services/TableHubService.cs new file mode 100644 index 0000000..8f929a6 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Services/TableHubService.cs @@ -0,0 +1,62 @@ +using Grpc.Net.Client; +using Microsoft.Extensions.Options; +using NimbleFlow.Api.Options; +using NimbleFlow.Data.Partials.DTOs; +using NimbleFlowHub.Contracts; +using TableHubPublisherClient = NimbleFlowHub.Contracts.TableHubPublisher.TableHubPublisherClient; + +namespace NimbleFlow.Api.Services; + +public class TableHubService +{ + private readonly HubServiceOptions _hubServiceOptions; + + public TableHubService(IOptions hubServiceOptions) + { + _hubServiceOptions = hubServiceOptions.Value; + } + + public async Task PublishTableCreatedAsync(TableDto message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new TableHubPublisherClient(channel); + _ = await grpcClient.PublishTableCreatedAsync(new PublishTableValue + { + Id = message.Id.ToString(), + Accountable = message.Accountable, + IsFullyPaid = message.IsFullyPaid + }); + } + + public async Task PublishTableUpdatedAsync(TableDto message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new TableHubPublisherClient(channel); + _ = await grpcClient.PublishTableUpdatedAsync(new PublishTableValue + { + Id = message.Id.ToString(), + Accountable = message.Accountable, + IsFullyPaid = message.IsFullyPaid + }); + } + + public async Task PublishManyTablesDeletedAsync(IEnumerable message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new TableHubPublisherClient(channel); + _ = await grpcClient.PublishManyTablesDeletedAsync(new PublishTableIds + { + Ids = { message.Select(x => x.ToString()) } + }); + } + + public async Task PublishTableDeletedAsync(Guid message) + { + using var channel = GrpcChannel.ForAddress(_hubServiceOptions.GrpcConnectionUrl); + var grpcClient = new TableHubPublisherClient(channel); + _ = await grpcClient.PublishTableDeletedAsync(new PublishTableId + { + Id = message.ToString() + }); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Services/TableService.cs b/AspDotNetCore/Src/NimbleFlow.Api/Services/TableService.cs new file mode 100644 index 0000000..e0fe945 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Services/TableService.cs @@ -0,0 +1,50 @@ +using System.Net; +using Microsoft.EntityFrameworkCore; +using NimbleFlow.Api.Extensions; +using NimbleFlow.Api.Repositories; +using NimbleFlow.Api.Services.Base; +using NimbleFlow.Contracts.DTOs.Tables; +using NimbleFlow.Data.Context; +using NimbleFlow.Data.Models; +using NimbleFlow.Data.Partials.DTOs; + +namespace NimbleFlow.Api.Services; + +public class TableService : ServiceBase +{ + private readonly TableRepository _tableRepository; + + public TableService(TableRepository tableRepository) : base(tableRepository) + { + _tableRepository = tableRepository; + } + + public async Task<(HttpStatusCode, TableDto?)> UpdateTableById(Guid tableId, UpdateTableDto tableDto) + { + var tableEntity = await _tableRepository.GetEntityById(tableId); + if (tableEntity is null) + return (HttpStatusCode.NotFound, null); + + var shouldUpdate = false; + if (tableDto.Accountable.IsNotNullAndNotEquals(tableEntity.Accountable)) + { + tableEntity.Accountable = tableDto.Accountable ?? throw new NullReferenceException(); + shouldUpdate = true; + } + + if (tableDto.IsFullyPaid is not null && tableEntity.IsFullyPaid != tableDto.IsFullyPaid) + { + tableEntity.IsFullyPaid = tableDto.IsFullyPaid.Value; + shouldUpdate = true; + } + + if (!shouldUpdate) + return (HttpStatusCode.NotModified, null); + + var updatedTable = await _tableRepository.UpdateEntity(tableEntity); + if (updatedTable is null) + return (HttpStatusCode.NotModified, null); + + return (HttpStatusCode.OK, updatedTable.ToDto()); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/Services/UploadService.cs b/AspDotNetCore/Src/NimbleFlow.Api/Services/UploadService.cs new file mode 100644 index 0000000..92ceea2 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/Services/UploadService.cs @@ -0,0 +1,72 @@ +using System.Net; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.S3.Transfer; +using Microsoft.Extensions.Options; +using NimbleFlow.Api.Options; +using NimbleFlow.Contracts.Enums; + +namespace NimbleFlow.Api.Services; + +public class UploadService +{ + private readonly AWSCredentials _awsCredentials; + private readonly AmazonS3Config _amazonS3Config; + private readonly string _bucketName; + private readonly bool _isProductionEnvironment; + + public UploadService(IOptions amazonOptions, IOptions amazonS3Options) + { + _awsCredentials = amazonOptions.Value.Credentials; + + ( + _amazonS3Config, + _bucketName, + _isProductionEnvironment + ) = amazonS3Options.Value; + } + + private static string GetObjectKey(FileTypeEnum fileTypeEnum) + => string.Join( + string.Empty, + Guid.NewGuid(), + fileTypeEnum switch + { + FileTypeEnum.Jpeg => ".jpeg", + FileTypeEnum.Png => ".png", + _ => string.Empty + } + ); + + private string GetObjectPath(string objectKey) + => _isProductionEnvironment switch + { + true => $"https://{_bucketName}.s3.{_amazonS3Config.RegionEndpoint?.SystemName}.amazonaws.com/{objectKey}", + _ => $"{_amazonS3Config.ServiceURL}{_bucketName}/{objectKey}" + }; + + public async Task<(HttpStatusCode, string)> UploadFileAsync( + Stream stream, + string contentType, + FileTypeEnum fileTypeEnum + ) + { + using var client = new AmazonS3Client(_awsCredentials, _amazonS3Config); + var objectKey = GetObjectKey(fileTypeEnum); + var fileTransferUtility = new TransferUtility(client); + var fileTransferUtilityRequest = new TransferUtilityUploadRequest + { + BucketName = _bucketName, + Key = objectKey, + CannedACL = S3CannedACL.PublicRead, + ContentType = contentType, + StorageClass = S3StorageClass.Standard, + PartSize = stream.Length, + InputStream = stream + }; + + await fileTransferUtility.UploadAsync(fileTransferUtilityRequest); + return (HttpStatusCode.Created, GetObjectPath(objectKey)); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/appsettings.Development.json b/AspDotNetCore/Src/NimbleFlow.Api/appsettings.Development.json new file mode 100644 index 0000000..7eb0e23 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Serilog": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Debug", + "Microsoft.Hosting.Lifetime": "Debug" + } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Api/appsettings.json b/AspDotNetCore/Src/NimbleFlow.Api/appsettings.json new file mode 100644 index 0000000..ddb8862 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Api/appsettings.json @@ -0,0 +1,26 @@ +{ + "Serilog": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http1": { + "Url": "http://*:10506", + "Protocols": "Http1" + } + } + }, + "SwaggerOptions": { + "IsEnabled": true, + "Title": "NimbleFlow", + "Version": "v1", + "Description": "A study concept project", + "LicenseName": "MIT", + "LicenseUrl": "https://github.com/TI-TecnicamenteIdiotas/nimble-flow-backend/blob/main/LICENSE" + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/Constants/FileSignatures.cs b/AspDotNetCore/Src/NimbleFlow.Contracts/Constants/FileSignatures.cs new file mode 100644 index 0000000..60aa184 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/Constants/FileSignatures.cs @@ -0,0 +1,7 @@ +namespace NimbleFlow.Contracts.Constants; + +public static class FileSignatures +{ + public static readonly byte[] Jpeg = { 0xFF, 0xD8 }; + public static readonly byte[] Png = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Categories/CreateCategoryDto.cs b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Categories/CreateCategoryDto.cs new file mode 100644 index 0000000..2c1eecc --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Categories/CreateCategoryDto.cs @@ -0,0 +1,24 @@ +using NimbleFlow.Contracts.Interfaces; +using NimbleFlow.Data.Models; + +namespace NimbleFlow.Contracts.DTOs.Categories; + +public class CreateCategoryDto : IToModel +{ + public string Title { get; init; } + public int? ColorTheme { get; init; } + public int? CategoryIcon { get; init; } + + public CreateCategoryDto(string title) + { + Title = title; + } + + public Category ToModel() + => new() + { + Title = Title, + ColorTheme = ColorTheme, + CategoryIcon = CategoryIcon + }; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Categories/UpdateCategoryDto.cs b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Categories/UpdateCategoryDto.cs new file mode 100644 index 0000000..9a06613 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Categories/UpdateCategoryDto.cs @@ -0,0 +1,8 @@ +namespace NimbleFlow.Contracts.DTOs.Categories; + +public class UpdateCategoryDto +{ + public string? Title { get; set; } + public int? ColorTheme { get; set; } + public int? CategoryIcon { get; set; } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/PaginatedDto.cs b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/PaginatedDto.cs new file mode 100644 index 0000000..45b4ffc --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/PaginatedDto.cs @@ -0,0 +1,13 @@ +namespace NimbleFlow.Contracts.DTOs; + +public class PaginatedDto +{ + public int TotalItems { get; init; } + public IEnumerable Items { get; init; } + + public PaginatedDto(int totalItems, IEnumerable items) + { + TotalItems = totalItems; + Items = items; + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Products/CreateProductDto.cs b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Products/CreateProductDto.cs new file mode 100644 index 0000000..bbb8b60 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Products/CreateProductDto.cs @@ -0,0 +1,31 @@ +using NimbleFlow.Contracts.Interfaces; +using NimbleFlow.Data.Models; + +namespace NimbleFlow.Contracts.DTOs.Products; + +public class CreateProductDto : IToModel +{ + public string Title { get; init; } + public string? Description { get; init; } + public decimal Price { get; init; } + public string? ImageUrl { get; init; } + public bool IsFavorite { get; init; } + public Guid CategoryId { get; init; } + + public CreateProductDto(string title, Guid categoryId) + { + Title = title; + CategoryId = categoryId; + } + + public Product ToModel() + => new() + { + Title = Title, + Description = Description, + Price = Price, + ImageUrl = ImageUrl, + IsFavorite = IsFavorite, + CategoryId = CategoryId + }; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Products/UpdateProductDto.cs b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Products/UpdateProductDto.cs new file mode 100644 index 0000000..0590d40 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Products/UpdateProductDto.cs @@ -0,0 +1,11 @@ +namespace NimbleFlow.Contracts.DTOs.Products; + +public class UpdateProductDto +{ + public string? Title { get; set; } + public string? Description { get; set; } + public decimal? Price { get; set; } + public string? ImageUrl { get; set; } + public bool? IsFavorite { get; set; } + public Guid? CategoryId { get; set; } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Tables/CreateTableDto.cs b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Tables/CreateTableDto.cs new file mode 100644 index 0000000..fbdc40c --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Tables/CreateTableDto.cs @@ -0,0 +1,22 @@ +using NimbleFlow.Contracts.Interfaces; +using NimbleFlow.Data.Models; + +namespace NimbleFlow.Contracts.DTOs.Tables; + +public class CreateTableDto : IToModel +{ + public string Accountable { get; init; } + public bool IsFullyPaid { get; init; } + + public CreateTableDto(string accountable) + { + Accountable = accountable; + } + + public Table ToModel() + => new() + { + Accountable = Accountable, + IsFullyPaid = IsFullyPaid + }; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Tables/UpdateTableDto.cs b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Tables/UpdateTableDto.cs new file mode 100644 index 0000000..cc53b66 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/DTOs/Tables/UpdateTableDto.cs @@ -0,0 +1,7 @@ +namespace NimbleFlow.Contracts.DTOs.Tables; + +public sealed class UpdateTableDto +{ + public string? Accountable { get; set; } = null; + public bool? IsFullyPaid { get; set; } = null; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/Enums/FileTypeEnum.cs b/AspDotNetCore/Src/NimbleFlow.Contracts/Enums/FileTypeEnum.cs new file mode 100644 index 0000000..5f0af6a --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/Enums/FileTypeEnum.cs @@ -0,0 +1,8 @@ +namespace NimbleFlow.Contracts.Enums; + +public enum FileTypeEnum +{ + Unknown, + Jpeg, + Png +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/Interfaces/IToModel.cs b/AspDotNetCore/Src/NimbleFlow.Contracts/Interfaces/IToModel.cs new file mode 100644 index 0000000..ab85dfc --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/Interfaces/IToModel.cs @@ -0,0 +1,6 @@ +namespace NimbleFlow.Contracts.Interfaces; + +public interface IToModel +{ + public TModel ToModel(); +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/NimbleFlow.Contracts.csproj b/AspDotNetCore/Src/NimbleFlow.Contracts/NimbleFlow.Contracts.csproj new file mode 100644 index 0000000..e66dbf8 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/NimbleFlow.Contracts.csproj @@ -0,0 +1,28 @@ + + + + net6.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/Protos/categoryHubPublisher.proto b/AspDotNetCore/Src/NimbleFlow.Contracts/Protos/categoryHubPublisher.proto new file mode 100644 index 0000000..df9dd81 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/Protos/categoryHubPublisher.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +option csharp_namespace = "NimbleFlowHub.Contracts"; + +import "google/protobuf/empty.proto"; + +package categoryHubPublisher; + +service CategoryHubPublisher { + rpc PublishCategoryCreated (PublishCategoryValue) returns (google.protobuf.Empty); + rpc PublishCategoryUpdated (PublishCategoryValue) returns (google.protobuf.Empty); + rpc PublishManyCategoriesDeleted (PublishCategoryIds) returns (google.protobuf.Empty); + rpc PublishCategoryDeleted (PublishCategoryId) returns (google.protobuf.Empty); +} + +message PublishCategoryIds { + repeated string ids = 1; +} + +message PublishCategoryId { + string id = 1; +} + +message PublishCategoryValue { + string id = 1; + string title = 2; + int32 colorTheme = 3; + int32 categoryIcon = 4; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/Protos/productHubPublisher.proto b/AspDotNetCore/Src/NimbleFlow.Contracts/Protos/productHubPublisher.proto new file mode 100644 index 0000000..6f95c0f --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/Protos/productHubPublisher.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +option csharp_namespace = "NimbleFlowHub.Contracts"; + +import "google/protobuf/empty.proto"; + +package productHubPublisher; + +service ProductHubPublisher { + rpc PublishProductCreated (PublishProductValue) returns (google.protobuf.Empty); + rpc PublishProductUpdated (PublishProductValue) returns (google.protobuf.Empty); + rpc PublishManyProductsDeleted (PublishProductIds) returns (google.protobuf.Empty); + rpc PublishProductDeleted (PublishProductId) returns (google.protobuf.Empty); +} + +message PublishProductIds { + repeated string ids = 1; +} + +message PublishProductId { + string id = 1; +} + +message PublishProductValue { + string id = 1; + string title = 2; + string description = 3; + float price = 4; + string image_url = 5; + bool is_favorite = 6; + string category_id = 7; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Contracts/Protos/tableHubPublisher.proto b/AspDotNetCore/Src/NimbleFlow.Contracts/Protos/tableHubPublisher.proto new file mode 100644 index 0000000..5360317 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Contracts/Protos/tableHubPublisher.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +option csharp_namespace = "NimbleFlowHub.Contracts"; + +import "google/protobuf/empty.proto"; + +package tableHubPublisher; + +service TableHubPublisher { + rpc PublishTableCreated (PublishTableValue) returns (google.protobuf.Empty); + rpc PublishTableUpdated (PublishTableValue) returns (google.protobuf.Empty); + rpc PublishManyTablesDeleted (PublishTableIds) returns (google.protobuf.Empty); + rpc PublishTableDeleted (PublishTableId) returns (google.protobuf.Empty); +} + +message PublishTableIds { + repeated string ids = 1; +} + +message PublishTableId { + string id = 1; +} + +message PublishTableValue { + string id = 1; + string accountable = 2; + bool is_fully_paid = 3; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Context/NimbleFlowContext.cs b/AspDotNetCore/Src/NimbleFlow.Data/Context/NimbleFlowContext.cs new file mode 100644 index 0000000..43fae15 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Context/NimbleFlowContext.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using NimbleFlow.Data; +using NimbleFlow.Data.Models; + +namespace NimbleFlow.Data.Context +{ + public partial class NimbleFlowContext : DbContext + { + public NimbleFlowContext() + { + } + + public NimbleFlowContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet Categories { get; set; } = null!; + public virtual DbSet Products { get; set; } = null!; + public virtual DbSet
Tables { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("category"); + + entity.HasIndex(e => e.Title, "category_title_key") + .IsUnique(); + + entity.Property(e => e.Id) + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + entity.Property(e => e.CategoryIcon).HasColumnName("category_icon"); + + entity.Property(e => e.ColorTheme).HasColumnName("color_theme"); + + entity.Property(e => e.CreatedAt) + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + entity.Property(e => e.DeletedAt).HasColumnName("deleted_at"); + + entity.Property(e => e.Title) + .HasMaxLength(32) + .HasColumnName("title"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("product"); + + entity.HasIndex(e => e.Title, "product_title_key") + .IsUnique(); + + entity.Property(e => e.Id) + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + entity.Property(e => e.CategoryId).HasColumnName("category_id"); + + entity.Property(e => e.CreatedAt) + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + entity.Property(e => e.DeletedAt).HasColumnName("deleted_at"); + + entity.Property(e => e.Description) + .HasMaxLength(512) + .HasColumnName("description"); + + entity.Property(e => e.ImageUrl).HasColumnName("image_url"); + + entity.Property(e => e.IsFavorite).HasColumnName("is_favorite"); + + entity.Property(e => e.Price).HasColumnName("price"); + + entity.Property(e => e.Title) + .HasMaxLength(64) + .HasColumnName("title"); + + entity.HasOne(d => d.Category) + .WithMany(p => p.Products) + .HasForeignKey(d => d.CategoryId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("product_category_id_fkey"); + }); + + modelBuilder.Entity
(entity => + { + entity.ToTable("table"); + + entity.Property(e => e.Id) + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + entity.Property(e => e.Accountable) + .HasMaxLength(256) + .HasColumnName("accountable"); + + entity.Property(e => e.CreatedAt) + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + entity.Property(e => e.DeletedAt).HasColumnName("deleted_at"); + + entity.Property(e => e.IsFullyPaid).HasColumnName("is_fully_paid"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Models/Category.cs b/AspDotNetCore/Src/NimbleFlow.Data/Models/Category.cs new file mode 100644 index 0000000..fea19bd --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Models/Category.cs @@ -0,0 +1,19 @@ +namespace NimbleFlow.Data.Models +{ + public partial class Category + { + public Category() + { + Products = new HashSet(); + } + + public Guid Id { get; set; } + public string Title { get; set; } = null!; + public int? ColorTheme { get; set; } + public int? CategoryIcon { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? DeletedAt { get; set; } + + public virtual ICollection Products { get; set; } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Models/Product.cs b/AspDotNetCore/Src/NimbleFlow.Data/Models/Product.cs new file mode 100644 index 0000000..940921f --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Models/Product.cs @@ -0,0 +1,18 @@ +namespace NimbleFlow.Data.Models +{ + public partial class Product + { + + public Guid Id { get; set; } + public string Title { get; set; } = null!; + public string? Description { get; set; } + public decimal Price { get; set; } + public string? ImageUrl { get; set; } + public bool IsFavorite { get; set; } + public Guid CategoryId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? DeletedAt { get; set; } + + public virtual Category Category { get; set; } = null!; + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Models/Table.cs b/AspDotNetCore/Src/NimbleFlow.Data/Models/Table.cs new file mode 100644 index 0000000..0fcbc1d --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Models/Table.cs @@ -0,0 +1,11 @@ +namespace NimbleFlow.Data.Models +{ + public partial class Table + { + public Guid Id { get; set; } + public string Accountable { get; set; } = null!; + public bool IsFullyPaid { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? DeletedAt { get; set; } + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/NimbleFlow.Data.csproj b/AspDotNetCore/Src/NimbleFlow.Data/NimbleFlow.Data.csproj new file mode 100644 index 0000000..033f7e0 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/NimbleFlow.Data.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + enable + enable + + + + + + + + + diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Partials/DTOs/CategoryDto.cs b/AspDotNetCore/Src/NimbleFlow.Data/Partials/DTOs/CategoryDto.cs new file mode 100644 index 0000000..fe4d561 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Partials/DTOs/CategoryDto.cs @@ -0,0 +1,15 @@ +namespace NimbleFlow.Data.Partials.DTOs; + +public class CategoryDto +{ + public Guid Id { get; init; } + public string Title { get; set; } + public int? ColorTheme { get; set; } + public int? CategoryIcon { get; set; } + + public CategoryDto(Guid id, string title) + { + Id = id; + Title = title; + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Partials/DTOs/ProductDto.cs b/AspDotNetCore/Src/NimbleFlow.Data/Partials/DTOs/ProductDto.cs new file mode 100644 index 0000000..006bda6 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Partials/DTOs/ProductDto.cs @@ -0,0 +1,19 @@ +namespace NimbleFlow.Data.Partials.DTOs; + +public class ProductDto +{ + public Guid Id { get; init; } + public string Title { get; init; } + public string? Description { get; init; } + public decimal Price { get; init; } + public string? ImageUrl { get; init; } + public bool IsFavorite { get; init; } + public Guid CategoryId { get; init; } + + public ProductDto(Guid id, string title, Guid categoryId) + { + Id = id; + Title = title; + CategoryId = categoryId; + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Partials/DTOs/TableDto.cs b/AspDotNetCore/Src/NimbleFlow.Data/Partials/DTOs/TableDto.cs new file mode 100644 index 0000000..c230b76 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Partials/DTOs/TableDto.cs @@ -0,0 +1,14 @@ +namespace NimbleFlow.Data.Partials.DTOs; + +public class TableDto +{ + public Guid Id { get; init; } + public string Accountable { get; init; } + public bool IsFullyPaid { get; init; } + + public TableDto(Guid id, string accountable) + { + Id = id; + Accountable = accountable; + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Partials/Interfaces/ICreatedAtDeletedAt.cs b/AspDotNetCore/Src/NimbleFlow.Data/Partials/Interfaces/ICreatedAtDeletedAt.cs new file mode 100644 index 0000000..7443f55 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Partials/Interfaces/ICreatedAtDeletedAt.cs @@ -0,0 +1,7 @@ +namespace NimbleFlow.Data.Partials.Interfaces; + +public interface ICreatedAtDeletedAt +{ + public DateTime CreatedAt { get; set; } + public DateTime? DeletedAt { get; set; } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Partials/Interfaces/IIdentifiable.cs b/AspDotNetCore/Src/NimbleFlow.Data/Partials/Interfaces/IIdentifiable.cs new file mode 100644 index 0000000..042fab4 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Partials/Interfaces/IIdentifiable.cs @@ -0,0 +1,6 @@ +namespace NimbleFlow.Data.Partials.Interfaces; + +public interface IIdentifiable +{ + public T Id { get; set; } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Partials/Interfaces/IToDto.cs b/AspDotNetCore/Src/NimbleFlow.Data/Partials/Interfaces/IToDto.cs new file mode 100644 index 0000000..153af52 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Partials/Interfaces/IToDto.cs @@ -0,0 +1,6 @@ +namespace NimbleFlow.Data.Partials.Interfaces; + +public interface IToDto +{ + public TDto ToDto(); +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Data/Partials/PartialImplementers.cs b/AspDotNetCore/Src/NimbleFlow.Data/Partials/PartialImplementers.cs new file mode 100644 index 0000000..efdde3b --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Data/Partials/PartialImplementers.cs @@ -0,0 +1,36 @@ +using NimbleFlow.Data.Partials.DTOs; +using NimbleFlow.Data.Partials.Interfaces; + +// ReSharper disable once CheckNamespace +namespace NimbleFlow.Data.Models; + +public partial class Category : IIdentifiable, ICreatedAtDeletedAt, IToDto +{ + public CategoryDto ToDto() + => new(Id, Title) + { + ColorTheme = ColorTheme, + CategoryIcon = CategoryIcon + }; +} + +public partial class Product : IIdentifiable, ICreatedAtDeletedAt, IToDto +{ + public ProductDto ToDto() + => new(Id, Title, CategoryId) + { + Description = Description, + Price = Price, + ImageUrl = ImageUrl, + IsFavorite = IsFavorite + }; +} + +public partial class Table : IIdentifiable, ICreatedAtDeletedAt, IToDto +{ + public TableDto ToDto() + => new(Id, Accountable) + { + IsFullyPaid = IsFullyPaid + }; +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Tests/Base/TestBase.cs b/AspDotNetCore/Src/NimbleFlow.Tests/Base/TestBase.cs new file mode 100644 index 0000000..4aa34b7 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Tests/Base/TestBase.cs @@ -0,0 +1,47 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using NimbleFlow.Api.Controllers; +using NimbleFlow.Api.Repositories; +using NimbleFlow.Api.Services; +using NimbleFlow.Data.Context; + +namespace NimbleFlow.Tests.Base; + +public abstract class TestBase : IDisposable +{ + private readonly SqliteConnection _connection; + + protected readonly CategoryController CategoryController; + protected readonly ProductController ProductController; + protected readonly TableController TableController; + + protected TestBase() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _connection.CreateFunction("now", () => DateTime.UtcNow); + _connection.CreateFunction("gen_random_uuid", Guid.NewGuid); + + var dbContextOptionsBuilder = new DbContextOptionsBuilder().UseSqlite(_connection); + var dbContext = new NimbleFlowContext(dbContextOptionsBuilder.Options); + _ = dbContext.Database.EnsureCreated(); + + var categoryRepository = new CategoryRepository(dbContext); + var categoryService = new CategoryService(categoryRepository); + CategoryController = new CategoryController(categoryService, null); + + var productRepository = new ProductRepository(dbContext); + var productService = new ProductService(productRepository); + ProductController = new ProductController(productService, null); + + var tableRepository = new TableRepository(dbContext); + var tableService = new TableService(tableRepository); + TableController = new TableController(tableService, null); + } + + public void Dispose() + { + _connection.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Tests/Helpers/CategoryTestHelper.cs b/AspDotNetCore/Src/NimbleFlow.Tests/Helpers/CategoryTestHelper.cs new file mode 100644 index 0000000..d64c134 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Tests/Helpers/CategoryTestHelper.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using NimbleFlow.Api.Controllers; +using NimbleFlow.Contracts.DTOs.Categories; +using NimbleFlow.Data.Partials.DTOs; + +namespace NimbleFlow.Tests.Helpers; + +internal static class CategoryTestHelper +{ + internal static async Task CreateCategoryTestHelper( + this CategoryController categoryController, + string categoryTitle + ) + { + var categoryDto = new CreateCategoryDto(categoryTitle); + var createCategoryResponse = await categoryController.CreateCategory(categoryDto); + var createdCategory = ((createCategoryResponse as CreatedResult)!.Value as CategoryDto)!; + return createdCategory; + } + + internal static async Task CreateManyCategoriesTestHelper( + this CategoryController categoryController, + params string[] categoryTitles + ) + { + var categoriesDto = categoryTitles.Select(x => new CreateCategoryDto(x)).ToArray(); + var createCategoryResponses = new List(); + foreach (var categoryDto in categoriesDto) + { + var response = await categoryController.CreateCategory(categoryDto); + createCategoryResponses.Add(response); + } + + return createCategoryResponses.Select(x => ((x as CreatedResult)!.Value as CategoryDto)!).ToArray(); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Tests/Helpers/ProductTestHelper.cs b/AspDotNetCore/Src/NimbleFlow.Tests/Helpers/ProductTestHelper.cs new file mode 100644 index 0000000..dc2d20e --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Tests/Helpers/ProductTestHelper.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Mvc; +using NimbleFlow.Api.Controllers; +using NimbleFlow.Contracts.DTOs.Products; +using NimbleFlow.Data.Partials.DTOs; + +namespace NimbleFlow.Tests.Helpers; + +internal static class ProductTestHelper +{ + internal static async Task CreateProductTestHelper( + this ProductController productController, + string productTitle, + CategoryDto categoryDto + ) + { + var productDto = new CreateProductDto(productTitle, categoryDto.Id) + { + Description = null, + Price = new decimal(10.0), + ImageUrl = null, + IsFavorite = false + }; + + var createProductResponse = await productController.CreateProduct(productDto); + var createdProduct = ((createProductResponse as CreatedResult)!.Value as ProductDto)!; + return createdProduct; + } + + internal static async Task CreateManyProductsTestHelper( + this ProductController categoryController, + CategoryDto categoryDto, + params string[] productTitles + ) + { + var productsDto = productTitles.Select(x => new CreateProductDto(x, categoryDto.Id)).ToArray(); + var createProductResponses = new List(); + foreach (var productDto in productsDto) + { + var response = await categoryController.CreateProduct(productDto); + createProductResponses.Add(response); + } + + return createProductResponses.Select(x => ((x as CreatedResult)!.Value as ProductDto)!).ToArray(); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Tests/Helpers/TableTestHelper.cs b/AspDotNetCore/Src/NimbleFlow.Tests/Helpers/TableTestHelper.cs new file mode 100644 index 0000000..4a3d7ba --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Tests/Helpers/TableTestHelper.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; +using NimbleFlow.Api.Controllers; +using NimbleFlow.Contracts.DTOs.Tables; +using NimbleFlow.Data.Partials.DTOs; + +namespace NimbleFlow.Tests.Helpers; + +internal static class TableTestHelper +{ + internal static async Task CreateTableTestHelper( + this TableController tableController, + string tableAccountable + ) + { + var tableDto = new CreateTableDto(tableAccountable) + { + IsFullyPaid = false + }; + + var createTableResponse = await tableController.CreateTable(tableDto); + var createdTable = ((createTableResponse as CreatedResult)!.Value as TableDto)!; + return createdTable; + } + + internal static async Task CreateManyTablesTestHelper( + this TableController tableController, + params string[] tableAccountabilities + ) + { + var tablesDto = tableAccountabilities.Select(x => new CreateTableDto(x)).ToArray(); + var createTableResponses = new List(); + foreach (var tableDto in tablesDto) + { + var response = await tableController.CreateTable(tableDto); + createTableResponses.Add(response); + } + + return createTableResponses.Select(x => ((x as CreatedResult)!.Value as TableDto)!).ToArray(); + } +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Tests/NimbleFlow.Tests.csproj b/AspDotNetCore/Src/NimbleFlow.Tests/NimbleFlow.Tests.csproj new file mode 100644 index 0000000..906ddf6 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Tests/NimbleFlow.Tests.csproj @@ -0,0 +1,25 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/AspDotNetCore/Src/NimbleFlow.Tests/Src/CategoryTests.cs b/AspDotNetCore/Src/NimbleFlow.Tests/Src/CategoryTests.cs new file mode 100644 index 0000000..817dfd4 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Tests/Src/CategoryTests.cs @@ -0,0 +1,231 @@ +using Microsoft.AspNetCore.Mvc; +using NimbleFlow.Contracts.DTOs.Categories; +using NimbleFlow.Tests.Base; +using NimbleFlow.Tests.Helpers; + +namespace NimbleFlow.Tests; + +public class CategoryTests : TestBase +{ + #region Create + + [Fact] + public async Task Create_Category_ShouldReturnCreatedResult() + { + // Arrange + var categoryDto = new CreateCategoryDto("Category A"); + + // Act + var actionResult = await CategoryController.CreateCategory(categoryDto); + + // Assert + Assert.True(actionResult is CreatedResult); + } + + #endregion + + #region Get All Paginated + + [Fact] + public async Task GetAll_CategoriesPaginated_ShouldReturnOkObjectResult() + { + // Arrange + _ = await CategoryController.CreateCategoryTestHelper("Category A"); + + // Act + var actionResult = await CategoryController.GetAllCategoriesPaginated(); + + // Assert + Assert.True(actionResult is OkObjectResult); + } + + [Fact] + public async Task GetAll_CategoriesPaginated_ShouldReturnNoContentResult() + { + // Arrange + // Act + var actionResult = await CategoryController.GetAllCategoriesPaginated(); + + // Assert + Assert.True(actionResult is NoContentResult); + } + + #endregion + + #region Get By Ids + + [Fact] + public async Task Get_CategoriesByIds_ShouldReturnOkObjectResult() + { + // Arrange + var createdCategories = await CategoryController.CreateManyCategoriesTestHelper( + "Category A", + "Category B", + "Category C" + ); + var categoriesIds = createdCategories.Select(x => x.Id).ToArray(); + + // Act + var actionResult = await CategoryController.GetCategoriesByIds(categoriesIds); + + // Assert + Assert.True(actionResult is OkObjectResult); + } + + [Fact] + public async Task Get_CategoriesByIds_ShouldReturnNotFoundResult() + { + // Arrange + var categoriesIds = new[] + { + Guid.NewGuid(), + Guid.NewGuid() + }; + + // Act + var actionResult = await CategoryController.GetCategoriesByIds(categoriesIds); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion + + #region Get By Id + + [Fact] + public async Task Get_CategoryById_ShouldReturnOkObjectResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + + // Act + var actionResult = await CategoryController.GetCategoryById(createdCategory.Id); + + // Assert + Assert.True(actionResult is OkObjectResult); + } + + [Fact] + public async Task Get_CategoryById_ShouldReturnNotFound() + { + // Arrange + var categoryId = Guid.NewGuid(); + + // Act + var actionResult = await CategoryController.GetCategoryById(categoryId); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion + + #region Update By Id + + [Fact] + public async Task Update_CategoryById_ShouldReturnOkResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + var updateCategoryDto = new UpdateCategoryDto + { + Title = "Category A Updated" + }; + + // Act + var actionResult = await CategoryController.UpdateCategoryById(createdCategory.Id, updateCategoryDto); + + // Assert + Assert.True(actionResult is OkResult); + } + + [Fact] + public async Task Update_CategoryById_ShouldReturnConflictResult() + { + // Arrange + _ = await CategoryController.CreateCategoryTestHelper("Category A"); + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category B"); + var updateCategoryDto = new UpdateCategoryDto + { + Title = "Category A" + }; + + // Act + var actionResult = await CategoryController.UpdateCategoryById(createdCategory.Id, updateCategoryDto); + + // Assert + Assert.True(actionResult is ConflictResult); + } + + #endregion + + #region Delete By Ids + + [Fact] + public async Task Delete_CategoriesByIds_ShouldReturnOkResult() + { + // Arrange + var createdCategories = await CategoryController.CreateManyCategoriesTestHelper( + "Category A", + "Category B", + "Category C" + ); + var categoriesIds = createdCategories.Select(x => x.Id).ToArray(); + + // Act + var actionResult = await CategoryController.DeleteCategoriesByIds(categoriesIds); + + // Assert + Assert.True(actionResult is OkResult); + } + + [Fact] + public async Task Delete_CategoriesByIds_ShouldReturnNotFoundResult() + { + // Arrange + var categoriesIds = new[] + { + Guid.NewGuid(), + Guid.NewGuid() + }; + + // Act + var actionResult = await CategoryController.DeleteCategoriesByIds(categoriesIds); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion + + #region Delete By Id + + [Fact] + public async Task Delete_CategoryById_ShouldReturnOkResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + + // Act + var actionResult = await CategoryController.DeleteCategoryById(createdCategory.Id); + + // Assert + Assert.True(actionResult is OkResult); + } + + [Fact] + public async Task Delete_CategoryById_ShouldReturnNotFoundResult() + { + // Arrange + var categoryId = Guid.NewGuid(); + + // Act + var actionResult = await CategoryController.DeleteCategoryById(categoryId); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Tests/Src/ProductTests.cs b/AspDotNetCore/Src/NimbleFlow.Tests/Src/ProductTests.cs new file mode 100644 index 0000000..8a713eb --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Tests/Src/ProductTests.cs @@ -0,0 +1,266 @@ +using Microsoft.AspNetCore.Mvc; +using NimbleFlow.Contracts.DTOs.Products; +using NimbleFlow.Tests.Base; +using NimbleFlow.Tests.Helpers; + +namespace NimbleFlow.Tests; + +public class ProductTests : TestBase +{ + #region Create + + [Fact] + public async Task Create_Product_ShouldReturnCreatedResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + var productDto = new CreateProductDto("Product A", createdCategory.Id) + { + Description = null, + Price = new decimal(10.0), + ImageUrl = null, + IsFavorite = false, + }; + + // Act + var actionResult = await ProductController.CreateProduct(productDto); + + // Assert + Assert.True(actionResult is CreatedResult); + } + + [Fact] + public async Task Create_Product_ShouldReturnBadRequestResult() + { + // Arrange + var productDto = new CreateProductDto("Product A", Guid.NewGuid()) + { + Description = null, + Price = new decimal(10.0), + ImageUrl = null, + IsFavorite = false, + }; + + // Act + var actionResult = await ProductController.CreateProduct(productDto); + + // Assert + Assert.True(actionResult is BadRequestResult); + } + + #endregion + + #region Get All Paginated + + [Fact] + public async Task GetAll_ProductsPaginated_ShouldReturnOkObjectResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + _ = await ProductController.CreateProductTestHelper("Product A", createdCategory); + + // Act + var actionResult = await ProductController.GetAllProductsPaginated(); + + // Assert + Assert.True(actionResult is OkObjectResult); + } + + [Fact] + public async Task GetAll_ProductsPaginated_ShouldReturnNoContentResult() + { + // Arrange + // Act + var actionResult = await ProductController.GetAllProductsPaginated(); + + // Assert + Assert.True(actionResult is NoContentResult); + } + + #endregion + + #region Get By Ids + + [Fact] + public async Task Get_ProductsByIds_ShouldReturnOkObjectResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + var createdProducts = await ProductController.CreateManyProductsTestHelper( + createdCategory, + "Product A", + "Product B", + "Product C" + ); + var productsIds = createdProducts.Select(x => x.Id).ToArray(); + + // Act + var actionResult = await ProductController.GetProductsByIds(productsIds); + + // Assert + Assert.True(actionResult is OkObjectResult); + } + + [Fact] + public async Task Get_ProductsByIds_ShouldReturnNotFoundResult() + { + // Arrange + var productsIds = new[] + { + Guid.NewGuid(), + Guid.NewGuid() + }; + + // Act + var actionResult = await ProductController.GetProductsByIds(productsIds); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion + + #region Get By Id + + [Fact] + public async Task Get_ProductById_ShouldReturnOkObjectResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + var createdProduct = await ProductController.CreateProductTestHelper("Product A", createdCategory); + + // Act + var actionResult = await ProductController.GetProductById(createdProduct.Id); + + // Assert + Assert.True(actionResult is OkObjectResult); + } + + [Fact] + public async Task Get_ProductById_ShouldReturnNotFound() + { + // Arrange + var productId = Guid.NewGuid(); + + // Act + var actionResult = await ProductController.GetProductById(productId); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion + + #region Update By Id + + [Fact] + public async Task Update_ProductById_ShouldReturnOkResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + var createdProduct = await ProductController.CreateProductTestHelper("Product A", createdCategory); + var updateProductDto = new UpdateProductDto + { + Title = "Product A Updated" + }; + + // Act + var actionResult = await ProductController.UpdateProductById(createdProduct.Id, updateProductDto); + + // Assert + Assert.True(actionResult is OkResult); + } + + [Fact] + public async Task Update_ProductById_ShouldReturnConflictResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + _ = await ProductController.CreateProductTestHelper("Product A", createdCategory); + var createdProduct = await ProductController.CreateProductTestHelper("Product B", createdCategory); + var updateProductDto = new UpdateProductDto + { + Title = "Product A" + }; + + // Act + var actionResult = await ProductController.UpdateProductById(createdProduct.Id, updateProductDto); + + // Assert + Assert.True(actionResult is ConflictResult); + } + + #endregion + + #region Delete By Ids + + [Fact] + public async Task Delete_ProductsByIds_ShouldReturnOkResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + var createdProducts = await ProductController.CreateManyProductsTestHelper( + createdCategory, + "Product A", + "Product B", + "Product C" + ); + var productsIds = createdProducts.Select(x => x.Id).ToArray(); + + // Act + var actionResult = await ProductController.DeleteProductsByIds(productsIds); + + // Assert + Assert.True(actionResult is OkResult); + } + + [Fact] + public async Task Delete_ProductsByIds_ShouldReturnNotFoundResult() + { + // Arrange + var productsIds = new[] + { + Guid.NewGuid(), + Guid.NewGuid() + }; + + // Act + var actionResult = await ProductController.DeleteProductsByIds(productsIds); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion + + #region Delete By Id + + [Fact] + public async Task Delete_ProductById_ShouldReturnOkResult() + { + // Arrange + var createdCategory = await CategoryController.CreateCategoryTestHelper("Category A"); + var createdProduct = await ProductController.CreateProductTestHelper("Product A", createdCategory); + + // Act + var actionResult = await ProductController.DeleteProductById(createdProduct.Id); + + // Assert + Assert.True(actionResult is OkResult); + } + + [Fact] + public async Task Delete_ProductById_ShouldReturnNotFoundResult() + { + // Arrange + var productId = Guid.NewGuid(); + + // Act + var actionResult = await ProductController.DeleteProductById(productId); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Tests/Src/TableTests.cs b/AspDotNetCore/Src/NimbleFlow.Tests/Src/TableTests.cs new file mode 100644 index 0000000..5468e96 --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Tests/Src/TableTests.cs @@ -0,0 +1,216 @@ +using Microsoft.AspNetCore.Mvc; +using NimbleFlow.Contracts.DTOs.Tables; +using NimbleFlow.Tests.Base; +using NimbleFlow.Tests.Helpers; + +namespace NimbleFlow.Tests; + +public class TableTests : TestBase +{ + #region Create + + [Fact] + public async Task Create_Table_ShouldReturnCreatedResult() + { + // Arrange + var tableDto = new CreateTableDto("Accountable A") + { + IsFullyPaid = false + }; + + // Act + var actionResult = await TableController.CreateTable(tableDto); + + // Assert + Assert.True(actionResult is CreatedResult); + } + + #endregion + + #region Get All Paginated + + [Fact] + public async Task GetAll_TablesPaginated_ShouldReturnOkObjectResult() + { + // Arrange + _ = await TableController.CreateTableTestHelper("Table A"); + + // Act + var actionResult = await TableController.GetAllTablesPaginated(); + + // Assert + Assert.True(actionResult is OkObjectResult); + } + + [Fact] + public async Task GetAll_TablesPaginated_ShouldReturnNoContentResult() + { + // Arrange + // Act + var actionResult = await TableController.GetAllTablesPaginated(); + + // Assert + Assert.True(actionResult is NoContentResult); + } + + #endregion + + #region Get By Ids + + [Fact] + public async Task Get_TablesByIds_ShouldReturnOkObjectResult() + { + // Arrange + var createdTables = await TableController.CreateManyTablesTestHelper( + "Table A", + "Table B", + "Table C" + ); + var tablesIds = createdTables.Select(x => x.Id).ToArray(); + + // Act + var actionResult = await TableController.GetTablesByIds(tablesIds); + + // Assert + Assert.True(actionResult is OkObjectResult); + } + + [Fact] + public async Task Get_TablesByIds_ShouldReturnNotFoundResult() + { + // Arrange + var tablesIds = new[] + { + Guid.NewGuid(), + Guid.NewGuid() + }; + + // Act + var actionResult = await TableController.GetTablesByIds(tablesIds); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion + + #region Get By Id + + [Fact] + public async Task Get_TableById_ShouldReturnOkObjectResult() + { + // Arrange + var createdTable = await TableController.CreateTableTestHelper("Table A"); + + // Act + var actionResult = await TableController.GetTableById(createdTable.Id); + + // Assert + Assert.True(actionResult is OkObjectResult); + } + + [Fact] + public async Task Get_TableById_ShouldReturnNotFound() + { + // Arrange + var tableId = Guid.NewGuid(); + + // Act + var actionResult = await TableController.GetTableById(tableId); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion + + #region Update By Id + + [Fact] + public async Task Update_TableById_ShouldReturnOkResult() + { + // Arrange + var createdTable = await TableController.CreateTableTestHelper("Table A"); + var updateTableDto = new UpdateTableDto + { + Accountable = "Table A Updated" + }; + + // Act + var actionResult = await TableController.UpdateTableById(createdTable.Id, updateTableDto); + + // Assert + Assert.True(actionResult is OkResult); + } + + #endregion + + #region Delete By Ids + + [Fact] + public async Task Delete_TablesByIds_ShouldReturnOkResult() + { + // Arrange + var createdTables = await TableController.CreateManyTablesTestHelper( + "Table A", + "Table B", + "Table C" + ); + var tablesIds = createdTables.Select(x => x.Id).ToArray(); + + // Act + var actionResult = await TableController.DeleteTablesByIds(tablesIds); + + // Assert + Assert.True(actionResult is OkResult); + } + + [Fact] + public async Task Delete_TablesByIds_ShouldReturnNotFoundResult() + { + // Arrange + var tablesIds = new[] + { + Guid.NewGuid(), + Guid.NewGuid() + }; + + // Act + var actionResult = await TableController.DeleteTablesByIds(tablesIds); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion + + #region Delete By Id + + [Fact] + public async Task Delete_TableById_ShouldReturnOkResult() + { + // Arrange + var createdTable = await TableController.CreateTableTestHelper("Table A"); + + // Act + var actionResult = await TableController.DeleteTableById(createdTable.Id); + + // Assert + Assert.True(actionResult is OkResult); + } + + [Fact] + public async Task Delete_TableById_ShouldReturnNotFoundResult() + { + // Arrange + var tableId = Guid.NewGuid(); + + // Act + var actionResult = await TableController.DeleteTableById(tableId); + + // Assert + Assert.True(actionResult is NotFoundResult); + } + + #endregion +} \ No newline at end of file diff --git a/AspDotNetCore/Src/NimbleFlow.Tests/Usings.cs b/AspDotNetCore/Src/NimbleFlow.Tests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/AspDotNetCore/Src/NimbleFlow.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/AspDotNetCoreHub/NimbleFlowHub.sln b/AspDotNetCoreHub/NimbleFlowHub.sln new file mode 100644 index 0000000..ae244b6 --- /dev/null +++ b/AspDotNetCoreHub/NimbleFlowHub.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NimbleFlowHub.Api", "Src\NimbleFlowHub.Api\NimbleFlowHub.Api.csproj", "{12B1315B-AF2D-4573-ABD8-86A270B1DFE1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NimbleFlowHub.Contracts", "Src\NimbleFlowHub.Contracts\NimbleFlowHub.Contracts.csproj", "{0F5F968B-100C-4891-981E-B4A9F8699C8F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {12B1315B-AF2D-4573-ABD8-86A270B1DFE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {12B1315B-AF2D-4573-ABD8-86A270B1DFE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {12B1315B-AF2D-4573-ABD8-86A270B1DFE1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {12B1315B-AF2D-4573-ABD8-86A270B1DFE1}.Release|Any CPU.Build.0 = Release|Any CPU + {0F5F968B-100C-4891-981E-B4A9F8699C8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F5F968B-100C-4891-981E-B4A9F8699C8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F5F968B-100C-4891-981E-B4A9F8699C8F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F5F968B-100C-4891-981E-B4A9F8699C8F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/.dockerignore b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/.dockerignore new file mode 100644 index 0000000..a79cbcd --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/.dockerignore @@ -0,0 +1,29 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +#**/*[Tt]est* +#**/*[Tt]est*/* +#*[Tt]est* +#*[Tt]est*/* +LICENSE +README.md \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Controllers/HealthCheckController.cs b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Controllers/HealthCheckController.cs new file mode 100644 index 0000000..2aef392 --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Controllers/HealthCheckController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace NimbleFlowHub.Api.Controllers; + +[ApiController] +[Route("[controller]")] +public class HealthCheckController : ControllerBase +{ + /// Checks if service is healthy + /// Ok + [HttpGet] + public Task GetHealthCheckStatus() => Task.FromResult(Ok()); +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Dockerfile b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Dockerfile new file mode 100644 index 0000000..33ad36b --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build + +ENV PROTOBUF_PROTOC=/usr/bin/protoc +ENV GRPC_PROTOC_PLUGIN=/usr/bin/grpc_csharp_plugin +ENV gRPC_PluginFullPath=/usr/bin/grpc_csharp_plugin +RUN apk add protobuf protobuf-dev grpc grpc-plugins + +WORKDIR /build + +COPY *.sln ./ +COPY ./Src ./Src + +RUN dotnet restore "Src/NimbleFlowHub.Api/NimbleFlowHub.Api.csproj" +RUN dotnet publish "Src/NimbleFlowHub.Api/NimbleFlowHub.Api.csproj" -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "NimbleFlowHub.Api.dll"] \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Extensions/WebSocketExtensions.cs b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Extensions/WebSocketExtensions.cs new file mode 100644 index 0000000..6084bd5 --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Extensions/WebSocketExtensions.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using Microsoft.AspNetCore.SignalR; + +namespace NimbleFlowHub.Api.Extensions; + +public static partial class GeneralExtensions +{ + public static Task SendJsonAsync( + this IClientProxy clientProxy, + string method, + object? value, + CancellationToken cancellationToken = default + ) => clientProxy.SendAsync( + method, + JsonSerializer.Serialize( + value, + options: new JsonSerializerOptions(JsonSerializerDefaults.Web) + ), + cancellationToken + ); +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Hubs/MainHub.cs b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Hubs/MainHub.cs new file mode 100644 index 0000000..d1a0caa --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Hubs/MainHub.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.SignalR; + +namespace NimbleFlowHub.Api.Hubs; + +public class MainHub : Hub +{ +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/NimbleFlowHub.Api.csproj b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/NimbleFlowHub.Api.csproj new file mode 100644 index 0000000..de07de7 --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/NimbleFlowHub.Api.csproj @@ -0,0 +1,18 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Program.cs b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Program.cs new file mode 100644 index 0000000..2a0382f --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Program.cs @@ -0,0 +1,22 @@ +using NimbleFlowHub.Api.Hubs; +using NimbleFlowHub.Api.Services; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger(); +builder.Host.UseSerilog(); + +builder.Services.AddControllers(); +builder.Services.AddSignalR(); +builder.Services.AddGrpc(); + +var app = builder.Build(); + +app.MapControllers(); +app.MapHub("/main"); +app.MapGrpcService(); +app.MapGrpcService(); +app.MapGrpcService(); + +app.Run(); \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Properties/launchSettings.json b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Properties/launchSettings.json new file mode 100644 index 0000000..3c6c19f --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "NimbleFlowHub": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Services/CategoryHubService.cs b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Services/CategoryHubService.cs new file mode 100644 index 0000000..9e9cb8c --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Services/CategoryHubService.cs @@ -0,0 +1,47 @@ +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.AspNetCore.SignalR; +using NimbleFlowHub.Api.Extensions; +using NimbleFlowHub.Api.Hubs; +using NimbleFlowHub.Contracts; +using CategoryHubPublisherBase = NimbleFlowHub.Contracts.CategoryHubPublisher.CategoryHubPublisherBase; + +namespace NimbleFlowHub.Api.Services; + +public class CategoryHubService : CategoryHubPublisherBase +{ + private readonly IHubContext _hubContext; + + public CategoryHubService(IHubContext hubContext) + { + _hubContext = hubContext; + } + + public override async Task PublishCategoryCreated(PublishCategoryValue request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("CategoryCreated", request); + await _hubContext.Clients.All.SendJsonAsync("ProductCategoryCreated", request); + return new Empty(); + } + + public override async Task PublishCategoryUpdated(PublishCategoryValue request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("CategoryUpdated", request); + await _hubContext.Clients.All.SendJsonAsync("ProductCategoryUpdated", request); + return new Empty(); + } + + public override async Task PublishManyCategoriesDeleted(PublishCategoryIds request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("ManyCategoriesDeleted", request); + await _hubContext.Clients.All.SendJsonAsync("ManyProductsCategoriesDeleted", request); + return new Empty(); + } + + public override async Task PublishCategoryDeleted(PublishCategoryId request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("CategoryDeleted", request); + await _hubContext.Clients.All.SendJsonAsync("ProductCategoryDeleted", request); + return new Empty(); + } +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Services/ProductHubService.cs b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Services/ProductHubService.cs new file mode 100644 index 0000000..bf70f95 --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Services/ProductHubService.cs @@ -0,0 +1,44 @@ +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.AspNetCore.SignalR; +using NimbleFlowHub.Api.Extensions; +using NimbleFlowHub.Api.Hubs; +using NimbleFlowHub.Contracts; +using ProductHubPublisherBase = NimbleFlowHub.Contracts.ProductHubPublisher.ProductHubPublisherBase; + +namespace NimbleFlowHub.Api.Services; + +public class ProductHubService : ProductHubPublisherBase +{ + private readonly IHubContext _hubContext; + + public ProductHubService(IHubContext hubContext) + { + _hubContext = hubContext; + } + + public override async Task PublishProductCreated(PublishProductValue request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("ProductCreated", request); + return new Empty(); + } + + public override async Task PublishProductUpdated(PublishProductValue request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("ProductUpdated", request); + return new Empty(); + } + + public override async Task PublishManyProductsDeleted(PublishProductIds request, + ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("ManyProductsDeleted", request); + return new Empty(); + } + + public override async Task PublishProductDeleted(PublishProductId request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("ProductDeleted", request); + return new Empty(); + } +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Services/TableHubService.cs b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Services/TableHubService.cs new file mode 100644 index 0000000..e8febcc --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/Services/TableHubService.cs @@ -0,0 +1,43 @@ +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.AspNetCore.SignalR; +using NimbleFlowHub.Api.Extensions; +using NimbleFlowHub.Api.Hubs; +using NimbleFlowHub.Contracts; +using TableHubPublisherBase = NimbleFlowHub.Contracts.TableHubPublisher.TableHubPublisherBase; + +namespace NimbleFlowHub.Api.Services; + +public class TableHubService : TableHubPublisherBase +{ + private readonly IHubContext _hubContext; + + public TableHubService(IHubContext hubContext) + { + _hubContext = hubContext; + } + + public override async Task PublishTableCreated(PublishTableValue request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("TableCreated", request); + return new Empty(); + } + + public override async Task PublishTableUpdated(PublishTableValue request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("TableUpdated", request); + return new Empty(); + } + + public override async Task PublishManyTablesDeleted(PublishTableIds request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("ManyTablesDeleted", request); + return new Empty(); + } + + public override async Task PublishTableDeleted(PublishTableId request, ServerCallContext context) + { + await _hubContext.Clients.All.SendJsonAsync("TableDeleted", request); + return new Empty(); + } +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/appsettings.Development.json b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/appsettings.Development.json new file mode 100644 index 0000000..7eb0e23 --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Serilog": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Debug", + "Microsoft.Hosting.Lifetime": "Debug" + } + } +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Api/appsettings.json b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/appsettings.json new file mode 100644 index 0000000..9e3c32b --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Api/appsettings.json @@ -0,0 +1,22 @@ +{ + "Serilog": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http1": { + "Url": "http://*:10504", + "Protocols": "Http1" + }, + "Http2": { + "Url": "http://*:10505", + "Protocols": "Http2" + } + } + } +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/NimbleFlowHub.Contracts.csproj b/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/NimbleFlowHub.Contracts.csproj new file mode 100644 index 0000000..d3109f5 --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/NimbleFlowHub.Contracts.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/Protos/categoryHubPublisher.proto b/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/Protos/categoryHubPublisher.proto new file mode 100644 index 0000000..df9dd81 --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/Protos/categoryHubPublisher.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +option csharp_namespace = "NimbleFlowHub.Contracts"; + +import "google/protobuf/empty.proto"; + +package categoryHubPublisher; + +service CategoryHubPublisher { + rpc PublishCategoryCreated (PublishCategoryValue) returns (google.protobuf.Empty); + rpc PublishCategoryUpdated (PublishCategoryValue) returns (google.protobuf.Empty); + rpc PublishManyCategoriesDeleted (PublishCategoryIds) returns (google.protobuf.Empty); + rpc PublishCategoryDeleted (PublishCategoryId) returns (google.protobuf.Empty); +} + +message PublishCategoryIds { + repeated string ids = 1; +} + +message PublishCategoryId { + string id = 1; +} + +message PublishCategoryValue { + string id = 1; + string title = 2; + int32 colorTheme = 3; + int32 categoryIcon = 4; +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/Protos/productHubPublisher.proto b/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/Protos/productHubPublisher.proto new file mode 100644 index 0000000..6f95c0f --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/Protos/productHubPublisher.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +option csharp_namespace = "NimbleFlowHub.Contracts"; + +import "google/protobuf/empty.proto"; + +package productHubPublisher; + +service ProductHubPublisher { + rpc PublishProductCreated (PublishProductValue) returns (google.protobuf.Empty); + rpc PublishProductUpdated (PublishProductValue) returns (google.protobuf.Empty); + rpc PublishManyProductsDeleted (PublishProductIds) returns (google.protobuf.Empty); + rpc PublishProductDeleted (PublishProductId) returns (google.protobuf.Empty); +} + +message PublishProductIds { + repeated string ids = 1; +} + +message PublishProductId { + string id = 1; +} + +message PublishProductValue { + string id = 1; + string title = 2; + string description = 3; + float price = 4; + string image_url = 5; + bool is_favorite = 6; + string category_id = 7; +} \ No newline at end of file diff --git a/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/Protos/tableHubPublisher.proto b/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/Protos/tableHubPublisher.proto new file mode 100644 index 0000000..5360317 --- /dev/null +++ b/AspDotNetCoreHub/Src/NimbleFlowHub.Contracts/Protos/tableHubPublisher.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +option csharp_namespace = "NimbleFlowHub.Contracts"; + +import "google/protobuf/empty.proto"; + +package tableHubPublisher; + +service TableHubPublisher { + rpc PublishTableCreated (PublishTableValue) returns (google.protobuf.Empty); + rpc PublishTableUpdated (PublishTableValue) returns (google.protobuf.Empty); + rpc PublishManyTablesDeleted (PublishTableIds) returns (google.protobuf.Empty); + rpc PublishTableDeleted (PublishTableId) returns (google.protobuf.Empty); +} + +message PublishTableIds { + repeated string ids = 1; +} + +message PublishTableId { + string id = 1; +} + +message PublishTableValue { + string id = 1; + string accountable = 2; + bool is_fully_paid = 3; +} \ No newline at end of file diff --git a/SpringBoot/.gitignore b/SpringBoot/.gitignore new file mode 100644 index 0000000..6e88100 --- /dev/null +++ b/SpringBoot/.gitignore @@ -0,0 +1,4 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ \ No newline at end of file diff --git a/SpringBoot/.postman/NimbleFlow Spring Boot API.postman_collection.json b/SpringBoot/.postman/NimbleFlow Spring Boot API.postman_collection.json new file mode 100644 index 0000000..794de79 --- /dev/null +++ b/SpringBoot/.postman/NimbleFlow Spring Boot API.postman_collection.json @@ -0,0 +1,463 @@ +{ + "info": { + "_postman_id": "245b4748-4fda-436c-b9e4-d5fe0f43ab61", + "name": "NimbleFlow Spring Boot API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "27841879" + }, + "item": [ + { + "name": "Order Controller", + "item": [ + { + "name": "Save order", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate the save order endpoint\", function() {\r", + " pm.response.to.have.status(201);\r", + " pm.response.to.have.jsonBody\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"tableId\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\r\n \"createdAt\": \"2023-05-10T00:36:25.970Z\",\r\n \"paymentMethod\": \"CASH\",\r\n \"products\": [\r\n {\r\n \"id\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\r\n \"amount\": 5\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_api_path}}/api/v1/orders", + "host": [ + "{{base_api_path}}" + ], + "path": [ + "api", + "v1", + "orders" + ] + } + }, + "response": [] + }, + { + "name": "Get orders by tableId", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate the get orders by tableId endpoint\", function() {\r", + " pm.expect(pm.response.code).to.be.oneOf([200, 204])\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_api_path}}/api/v1/orders/{{table_id}}?getDeletedOrders=false", + "host": [ + "{{base_api_path}}" + ], + "path": [ + "api", + "v1", + "orders", + "{{table_id}}" + ], + "query": [ + { + "key": "getDeletedOrders", + "value": "false" + } + ] + } + }, + "response": [] + }, + { + "name": "Get orders by interval", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate the get orders by interval endpoint\", function() {\r", + " pm.expect(pm.response.code).to.be.oneOf([200, 204])\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_api_path}}/api/v1/orders/interval?starDate=2023-05-10T00%3A12%3A20.214Z&endDate=2023-05-14T00%3A12%3A20.214Z&getDeletedOrders=false", + "host": [ + "{{base_api_path}}" + ], + "path": [ + "api", + "v1", + "orders", + "interval" + ], + "query": [ + { + "key": "starDate", + "value": "2023-05-10T00%3A12%3A20.214Z" + }, + { + "key": "endDate", + "value": "2023-05-14T00%3A12%3A20.214Z" + }, + { + "key": "getDeletedOrders", + "value": "false" + } + ] + } + }, + "response": [] + }, + { + "name": "Update order", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate the update endpoint\", function() {\r", + " pm.response.to.have.status(200);\r", + " pm.response.to.have.jsonBody\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"id\": \"bac24144-2891-466a-ba5a-fd20faa04811\",\r\n \"paymentMethod\": \"PIX\",\r\n \"products\": [\r\n {\r\n \"id\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\r\n \"amount\": 10\r\n }\r\n ],\r\n \"active\": true\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_api_path}}/api/v1/orders", + "host": [ + "{{base_api_path}}" + ], + "path": [ + "api", + "v1", + "orders" + ] + } + }, + "response": [] + }, + { + "name": "Delete order by id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate the delete order by id endpoint\", function() {\r", + " pm.expect(pm.response.code).to.be.oneOf([200, 204])\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_api_path}}/api/v1/orders/{{order_id}}", + "host": [ + "{{base_api_path}}" + ], + "path": [ + "api", + "v1", + "orders", + "{{order_id}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete order by tableId", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate the delete order by tableId endpoint\", function() {\r", + " pm.expect(pm.response.code).to.be.oneOf([200, 204])\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_api_path}}/api/v1/orders/table-id/{{table_id}}", + "host": [ + "{{base_api_path}}" + ], + "path": [ + "api", + "v1", + "orders", + "table-id", + "{{table_id}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Report Controller", + "item": [ + { + "name": "Get top sold products", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate the endpoint for getting top sold products report\", function() {\r", + " pm.expect(pm.response.code).to.be.oneOf([200, 204])\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_api_path}}/api/v1/report/products/top-sold?getDeletedOrders=false&maxProducts=10", + "host": [ + "{{base_api_path}}" + ], + "path": [ + "api", + "v1", + "report", + "products", + "top-sold" + ], + "query": [ + { + "key": "getDeletedOrders", + "value": "false" + }, + { + "key": "maxProducts", + "value": "10" + } + ] + } + }, + "response": [] + }, + { + "name": "Get orders month report", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate the endpoint for getting orders month report\", function() {\r", + " pm.expect(pm.response.code).to.be.oneOf([200, 204])\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_api_path}}/api/v1/report/orders/month-report?getDeletedOrders=false", + "host": [ + "{{base_api_path}}" + ], + "path": [ + "api", + "v1", + "report", + "orders", + "month-report" + ], + "query": [ + { + "key": "getDeletedOrders", + "value": "false" + } + ] + } + }, + "response": [] + }, + { + "name": "Get orders report by interval", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate the endpoint for getting report by interval\", function() {\r", + " pm.expect(pm.response.code).to.be.oneOf([200, 204])\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_api_path}}/api/v1/report/orders/interval?getDeletedOrders=false&startDate=2023-05-01T00%3A12%3A20.214Z&endDate=2023-05-31T00%3A12%3A20.214Z", + "host": [ + "{{base_api_path}}" + ], + "path": [ + "api", + "v1", + "report", + "orders", + "interval" + ], + "query": [ + { + "key": "getDeletedOrders", + "value": "false" + }, + { + "key": "startDate", + "value": "2023-05-01T00%3A12%3A20.214Z" + }, + { + "key": "endDate", + "value": "2023-05-31T00%3A12%3A20.214Z" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "API Health Check", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Validate the health of the API\", function() {\r", + " pm.response.to.have.status(200)\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_api_path}}/actuator/health", + "host": [ + "{{base_api_path}}" + ], + "path": [ + "actuator", + "health" + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "base_api_path", + "value": "http://localhost:10507/nimbleflow-api", + "type": "string" + }, + { + "key": "table_id", + "value": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "type": "string" + }, + { + "key": "order_id", + "value": "bac24144-2891-466a-ba5a-fd20faa04811", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/SpringBoot/Dockerfile b/SpringBoot/Dockerfile new file mode 100644 index 0000000..f0247a5 --- /dev/null +++ b/SpringBoot/Dockerfile @@ -0,0 +1,34 @@ +FROM maven:3.9.2-eclipse-temurin-17-alpine AS build + +WORKDIR /build +COPY . . + +RUN mvn clean package -DskipTests + +FROM maven:3.9.2-eclipse-temurin-17-alpine AS custom-jre + +# required for strip-debug to work +RUN apk add --no-cache binutils + +# Build small JRE image +RUN ${JAVA_HOME}/bin/jlink \ + --verbose \ + --add-modules ALL-MODULE-PATH \ + --strip-debug \ + --no-man-pages \ + --no-header-files \ + --compress=2 \ + --output /jre + +FROM alpine:latest + +ENV JAVA_HOME=/jre +ENV PATH="${JAVA_HOME}/bin:${PATH}" + +# copy JRE from the custom-jre builder +COPY --from=custom-jre /jre/ $JAVA_HOME + +WORKDIR /app + +COPY --from=build /build/target/nimble-flow-backend-3.0.5.jar /app/nimble-flow-backend-3.0.5.jar +ENTRYPOINT ["java", "-jar", "nimble-flow-backend-3.0.5.jar", "--spring.profiles.active=container"] \ No newline at end of file diff --git a/SpringBoot/HELP.md b/SpringBoot/HELP.md new file mode 100644 index 0000000..f74d9ff --- /dev/null +++ b/SpringBoot/HELP.md @@ -0,0 +1,20 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) +* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/docs/3.0.4/maven-plugin/reference/html/) +* [Create an OCI image](https://docs.spring.io/spring-boot/docs/3.0.4/maven-plugin/reference/html/#build-image) +* [Spring Boot DevTools](https://docs.spring.io/spring-boot/docs/3.0.4/reference/htmlsingle/#using.devtools) +* [Spring Web](https://docs.spring.io/spring-boot/docs/3.0.4/reference/htmlsingle/#web) +* [Spring Data JPA](https://docs.spring.io/spring-boot/docs/3.0.4/reference/htmlsingle/#data.sql.jpa-and-spring-data) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) +* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) + diff --git a/SpringBoot/mvnw b/SpringBoot/mvnw new file mode 100644 index 0000000..8a8fb22 --- /dev/null +++ b/SpringBoot/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/SpringBoot/mvnw.cmd b/SpringBoot/mvnw.cmd new file mode 100644 index 0000000..1d8ab01 --- /dev/null +++ b/SpringBoot/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/SpringBoot/pom.xml b/SpringBoot/pom.xml new file mode 100644 index 0000000..4eb0786 --- /dev/null +++ b/SpringBoot/pom.xml @@ -0,0 +1,146 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.0.5 + + + com.nimbleflow + nimble-flow-backend + nimble-flow-backend + Nimbleflow API + + 17 + com.nimbleflow.api.ApiApplication + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + com.amazonaws + aws-java-sdk-dynamodb + 1.12.440 + + + com.github.derjust + spring-data-dynamodb + 5.1.0 + + + org.projectlombok + lombok + 1.18.26 + provided + + + org.springframework + spring-web + 6.0.6 + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.1.0 + + + org.modelmapper + modelmapper + 3.1.1 + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + org.apache.commons + commons-csv + 1.10.0 + + + org.apache.logging.log4j + log4j-core + 2.20.0 + + + org.mockito + mockito-junit-jupiter + 5.3.1 + test + + + org.junit.jupiter + junit-jupiter-api + 5.10.0-M1 + test + + + software.amazon.awssdk + dynamodb-enhanced + 2.20.83 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/ApiApplication.java b/SpringBoot/src/main/java/com/nimbleflow/api/ApiApplication.java new file mode 100644 index 0000000..4f57655 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/ApiApplication.java @@ -0,0 +1,12 @@ +package com.nimbleflow.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; + +@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) +public class ApiApplication { + public static void main(String[] args) { + SpringApplication.run(ApiApplication.class, args); + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/config/CORSConfig.java b/SpringBoot/src/main/java/com/nimbleflow/api/config/CORSConfig.java new file mode 100644 index 0000000..c943dac --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/config/CORSConfig.java @@ -0,0 +1,27 @@ +package com.nimbleflow.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.web.cors.*; + +import java.util.Arrays; + +@Configuration +public class CORSConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedMethods(Arrays.asList( + HttpMethod.GET.name(), + HttpMethod.POST.name(), + HttpMethod.DELETE.name(), + HttpMethod.PUT.name() + )); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/config/DynamoDBConfig.java b/SpringBoot/src/main/java/com/nimbleflow/api/config/DynamoDBConfig.java new file mode 100644 index 0000000..b127b8b --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/config/DynamoDBConfig.java @@ -0,0 +1,58 @@ +package com.nimbleflow.api.config; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration; +import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverter; +import org.socialsignin.spring.data.dynamodb.repository.config.EnableDynamoDBRepositories; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.ZonedDateTime; + +@Configuration +@EnableDynamoDBRepositories(basePackages = "com.nimbleflow.api") +public class DynamoDBConfig { + @Value("${amazon.dynamodb.endpoint}") + private String amazonDynamoDBEndpoint; + + @Value("${amazon.dynamodb.region}") + private String amazonDynamoDBRegion; + + @Value("${amazon.aws.accesskey}") + private String amazonAWSAccessKey; + + @Value("${amazon.aws.secretkey}") + private String amazonAWSSecretKey; + + @Bean + public AmazonDynamoDB amazonDynamoDB() { + + return AmazonDynamoDBClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(amazonAWSCredentials())) + .withEndpointConfiguration(new EndpointConfiguration(amazonDynamoDBEndpoint, amazonDynamoDBRegion)) + .build(); + } + + @Bean + public AWSCredentials amazonAWSCredentials() { + return new BasicAWSCredentials(amazonAWSAccessKey, amazonAWSSecretKey); + } + + static public class ZonedDateTimeConverter implements DynamoDBTypeConverter { + @Override + public String convert(final ZonedDateTime time) { + return time.toString(); + } + + @Override + public ZonedDateTime unconvert(final String stringValue) { + return ZonedDateTime.parse(stringValue); + } + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/config/ExceptionHandlerConfig.java b/SpringBoot/src/main/java/com/nimbleflow/api/config/ExceptionHandlerConfig.java new file mode 100644 index 0000000..f478ccd --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/config/ExceptionHandlerConfig.java @@ -0,0 +1,92 @@ +package com.nimbleflow.api.config; + +import com.nimbleflow.api.exception.BadRequestException; +import com.nimbleflow.api.exception.UnauthorizedException; +import com.nimbleflow.api.exception.response.BaseExceptionResponse; +import io.jsonwebtoken.security.SignatureException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Slf4j +@ControllerAdvice +public class ExceptionHandlerConfig { + @ExceptionHandler({BadRequestException.class}) + @ResponseBody + public ResponseEntity badRequestHandler(HttpServletRequest request, Exception exception) { + BaseExceptionResponse responseBody = BaseExceptionResponse.builder() + .timestamp(ZonedDateTime.now()) + .status(400) + .error("Bad Request") + .message(exception.getMessage()) + .path(request.getServletPath()) + .build(); + + log.error(String.format("%s: %s (%s)", exception.getCause(), exception.getMessage(), Arrays.toString(exception.getStackTrace()))); + return new ResponseEntity<>(responseBody, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseBody + public ResponseEntity methodArgumentNotValidHandler(HttpServletRequest request, MethodArgumentNotValidException exception) { + List errors = exception.getBindingResult().getFieldErrors(); + + List errorList = new ArrayList(); + + for (FieldError error : errors) { + errorList.add(String.format("%s %s", error.getField(), error.getDefaultMessage())); + } + + BaseExceptionResponse responseBody = BaseExceptionResponse.builder() + .timestamp(ZonedDateTime.now()) + .status(400) + .error("Bad Request") + .message(errorList.toString()) + .path(request.getServletPath()) + .build(); + + log.error(String.format("%s: %s (%s)", exception.getCause(), exception.getMessage(), Arrays.toString(exception.getStackTrace()))); + return new ResponseEntity<>(responseBody, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler({UnauthorizedException.class, SignatureException.class}) + @ResponseBody + public ResponseEntity unauthorizedHandler(HttpServletRequest request, Exception exception) { + BaseExceptionResponse responseBody = BaseExceptionResponse.builder() + .timestamp(ZonedDateTime.now()) + .status(401) + .error("Unauthorized") + .message(UnauthorizedException.MESSAGE) + .path(request.getServletPath()) + .build(); + + log.error(String.format("%s: %s (%s)", exception.getCause(), exception.getMessage(), Arrays.toString(exception.getStackTrace()))); + return new ResponseEntity<>(responseBody, HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(Exception.class) + @ResponseBody + public ResponseEntity defaultHandler(HttpServletRequest request, Exception exception) { + BaseExceptionResponse responseBody = BaseExceptionResponse.builder() + .timestamp(ZonedDateTime.now()) + .status(500) + .error("Internal Server Error") + .message(exception.getMessage()) + .path(request.getServletPath()) + .build(); + + log.error(String.format("%s: %s (%s)", exception.getCause(), exception.getMessage(), Arrays.toString(exception.getStackTrace()))); + return new ResponseEntity<>(responseBody, HttpStatus.INTERNAL_SERVER_ERROR); + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/config/ModelMapperConfig.java b/SpringBoot/src/main/java/com/nimbleflow/api/config/ModelMapperConfig.java new file mode 100644 index 0000000..2e7f535 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/config/ModelMapperConfig.java @@ -0,0 +1,21 @@ +package com.nimbleflow.api.config; + +import org.modelmapper.Conditions; +import org.modelmapper.ModelMapper; +import org.modelmapper.convention.MatchingStrategies; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ModelMapperConfig { + @Bean + public ModelMapper modelMapper() { + ModelMapper modelMapper = new ModelMapper(); + modelMapper.getConfiguration() + .setPropertyCondition(Conditions.isNotNull()) + .setMatchingStrategy(MatchingStrategies.STRICT) + .setSkipNullEnabled(true); + + return modelMapper; + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/config/OpenAPIConfig.java b/SpringBoot/src/main/java/com/nimbleflow/api/config/OpenAPIConfig.java new file mode 100644 index 0000000..dbcb7da --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/config/OpenAPIConfig.java @@ -0,0 +1,24 @@ +package com.nimbleflow.api.config; + +import io.swagger.v3.oas.models.ExternalDocumentation; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenAPIConfig { + @Bean + public OpenAPI openApi() { + return new OpenAPI() + .info(new Info() + .title("NimbleFlow - Products Reports API") + .description("NimbleFlow Spring-Boot API to generate products reports") + .version("v1.0.0") + .license(new License().name("MIT"))) + .externalDocs(new ExternalDocumentation() + .description("NimbleFlow Documentation") + .url("https://github.com/TI-TecnicamenteIdiotas/order-flow-backend")); + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/Order.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/Order.java new file mode 100644 index 0000000..1934d16 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/Order.java @@ -0,0 +1,48 @@ +package com.nimbleflow.api.domain.order; + +import com.amazonaws.services.dynamodbv2.datamodeling.*; +import com.nimbleflow.api.config.DynamoDBConfig.ZonedDateTimeConverter; +import com.nimbleflow.api.domain.order.enums.PaymentMethod; +import com.nimbleflow.api.domain.product.ProductDTO; +import com.nimbleflow.api.utils.dynamodb.converters.ListProductDTOConverter; +import com.nimbleflow.api.utils.dynamodb.converters.UUIDConverter; +import lombok.*; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +@Data +@Builder +@ToString +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +@DynamoDBTable(tableName = "order") +public class Order { + + @DynamoDBHashKey + @DynamoDBTypeConverted(converter = UUIDConverter.class) + @DynamoDBAutoGeneratedKey + private UUID id; + + @DynamoDBAttribute + @DynamoDBTypeConverted(converter = UUIDConverter.class) + private UUID tableId; + + @DynamoDBAttribute + @DynamoDBTypeConverted(converter = ZonedDateTimeConverter.class) + private ZonedDateTime createdAt; + + @DynamoDBAttribute + @DynamoDBTypeConvertedEnum + private PaymentMethod paymentMethod; + + @DynamoDBAttribute + @DynamoDBTypeConverted(converter = ListProductDTOConverter.class) + private List products; + + @DynamoDBAttribute + private boolean active; + +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderController.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderController.java new file mode 100644 index 0000000..83c1146 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderController.java @@ -0,0 +1,126 @@ +package com.nimbleflow.api.domain.order; + +import com.nimbleflow.api.exception.response.example.ExceptionResponseExample; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +@Slf4j +@RestController +@RequiredArgsConstructor +@Tag(name = "Order Controller") +@ApiResponse( + responseCode = "401", + description = "Unauthorized", + content = @Content(schema = @Schema(implementation = ExceptionResponseExample.UnauthorizedException.class)) +) +@RequestMapping(value = "api/v1/orders", produces = MediaType.APPLICATION_JSON_VALUE) +public class OrderController { + + private final OrderService orderService; + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation(description = "Save order information") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Created"), + @ApiResponse( + responseCode = "400", + description = "Bad Request (thrown when dto has invalid (null, empty) parameters)", + content = @Content(schema = @Schema(implementation = ExceptionResponseExample.BadRequestException.class)) + ) + }) + public ResponseEntity saveOrder(@RequestBody @Validated OrderDTO dto) { + log.info(String.format("Saving order: %s", dto)); + OrderDTO body = orderService.saveOrder(dto); + return new ResponseEntity<>(body, HttpStatus.CREATED); + } + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation(description = "Update order information") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse( + responseCode = "400", + description = "Bad Request (thrown when dto has invalid (null, empty) parameters)", + content = @Content(schema = @Schema(implementation = ExceptionResponseExample.BadRequestException.class)) + ) + }) + public ResponseEntity updateOrder(@RequestBody OrderDTO dto) { + log.info(String.format("Updating order: %s", dto)); + OrderDTO body = orderService.updateOrderById(dto); + return new ResponseEntity<>(body, HttpStatus.OK); + } + + @GetMapping("{tableId}") + @Operation(description = "Find orders by tableId") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok"), + @ApiResponse(responseCode = "204", description = "No Content") + }) + public ResponseEntity> findOrdersByTableId( + @PathVariable UUID tableId, + @RequestParam(value = "getDeletedOrders", required = false) boolean getDeletedOrders + ) { + log.info(String.format("Find orders by tableId: %s; getDeletedOrders: %s", tableId, getDeletedOrders)); + List body = orderService.findOrdersByTableId(tableId, getDeletedOrders); + HttpStatus httpStatus = body.isEmpty() ? HttpStatus.NO_CONTENT : HttpStatus.OK; + return new ResponseEntity<>(body, httpStatus); + } + + @DeleteMapping("table-id/{tableId}") + @Operation(description = "Delete orders by tableId (logical exclusion)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok"), + @ApiResponse(responseCode = "204", description = "No Content") + }) + public ResponseEntity> deleteOrdersByTableId(@PathVariable UUID tableId) { + log.info(String.format("Delete orders by tableId: %s", tableId)); + List body = orderService.deleteOrdersByTableId(tableId); + HttpStatus httpStatus = body.isEmpty() ? HttpStatus.NO_CONTENT : HttpStatus.OK; + return new ResponseEntity<>(body, httpStatus); + } + + @DeleteMapping("{id}") + @Operation(description = "Delete orders by tableId (logical exclusion)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok"), + @ApiResponse(responseCode = "204", description = "No Content") + }) + public ResponseEntity deleteOrderById(@PathVariable UUID id) { + log.info(String.format("Delete order by id: %s", id)); + OrderDTO body = orderService.deleteOrderById(id); + HttpStatus httpStatus = body != null ? HttpStatus.OK : HttpStatus.NO_CONTENT; + return new ResponseEntity<>(body, httpStatus); + } + + @GetMapping("/interval") + @Operation(description = "Find orders by interval") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok"), + @ApiResponse(responseCode = "204", description = "No Content") + }) + public ResponseEntity> findOrdersByInterval( + @RequestParam(value = "getDeletedOrders", required = false) boolean getDeletedOrders, + @RequestParam(value = "starDate") ZonedDateTime startDate, + @RequestParam(value = "endDate") ZonedDateTime endDate + ) { + log.info(String.format("Find orders by interval: %s, %s; getDeletedOrders: %s", startDate, endDate, getDeletedOrders)); + List body = orderService.findOrdersByInterval(startDate, endDate, getDeletedOrders); + HttpStatus httpStatus = body.isEmpty() ? HttpStatus.NO_CONTENT : HttpStatus.OK; + return new ResponseEntity<>(body, httpStatus); + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderDTO.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderDTO.java new file mode 100644 index 0000000..066be93 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderDTO.java @@ -0,0 +1,35 @@ +package com.nimbleflow.api.domain.order; + +import com.nimbleflow.api.domain.order.enums.PaymentMethod; +import com.nimbleflow.api.domain.product.ProductDTO; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +@Data +@Builder +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +public class OrderDTO { + + private UUID id; + + @NotNull + private UUID tableId; + + @NotNull + private ZonedDateTime createdAt; + + @NotNull + private PaymentMethod paymentMethod; + + @NotNull + private List products; + + private Boolean active; + +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderRepository.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderRepository.java new file mode 100644 index 0000000..19575fb --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderRepository.java @@ -0,0 +1,28 @@ +package com.nimbleflow.api.domain.order; + +import jakarta.validation.constraints.NotNull; +import org.socialsignin.spring.data.dynamodb.repository.EnableScan; +import org.springframework.data.repository.CrudRepository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@EnableScan +public interface OrderRepository extends CrudRepository { + @NotNull + List findAll(); + + Optional findById(UUID id); + + List findByActiveIsTrue(); + + List findByTableId(UUID tableId); + + List findByTableIdAndActiveIsTrue(UUID orderId); + + List findByCreatedAtBetween(ZonedDateTime startDate, ZonedDateTime endDate); + + List findByCreatedAtBetweenAndActiveIsTrue(ZonedDateTime startDate, ZonedDateTime endDate); +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderService.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderService.java new file mode 100644 index 0000000..9cb77b8 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/OrderService.java @@ -0,0 +1,153 @@ +package com.nimbleflow.api.domain.order; + +import com.nimbleflow.api.exception.BadRequestException; +import com.nimbleflow.api.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.modelmapper.ModelMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OrderService { + private final OrderRepository orderRepository; + private ModelMapper modelMapper; + + @Autowired + public void setModelMapper(@Lazy ModelMapper modelMapper) { + this.modelMapper = modelMapper; + } + + public OrderDTO saveOrder(OrderDTO orderDTO) { + if (orderDTO.getId() != null) { + orderDTO.setId(null); + } + + if (orderDTO.getActive() == null) { + orderDTO.setActive(true); + } + + Order order = modelMapper.map(orderDTO, Order.class); + order = orderRepository.save(order); + log.info(String.format("Order saved successfully: %s", order)); + + orderDTO = modelMapper.map(order, OrderDTO.class); + return orderDTO; + } + + @SneakyThrows + public OrderDTO updateOrderById(OrderDTO orderDTO) { + if (orderDTO.getId() == null) + throw new BadRequestException("Please, inform the id of the order you want to update"); + + Order order = findOrderById(orderDTO.getId()) + .orElseThrow(() -> new NotFoundException(String.format("The order with id %s was not found", orderDTO.getId()))); + + modelMapper.map(orderDTO, order); + order = orderRepository.save(order); + log.info(String.format("Order updated successfully: %s", order)); + + return modelMapper.map(order, OrderDTO.class); + } + + public List findOrdersByTableId(UUID tableId, boolean getDeletedOrders) { + List orders; + + if (getDeletedOrders) { + orders = orderRepository.findByTableId(tableId); + } else { + orders = orderRepository.findByTableIdAndActiveIsTrue(tableId); + } + + if (orders.isEmpty()) return new ArrayList<>(); + + List orderDTOS = new ArrayList<>(); + + orders.forEach(order -> { + orderDTOS.add(modelMapper.map(order, OrderDTO.class)); + }); + + return orderDTOS; + } + + public List deleteOrdersByTableId(UUID orderId) { + List orders = orderRepository.findByTableId(orderId); + List orderDTOS = new ArrayList<>(); + + if (orders.isEmpty()) { + return new ArrayList<>(); + } + + orders.forEach(order -> { + order.setActive(false); + order = orderRepository.save(order); + log.info(String.format("Order deleted successfully: %s", order)); + orderDTOS.add(modelMapper.map(order, OrderDTO.class)); + }); + + return orderDTOS; + } + + public OrderDTO deleteOrderById(UUID orderId) { + Order order = orderRepository.findById(orderId).orElse(null); + + if (order == null) return null; + + order.setActive(false); + order = orderRepository.save(order); + log.info(String.format("Order deleted successfully: %s", order)); + + return modelMapper.map(order, OrderDTO.class); + } + + public List getAllMothOrders(boolean getDeletedOrders) { + int dayOfMonth = ZonedDateTime.now().getDayOfMonth(); + int daysToSubtract = (dayOfMonth + 1) - dayOfMonth; + ZonedDateTime startDate = ZonedDateTime.now().minusDays(daysToSubtract); + + return findOrdersByInterval(startDate, ZonedDateTime.now(), getDeletedOrders); + } + + public List findOrdersByInterval(ZonedDateTime startDate, ZonedDateTime endDate, boolean getDeletedOrders) { + List orders; + + if (getDeletedOrders) { + orders = orderRepository.findByCreatedAtBetween(startDate, endDate); + } else { + orders = orderRepository.findByCreatedAtBetweenAndActiveIsTrue(startDate, endDate); + } + + return orders.stream() + .map(order -> modelMapper.map(order, OrderDTO.class)) + .toList(); + } + + public List findAllOrders(boolean getDeletedOrders) { + List orders; + + if (getDeletedOrders) { + orders = orderRepository.findAll(); + } else { + orders = orderRepository.findByActiveIsTrue(); + } + + return orders.stream() + .map(order -> modelMapper.map(order, OrderDTO.class)) + .toList(); + } + + public Optional findOrderById(UUID id) { + return orderRepository.findById(id); + } + +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/enums/PaymentMethod.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/enums/PaymentMethod.java new file mode 100644 index 0000000..be07489 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/order/enums/PaymentMethod.java @@ -0,0 +1,8 @@ +package com.nimbleflow.api.domain.order.enums; + +public enum PaymentMethod { + CASH, + PIX, + CREDIT_CARD, + DEBIT_CARD +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/product/ProductDTO.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/product/ProductDTO.java new file mode 100644 index 0000000..daddabc --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/product/ProductDTO.java @@ -0,0 +1,27 @@ +package com.nimbleflow.api.domain.product; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute; +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverted; +import com.nimbleflow.api.utils.dynamodb.converters.UUIDConverter; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.util.UUID; + +@Data +@Builder +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +public class ProductDTO { + + @NotNull + @DynamoDBAttribute + @DynamoDBTypeConverted(converter = UUIDConverter.class) + private UUID id; + + @NotNull + @DynamoDBAttribute + private Integer amount; + +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/product/ProductService.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/product/ProductService.java new file mode 100644 index 0000000..21f592e --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/product/ProductService.java @@ -0,0 +1,71 @@ +package com.nimbleflow.api.domain.product; + +import com.nimbleflow.api.domain.order.OrderDTO; +import com.nimbleflow.api.domain.order.OrderService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductService { + + private final OrderService orderService; + + public List getTopSoldProducts(Integer maxProducts, boolean getDeletedOrders) { + log.info(String.format("Get top sold products (maxProducts: %s, getDeletedOrders: %s)", maxProducts, getDeletedOrders)); + + List orders = orderService.findAllOrders(getDeletedOrders); + + Map productsIdsCount = new HashMap<>(); + + if (orders.isEmpty()) { + return new ArrayList<>(); + } + + orders.forEach(orderDTO -> { + orderDTO.getProducts().forEach(productDTO -> { + Integer productAmount = productsIdsCount.get(productDTO.getId()); + + if (productAmount == null) { + productsIdsCount.put(productDTO.getId(), productDTO.getAmount()); + } else { + productAmount += productDTO.getAmount(); + productsIdsCount.put(productDTO.getId(), productAmount); + } + }); + }); + + List sortedProductsByAmount = new ArrayList<>(); + + for (Map.Entry entrySet : productsIdsCount.entrySet()) { + sortedProductsByAmount.add(ProductDTO.builder() + .id(entrySet.getKey()) + .amount(entrySet.getValue()) + .build()); + } + + sortedProductsByAmount.sort(Comparator.comparingInt(ProductDTO::getAmount)); + + sortedProductsByAmount = getFilteredProductsByMaxOfProductsArray(sortedProductsByAmount, maxProducts); + + return sortedProductsByAmount; + } + + private List getFilteredProductsByMaxOfProductsArray(List products, Integer maxProducts) { + if (maxProducts != null) { + if (products.size() > maxProducts) { + products = products.subList(0, maxProducts); + } + } else { + if (products.size() > 5) { + products = products.subList(0, 5); + } + } + + return products; + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/ReportController.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/ReportController.java new file mode 100644 index 0000000..270f690 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/ReportController.java @@ -0,0 +1,86 @@ +package com.nimbleflow.api.domain.report; + +import com.nimbleflow.api.domain.order.OrderDTO; +import com.nimbleflow.api.domain.product.ProductDTO; +import com.nimbleflow.api.domain.report.dto.ReportDTO; +import com.nimbleflow.api.exception.response.example.ExceptionResponseExample; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.ZonedDateTime; + +@Slf4j +@RestController +@RequiredArgsConstructor +@Tag(name = "Report Controller") +@ApiResponse( + responseCode = "401", + description = "Unauthorized", + content = @Content(schema = @Schema(implementation = ExceptionResponseExample.UnauthorizedException.class)) +) +@RequestMapping(value = "api/v1/report", produces = MediaType.APPLICATION_JSON_VALUE) +public class ReportController { + + private final ReportService reportService; + + @GetMapping("/orders/month-report") + @Operation(description = "Get orders month report") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok"), + @ApiResponse(responseCode = "204", description = "No Content", content = @Content) + }) + public ResponseEntity> findOrdersByInterval( + @RequestParam(value = "getDeletedOrders", required = false) boolean getDeletedOrders + ) { + log.info(String.format("Getting orders month report, getDeletedOrders: %s", getDeletedOrders)); + ReportDTO responseBody = reportService.getOrdersMonthReport(getDeletedOrders); + HttpStatus httpStatus = !responseBody.getItems().isEmpty() ? HttpStatus.OK : HttpStatus.NO_CONTENT; + return new ResponseEntity<>(responseBody, httpStatus); + } + + @GetMapping("/orders/interval") + @Operation(description = "Get orders report by interval") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok"), + @ApiResponse(responseCode = "204", description = "No Content", content = @Content) + }) + public ResponseEntity> getOrdersReportByInterval( + @RequestParam(value = "getDeletedOrders", required = false) boolean getDeletedOrders, + @RequestParam(value = "startDate") ZonedDateTime startDate, + @RequestParam(value = "endDate") ZonedDateTime endDate + ) { + log.info(String.format("Getting orders report by interval (startDate: %s, endDate: %s, getDeletedOrders: %s)", startDate, endDate, getDeletedOrders)); + ReportDTO responseBody = reportService.getOrdersReportByInterval(startDate, endDate, getDeletedOrders); + HttpStatus httpStatus = !responseBody.getItems().isEmpty() ? HttpStatus.OK : HttpStatus.NO_CONTENT; + return new ResponseEntity<>(responseBody, httpStatus); + } + + @GetMapping("/products/top-sold") + @Operation(description = "Get top sol sold products report") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok"), + @ApiResponse(responseCode = "204", description = "No Content", content = @Content) + }) + public ResponseEntity> getTopSoldProductsReport( + @RequestParam(value = "getDeletedOrders", required = false) boolean getDeletedOrders, + @RequestParam(value = "maxProducts", required = false) Integer maxProducts + ) { + log.info(String.format("Getting top sold products report (maxProducts: %s, getDeletedOrders: %s)", maxProducts, getDeletedOrders)); + ReportDTO responseBody = reportService.getTopSoldProductsReport(maxProducts, getDeletedOrders); + HttpStatus httpStatus = !responseBody.getItems().isEmpty() ? HttpStatus.OK : HttpStatus.NO_CONTENT; + return new ResponseEntity<>(responseBody, httpStatus); + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/ReportService.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/ReportService.java new file mode 100644 index 0000000..502175f --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/ReportService.java @@ -0,0 +1,154 @@ +package com.nimbleflow.api.domain.report; + +import com.nimbleflow.api.domain.order.OrderDTO; +import com.nimbleflow.api.domain.order.OrderService; +import com.nimbleflow.api.domain.product.ProductDTO; +import com.nimbleflow.api.domain.product.ProductService; +import com.nimbleflow.api.domain.report.dto.OrderReportDTO; +import com.nimbleflow.api.domain.report.dto.ReportDTO; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReportService { + private final OrderService orderService; + private final ProductService productService; + + public ReportDTO getOrdersMonthReport(boolean getDeletedOrders) { + List orders = orderService.getAllMothOrders(getDeletedOrders); + return getOrderDTOReportDTO(orders); + } + + public ReportDTO getOrdersReportByInterval(ZonedDateTime startDate, ZonedDateTime endDate, boolean getDeletedOrders) { + List orders = orderService.findOrdersByInterval(startDate, endDate, getDeletedOrders); + return getOrderDTOReportDTO(orders); + } + + public ReportDTO getTopSoldProductsReport(Integer maxProducts, boolean getDeletedOrders) { + List products = productService.getTopSoldProducts(maxProducts, getDeletedOrders); + + if (products.isEmpty()) { + return ReportDTO.builder().build(); + } + + Map headersAndRespectiveAttributes = new HashMap<>(); + headersAndRespectiveAttributes.put("Product id", "id"); + headersAndRespectiveAttributes.put("Total amount", "amount"); + + return ReportDTO.builder() + .items(products) + .csv(convertListOfItemsToCSV(products, headersAndRespectiveAttributes)) + .build(); + } + + private ReportDTO getOrderDTOReportDTO(List orders) { + if (orders.isEmpty()) { + return ReportDTO.builder().build(); + } + + Map headersAndRespectiveAttributes = new HashMap<>(); + headersAndRespectiveAttributes.put("Order id", "id"); + headersAndRespectiveAttributes.put("Table id", "tableId"); + headersAndRespectiveAttributes.put("Creation date", "createdAt"); + headersAndRespectiveAttributes.put("Payment method", "paymentMethod"); + headersAndRespectiveAttributes.put("Products ids and amount", "productsIdsAndAmount"); + headersAndRespectiveAttributes.put("Active", "active"); + + List reports = new ArrayList<>(); + + orders.forEach(orderDTO -> { + reports.add(buildOrderReportDTO(orderDTO)); + }); + + return ReportDTO.builder() + .items(orders) + .csv(convertListOfItemsToCSV(reports, headersAndRespectiveAttributes)) + .build(); + } + + private OrderReportDTO buildOrderReportDTO(OrderDTO orderDTO) { + OrderReportDTO reportDTO = OrderReportDTO.builder() + .createdAt(orderDTO.getCreatedAt()) + .id(orderDTO.getId()) + .active(orderDTO.getActive()) + .paymentMethod(orderDTO.getPaymentMethod()) + .tableId(orderDTO.getTableId()) + .build(); + + for (int i = 0; i < orderDTO.getProducts().size(); i++) { + ProductDTO productDTO = orderDTO.getProducts().get(i); + + if (orderDTO.getProducts().size() > 1 && (i + 1) < orderDTO.getProducts().size()) { + reportDTO.setProductsIdsAndAmount(String.format("%s: %s\n", productDTO.getId(), productDTO.getAmount())); + } else { + reportDTO.setProductsIdsAndAmount(String.format("%s: %s", productDTO.getId(), productDTO.getAmount())); + } + } + + return reportDTO; + } + + @SneakyThrows + private String convertListOfItemsToCSV(List items, Map headersAndRespectiveAttributes) { + log.info(String.format("Converting list of items to CSV (items: %s, headersAndRespectiveAttributes: %s)", items, headersAndRespectiveAttributes)); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + OutputStreamWriter writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8); + + CSVFormat csvFormat = CSVFormat.Builder.create().setHeader(headersAndRespectiveAttributes.keySet().toArray(String[]::new)).build(); + CSVPrinter csvPrinter = new CSVPrinter(writer, csvFormat); + List> fieldsAndValues = new ArrayList<>(); + + for (Object item : items) { + Class itemClass = item.getClass(); + + Field[] fields = itemClass.getDeclaredFields(); + HashMap fieldAndValue = new HashMap<>(); + for (Field field : fields) { + field.setAccessible(true); + String fieldName = field.getName(); + Object fieldValue; + try { + fieldValue = field.get(item); + if (fieldValue == null) fieldValue = ""; + fieldAndValue.put(fieldName, fieldValue); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + } + + fieldsAndValues.add(fieldAndValue); + } + + for (Map map : fieldsAndValues) { + List valuesToSetOnReport = new ArrayList<>(); + + for (Map.Entry entrySet : headersAndRespectiveAttributes.entrySet()) { + valuesToSetOnReport.add(map.get(entrySet.getValue())); + } + + csvPrinter.printRecord(valuesToSetOnReport); + } + + csvPrinter.flush(); + csvPrinter.close(); + + return outputStream.toString(); + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/dto/OrderReportDTO.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/dto/OrderReportDTO.java new file mode 100644 index 0000000..df7a30c --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/dto/OrderReportDTO.java @@ -0,0 +1,23 @@ +package com.nimbleflow.api.domain.report.dto; + +import com.nimbleflow.api.domain.order.enums.PaymentMethod; +import lombok.*; + +import java.time.ZonedDateTime; +import java.util.UUID; + +@Data +@Builder +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +public class OrderReportDTO { + + private UUID id; + private UUID tableId; + private ZonedDateTime createdAt; + private PaymentMethod paymentMethod; + private String productsIdsAndAmount; + private boolean active; + +} diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/dto/ReportDTO.java b/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/dto/ReportDTO.java new file mode 100644 index 0000000..2ecdf8b --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/domain/report/dto/ReportDTO.java @@ -0,0 +1,24 @@ +package com.nimbleflow.api.domain.report.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Builder +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +public class ReportDTO { + + @Schema(example = "Deletion date,Creation date,Order id,Table id,Payment method,Products ids and amount\n" + + "\"\",2023-05-12T21:05:59.258452200-03:00[America/Sao_Paulo],00000000-0000-0005-0000-00000000000f,00000000-0000-0007-0000-00000000000f,CASH,00000000-0000-0005-0000-00000000000f: 2\n" + + "\"\",2023-05-12T21:05:59.258452200-03:00[America/Sao_Paulo],00000000-0000-0005-0000-00000000000f,00000000-0000-0007-0000-00000000000f,CASH,00000000-0000-0005-0000-00000000000f: 2\n") + private String csv; + + @Builder.Default + private List items = new ArrayList<>(); + +} diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/exception/BadRequestException.java b/SpringBoot/src/main/java/com/nimbleflow/api/exception/BadRequestException.java new file mode 100644 index 0000000..1f10537 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/exception/BadRequestException.java @@ -0,0 +1,11 @@ +package com.nimbleflow.api.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.BAD_REQUEST) +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/exception/NotFoundException.java b/SpringBoot/src/main/java/com/nimbleflow/api/exception/NotFoundException.java new file mode 100644 index 0000000..dc7c696 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/exception/NotFoundException.java @@ -0,0 +1,12 @@ +package com.nimbleflow.api.exception; + +import jakarta.servlet.ServletException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.UNAUTHORIZED) +public class NotFoundException extends ServletException { + public NotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/exception/UnauthorizedException.java b/SpringBoot/src/main/java/com/nimbleflow/api/exception/UnauthorizedException.java new file mode 100644 index 0000000..25a5c78 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/exception/UnauthorizedException.java @@ -0,0 +1,18 @@ +package com.nimbleflow.api.exception; + +import jakarta.servlet.ServletException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.UNAUTHORIZED) +public class UnauthorizedException extends ServletException { + public static final String MESSAGE = "Empty or invalid Authorization header"; + + public UnauthorizedException() { + super(MESSAGE); + } + + public UnauthorizedException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/exception/response/BaseExceptionResponse.java b/SpringBoot/src/main/java/com/nimbleflow/api/exception/response/BaseExceptionResponse.java new file mode 100644 index 0000000..1fc924e --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/exception/response/BaseExceptionResponse.java @@ -0,0 +1,20 @@ +package com.nimbleflow.api.exception.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BaseExceptionResponse { + private ZonedDateTime timestamp; + private int status; + private String error; + private String message; + private String path; +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/exception/response/example/ExceptionResponseExample.java b/SpringBoot/src/main/java/com/nimbleflow/api/exception/response/example/ExceptionResponseExample.java new file mode 100644 index 0000000..a99d054 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/exception/response/example/ExceptionResponseExample.java @@ -0,0 +1,43 @@ +package com.nimbleflow.api.exception.response.example; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.ZonedDateTime; + +public interface ExceptionResponseExample { + @Getter + public static final class BadRequestException extends BaseExceptionResponseExample { + @Schema(example = "2023-04-30T15:18:24.883+00:00") + private ZonedDateTime timestamp; + + @Schema(example = "400") + private int status; + + @Schema(example = "Bad Request") + private String error; + } + + @Getter + public static final class UnauthorizedException extends BaseExceptionResponseExample { + @Schema(example = "2023-04-30T15:18:24.883+00:00") + private ZonedDateTime timestamp; + + @Schema(example = "401") + private int status; + + @Schema(example = "Unauthorized") + private String error; + } + + @Getter + public static abstract class BaseExceptionResponseExample { + private ZonedDateTime timestamp; + + @Schema(example = "Exception message") + private String message; + + @Schema(example = "request/path") + private String path; + } +} \ No newline at end of file diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/utils/dynamodb/converters/ListProductDTOConverter.java b/SpringBoot/src/main/java/com/nimbleflow/api/utils/dynamodb/converters/ListProductDTOConverter.java new file mode 100644 index 0000000..c748086 --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/utils/dynamodb/converters/ListProductDTOConverter.java @@ -0,0 +1,33 @@ +package com.nimbleflow.api.utils.dynamodb.converters; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbleflow.api.domain.product.ProductDTO; + +import java.io.IOException; +import java.util.List; + +public class ListProductDTOConverter implements DynamoDBTypeConverter> { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convert(List productDTOs) { + try { + return objectMapper.writeValueAsString(productDTOs); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to convert MyObject to JSON string", e); + } + } + + @Override + public List unconvert(String json) { + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to convert JSON string to MyObject", e); + } + } +} diff --git a/SpringBoot/src/main/java/com/nimbleflow/api/utils/dynamodb/converters/UUIDConverter.java b/SpringBoot/src/main/java/com/nimbleflow/api/utils/dynamodb/converters/UUIDConverter.java new file mode 100644 index 0000000..b58102b --- /dev/null +++ b/SpringBoot/src/main/java/com/nimbleflow/api/utils/dynamodb/converters/UUIDConverter.java @@ -0,0 +1,18 @@ +package com.nimbleflow.api.utils.dynamodb.converters; + +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverter; + +import java.util.UUID; + +public class UUIDConverter implements DynamoDBTypeConverter { + + @Override + public String convert(UUID uuid) { + return uuid != null ? uuid.toString() : null; + } + + @Override + public UUID unconvert(String s) { + return s != null ? UUID.fromString(s) : null; + } +} diff --git a/SpringBoot/src/main/resources/application-container.yaml b/SpringBoot/src/main/resources/application-container.yaml new file mode 100644 index 0000000..a2fbab1 --- /dev/null +++ b/SpringBoot/src/main/resources/application-container.yaml @@ -0,0 +1,15 @@ +server: + port: 10507 + error: + include-stacktrace: always + include-message: always + servlet: + context-path: /nimbleflow-api + +amazon: + aws: + accesskey: ${AWS_ACCESS_KEY_ID} + secretkey: ${AWS_SECRET_ACCESS_KEY} + dynamodb: + endpoint: ${NO_SQL_CONNECTION_STRING} + region: ${AWS_REGION} \ No newline at end of file diff --git a/SpringBoot/src/main/resources/application.yaml b/SpringBoot/src/main/resources/application.yaml new file mode 100644 index 0000000..eb34b85 --- /dev/null +++ b/SpringBoot/src/main/resources/application.yaml @@ -0,0 +1,15 @@ +server: + port: 10507 + error: + include-stacktrace: always + include-message: always + servlet: + context-path: /nimbleflow-api + +amazon: + aws: + accesskey: + secretkey: + dynamodb: + endpoint: http://localhost:10501 + region: us-east-1 \ No newline at end of file diff --git a/SpringBoot/src/test/java/com/nimbleflow/api/unit/OrderServiceTest.java b/SpringBoot/src/test/java/com/nimbleflow/api/unit/OrderServiceTest.java new file mode 100644 index 0000000..60d2a13 --- /dev/null +++ b/SpringBoot/src/test/java/com/nimbleflow/api/unit/OrderServiceTest.java @@ -0,0 +1,164 @@ +package com.nimbleflow.api.unit; + +import com.nimbleflow.api.domain.order.Order; +import com.nimbleflow.api.domain.order.OrderDTO; +import com.nimbleflow.api.domain.order.OrderRepository; +import com.nimbleflow.api.domain.order.OrderService; +import com.nimbleflow.api.exception.BadRequestException; +import com.nimbleflow.api.exception.NotFoundException; +import com.nimbleflow.api.utils.ObjectBuilder; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.modelmapper.ModelMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@SpringBootTest +@DisplayName("OrderService") +public class OrderServiceTest { + + @InjectMocks + private OrderService underTest; + + @Mock + private OrderRepository orderRepository; + + @Autowired + private ModelMapper modelMapper; + + @BeforeEach + void setUp() { + underTest.setModelMapper(modelMapper); + } + + @Test + @DisplayName("Validate if saveOrder() saves the order correctly and returns the OrderDTO correctly") + void saveOrderSuccess() { + Order order = ObjectBuilder.buildOrder(); + + Mockito.when(orderRepository.save(Mockito.any(Order.class))) + .thenReturn(order); + + OrderDTO result = underTest.saveOrder(ObjectBuilder.buildOrderDTO()); + + Assertions.assertNotNull(result); + Assertions.assertEquals(order.getId(), result.getId()); + Assertions.assertEquals(order.getTableId(), result.getTableId()); + Assertions.assertEquals(order.getCreatedAt(), result.getCreatedAt()); + Assertions.assertEquals(order.getProducts(), result.getProducts()); + Assertions.assertEquals(order.getPaymentMethod(), result.getPaymentMethod()); + Assertions.assertEquals(order.isActive(), result.getActive()); + } + + @Test + @DisplayName("Validate if updateOrderById() updates the order correctly and returns the OrderDTO correctly") + void updateOrderByIdSuccess() { + Order order = ObjectBuilder.buildOrder(); + + Mockito.when(orderRepository.save(Mockito.any(Order.class))) + .thenReturn(order); + + Mockito.when(orderRepository.findById(Mockito.any(UUID.class))) + .thenReturn(Optional.of(ObjectBuilder.buildOrder())); + + OrderDTO result = underTest.updateOrderById(ObjectBuilder.buildOrderDTO()); + + Assertions.assertNotNull(result); + Assertions.assertEquals(order.getId(), result.getId()); + Assertions.assertEquals(order.getTableId(), result.getTableId()); + Assertions.assertEquals(order.getCreatedAt(), result.getCreatedAt()); + Assertions.assertEquals(order.getProducts(), result.getProducts()); + Assertions.assertEquals(order.getPaymentMethod(), result.getPaymentMethod()); + Assertions.assertEquals(order.isActive(), result.getActive()); + } + + @Test + @DisplayName("Validate if updateOrderById() throws BadRequestException when id is null") + void updateOrderByIdFailure1() { + OrderDTO orderDTO = ObjectBuilder.buildOrderDTO(); + orderDTO.setId(null); + + Throwable throwable = Assertions.assertThrows(BadRequestException.class, () -> { + underTest.updateOrderById(orderDTO); + }); + + Assertions.assertEquals("Please, inform the id of the order you want to update", throwable.getMessage()); + } + + @Test + @DisplayName("Validate if updateOrderById() throws NotFoundException when no data is found") + void updateOrderByIdFailure2() { + OrderDTO orderDTO = ObjectBuilder.buildOrderDTO(); + + Throwable throwable = Assertions.assertThrows(NotFoundException.class, () -> { + underTest.updateOrderById(orderDTO); + }); + + Assertions.assertEquals(String.format("The order with id %s was not found", orderDTO.getId()), throwable.getMessage()); + } + + @Test + @DisplayName("Validate if findOrdersByTableId() returns the expected values") + void findOrdersByTableIdSuccess() { + List orders = ObjectBuilder.buildListOfOrder(); + + Mockito.when(orderRepository.findByTableIdAndActiveIsTrue(Mockito.any(UUID.class))) + .thenReturn(orders); + + List result = underTest.findOrdersByTableId(ObjectBuilder.buildOrder().getTableId(), false); + + Assertions.assertNotNull(result); + Assertions.assertEquals(orders.size(), result.size()); + } + + @Test + @DisplayName("Validate if findOrdersByTableIdSuccess() deletes the orders as expected") + void deleteOrdersByTableIdSuccess() { + List orders = ObjectBuilder.buildListOfOrder(); + + Mockito.when(orderRepository.findByTableId(Mockito.any(UUID.class))) + .thenReturn(orders); + + Order order = ObjectBuilder.buildOrder(); + order.setActive(false); + + Mockito.when(orderRepository.save(Mockito.any(Order.class))) + .thenReturn(order); + + List result = underTest.deleteOrdersByTableId(ObjectBuilder.buildOrder().getTableId()); + + Assertions.assertNotNull(result); + Assertions.assertEquals(orders.size(), result.size()); + + for (OrderDTO orderDTO : result) { + Assertions.assertNotNull(orderDTO.getActive()); + } + } + + @Test + @DisplayName("Validate if findOrdersByIdSuccess() deletes the order as expected") + void deleteOrdersByIdSuccess() { + Mockito.when(orderRepository.findById(Mockito.any(UUID.class))) + .thenReturn(Optional.of(ObjectBuilder.buildOrder())); + + Order order = ObjectBuilder.buildOrder(); + order.setActive(false); + + Mockito.when(orderRepository.save(Mockito.any(Order.class))) + .thenReturn(order); + + OrderDTO result = underTest.deleteOrderById(ObjectBuilder.buildOrder().getTableId()); + + Assertions.assertNotNull(result); + Assertions.assertNotNull(result.getActive()); + } +} \ No newline at end of file diff --git a/SpringBoot/src/test/java/com/nimbleflow/api/unit/ProductServiceTest.java b/SpringBoot/src/test/java/com/nimbleflow/api/unit/ProductServiceTest.java new file mode 100644 index 0000000..5690409 --- /dev/null +++ b/SpringBoot/src/test/java/com/nimbleflow/api/unit/ProductServiceTest.java @@ -0,0 +1,83 @@ +package com.nimbleflow.api.unit; + +import com.nimbleflow.api.domain.order.OrderDTO; +import com.nimbleflow.api.domain.order.OrderService; +import com.nimbleflow.api.domain.product.ProductDTO; +import com.nimbleflow.api.domain.product.ProductService; +import com.nimbleflow.api.utils.ObjectBuilder; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; +import java.util.Random; +import java.util.UUID; + +@SpringBootTest +@DisplayName("ProductService") +public class ProductServiceTest { + @InjectMocks + private ProductService underTest; + + @Mock + private OrderService orderService; + + @Test + @DisplayName("Validate if getTopSoldProducts() returns 5 products when calling it with null maxProducts") + void getTopSoldProductsMaxProductsNull() { + List orderDTOs = new java.util.ArrayList<>(ObjectBuilder.buildListOfOrderDTO()); + + for (int i = 0; i < 10; i++) { + ProductDTO product = ObjectBuilder.buildProductDTO(); + product.setId(new UUID(new Random().nextLong(), 15L)); + + OrderDTO orderDTO = ObjectBuilder.buildOrderDTO(); + orderDTO.setProducts(new java.util.ArrayList<>(orderDTO.getProducts())); + orderDTO.getProducts().add(product); + + orderDTOs.add(orderDTO); + } + + Mockito.when(orderService.findAllOrders(Mockito.anyBoolean())) + .thenReturn(orderDTOs); + + List result = underTest.getTopSoldProducts(null, false); + + Assertions.assertNotNull(result); + Assertions.assertFalse(result.isEmpty()); + Assertions.assertEquals(5, result.size()); + } + + @Test + @DisplayName("Validate if getTopSoldProducts() returns 2 products when calling it with maxProducts = 2") + void getTopSoldProductsMaxProductsEqual2() { + final int MAX_PRODUCTS = 2; + + List orderDTOs = new java.util.ArrayList<>(ObjectBuilder.buildListOfOrderDTO()); + + for (int i = 0; i < 10; i++) { + ProductDTO product = ObjectBuilder.buildProductDTO(); + product.setId(new UUID(new Random().nextLong(), 15L)); + + OrderDTO orderDTO = ObjectBuilder.buildOrderDTO(); + orderDTO.setProducts(new java.util.ArrayList<>(orderDTO.getProducts())); + orderDTO.getProducts().add(product); + + orderDTOs.add(orderDTO); + } + + Mockito.when(orderService.findAllOrders(Mockito.anyBoolean())) + .thenReturn(orderDTOs); + + List result = underTest.getTopSoldProducts(MAX_PRODUCTS, false); + + Assertions.assertNotNull(result); + Assertions.assertFalse(result.isEmpty()); + Assertions.assertEquals(MAX_PRODUCTS, result.size()); + } + +} \ No newline at end of file diff --git a/SpringBoot/src/test/java/com/nimbleflow/api/unit/ReportServiceTest.java b/SpringBoot/src/test/java/com/nimbleflow/api/unit/ReportServiceTest.java new file mode 100644 index 0000000..44a2f08 --- /dev/null +++ b/SpringBoot/src/test/java/com/nimbleflow/api/unit/ReportServiceTest.java @@ -0,0 +1,82 @@ +package com.nimbleflow.api.unit; + +import com.nimbleflow.api.domain.order.OrderDTO; +import com.nimbleflow.api.domain.order.OrderService; +import com.nimbleflow.api.domain.product.ProductDTO; +import com.nimbleflow.api.domain.product.ProductService; +import com.nimbleflow.api.domain.report.ReportService; +import com.nimbleflow.api.domain.report.dto.ReportDTO; +import com.nimbleflow.api.utils.ObjectBuilder; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.ZonedDateTime; +import java.util.List; + +@SpringBootTest +@DisplayName("ReportService") +public class ReportServiceTest { + @InjectMocks + private ReportService underTest; + + @Mock + private OrderService orderService; + + @Mock + private ProductService productService; + + @Test + @DisplayName("Validate if getOrdersMonthReportSuccess() returns the expected values when there's registered orders in the month") + void getOrdersMonthReportSuccess() { + Mockito.when(orderService.getAllMothOrders(Mockito.anyBoolean())) + .thenReturn(ObjectBuilder.buildListOfOrderDTO()); + + ReportDTO result = underTest.getOrdersMonthReport(false); + + Assertions.assertNotNull(result); + Assertions.assertNotNull(result.getCsv()); + Assertions.assertNotNull(result.getItems()); + Assertions.assertEquals(2, result.getItems().size()); + } + + @Test + @DisplayName("Validate if getOrdersReportByIntervalSuccess() returns the expected values when there's registered orders in the interval") + void getOrdersReportByIntervalSuccess() { + Mockito.when(orderService.findOrdersByInterval(Mockito.any(ZonedDateTime.class), Mockito.any(ZonedDateTime.class), Mockito.anyBoolean())) + .thenReturn(ObjectBuilder.buildListOfOrderDTO()); + + ReportDTO result = underTest.getOrdersReportByInterval(ZonedDateTime.now().minusMonths(1), ZonedDateTime.now(), false); + + Assertions.assertNotNull(result); + Assertions.assertNotNull(result.getCsv()); + Assertions.assertNotNull(result.getItems()); + Assertions.assertEquals(2, result.getItems().size()); + } + + @Test + @DisplayName("Validate if getTopSoldProductsReport() returns the expected values") + void getTopSoldProductsReportSuccess() { + List productDTOs = new java.util.ArrayList<>(ObjectBuilder.buildListOfProductDTO()); + productDTOs.add(ObjectBuilder.buildProductDTO()); + productDTOs.add(ObjectBuilder.buildProductDTO()); + productDTOs.add(ObjectBuilder.buildProductDTO()); + productDTOs.add(ObjectBuilder.buildProductDTO()); + productDTOs.add(ObjectBuilder.buildProductDTO()); + productDTOs.add(ObjectBuilder.buildProductDTO()); + productDTOs.add(ObjectBuilder.buildProductDTO()); + + Mockito.when(productService.getTopSoldProducts(Mockito.any(), Mockito.anyBoolean())) + .thenReturn(productDTOs); + + ReportDTO result = underTest.getTopSoldProductsReport(null, false); + + Assertions.assertNotNull(result); + Assertions.assertNotNull(result.getCsv()); + Assertions.assertNotNull(result.getItems()); + } +} \ No newline at end of file diff --git a/SpringBoot/src/test/java/com/nimbleflow/api/utils/ObjectBuilder.java b/SpringBoot/src/test/java/com/nimbleflow/api/utils/ObjectBuilder.java new file mode 100644 index 0000000..59d98c4 --- /dev/null +++ b/SpringBoot/src/test/java/com/nimbleflow/api/utils/ObjectBuilder.java @@ -0,0 +1,63 @@ +package com.nimbleflow.api.utils; + +import com.nimbleflow.api.domain.order.Order; +import com.nimbleflow.api.domain.order.OrderDTO; +import com.nimbleflow.api.domain.order.enums.PaymentMethod; +import com.nimbleflow.api.domain.product.ProductDTO; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +public class ObjectBuilder { + public static OrderDTO buildOrderDTO() { + return OrderDTO.builder() + .createdAt(ZonedDateTime.now().minusMonths(1L)) + .active(true) + .id(new UUID(5L, 15L)) + .tableId(new UUID(7L, 15L)) + .paymentMethod(PaymentMethod.CASH) + .products(buildListOfProductDTO()) + .build(); + } + + public static ProductDTO buildProductDTO() { + return ProductDTO.builder() + .id(new UUID(5L, 15L)) + .amount(5) + .build(); + } + + public static Order buildOrder() { + return Order.builder() + .id(new UUID(5L, 15L)) + .active(true) + .tableId(new UUID(5L, 15L)) + .products(buildListOfProductDTO()) + .createdAt(ZonedDateTime.now().minusMonths(1L)) + .paymentMethod(PaymentMethod.CASH) + .build(); + } + + public static List buildListOfOrder() { + Order order = buildOrder(); + order.setId(new UUID(7L, 15L)); + + return List.of(buildOrder(), order); + } + + public static List buildListOfProductDTO() { + ProductDTO product = buildProductDTO(); + product.setAmount(2); + product.setId(new UUID(5L, 15L)); + + return List.of(buildProductDTO(), product); + } + + public static List buildListOfOrderDTO() { + OrderDTO order = buildOrderDTO(); + order.setId(new UUID(5L, 15L)); + + return List.of(buildOrderDTO(), order); + } +} \ No newline at end of file diff --git a/SpringBoot/src/test/resources/application.yaml b/SpringBoot/src/test/resources/application.yaml new file mode 100644 index 0000000..bdebf35 --- /dev/null +++ b/SpringBoot/src/test/resources/application.yaml @@ -0,0 +1,15 @@ +server: + port: 8080 + error: + include-stacktrace: always + include-message: always + servlet: + context-path: /nimbleflow-api + +amazon: + aws: + accesskey: guest + secretkey: guest + dynamodb: + endpoint: http://localhost:10501 + region: us-east-1 \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..8cbe9e9 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,109 @@ +version: '3.9' + +services: + nimbleflow.sql.db: + container_name: nimbleflow.sql.db + image: postgres:alpine + environment: + - POSTGRES_DB=postgres + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres123 + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U postgres -d postgres" + ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 5s + ports: + - "10500:5432" + volumes: + - nimbleflow.sql.db:/var/lib/postgresql/data + - .assets/initsql:/docker-entrypoint-initdb.d + restart: always + + nimbleflow.nosql.db: + container_name: nimbleflow.nosql.db + image: "amazon/dynamodb-local:latest" + user: root + command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data" + ports: + - "10501:8000" + volumes: + - nimbleflow.nosql.db:/home/dynamodblocal/data + working_dir: /home/dynamodblocal + restart: always + + nimbleflow.minio: + container_name: nimbleflow.minio + image: minio/minio:latest + environment: + - MINIO_ROOT_USER=${AWS_ACCESS_KEY_ID} + - MINIO_ROOT_PASSWORD=${AWS_SECRET_ACCESS_KEY} + command: 'minio server /data/minio --console-address ":9090"' + ports: + - "10502:9000" + - "10503:9090" + volumes: + - nimbleflow.minio:/data + restart: always + + nimbleflow.aws.up: + container_name: nimbleflow.aws.up + build: + context: ./.assets/initaws + dockerfile: Dockerfile + depends_on: + nimbleflow.nosql.db: + condition: service_started + nimbleflow.minio: + condition: service_started + environment: + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - AWS_REGION=${AWS_REGION} + - AWS_S3_SERVICE_URL=${AWS_S3_SERVICE_URL} + - NO_SQL_CONNECTION_STRING=${NO_SQL_CONNECTION_STRING} + - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME} + + nimbleflow.aspnetcore.hub: + build: + context: ./AspDotNetCoreHub + dockerfile: Src/NimbleFlowHub.Api/Dockerfile + + nimbleflow.aspnetcore: + build: + context: ./AspDotNetCore + dockerfile: Src/NimbleFlow.Api/Dockerfile + depends_on: + nimbleflow.aspnetcore.hub: + condition: service_started + nimbleflow.sql.db: + condition: service_healthy + nimbleflow.aws.up: + condition: service_completed_successfully + extra_hosts: + - "host.docker.internal:host-gateway" + + nimbleflow.java: + build: + context: ./SpringBoot + dockerfile: Dockerfile + depends_on: + nimbleflow.aspnetcore: + condition: service_started + +networks: + default: + name: nimbleflow-network + +volumes: + nimbleflow.sql.db: + driver: local + nimbleflow.nosql.db: + driver: local + nimbleflow.minio: + driver: local \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..76a92ca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.9' + +services: + nimbleflow.aspnetcore.hub: + container_name: nimbleflow.aspnetcore.hub + image: ${CONTAINER_REGISTRY}/nimbleflow.aspnetcore.hub:${TAG:-latest} + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT} + ports: + - "10504:10504" + - "10505:10505" + + nimbleflow.aspnetcore: + container_name: nimbleflow.aspnetcore + image: ${CONTAINER_REGISTRY}/nimbleflow.aspnetcore:${TAG:-latest} + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT} + - SQL_CONNECTION_STRING=${SQL_CONNECTION_STRING} + - HUB_SERVER_URL=${HUB_SERVER_URL} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - AWS_REGION=${AWS_REGION} + - AWS_S3_SERVICE_URL=${AWS_S3_SERVICE_URL} + - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME} + ports: + - "10506:10506" + + nimbleflow.java: + container_name: nimbleflow.java + image: ${CONTAINER_REGISTRY}/nimbleflow.java:${TAG:-latest} + environment: + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - AWS_REGION=${AWS_REGION} + - NO_SQL_CONNECTION_STRING=${NO_SQL_CONNECTION_STRING} + ports: + - "10507:10507" \ No newline at end of file