diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..34130b8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,3 @@ +[*.cs] +dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.CA1062.severity = none \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ce6fdd --- /dev/null +++ b/.gitignore @@ -0,0 +1,340 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- Backup*.rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb \ No newline at end of file diff --git a/MyWarehouse.sln b/MyWarehouse.sln new file mode 100644 index 0000000..4fba49e --- /dev/null +++ b/MyWarehouse.sln @@ -0,0 +1,86 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29806.167 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi", "src\WebApi\WebApi.csproj", "{F0F669DD-C09B-4566-9B70-9E78B6263FD9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "config", "config", "{DA2BCB9D-91B8-4F9F-99CC-EC29180B6549}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application", "src\Application\Application.csproj", "{D46C09F2-616B-4AC9-BB1E-CE93DC1417F0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{4E3F3004-33FD-4030-89A9-8112628AB101}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.UnitTests", "tests\Infrastructure.UnitTests\Infrastructure.UnitTests.csproj", "{18EC9BA3-B9E3-4A6D-A10F-E28B514EA3F5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain.UnitTests", "tests\Domain.UnitTests\Domain.UnitTests.csproj", "{BF408BB0-E391-4AED-8E4B-F7B2435C5C2F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{B2315480-4D42-4643-B60E-DE823C32A92D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application.IntegrationTests", "tests\Application.IntegrationTests\Application.IntegrationTests.csproj", "{6EC6AD37-1D7D-419E-B191-6BC8C74F164D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BD794B73-A20A-45A8-B38D-9318D181615F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SampleData", "src\SampleData\SampleData.csproj", "{FB414F9F-BA98-4FFB-9821-A3D70C1DE1DA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "src\Domain\Domain.csproj", "{DB2A0D38-6829-4413-8EE9-511BEAFDA5D7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F0F669DD-C09B-4566-9B70-9E78B6263FD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0F669DD-C09B-4566-9B70-9E78B6263FD9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0F669DD-C09B-4566-9B70-9E78B6263FD9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0F669DD-C09B-4566-9B70-9E78B6263FD9}.Release|Any CPU.Build.0 = Release|Any CPU + {D46C09F2-616B-4AC9-BB1E-CE93DC1417F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D46C09F2-616B-4AC9-BB1E-CE93DC1417F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D46C09F2-616B-4AC9-BB1E-CE93DC1417F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D46C09F2-616B-4AC9-BB1E-CE93DC1417F0}.Release|Any CPU.Build.0 = Release|Any CPU + {4E3F3004-33FD-4030-89A9-8112628AB101}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E3F3004-33FD-4030-89A9-8112628AB101}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E3F3004-33FD-4030-89A9-8112628AB101}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E3F3004-33FD-4030-89A9-8112628AB101}.Release|Any CPU.Build.0 = Release|Any CPU + {18EC9BA3-B9E3-4A6D-A10F-E28B514EA3F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18EC9BA3-B9E3-4A6D-A10F-E28B514EA3F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18EC9BA3-B9E3-4A6D-A10F-E28B514EA3F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18EC9BA3-B9E3-4A6D-A10F-E28B514EA3F5}.Release|Any CPU.Build.0 = Release|Any CPU + {BF408BB0-E391-4AED-8E4B-F7B2435C5C2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF408BB0-E391-4AED-8E4B-F7B2435C5C2F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF408BB0-E391-4AED-8E4B-F7B2435C5C2F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF408BB0-E391-4AED-8E4B-F7B2435C5C2F}.Release|Any CPU.Build.0 = Release|Any CPU + {6EC6AD37-1D7D-419E-B191-6BC8C74F164D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6EC6AD37-1D7D-419E-B191-6BC8C74F164D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6EC6AD37-1D7D-419E-B191-6BC8C74F164D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6EC6AD37-1D7D-419E-B191-6BC8C74F164D}.Release|Any CPU.Build.0 = Release|Any CPU + {FB414F9F-BA98-4FFB-9821-A3D70C1DE1DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB414F9F-BA98-4FFB-9821-A3D70C1DE1DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB414F9F-BA98-4FFB-9821-A3D70C1DE1DA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB414F9F-BA98-4FFB-9821-A3D70C1DE1DA}.Release|Any CPU.Build.0 = Release|Any CPU + {DB2A0D38-6829-4413-8EE9-511BEAFDA5D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB2A0D38-6829-4413-8EE9-511BEAFDA5D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB2A0D38-6829-4413-8EE9-511BEAFDA5D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB2A0D38-6829-4413-8EE9-511BEAFDA5D7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F0F669DD-C09B-4566-9B70-9E78B6263FD9} = {BD794B73-A20A-45A8-B38D-9318D181615F} + {D46C09F2-616B-4AC9-BB1E-CE93DC1417F0} = {BD794B73-A20A-45A8-B38D-9318D181615F} + {4E3F3004-33FD-4030-89A9-8112628AB101} = {BD794B73-A20A-45A8-B38D-9318D181615F} + {18EC9BA3-B9E3-4A6D-A10F-E28B514EA3F5} = {B2315480-4D42-4643-B60E-DE823C32A92D} + {BF408BB0-E391-4AED-8E4B-F7B2435C5C2F} = {B2315480-4D42-4643-B60E-DE823C32A92D} + {6EC6AD37-1D7D-419E-B191-6BC8C74F164D} = {B2315480-4D42-4643-B60E-DE823C32A92D} + {FB414F9F-BA98-4FFB-9821-A3D70C1DE1DA} = {BD794B73-A20A-45A8-B38D-9318D181615F} + {DB2A0D38-6829-4413-8EE9-511BEAFDA5D7} = {BD794B73-A20A-45A8-B38D-9318D181615F} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5FECF7DF-777D-49DD-AFB4-D98906EA079B} + EndGlobalSection +EndGlobal diff --git a/scripts/addMigration.cmd b/scripts/addMigration.cmd new file mode 100644 index 0000000..e92602c --- /dev/null +++ b/scripts/addMigration.cmd @@ -0,0 +1,2 @@ +::Usage: AddMigration +@dotnet ef migrations add --project ../src/MyWarehouse.Infrastructure --startup-project ../src/MyWarehouse.Api %* \ No newline at end of file diff --git a/scripts/dbContextInfo.cmd b/scripts/dbContextInfo.cmd new file mode 100644 index 0000000..7c551d8 --- /dev/null +++ b/scripts/dbContextInfo.cmd @@ -0,0 +1 @@ +@dotnet ef dbcontext info --project ../src/MyWarehouse.Infrastructure --startup-project ../src/MyWarehouse.Api \ No newline at end of file diff --git a/scripts/readme b/scripts/readme new file mode 100644 index 0000000..5c1050a --- /dev/null +++ b/scripts/readme @@ -0,0 +1,3 @@ +The point of these one-liner migration "scripts" is that it's absolutely PITA that the startup project always has to be specified for dotnet ef migration commands. + +Once ef tools finally add the option to permanently set the startup project, the migration scripts can be deleted. \ No newline at end of file diff --git a/scripts/removeLastMigration.cmd b/scripts/removeLastMigration.cmd new file mode 100644 index 0000000..6a8a1c0 --- /dev/null +++ b/scripts/removeLastMigration.cmd @@ -0,0 +1 @@ +@dotnet ef migrations remove --project ../src/MyWarehouse.Infrastructure --startup-project ../src/MyWarehouse.Api \ No newline at end of file diff --git a/scripts/updateDatabase.cmd b/scripts/updateDatabase.cmd new file mode 100644 index 0000000..e62598e --- /dev/null +++ b/scripts/updateDatabase.cmd @@ -0,0 +1,2 @@ +::Usage: UpdateDatebase (where is required only when reverting to an older migration) +@dotnet ef database update --project ../src/MyWarehouse.Infrastructure --startup-project ../src/MyWarehouse.Api %* \ No newline at end of file diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj new file mode 100644 index 0000000..ba7c3eb --- /dev/null +++ b/src/Application/Application.csproj @@ -0,0 +1,27 @@ + + + + net5.0 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Application/Common/AppOperationResult.cs b/src/Application/Common/AppOperationResult.cs new file mode 100644 index 0000000..83725d0 --- /dev/null +++ b/src/Application/Common/AppOperationResult.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Common +{ + //TODO: Complete 'operation result' scaffolding to return validation problems this way instead of throwing exceptions. + public class AppOperationResult + { + public bool OperationCompleted => Problems == null | !Problems.Any(); + public IDictionary Problems { get; } + } + + public class AppOperationResult : AppOperationResult + { + public T Result { get; init; } + } +} diff --git a/src/Application/Common/Behaviors/ErrorHandlerBehavior.cs b/src/Application/Common/Behaviors/ErrorHandlerBehavior.cs new file mode 100644 index 0000000..a5943c3 --- /dev/null +++ b/src/Application/Common/Behaviors/ErrorHandlerBehavior.cs @@ -0,0 +1,28 @@ +using MediatR; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Common.Behaviors +{ + public class ErrorHandlerBehavior : IPipelineBehavior + { + public ErrorHandlerBehavior() + { + + } + + public async Task Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next) + { + try + { + return await next(); + } + catch (Exception e) + { + throw; + // TODO: Implement logging as an application (instead of API). + } + } + } +} \ No newline at end of file diff --git a/src/Application/Common/Behaviors/RequestValidationBehavior.cs b/src/Application/Common/Behaviors/RequestValidationBehavior.cs new file mode 100644 index 0000000..e787283 --- /dev/null +++ b/src/Application/Common/Behaviors/RequestValidationBehavior.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using MediatR; +using MyWarehouse.Application.Common.Exceptions; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Common.Behaviors +{ + public class RequestValidationBehavior : IPipelineBehavior + where TRequest : IRequest + { + private readonly IEnumerable> _validators; + + public RequestValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public Task Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next) + { + if (_validators.Any()) + { + var context = new ValidationContext(request); + + var failures = _validators + .Select(v => v.Validate(context)) + .SelectMany(result => result.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + throw new InputValidationException(failures); + } + } + + return next(); + } + } +} diff --git a/src/Application/Common/Dependencies/DataAccess/IUnitOfWork.cs b/src/Application/Common/Dependencies/DataAccess/IUnitOfWork.cs new file mode 100644 index 0000000..6fb0873 --- /dev/null +++ b/src/Application/Common/Dependencies/DataAccess/IUnitOfWork.cs @@ -0,0 +1,15 @@ +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories; +using System; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Common.Dependencies.DataAccess +{ + public interface IUnitOfWork : IDisposable + { + public IPartnerRepository Partners { get; } + public IProductRepository Products { get; } + public ITransactionRepository Transactions { get; } + + public Task SaveChanges(); + } +} diff --git a/src/Application/Common/Dependencies/DataAccess/Repositories/Common/IListResponseModel.cs b/src/Application/Common/Dependencies/DataAccess/Repositories/Common/IListResponseModel.cs new file mode 100644 index 0000000..6c89b9b --- /dev/null +++ b/src/Application/Common/Dependencies/DataAccess/Repositories/Common/IListResponseModel.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common +{ + public interface IListResponseModel + { + int PageIndex { get; } + int PageSize { get; } + + int PageCount { get; } + int RowCount { get; } + + string ActiveFilter { get; } + string ActiveOrderBy { get; } + + int FirstRowOnPage { get; } + int LastRowOnPage { get; } + + IEnumerable Results { get; set; } + } +} \ No newline at end of file diff --git a/src/Application/Common/Dependencies/DataAccess/Repositories/Common/IRepository.cs b/src/Application/Common/Dependencies/DataAccess/Repositories/Common/IRepository.cs new file mode 100644 index 0000000..1838e2c --- /dev/null +++ b/src/Application/Common/Dependencies/DataAccess/Repositories/Common/IRepository.cs @@ -0,0 +1,37 @@ +using MyWarehouse.Application.Common.Mapping; +using MyWarehouse.Domain.Common; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common +{ + public interface IRepository where TEntity : IEntity + { + /// + /// Returns the entity corresponding to the given ID, or default if not found. + /// + Task GetByIdAsync(int id); + + Task> GetFiltered(Expression> filter, bool readOnly = false); + + void Add(TEntity entity); + void AddRange(IEnumerable entities); + + void Remove(TEntity entity); + void RemoveRange(IEnumerable entities); + + void StartTracking(TEntity entity); + + /// + /// Finds the entity of the given Id, and returns it mapped to the specified mappable type, or returns default if not found. + /// + Task GetProjectedAsync(int id, bool readOnly = false) where TDto : IMapFrom; + + /// + /// Finds the list of entities corresponding to the provided query, and returns them mapped to the specified mappable type. + /// + Task> GetProjectedListAsync(ListQueryModel model, Expression> additionalFilter = null, bool readOnly = false) where TDto : IMapFrom; + } +} \ No newline at end of file diff --git a/src/Application/Common/Dependencies/DataAccess/Repositories/Common/ListQueryModel.cs b/src/Application/Common/Dependencies/DataAccess/Repositories/Common/ListQueryModel.cs new file mode 100644 index 0000000..b14b171 --- /dev/null +++ b/src/Application/Common/Dependencies/DataAccess/Repositories/Common/ListQueryModel.cs @@ -0,0 +1,54 @@ +using MediatR; +using MyWarehouse.Application.Common.Exceptions; +using System; + +namespace MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common +{ + public class ListQueryModel : IRequest> + { + /// + /// The index of the page to fetch. + /// + //[Range(1, int.MaxValue, ErrorMessage = "PAGE_INDEX_MUST_BE_POSITIVE")] + public int PageIndex { get; set; } = 1; + + /// + /// The page size used for fetching data. + /// + //[Range(1, MAX_PAGESIZE, ErrorMessage = "PAGE_SIZE_OUT_OF_BOUNDS")] + public int PageSize { get; set; } = DEFAULT_PAGESIZE; + + /// + /// The expression used for sorting. + /// + public string OrderBy { get; set; } = "id"; + + /// + /// The expression used for filtering the results. + /// + public string Filter { get; set; } + + private const int DEFAULT_PAGESIZE = 20; + private const int MAX_PAGESIZE = 100; + + public void ThrowOrderByIncorrectException(Exception innerException) + { + throw new InputValidationException(innerException, + ( + PropertyName: nameof(OrderBy), + ErrorMessage: $"The specified orderBy string '{OrderBy}' is invalid." + ) + ); + } + + public void ThrowFilterIncorrectException(Exception innerException) + { + throw new InputValidationException(innerException, + ( + PropertyName: nameof(Filter), + ErrorMessage: $"The specified filter string '{Filter}' is invalid." + ) + ); + } + } +} \ No newline at end of file diff --git a/src/Application/Common/Dependencies/DataAccess/Repositories/IPartnerRepository.cs b/src/Application/Common/Dependencies/DataAccess/Repositories/IPartnerRepository.cs new file mode 100644 index 0000000..08f137a --- /dev/null +++ b/src/Application/Common/Dependencies/DataAccess/Repositories/IPartnerRepository.cs @@ -0,0 +1,9 @@ +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using MyWarehouse.Domain.Partners; + +namespace MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories +{ + public interface IPartnerRepository : IRepository + { + } +} \ No newline at end of file diff --git a/src/Application/Common/Dependencies/DataAccess/Repositories/IProductRepository.cs b/src/Application/Common/Dependencies/DataAccess/Repositories/IProductRepository.cs new file mode 100644 index 0000000..441eaf5 --- /dev/null +++ b/src/Application/Common/Dependencies/DataAccess/Repositories/IProductRepository.cs @@ -0,0 +1,15 @@ +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using MyWarehouse.Domain.Common.ValueObjects.Mass; +using MyWarehouse.Domain.Common.ValueObjects.Money; +using MyWarehouse.Domain.Products; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories +{ + public interface IProductRepository : IRepository + { + Task> GetHeaviestProducts(int numberOfProducts); + Task> GetMostStockedProducts(int numberOfProducts); + } +} \ No newline at end of file diff --git a/src/Application/Common/Dependencies/DataAccess/Repositories/ITransactionRepository.cs b/src/Application/Common/Dependencies/DataAccess/Repositories/ITransactionRepository.cs new file mode 100644 index 0000000..05cd6af --- /dev/null +++ b/src/Application/Common/Dependencies/DataAccess/Repositories/ITransactionRepository.cs @@ -0,0 +1,11 @@ +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using MyWarehouse.Domain.Transactions; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories +{ + public interface ITransactionRepository : IRepository + { + Task GetEntireTransaction(int id); + } +} \ No newline at end of file diff --git a/src/Application/Common/Dependencies/Services/ICurrentUserService.cs b/src/Application/Common/Dependencies/Services/ICurrentUserService.cs new file mode 100644 index 0000000..6d5da09 --- /dev/null +++ b/src/Application/Common/Dependencies/Services/ICurrentUserService.cs @@ -0,0 +1,7 @@ +namespace MyWarehouse.Application.Dependencies.Services +{ + public interface ICurrentUserService + { + string UserId { get; } + } +} \ No newline at end of file diff --git a/src/Application/Common/Dependencies/Services/IDateTime.cs b/src/Application/Common/Dependencies/Services/IDateTime.cs new file mode 100644 index 0000000..4b03178 --- /dev/null +++ b/src/Application/Common/Dependencies/Services/IDateTime.cs @@ -0,0 +1,9 @@ +using System; + +namespace MyWarehouse.Application.Dependencies.Services +{ + public interface IDateTime + { + DateTime Now { get; } + } +} diff --git a/src/Application/Common/Dependencies/Services/IStockStatisticsService.cs b/src/Application/Common/Dependencies/Services/IStockStatisticsService.cs new file mode 100644 index 0000000..58a16c3 --- /dev/null +++ b/src/Application/Common/Dependencies/Services/IStockStatisticsService.cs @@ -0,0 +1,24 @@ +using MyWarehouse.Domain.Common.ValueObjects.Mass; +using MyWarehouse.Domain.Common.ValueObjects.Money; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Dependencies.Services +{ + public interface IStockStatisticsService + { + /// + /// Returns the aggregate mass of all stocked products. + /// + Task GetProductStockTotalMass(MassUnit unit); + + /// + /// Returns the aggregate value of all stocked products. + /// + Task GetProductStockTotalValue(); + + /// + /// Returns the number of individual products available, and the total number of stocked products. + /// + Task<(int ProductCount, int TotalStock)> GetProductStockCounts(); + } +} \ No newline at end of file diff --git a/src/Application/Common/Exceptions/EntityNotFoundException.cs b/src/Application/Common/Exceptions/EntityNotFoundException.cs new file mode 100644 index 0000000..5bc5264 --- /dev/null +++ b/src/Application/Common/Exceptions/EntityNotFoundException.cs @@ -0,0 +1,27 @@ +using System; + +namespace MyWarehouse.Application.Common.Exceptions +{ + public class EntityNotFoundException : Exception + { + public EntityNotFoundException() + : base("Entity was not found.") + { + } + + public EntityNotFoundException(string message) + : base(message) + { + } + + public EntityNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } + + public EntityNotFoundException(string entityName, object entityId) + : base($"Entity '{entityName}' with ID '{entityId}' was not found.") + { + } + } +} diff --git a/src/Application/Common/Exceptions/InputValidationException.cs b/src/Application/Common/Exceptions/InputValidationException.cs new file mode 100644 index 0000000..ce3d6d8 --- /dev/null +++ b/src/Application/Common/Exceptions/InputValidationException.cs @@ -0,0 +1,68 @@ +using FluentValidation.Results; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace MyWarehouse.Application.Common.Exceptions +{ + // TODO: Replace throwing exceptions for validation with an app-wide result model that contains operation result. + public class InputValidationException : Exception + { + public IDictionary Errors { get; } + + public InputValidationException(Exception innerException = null) + : base("One or more validation failures have occurred.", innerException) + { + Errors = new Dictionary(); + } + + public InputValidationException(params (string PropertyName, string ErrorMessage)[] failures) : this() + { + var failureGroups = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage); + + foreach (var failureGroup in failureGroups) + { + var propertyName = failureGroup.Key; + var propertyFailures = failureGroup.ToArray(); + + Errors.Add(propertyName, propertyFailures); + } + } + + public InputValidationException(Exception innerException, params (string PropertyName, string ErrorMessage)[] failures) : this(innerException) + { + var failureGroups = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage); + + foreach (var failureGroup in failureGroups) + { + var propertyName = failureGroup.Key; + var propertyFailures = failureGroup.ToArray(); + + Errors.Add(propertyName, propertyFailures); + } + } + + public InputValidationException(IEnumerable failures) + : this() + { + var failureGroups = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage); + + foreach (var failureGroup in failureGroups) + { + var propertyName = failureGroup.Key; + var propertyFailures = failureGroup.ToArray(); + + Errors.Add(propertyName, propertyFailures); + } + } + + public override string ToString() + { + return nameof(InputValidationException) + ": " + JsonSerializer.Serialize(this, new JsonSerializerOptions() { WriteIndented = true }); + } + } +} diff --git a/src/Application/Common/Mapping/IMapFrom.cs b/src/Application/Common/Mapping/IMapFrom.cs new file mode 100644 index 0000000..b79911d --- /dev/null +++ b/src/Application/Common/Mapping/IMapFrom.cs @@ -0,0 +1,17 @@ +using AutoMapper; + +namespace MyWarehouse.Application.Common.Mapping +{ + /// + /// Interface for DTOs, expressing that the DTO maps from a certain domain entity. + /// + /// Type of the domain entity this DTO maps from. + public interface IMapFrom + { + /// + /// Default mapping implementation. If special mapping is required, + /// override this with an explicit Mapping() method declaration in the implementing DTO class. + /// + void Mapping(Profile profile) => profile.CreateMap(typeof(TEntity), GetType()); + } +} diff --git a/src/Application/Common/Mapping/MappingProfile.cs b/src/Application/Common/Mapping/MappingProfile.cs new file mode 100644 index 0000000..0323bc7 --- /dev/null +++ b/src/Application/Common/Mapping/MappingProfile.cs @@ -0,0 +1,38 @@ +using AutoMapper; +using System; +using System.Linq; +using System.Reflection; + +namespace MyWarehouse.Application.Common.Mapping +{ + /// + /// AutoMapper profile configuring automatic mapping for DTOs that implement IMapFrom<> interface. + /// This profile is automatically picked up by AutoMapper, provided that AutoMapper is properly registered. + /// + public class MappingProfile : Profile + { + public MappingProfile() + { + ApplyMappingsFromAssembly(Assembly.GetExecutingAssembly()); + } + + private void ApplyMappingsFromAssembly(Assembly assembly) + { + var types = assembly.GetExportedTypes() + .Where(t => t.GetInterfaces().Any(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IMapFrom<>))) + .ToList(); + + foreach (var type in types) + { + var instance = Activator.CreateInstance(type); + + var methodInfo = type.GetMethod("Mapping") + ?? type.GetInterface("IMapFrom`1").GetMethod("Mapping"); + + methodInfo?.Invoke(instance, new object[] { this }); + + } + } + } +} \ No newline at end of file diff --git a/src/Application/Partners/CreatePartner/CreatePartnerCommand.cs b/src/Application/Partners/CreatePartner/CreatePartnerCommand.cs new file mode 100644 index 0000000..397cdac --- /dev/null +++ b/src/Application/Partners/CreatePartner/CreatePartnerCommand.cs @@ -0,0 +1,47 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Domain.Partners; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Partners.CreatePartner +{ + public record CreatePartnerCommand : IRequest + { + public string Name { get; init; } + public AddressDto Address { get; init; } + + public record AddressDto + { + public string Country { get; init; } + public string ZipCode { get; init; } + public string Street { get; init; } + public string City { get; init; } + } + } + + public class CreatePartnerCommandHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + + public CreatePartnerCommandHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public async Task Handle(CreatePartnerCommand request, CancellationToken cancellationToken) + { + var partner = new Partner( + name: request.Name.Trim(), + address: new Address( + country: request.Address.Country.Trim(), + zipcode: request.Address.ZipCode.Trim(), + street: request.Address.Street.Trim(), + city: request.Address.City.Trim() + )); + + _unitOfWork.Partners.Add(partner); + await _unitOfWork.SaveChanges(); + + return partner.Id; + } + } +} diff --git a/src/Application/Partners/CreatePartner/CreatePartnerCommandValidator.cs b/src/Application/Partners/CreatePartner/CreatePartnerCommandValidator.cs new file mode 100644 index 0000000..5976dff --- /dev/null +++ b/src/Application/Partners/CreatePartner/CreatePartnerCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using MyWarehouse.Domain.Partners; + +namespace MyWarehouse.Application.Partners.CreatePartner +{ + public class CreatePartnerCommandValidator : AbstractValidator + { + public CreatePartnerCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(PartnerInvariants.NameMaxLength); + + RuleFor(x => x.Address).NotNull().DependentRules(() => + { + RuleFor(x => x.Address.Country).NotNull().MaximumLength(100); + RuleFor(x => x.Address.ZipCode).NotNull().MaximumLength(100); + RuleFor(x => x.Address.Street).NotNull().MaximumLength(100); + RuleFor(x => x.Address.City).NotNull().MaximumLength(100); + }); + } + } +} diff --git a/src/Application/Partners/DeletePartner/DeletePartnerCommand.cs b/src/Application/Partners/DeletePartner/DeletePartnerCommand.cs new file mode 100644 index 0000000..91942d9 --- /dev/null +++ b/src/Application/Partners/DeletePartner/DeletePartnerCommand.cs @@ -0,0 +1,33 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Exceptions; +using MyWarehouse.Domain.Partners; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Partners.DeletePartner +{ + public record DeletePartnerCommand : IRequest + { + public int Id { get; init; } + } + + public class DeletePartnerCommandHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + + public DeletePartnerCommandHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public async Task Handle(DeletePartnerCommand request, CancellationToken cancellationToken) + { + var partner = await _unitOfWork.Partners.GetByIdAsync(request.Id) + ?? throw new EntityNotFoundException(nameof(Partner), request.Id); + + _unitOfWork.Partners.Remove(partner); + await _unitOfWork.SaveChanges(); + + return Unit.Value; + } + } +} diff --git a/src/Application/Partners/GetPartnerDetails/GetPartnerDetailsQuery.cs b/src/Application/Partners/GetPartnerDetails/GetPartnerDetailsQuery.cs new file mode 100644 index 0000000..583203f --- /dev/null +++ b/src/Application/Partners/GetPartnerDetails/GetPartnerDetailsQuery.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Exceptions; +using MyWarehouse.Domain.Partners; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Partners.GetPartnerDetails +{ + public record GetPartnerDetailsQuery : IRequest + { + public int Id { get; set; } + } + + public class GetPartnerDetailsQueryHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetPartnerDetailsQueryHandler(IUnitOfWork unitOfWork, IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle(GetPartnerDetailsQuery request, CancellationToken cancellationToken) + { + var partner = await _unitOfWork.Partners.GetByIdAsync(request.Id) + ?? throw new EntityNotFoundException(nameof(Partner), request.Id); + + return _mapper.Map(partner); + } + } +} diff --git a/src/Application/Partners/GetPartnerDetails/PartnerDetailsDto.cs b/src/Application/Partners/GetPartnerDetails/PartnerDetailsDto.cs new file mode 100644 index 0000000..d1638a0 --- /dev/null +++ b/src/Application/Partners/GetPartnerDetails/PartnerDetailsDto.cs @@ -0,0 +1,31 @@ +using AutoMapper; +using MyWarehouse.Application.Common.Mapping; +using MyWarehouse.Domain.Partners; +using System; + +namespace MyWarehouse.Application.Partners.GetPartnerDetails +{ + public record PartnerDetailsDto : IMapFrom + { + public int Id { get; init; } + public string Name { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime? LastModifiedAt { get; init; } + + public AddressDto Address { get; init; } + + public void Mapping(Profile profile) + { + profile.CreateMap(); + profile.CreateMap(); + } + + public record AddressDto + { + public string Country { get; init; } + public string ZipCode { get; init; } + public string City { get; init; } + public string Street { get; init; } + } + } +} diff --git a/src/Application/Partners/GetPartnersList/GetPartnersListQuery.cs b/src/Application/Partners/GetPartnersList/GetPartnersListQuery.cs new file mode 100644 index 0000000..676c7f5 --- /dev/null +++ b/src/Application/Partners/GetPartnersList/GetPartnersListQuery.cs @@ -0,0 +1,19 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Partners.GetPartners +{ + public class GetPartnersListQueryHandler : IRequestHandler, IListResponseModel> + { + private readonly IUnitOfWork _unitOfWork; + + public GetPartnersListQueryHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public Task> Handle(ListQueryModel request, CancellationToken cancellationToken) + => _unitOfWork.Partners.GetProjectedListAsync(request, readOnly: true); + } +} diff --git a/src/Application/Partners/GetPartnersList/PartnerDto.cs b/src/Application/Partners/GetPartnersList/PartnerDto.cs new file mode 100644 index 0000000..04d954c --- /dev/null +++ b/src/Application/Partners/GetPartnersList/PartnerDto.cs @@ -0,0 +1,28 @@ +using AutoMapper; +using MyWarehouse.Application.Common.Mapping; +using MyWarehouse.Domain.Partners; + +namespace MyWarehouse.Application.Partners.GetPartners +{ + public record PartnerDto : IMapFrom + { + public int Id { get; init; } + public string Name { get; init; } + + public string Address { get; init; } + + public string Country { get; init; } + public string ZipCode { get; init; } + public string City { get; init; } + public string Street { get; init; } + + public void Mapping(Profile profile) + { + profile.CreateMap() + .ForMember(dest => dest.Country, x => x.MapFrom(src => src.Address.Country)) + .ForMember(dest => dest.ZipCode, x => x.MapFrom(src => src.Address.ZipCode)) + .ForMember(dest => dest.City, x => x.MapFrom(src => src.Address.City)) + .ForMember(dest => dest.Street, x => x.MapFrom(src => src.Address.Street)); + } + } +} \ No newline at end of file diff --git a/src/Application/Partners/UpdatePartner/UpdatePartnerCommand.cs b/src/Application/Partners/UpdatePartner/UpdatePartnerCommand.cs new file mode 100644 index 0000000..ceff9ec --- /dev/null +++ b/src/Application/Partners/UpdatePartner/UpdatePartnerCommand.cs @@ -0,0 +1,55 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Exceptions; +using MyWarehouse.Domain.Partners; +using System.ComponentModel.DataAnnotations; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Partners.UpdatePartner +{ + public record UpdatePartnerCommand : IRequest + { + public int Id { get; init; } + public string Name { get; init; } + public AddressDto Address { get; init; } + + public record AddressDto + { + [Required, MinLength(1), MaxLength(100)] + public string Country { get; init; } + [Required, MinLength(1), MaxLength(100)] + public string ZipCode { get; init; } + [Required, MinLength(1), MaxLength(100)] + public string Street { get; init; } + [Required, MinLength(1), MaxLength(100)] + public string City { get; init; } + } + } + + public class UpdatePartnerCommandHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + + public UpdatePartnerCommandHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public async Task Handle(UpdatePartnerCommand request, CancellationToken cancellationToken) + { + var partner = await _unitOfWork.Partners.GetByIdAsync(request.Id) + ?? throw new EntityNotFoundException(nameof(Partner), request.Id); + + partner.UpdateName(request.Name.Trim()); + partner.UpdateAddress(new Address( + country: request.Address.Country.Trim(), + zipcode: request.Address.ZipCode.Trim(), + street: request.Address.Street.Trim(), + city: request.Address.City.Trim() + )); + + await _unitOfWork.SaveChanges(); + + return Unit.Value; + } + } +} diff --git a/src/Application/Partners/UpdatePartner/UpdatePartnerCommandValidator.cs b/src/Application/Partners/UpdatePartner/UpdatePartnerCommandValidator.cs new file mode 100644 index 0000000..cdbcf34 --- /dev/null +++ b/src/Application/Partners/UpdatePartner/UpdatePartnerCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using MyWarehouse.Domain.Partners; + +namespace MyWarehouse.Application.Partners.UpdatePartner +{ + public class UpdatePartnerCommandValidator : AbstractValidator + { + public UpdatePartnerCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(PartnerInvariants.NameMaxLength); + + RuleFor(x => x.Address).NotNull().DependentRules(() => + { + RuleFor(x => x.Address.Country).NotNull().MaximumLength(100); + RuleFor(x => x.Address.ZipCode).NotNull().MaximumLength(100); + RuleFor(x => x.Address.Street).NotNull().MaximumLength(100); + RuleFor(x => x.Address.City).NotNull().MaximumLength(100); + }); + } + } +} diff --git a/src/Application/Products/CreateProduct/CreateProductCommand.cs b/src/Application/Products/CreateProduct/CreateProductCommand.cs new file mode 100644 index 0000000..312734d --- /dev/null +++ b/src/Application/Products/CreateProduct/CreateProductCommand.cs @@ -0,0 +1,45 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Domain.Common.ValueObjects.Mass; +using MyWarehouse.Domain.Common.ValueObjects.Money; +using MyWarehouse.Domain.Products; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Products.CreateProduct +{ + public record CreateProductCommand : IRequest + { + public string Name { get; init; } + public string Description { get; init; } + + public float MassValue { get; init; } + public string MassUnitSymbol { get; init; } + + public decimal PriceAmount { get; init; } + public string PriceCurrencyCode { get; init; } + } + + public class CreateProductCommandHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + + public CreateProductCommandHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken) + { + var product = new Product( + name: request.Name.Trim(), + description: request.Description.Trim(), + price: new Money(request.PriceAmount, ProductInvariants.DefaultPriceCurrency), + mass: new Mass(request.MassValue, ProductInvariants.DefaultMassUnit) + ); + + _unitOfWork.Products.Add(product); + await _unitOfWork.SaveChanges(); + + return product.Id; + } + } +} diff --git a/src/Application/Products/CreateProduct/CreateProductCommandValidator.cs b/src/Application/Products/CreateProduct/CreateProductCommandValidator.cs new file mode 100644 index 0000000..8bb5af7 --- /dev/null +++ b/src/Application/Products/CreateProduct/CreateProductCommandValidator.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using MyWarehouse.Domain.Products; + +namespace MyWarehouse.Application.Products.CreateProduct +{ + public class CreateProductCommandValidator : AbstractValidator + { + public CreateProductCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(ProductInvariants.NameMaxLength); + + RuleFor(x => x.Description) + .NotEmpty() + .MaximumLength(ProductInvariants.DescriptionMaxLength); + + RuleFor(x => x.MassValue) + .GreaterThanOrEqualTo(ProductInvariants.MassMinimum); + + RuleFor(x => x.PriceAmount) + .GreaterThanOrEqualTo(ProductInvariants.PriceMinimum); + + RuleFor(x => x.MassUnitSymbol) + .NotEmpty() + .Equal(ProductInvariants.DefaultMassUnit.Symbol) + .WithMessage(x => $"Mass unit '{x.MassUnitSymbol}' cannot be accepted. Currently the only value supported is '{ProductInvariants.DefaultMassUnit.Symbol}'."); + + RuleFor(x => x.PriceCurrencyCode) + .NotEmpty() + .Equal(ProductInvariants.DefaultPriceCurrency.Code) + .WithMessage(x => $"Currency code '{x.PriceCurrencyCode}' cannot be accepted. Currently the only acceptable value is '{ProductInvariants.DefaultPriceCurrency.Code}'."); + } + } +} diff --git a/src/Application/Products/DeleteProduct/DeleteProductCommand.cs b/src/Application/Products/DeleteProduct/DeleteProductCommand.cs new file mode 100644 index 0000000..c19c00a --- /dev/null +++ b/src/Application/Products/DeleteProduct/DeleteProductCommand.cs @@ -0,0 +1,33 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Exceptions; +using MyWarehouse.Domain.Products; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Partners.DeletePartner +{ + public record DeleteProductCommand : IRequest + { + public int Id { get; init; } + } + + public class DeleteProductCommandHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + + public DeleteProductCommandHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public async Task Handle(DeleteProductCommand request, CancellationToken cancellationToken) + { + var product = await _unitOfWork.Products.GetByIdAsync(request.Id) + ?? throw new EntityNotFoundException(nameof(Product), request.Id); + + _unitOfWork.Products.Remove(product); + await _unitOfWork.SaveChanges(); + + return Unit.Value; + } + } +} diff --git a/src/Application/Products/GetProductDetails/GetProductDetailsQuery.cs b/src/Application/Products/GetProductDetails/GetProductDetailsQuery.cs new file mode 100644 index 0000000..270b58c --- /dev/null +++ b/src/Application/Products/GetProductDetails/GetProductDetailsQuery.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Exceptions; +using MyWarehouse.Domain.Products; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Products.GetProduct +{ + public record GetProductDetailsQuery : IRequest + { + public int Id { get; set; } + } + + public class GetProductDetailsQueryHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetProductDetailsQueryHandler(IUnitOfWork unitOfWork, IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle(GetProductDetailsQuery request, CancellationToken cancellationToken) + { + var product = await _unitOfWork.Products.GetByIdAsync(request.Id) + ?? throw new EntityNotFoundException(nameof(Product), request.Id); + + return _mapper.Map(product); + } + } +} diff --git a/src/Application/Products/GetProductDetails/ProductDetailsDto.cs b/src/Application/Products/GetProductDetails/ProductDetailsDto.cs new file mode 100644 index 0000000..f043783 --- /dev/null +++ b/src/Application/Products/GetProductDetails/ProductDetailsDto.cs @@ -0,0 +1,28 @@ +using MyWarehouse.Application.Common.Mapping; +using MyWarehouse.Domain.Products; +using System; + +namespace MyWarehouse.Application.Products.GetProduct +{ + public record ProductDetailsDto : IMapFrom + { + public int Id { get; init; } + + public string Name { get; init; } + public string Description { get; init; } + + public DateTime CreatedAt { get; init; } + public string CreatedBy { get; init; } + + public DateTime? LastModifiedAt { get; init; } + public string LastModifiedBy { get; init; } + + public decimal PriceAmount { get; init; } + public string PriceCurrencyCode { get; init; } + + public float MassValue { get; init; } + public string MassUnitSymbol { get; init; } + + public int NumberInStock { get; init; } + } +} \ No newline at end of file diff --git a/src/Application/Products/GetProductsList/GetProductsListQuery.cs b/src/Application/Products/GetProductsList/GetProductsListQuery.cs new file mode 100644 index 0000000..eb78569 --- /dev/null +++ b/src/Application/Products/GetProductsList/GetProductsListQuery.cs @@ -0,0 +1,33 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Products.GetProducts +{ + public class GetProductsListQuery : ListQueryModel + { + public ProductStatus Status { get; init; } + public bool StockedOnly => Status == ProductStatus.Stocked; + + public enum ProductStatus + { + Default, + Stocked + } + } + + public class GetProductsListQueryHandler : IRequestHandler> + { + private readonly IUnitOfWork _unitOfWork; + + public GetProductsListQueryHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public Task> Handle(GetProductsListQuery request, CancellationToken cancellationToken) + => _unitOfWork.Products.GetProjectedListAsync(request, + additionalFilter: request.StockedOnly ? x => x.NumberInStock > 0 : null, + readOnly: true); + } +} diff --git a/src/Application/Products/GetProductsList/ProductDto.cs b/src/Application/Products/GetProductsList/ProductDto.cs new file mode 100644 index 0000000..11fd360 --- /dev/null +++ b/src/Application/Products/GetProductsList/ProductDto.cs @@ -0,0 +1,21 @@ +using MyWarehouse.Application.Common.Mapping; +using MyWarehouse.Domain.Products; + +namespace MyWarehouse.Application.Products.GetProducts +{ + public record ProductDto : IMapFrom + { + public int Id { get; init; } + + public string Name { get; init; } + public string Description { get; init; } + + public decimal PriceAmount { get; init; } + public string PriceCurrencyCode { get; init; } + + public float MassValue { get; init; } + public string MassUnitSymbol { get; init; } + + public int NumberInStock { get; init; } + } +} diff --git a/src/Application/Products/ProductStockCount/ProductStockCountDto.cs b/src/Application/Products/ProductStockCount/ProductStockCountDto.cs new file mode 100644 index 0000000..72a8f59 --- /dev/null +++ b/src/Application/Products/ProductStockCount/ProductStockCountDto.cs @@ -0,0 +1,8 @@ +namespace MyWarehouse.Application.Products.GetProductsSummary +{ + public record ProductStockCountDto + { + public int ProductCount { get; init; } + public int TotalStock { get; init; } + } +} diff --git a/src/Application/Products/ProductStockCount/ProductStockCountQuery.cs b/src/Application/Products/ProductStockCount/ProductStockCountQuery.cs new file mode 100644 index 0000000..11adbe2 --- /dev/null +++ b/src/Application/Products/ProductStockCount/ProductStockCountQuery.cs @@ -0,0 +1,29 @@ +using MediatR; +using MyWarehouse.Application.Dependencies.Services; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Products.GetProductsSummary +{ + public record ProductStockCountQuery : IRequest + { + } + + public class ProductStockCountQueryHandler : IRequestHandler + { + private readonly IStockStatisticsService _statisticsService; + + public ProductStockCountQueryHandler(IStockStatisticsService statisticsService) + => _statisticsService = statisticsService; + + public async Task Handle(ProductStockCountQuery request, CancellationToken cancellationToken) + { + var res = await _statisticsService.GetProductStockCounts(); + + return new ProductStockCountDto() { + ProductCount = res.ProductCount, + TotalStock = res.TotalStock + }; + } + } +} diff --git a/src/Application/Products/ProductStockMass/ProductStockMassQuery.cs b/src/Application/Products/ProductStockMass/ProductStockMassQuery.cs new file mode 100644 index 0000000..1d57e79 --- /dev/null +++ b/src/Application/Products/ProductStockMass/ProductStockMassQuery.cs @@ -0,0 +1,31 @@ +using MediatR; +using MyWarehouse.Application.Dependencies.Services; +using MyWarehouse.Domain.Common.ValueObjects.Mass; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Products.ProductStockMass +{ + public record ProductStockMassQuery : IRequest + { + } + + public class ProductStockMassQueryHandler : IRequestHandler + { + private readonly IStockStatisticsService _statisticsService; + + public ProductStockMassQueryHandler(IStockStatisticsService statisticsService) + => _statisticsService = statisticsService; + + public async Task Handle(ProductStockMassQuery request, CancellationToken cancellationToken) + { + var mass = await _statisticsService.GetProductStockTotalMass(MassUnit.Tonne); + + return new StockMassDto() + { + Value = mass.Value, + Unit = mass.Unit.Symbol + }; + } + } +} diff --git a/src/Application/Products/ProductStockMass/StockMassDto.cs b/src/Application/Products/ProductStockMass/StockMassDto.cs new file mode 100644 index 0000000..a505031 --- /dev/null +++ b/src/Application/Products/ProductStockMass/StockMassDto.cs @@ -0,0 +1,8 @@ +namespace MyWarehouse.Application.Products.ProductStockMass +{ + public record StockMassDto + { + public float Value { get; init; } + public string Unit { get; init; } + } +} diff --git a/src/Application/Products/ProductStockValue/ProductStockValueQuery.cs b/src/Application/Products/ProductStockValue/ProductStockValueQuery.cs new file mode 100644 index 0000000..22817e1 --- /dev/null +++ b/src/Application/Products/ProductStockValue/ProductStockValueQuery.cs @@ -0,0 +1,30 @@ +using MediatR; +using MyWarehouse.Application.Dependencies.Services; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Products.ProductStockValue +{ + public record ProductStockValueQuery : IRequest + { + } + + public class ProductStockValueQueryHandler : IRequestHandler + { + private readonly IStockStatisticsService _statisticsService; + + public ProductStockValueQueryHandler(IStockStatisticsService stockStatisticsService) + => _statisticsService = stockStatisticsService; + + public async Task Handle(ProductStockValueQuery request, CancellationToken cancellationToken) + { + var totalStockValue = await _statisticsService.GetProductStockTotalValue(); + + return new StockValueDto() + { + Amount = totalStockValue.Amount, + CurrencyCode = totalStockValue.Currency.Code + }; + } + } +} diff --git a/src/Application/Products/ProductStockValue/StockValueDto.cs b/src/Application/Products/ProductStockValue/StockValueDto.cs new file mode 100644 index 0000000..72ac133 --- /dev/null +++ b/src/Application/Products/ProductStockValue/StockValueDto.cs @@ -0,0 +1,8 @@ +namespace MyWarehouse.Application.Products.ProductStockValue +{ + public record StockValueDto + { + public decimal Amount { get; init; } + public string CurrencyCode { get; init; } + } +} diff --git a/src/Application/Products/UpdateProduct/UpdateProductCommand.cs b/src/Application/Products/UpdateProduct/UpdateProductCommand.cs new file mode 100644 index 0000000..9c1af58 --- /dev/null +++ b/src/Application/Products/UpdateProduct/UpdateProductCommand.cs @@ -0,0 +1,43 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Exceptions; +using MyWarehouse.Domain.Products; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Partners.UpdatePartner +{ + public record UpdateProductCommand : IRequest + { + public int Id { get; init; } + + public string Name { get; init; } + public string Description { get; init; } + + public float MassValue { get; init; } + public decimal PriceAmount { get; init; } + } + + public class UpdateProductCommandHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + + public UpdateProductCommandHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public async Task Handle(UpdateProductCommand request, CancellationToken cancellationToken) + { + var product = await _unitOfWork.Products.GetByIdAsync(request.Id) + ?? throw new EntityNotFoundException(nameof(Product), request.Id); + + product.UpdateName(request.Name.Trim()); + product.UpdateDescription(request.Description.Trim()); + product.UpdateMass(request.MassValue); + product.UpdatePrice(request.PriceAmount); + + await _unitOfWork.SaveChanges(); + + return Unit.Value; + } + } +} diff --git a/src/Application/Products/UpdateProduct/UpdateProductCommandValidator.cs b/src/Application/Products/UpdateProduct/UpdateProductCommandValidator.cs new file mode 100644 index 0000000..7c73fc6 --- /dev/null +++ b/src/Application/Products/UpdateProduct/UpdateProductCommandValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using MyWarehouse.Domain.Products; + +namespace MyWarehouse.Application.Partners.UpdatePartner +{ + public class UpdateProductCommandValidator : AbstractValidator + { + public UpdateProductCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(ProductInvariants.NameMaxLength); + + RuleFor(x => x.Description) + .NotEmpty() + .MaximumLength(ProductInvariants.DescriptionMaxLength); + + RuleFor(x => x.MassValue) + .GreaterThanOrEqualTo(ProductInvariants.MassMinimum); + + RuleFor(x => x.PriceAmount) + .GreaterThanOrEqualTo(ProductInvariants.PriceMinimum); + } + } +} diff --git a/src/Application/Startup.cs b/src/Application/Startup.cs new file mode 100644 index 0000000..50ac65d --- /dev/null +++ b/src/Application/Startup.cs @@ -0,0 +1,23 @@ +using AutoMapper; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using MyWarehouse.Application.Common.Behaviors; +using System.Reflection; + +namespace MyWarehouse.Core +{ + public static class Startup + { + public static IServiceCollection ConfigureServices(this IServiceCollection services) + { + services.AddAutoMapper(Assembly.GetExecutingAssembly()); + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + services.AddMediatR(Assembly.GetExecutingAssembly()); + + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>)); + + return services; + } + } +} diff --git a/src/Application/Transactions/CreateTransaction/CreateTransactionCommand.cs b/src/Application/Transactions/CreateTransaction/CreateTransactionCommand.cs new file mode 100644 index 0000000..c7693a6 --- /dev/null +++ b/src/Application/Transactions/CreateTransaction/CreateTransactionCommand.cs @@ -0,0 +1,59 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Exceptions; +using MyWarehouse.Domain; +using MyWarehouse.Domain.Products; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Transactions.CreateTransaction +{ + public record CreateTransactionCommand : IRequest + { + public int PartnerId { get; init; } + public TransactionType TransactionType { get; init; } + public TransactionLine[] TransactionLines { get; init; } + + public struct TransactionLine + { + public int ProductId { get; init; } + public int ProductQuantity { get; init; } + } + } + + public class CreateTransactionCommandHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + + public CreateTransactionCommandHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public async Task Handle(CreateTransactionCommand request, CancellationToken cancellationToken) + { + var partner = await _unitOfWork.Partners.GetByIdAsync(request.PartnerId) + ?? throw new InputValidationException((nameof(request.PartnerId), $"Partner (id: {request.PartnerId}) was not found.")); + + var transactionLines = new List<(Product product, int quantity)>(); + foreach (var line in request.TransactionLines) + { + var product = await _unitOfWork.Products.GetByIdAsync(line.ProductId) + ?? throw new InputValidationException((nameof(line.ProductId), $"Product (id: {line.ProductId}) was not found.")); + + transactionLines.Add((product, line.ProductQuantity)); + } + + var transaction = request.TransactionType switch + { + TransactionType.Sales => partner.SellTo(transactionLines), + TransactionType.Procurement => partner.ProcureFrom(transactionLines), + _ => throw new InvalidEnumArgumentException($"No operation is defined for {nameof(TransactionType)} of '{request.TransactionType}'.") + }; + + await _unitOfWork.SaveChanges(); + + return transaction.Id; + } + } +} diff --git a/src/Application/Transactions/CreateTransaction/CreateTransactionCommandValidator.cs b/src/Application/Transactions/CreateTransaction/CreateTransactionCommandValidator.cs new file mode 100644 index 0000000..0fdec38 --- /dev/null +++ b/src/Application/Transactions/CreateTransaction/CreateTransactionCommandValidator.cs @@ -0,0 +1,62 @@ +using FluentValidation; +using FluentValidation.Validators; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Transactions.CreateTransaction +{ + public class CreateTransactionCommandValidator : AbstractValidator + { + private readonly IProductRepository _productRepository; + + public CreateTransactionCommandValidator(IProductRepository productRepository) + { + _productRepository = productRepository; + + RuleFor(x => x.TransactionLines) + .NotEmpty() + .DependentRules(() => { + + RuleForEach(x => x.TransactionLines) + .Must(l => l.ProductQuantity > 0) + .WithMessage("Product quantity at line {CollectionIndex} must be larger than 0"); + + RuleFor(x => x.TransactionLines) + .Must(x => x.GroupBy(x => x.ProductId).Any(g => g.Count() == 1)) + .WithMessage("Can't have more than one transaction lines for the same product."); + + RuleFor(x => x.TransactionLines) + .MustAsync(HaveSufficientStock) + .WithMessage("Cannot record a sales transaction of quantity {RequestedQty} for product '{ProductName}', because current stock is {ProductStock}."); + }); + } + + private async Task HaveSufficientStock(CreateTransactionCommand c, CreateTransactionCommand.TransactionLine[] lines, PropertyValidatorContext ctx, CancellationToken _) + { + if (c.TransactionType == Domain.TransactionType.Procurement) + { + return true; + } + + var requestedProducts = await _productRepository.GetFiltered( + x => lines.Select(l => l.ProductId).Contains(x.Id)); + + foreach (var line in lines) + { + var product = requestedProducts.Where(p => p.Id == line.ProductId).Single(); + + if (product.NumberInStock < line.ProductQuantity) + { + ctx.MessageFormatter.AppendArgument("ProductName", product.Name); + ctx.MessageFormatter.AppendArgument("ProductStock", product.NumberInStock); + ctx.MessageFormatter.AppendArgument("RequestedQty", line.ProductQuantity); + return false; + } + } + + return true; + } + } +} diff --git a/src/Application/Transactions/GetTransactionDetails/GetTransactionDetailsQuery.cs b/src/Application/Transactions/GetTransactionDetails/GetTransactionDetailsQuery.cs new file mode 100644 index 0000000..38b9ac8 --- /dev/null +++ b/src/Application/Transactions/GetTransactionDetails/GetTransactionDetailsQuery.cs @@ -0,0 +1,31 @@ +using AutoMapper; +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Exceptions; +using System.Threading; +using System.Threading.Tasks; +using System.Transactions; + +namespace MyWarehouse.Application.Transactions.GetTransactionDetails +{ + public record GetTransactionDetailsQuery : IRequest + { + public int Id { get; set; } + } + + public class GetTransactionDetailsQueryHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetTransactionDetailsQueryHandler(IUnitOfWork unitOfWork, IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle(GetTransactionDetailsQuery request, CancellationToken cancellationToken) + => await _unitOfWork.Transactions.GetProjectedAsync(request.Id, readOnly: true) + ?? throw new EntityNotFoundException(nameof(Transaction), request.Id); + } +} diff --git a/src/Application/Transactions/GetTransactionDetails/TransactionDetailsDto.cs b/src/Application/Transactions/GetTransactionDetails/TransactionDetailsDto.cs new file mode 100644 index 0000000..0054760 --- /dev/null +++ b/src/Application/Transactions/GetTransactionDetails/TransactionDetailsDto.cs @@ -0,0 +1,53 @@ +using AutoMapper; +using MyWarehouse.Application.Common.Mapping; +using MyWarehouse.Domain.Transactions; +using System; +using System.Collections.Generic; + +namespace MyWarehouse.Application.Transactions.GetTransactionDetails +{ + public record TransactionDetailsDto : IMapFrom + { + public int Id { get; init; } + public int TransactionType { get; init; } + + public DateTime CreatedAt { get; init; } + public string CreatedBy { get; init; } + + public DateTime? ModifiedAt { get; init; } + public string ModifiedBy { get; init; } + + public int PartnerId { get; init; } + public string PartnerName { get; init; } + public string PartnerAddress { get; init; } + public bool PartnerIsDeleted { get; init; } + + public decimal TotalAmount { get; init; } + public string TotalCurrencyCode { get; init; } + + public List TransactionLines { get; init; } + + public struct TransactionLineDto + { + public int ProductId { get; init; } + public string ProductName { get; init; } + public int Quantity { get; init; } + public bool ProductIsDeleted { get; init; } + + public string UnitPrice { get; init; } + public decimal UnitPriceAmount { get; init; } + public string UnitPriceCurrencyCode { get; init; } + } + + public void Mapping(Profile profile) + { + profile.CreateMap() + .ForMember(dest => dest.PartnerIsDeleted, cfg => cfg + .MapFrom(src => src.Partner.DeletedAt != null)); + + profile.CreateMap() + .ForMember(dest => dest.ProductIsDeleted, cfg => cfg + .MapFrom(src => src.Product.DeletedAt != null)); + } + } +} \ No newline at end of file diff --git a/src/Application/Transactions/GetTransactionsList/GetTransactionsListQuery.cs b/src/Application/Transactions/GetTransactionsList/GetTransactionsListQuery.cs new file mode 100644 index 0000000..3df9b99 --- /dev/null +++ b/src/Application/Transactions/GetTransactionsList/GetTransactionsListQuery.cs @@ -0,0 +1,27 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using MyWarehouse.Domain; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Transactions.GetTransactionsList +{ + public class GetTransactionListQuery : ListQueryModel + { + public TransactionType? Type { get; init; } + } + + public class GetTransactionsListQueryHandler : IRequestHandler> + { + private readonly IUnitOfWork _unitOfWork; + + public GetTransactionsListQueryHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public async Task> Handle(GetTransactionListQuery request, CancellationToken cancellationToken) + => await _unitOfWork.Transactions.GetProjectedListAsync(request, + additionalFilter: request.Type.HasValue ? x => x.TransactionType == request.Type : null, + readOnly: true); + } +} diff --git a/src/Application/Transactions/GetTransactionsList/TransactionDto.cs b/src/Application/Transactions/GetTransactionsList/TransactionDto.cs new file mode 100644 index 0000000..664ebbf --- /dev/null +++ b/src/Application/Transactions/GetTransactionsList/TransactionDto.cs @@ -0,0 +1,17 @@ +using MyWarehouse.Application.Common.Mapping; +using MyWarehouse.Domain.Transactions; +using System; + +namespace MyWarehouse.Application.Transactions.GetTransactionsList +{ + public record TransactionDto : IMapFrom + { + public int Id { get; init; } + public DateTime CreatedAt { get; init; } + public int TransactionType { get; init; } + public string PartnerName { get; init; } + + public decimal TotalAmount { get; init; } + public string TotalCurrencyCode { get; init; } + } +} \ No newline at end of file diff --git a/src/Application/Transactions/RecordProcurement/RecordProcurementCommand.cs b/src/Application/Transactions/RecordProcurement/RecordProcurementCommand.cs new file mode 100644 index 0000000..3d39ef3 --- /dev/null +++ b/src/Application/Transactions/RecordProcurement/RecordProcurementCommand.cs @@ -0,0 +1,61 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Exceptions; +using MyWarehouse.Domain; +using MyWarehouse.Domain.Products; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Transactions.RecordProcurement +{ + public record RecordProcurementCommand : IRequest + { + public int PartnerId { get; init; } + public TransactionType TransactionType { get; init; } + public TransactionLine[] TransactionLines { get; init; } + + public struct TransactionLine + { + public int ProductId { get; init; } + public int ProductQuantity { get; init; } + public decimal ProductPurchaseUnitPriceAmount { get; init; } + public string ProductPurchaseUnitPriceCurrencyCode { get; init; } + } + } + + public class CreateTransactionCommandHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + + public CreateTransactionCommandHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public async Task Handle(RecordProcurementCommand request, CancellationToken cancellationToken) + { + var partner = await _unitOfWork.Partners.GetByIdAsync(request.PartnerId) + ?? throw new InputValidationException((nameof(request.PartnerId), $"Partner (id: {request.PartnerId}) was not found.")); + + var transactionLines = new List<(Product product, int quantity)>(); + foreach (var line in request.TransactionLines) + { + var product = await _unitOfWork.Products.GetByIdAsync(line.ProductId) + ?? throw new InputValidationException((nameof(line.ProductId), $"Product (id: {line.ProductId}) was not found.")); + + transactionLines.Add((product, line.ProductQuantity)); + } + + var transaction = request.TransactionType switch + { + TransactionType.Sales => partner.SellTo(transactionLines), + TransactionType.Procurement => partner.ProcureFrom(transactionLines), + _ => throw new InvalidEnumArgumentException($"No operation is defined for {nameof(TransactionType)} of '{request.TransactionType}'.") + }; + + await _unitOfWork.SaveChanges(); + + return transaction.Id; + } + } +} diff --git a/src/Application/Transactions/RecordProcurement/RecordProcurementCommandValidator.cs b/src/Application/Transactions/RecordProcurement/RecordProcurementCommandValidator.cs new file mode 100644 index 0000000..047a803 --- /dev/null +++ b/src/Application/Transactions/RecordProcurement/RecordProcurementCommandValidator.cs @@ -0,0 +1,62 @@ +using FluentValidation; +using FluentValidation.Validators; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Transactions.RecordProcurement +{ + public class RecordProcurementCommandValidator : AbstractValidator + { + private readonly IProductRepository _productRepository; + + public RecordProcurementCommandValidator(IProductRepository productRepository) + { + _productRepository = productRepository; + + RuleFor(x => x.TransactionLines) + .NotEmpty() + .DependentRules(() => { + + RuleForEach(x => x.TransactionLines) + .Must(l => l.ProductQuantity > 0) + .WithMessage("Product quantity at line {CollectionIndex} must be larger than 0"); + + RuleFor(x => x.TransactionLines) + .Must(x => x.GroupBy(x => x.ProductId).Any(g => g.Count() == 1)) + .WithMessage("Can't have more than one transaction lines for the same product."); + + RuleFor(x => x.TransactionLines) + .MustAsync(HaveSufficientStock) + .WithMessage("Cannot record a sales transaction of quantity {RequestedQty} for product '{ProductName}', because current stock is {ProductStock}."); + }); + } + + private async Task HaveSufficientStock(RecordProcurementCommand c, RecordProcurementCommand.TransactionLine[] lines, PropertyValidatorContext ctx, CancellationToken _) + { + if (c.TransactionType == Domain.TransactionType.Procurement) + { + return true; + } + + var requestedProducts = await _productRepository.GetFiltered( + x => lines.Select(l => l.ProductId).Contains(x.Id)); + + foreach (var line in lines) + { + var product = requestedProducts.Where(p => p.Id == line.ProductId).Single(); + + if (product.NumberInStock < line.ProductQuantity) + { + ctx.MessageFormatter.AppendArgument("ProductName", product.Name); + ctx.MessageFormatter.AppendArgument("ProductStock", product.NumberInStock); + ctx.MessageFormatter.AppendArgument("RequestedQty", line.ProductQuantity); + return false; + } + } + + return true; + } + } +} diff --git a/src/Application/Transactions/RecordSales/CreateTransactionCommand.cs b/src/Application/Transactions/RecordSales/CreateTransactionCommand.cs new file mode 100644 index 0000000..decc998 --- /dev/null +++ b/src/Application/Transactions/RecordSales/CreateTransactionCommand.cs @@ -0,0 +1,59 @@ +using MediatR; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Exceptions; +using MyWarehouse.Domain; +using MyWarehouse.Domain.Products; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Transactions.RecordSales +{ + public record CreateTransactionCommand : IRequest + { + public int PartnerId { get; init; } + public TransactionType TransactionType { get; init; } + public TransactionLine[] TransactionLines { get; init; } + + public struct TransactionLine + { + public int ProductId { get; init; } + public int ProductQuantity { get; init; } + } + } + + public class CreateTransactionCommandHandler : IRequestHandler + { + private readonly IUnitOfWork _unitOfWork; + + public CreateTransactionCommandHandler(IUnitOfWork unitOfWork) + => _unitOfWork = unitOfWork; + + public async Task Handle(CreateTransactionCommand request, CancellationToken cancellationToken) + { + var partner = await _unitOfWork.Partners.GetByIdAsync(request.PartnerId) + ?? throw new InputValidationException((nameof(request.PartnerId), $"Partner (id: {request.PartnerId}) was not found.")); + + var transactionLines = new List<(Product product, int quantity)>(); + foreach (var line in request.TransactionLines) + { + var product = await _unitOfWork.Products.GetByIdAsync(line.ProductId) + ?? throw new InputValidationException((nameof(line.ProductId), $"Product (id: {line.ProductId}) was not found.")); + + transactionLines.Add((product, line.ProductQuantity)); + } + + var transaction = request.TransactionType switch + { + TransactionType.Sales => partner.SellTo(transactionLines), + TransactionType.Procurement => partner.ProcureFrom(transactionLines), + _ => throw new InvalidEnumArgumentException($"No operation is defined for {nameof(TransactionType)} of '{request.TransactionType}'.") + }; + + await _unitOfWork.SaveChanges(); + + return transaction.Id; + } + } +} diff --git a/src/Application/Transactions/RecordSales/CreateTransactionCommandValidator.cs b/src/Application/Transactions/RecordSales/CreateTransactionCommandValidator.cs new file mode 100644 index 0000000..78a46c2 --- /dev/null +++ b/src/Application/Transactions/RecordSales/CreateTransactionCommandValidator.cs @@ -0,0 +1,62 @@ +using FluentValidation; +using FluentValidation.Validators; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MyWarehouse.Application.Transactions.RecordSales +{ + public class CreateTransactionCommandValidator : AbstractValidator + { + private readonly IProductRepository _productRepository; + + public CreateTransactionCommandValidator(IProductRepository productRepository) + { + _productRepository = productRepository; + + RuleFor(x => x.TransactionLines) + .NotEmpty() + .DependentRules(() => { + + RuleForEach(x => x.TransactionLines) + .Must(l => l.ProductQuantity > 0) + .WithMessage("Product quantity at line {CollectionIndex} must be larger than 0"); + + RuleFor(x => x.TransactionLines) + .Must(x => x.GroupBy(x => x.ProductId).Any(g => g.Count() == 1)) + .WithMessage("Can't have more than one transaction lines for the same product."); + + RuleFor(x => x.TransactionLines) + .MustAsync(HaveSufficientStock) + .WithMessage("Cannot record a sales transaction of quantity {RequestedQty} for product '{ProductName}', because current stock is {ProductStock}."); + }); + } + + private async Task HaveSufficientStock(CreateTransactionCommand c, CreateTransactionCommand.TransactionLine[] lines, PropertyValidatorContext ctx, CancellationToken _) + { + if (c.TransactionType == Domain.TransactionType.Procurement) + { + return true; + } + + var requestedProducts = await _productRepository.GetFiltered( + x => lines.Select(l => l.ProductId).Contains(x.Id)); + + foreach (var line in lines) + { + var product = requestedProducts.Where(p => p.Id == line.ProductId).Single(); + + if (product.NumberInStock < line.ProductQuantity) + { + ctx.MessageFormatter.AppendArgument("ProductName", product.Name); + ctx.MessageFormatter.AppendArgument("ProductStock", product.NumberInStock); + ctx.MessageFormatter.AppendArgument("RequestedQty", line.ProductQuantity); + return false; + } + } + + return true; + } + } +} diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj new file mode 100644 index 0000000..2f002aa --- /dev/null +++ b/src/Common/Common.csproj @@ -0,0 +1,7 @@ + + + + net5.0 + + + diff --git a/src/Common/Options/CoreSettings.cs b/src/Common/Options/CoreSettings.cs new file mode 100644 index 0000000..ac75ac1 --- /dev/null +++ b/src/Common/Options/CoreSettings.cs @@ -0,0 +1,28 @@ +namespace MyWarehouse.Common.Options +{ + /// + /// Strongly typed container for core API settings. + /// + //public class CoreSettings + //{ + // /// + // /// Identifies the name of the application for e.g. logging purposes. + // /// + // public string ApiName { get; init; } + + // /// + // /// The version of the API. + // /// + // public string ApiVersion { get; init; } + + // /// + // /// The URL of the main Azure Key Vault holding the sensitive data the API requires. + // /// + // public string KeyVaultUrl { get; init; } + + // /// + // /// Specifies whether Swagger API documentation page should be accessible. + // /// + // public bool UseSwagger { get; init; } + //} +} diff --git a/src/Domain/AssemblyConfig.cs b/src/Domain/AssemblyConfig.cs new file mode 100644 index 0000000..9bbfc76 --- /dev/null +++ b/src/Domain/AssemblyConfig.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Domain.UnitTests")] + namespace Domain { } diff --git a/src/Domain/Common/IAudited.cs b/src/Domain/Common/IAudited.cs new file mode 100644 index 0000000..f39911e --- /dev/null +++ b/src/Domain/Common/IAudited.cs @@ -0,0 +1,15 @@ +using System; + +namespace MyWarehouse.Domain.Common +{ + public interface IAudited + { + string CreatedBy { get; } + + DateTime CreatedAt { get; } + + string LastModifiedBy { get; } + + DateTime? LastModifiedAt { get; } + } +} \ No newline at end of file diff --git a/src/Domain/Common/IEntity.cs b/src/Domain/Common/IEntity.cs new file mode 100644 index 0000000..743763c --- /dev/null +++ b/src/Domain/Common/IEntity.cs @@ -0,0 +1,7 @@ +namespace MyWarehouse.Domain.Common +{ + public interface IEntity + { + int Id { get; } + } +} \ No newline at end of file diff --git a/src/Domain/Common/ISoftDeletable.cs b/src/Domain/Common/ISoftDeletable.cs new file mode 100644 index 0000000..46abb1a --- /dev/null +++ b/src/Domain/Common/ISoftDeletable.cs @@ -0,0 +1,11 @@ +using System; + +namespace MyWarehouse.Domain.Common +{ + public interface ISoftDeletable + { + public string DeletedBy { get; } + + public DateTime? DeletedAt { get; } + } +} \ No newline at end of file diff --git a/src/Domain/Common/MyEntity.cs b/src/Domain/Common/MyEntity.cs new file mode 100644 index 0000000..da5582c --- /dev/null +++ b/src/Domain/Common/MyEntity.cs @@ -0,0 +1,21 @@ +using System; + +namespace MyWarehouse.Domain.Common +{ + public abstract class MyEntity : IEntity, ISoftDeletable, IAudited + { + public int Id { get; private set; } + + public string CreatedBy { get; private set; } + + public DateTime CreatedAt { get; private set; } + + public string LastModifiedBy { get; private set; } + + public DateTime? LastModifiedAt { get; private set; } + + public string DeletedBy { get; private set; } + + public DateTime? DeletedAt { get; private set; } + } +} \ No newline at end of file diff --git a/src/Domain/Common/ValueObjects/Mass/Mass.cs b/src/Domain/Common/ValueObjects/Mass/Mass.cs new file mode 100644 index 0000000..5f00f92 --- /dev/null +++ b/src/Domain/Common/ValueObjects/Mass/Mass.cs @@ -0,0 +1,40 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Domain.Common.ValueObjects.Mass +{ + public record Mass + { + public float Value { get; init; } + + [Required] + public MassUnit Unit { get; init; } + + private Mass() + { } + + public Mass(float value, MassUnit unit) + { + if (value < 0) + throw new ArgumentException("Value cannot be negative", nameof(value)); + + Value = value; + Unit = unit; + } + + public Mass ConvertTo(MassUnit newUnit) + { + if (newUnit == Unit) + return this; + + return new Mass() + { + Value = Value * Unit.ConversionRateToGram / newUnit.ConversionRateToGram, + Unit = newUnit + }; + } + + public override string ToString() + => $"{Value:n} {Unit.Symbol}"; + } +} diff --git a/src/Domain/Common/ValueObjects/Mass/MassUnit.cs b/src/Domain/Common/ValueObjects/Mass/MassUnit.cs new file mode 100644 index 0000000..590c86e --- /dev/null +++ b/src/Domain/Common/ValueObjects/Mass/MassUnit.cs @@ -0,0 +1,29 @@ +using System; + +namespace MyWarehouse.Domain.Common.ValueObjects.Mass +{ + public record MassUnit + { + public string Name { get; init; } + public string Symbol { get; init; } + public float ConversionRateToGram { get; init; } + + private MassUnit() + { } + + public static readonly MassUnit Tonne = new MassUnit() { Name = "tonne", Symbol = "t", ConversionRateToGram = 1000000f }; + public static readonly MassUnit Kilogram = new MassUnit() { Name = "kilogram", Symbol = "kg", ConversionRateToGram = 1000f }; + public static readonly MassUnit Gram = new MassUnit() { Name = "gram", Symbol = "g", ConversionRateToGram = 1f }; + public static readonly MassUnit Pound = new MassUnit() { Name = "pound", Symbol = "lb", ConversionRateToGram = 453.59237f }; + + public static MassUnit FromSymbol(string unitSymbol) + => unitSymbol.ToLower() switch + { + "t" => Tonne, + "kg" => Kilogram, + "g" => Gram, + "lb" => Pound, + _ => throw new ArgumentException($"Uknown {nameof(MassUnit)} value '{unitSymbol}'.", nameof(unitSymbol)) + }; + } +} diff --git a/src/Domain/Common/ValueObjects/Money/Currency.cs b/src/Domain/Common/ValueObjects/Money/Currency.cs new file mode 100644 index 0000000..d9e8179 --- /dev/null +++ b/src/Domain/Common/ValueObjects/Money/Currency.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MyWarehouse.Domain.Common.ValueObjects.Money +{ + /// + /// Value object that represents currency. Simplified sample. + /// + public record Currency + { + public string Code { get; init; } + public string Symbol { get; init; } + public bool SymbolWellKnown { get; init; } = false; + + private static readonly IReadOnlyDictionary _lookup = new Dictionary() + { + ["USD"] = new Record("USD", "$", symbolWellKnown: true), + ["EUR"] = new Record("EUR", "€", symbolWellKnown: true), + ["JPY"] = new Record("JPY", "¥", symbolWellKnown: true), + ["GBP"] = new Record("GBP", "£", symbolWellKnown: true), + ["CAD"] = new Record("CAD", "C$"), + ["AUD"] = new Record("AUD", "A$"), + ["CHF"] = new Record("CHF", "Fr."), + ["NZD"] = new Record("NZD", "NZ$"), + ["RUB"] = new Record("RUB", "₽"), + ["HUF"] = new Record("HUF", "Ft"), + }; + + private Currency() + { } + + private Currency(Record record) + => (Code, Symbol, SymbolWellKnown) = (record.Code, record.Symbol, record.SymbolWellKnown); + + /// + /// Returns a currency instance from the provided ISO currency code string. + /// + /// Three letter ISO currency code. + /// Thrown when the provided code is not valid or not supported by the system. + public static Currency FromCode(string code) + { + if (!_lookup.TryGetValue(code.ToUpper(), out var record)) + throw new ArgumentException($"Code '{code}' is not a currency code we recognize.", nameof(code)); + + return new Currency(record); + } + + public static bool IsValidCurrencyCode(string code) + => _lookup.ContainsKey(code); + + public static List GetAllCurrencies + => _lookup.Select(x => new Currency(x.Value)).ToList(); + + public override string ToString() + => Code; + + /// + /// Returns an instance of the currency that is deemed default for the domain. + /// + public static Currency Default => USD; + + public static Currency USD = new Currency(_lookup["USD"]); + public static Currency EUR = new Currency(_lookup["EUR"]); + public static Currency JPY = new Currency(_lookup["JPY"]); + public static Currency GBP = new Currency(_lookup["GBP"]); + public static Currency CAD = new Currency(_lookup["CAD"]); + public static Currency AUD = new Currency(_lookup["AUD"]); + public static Currency CHF = new Currency(_lookup["CHF"]); + public static Currency NZD = new Currency(_lookup["NZD"]); + public static Currency RUB = new Currency(_lookup["RUB"]); + public static Currency HUF = new Currency(_lookup["HUF"]); + + private readonly struct Record + { + public readonly string Code; + public readonly string Symbol; + public readonly bool SymbolWellKnown; + + public Record(string code, string symbol, bool symbolWellKnown = false) + => (Code, Symbol, SymbolWellKnown) = (code, symbol, symbolWellKnown); + } + } +} diff --git a/src/Domain/Common/ValueObjects/Money/Money.cs b/src/Domain/Common/ValueObjects/Money/Money.cs new file mode 100644 index 0000000..8314329 --- /dev/null +++ b/src/Domain/Common/ValueObjects/Money/Money.cs @@ -0,0 +1,79 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Domain.Common.ValueObjects.Money +{ + /// + /// Value object representing money. Simplified sample. + /// + public record Money + { + public decimal Amount { get; init; } + + [Required] + public Currency Currency { get; init; } + + private Money() + { } + + public Money(decimal amount, Currency currency) + { + if (amount < 0) throw new ArgumentException("Value cannot be negative.", nameof(amount)); + if (currency == null) throw new ArgumentNullException(nameof(currency)); + + (Amount, Currency) = (amount, currency); + } + + public Money Copy() + => new Money(this.Amount, this.Currency); + + public Money AddAmount(decimal amount) + => new Money() + { + Amount = Amount + amount, + Currency = Currency + }; + + public static Money operator +(Money left, Money right) + { + if (!Equals(left.Currency, right.Currency)) + throw new InvalidOperationException($"Mixing currencies is not supported. Cannot add money of '{left.Currency}' and money of '{right.Currency}'."); + + return left.AddAmount(right.Amount); + } + + public static Money operator -(Money left, Money right) + { + if (!Equals(left.Currency, right.Currency)) + throw new InvalidOperationException($"Mixing currencies is not supported. Cannot subtract money of '{left.Currency}' and money of '{right.Currency}'."); + + return left.AddAmount(-right.Amount); + } + + public static Money operator *(Money left, Money right) + { + if (!Equals(left.Currency, right.Currency)) + throw new InvalidOperationException("Multiplication of two money instances require both to have the same currency."); + + return new Money() { Amount = left.Amount * right.Amount, Currency = left.Currency }; + } + + public static Money operator *(int scalar, Money money) + => new Money() { Currency = money.Currency, Amount = money.Amount * scalar }; + + public static Money operator *(Money money, int scalar) + => scalar * money; + + public static Money operator *(decimal scalar, Money money) + => new Money() { Currency = money.Currency, Amount = money.Amount * scalar }; + + public static Money operator *(Money money, decimal scalar) + => scalar * money; + + public override string ToString() + => string.Format("{0}{1}{2:n}", + Currency.SymbolWellKnown ? Currency.Symbol : Currency.Code, + Currency.SymbolWellKnown ? null : " ", + Amount); + } +} diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj new file mode 100644 index 0000000..0e2afcc --- /dev/null +++ b/src/Domain/Domain.csproj @@ -0,0 +1,11 @@ + + + + net5.0 + + + + + + + diff --git a/src/Domain/Exceptions/InsufficientStockException.cs b/src/Domain/Exceptions/InsufficientStockException.cs new file mode 100644 index 0000000..f84dec7 --- /dev/null +++ b/src/Domain/Exceptions/InsufficientStockException.cs @@ -0,0 +1,20 @@ +using MyWarehouse.Domain.Products; +using System; + +namespace MyWarehouse.Domain.Exceptions +{ + public class InsufficientStockException : Exception + { + public string ProductName { get; } + public int RequestedQuantity { get; } + public int ActualQuantity { get; } + + public InsufficientStockException(Product product, int requestedQuantity, int actualQuantity) + : base($"Quantity requested for sale ({requestedQuantity}) from product '{product.Name}' exceeds number in stock ({actualQuantity}).") + { + ProductName = product.Name; + RequestedQuantity = requestedQuantity; + ActualQuantity = actualQuantity; + } + } +} diff --git a/src/Domain/Partners/Address.cs b/src/Domain/Partners/Address.cs new file mode 100644 index 0000000..6e6fa1e --- /dev/null +++ b/src/Domain/Partners/Address.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Domain.Partners +{ + /// + /// Value object representing an address. + /// + public record Address + { + [Required, MinLength(1), MaxLength(100)] + public string Street { get; init; } + + [Required, MinLength(1), MaxLength(100)] + public string City { get; init; } + + [Required, MinLength(1), MaxLength(100)] + public string Country { get; init; } + + [Required, MinLength(1), MaxLength(100)] + public string ZipCode { get; init; } + + private Address() { } + + // TODO: Add validation and constraints + public Address(string street, string city, string country, string zipcode) + => (Street, City, Country, ZipCode) = (street, city, country, zipcode); + + public override string ToString() + => $"{Street}, {ZipCode} {City}, {Country}"; + } +} diff --git a/src/Domain/Partners/Partner.cs b/src/Domain/Partners/Partner.cs new file mode 100644 index 0000000..35078a4 --- /dev/null +++ b/src/Domain/Partners/Partner.cs @@ -0,0 +1,87 @@ +using MyWarehouse.Domain.Common; +using MyWarehouse.Domain.Products; +using MyWarehouse.Domain.Transactions; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace MyWarehouse.Domain.Partners +{ + /// + /// Simplified entity. In product context a partner would contain more fine grained name fields, + /// a complex address representation, phone number, invoicing/tax details, etc. + /// + public class Partner : MyEntity + { + [Required] + [StringLength(PartnerInvariants.NameMaxLength)] + public string Name { get; private set; } + + [Required] + public Address Address { get; private set; } + + public virtual IReadOnlyCollection Transactions => _transactions.AsReadOnly(); + private List _transactions = new List(); + + private Partner() // EF + {} + + public Partner(string name, Address address) + { + UpdateName(name); + UpdateAddress(address); + } + + /// + /// Generate a new sales transaction with this partner. + /// + public Transaction SellTo(IEnumerable<(Product product, int quantity)> items) + => CreateTransaction(items, TransactionType.Sales); + + /// + /// Generate a new procurement transaction with this partner. + /// + public Transaction ProcureFrom(IEnumerable<(Product product, int quantity)> items) + => CreateTransaction(items, TransactionType.Procurement); + + public void UpdateName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Name cannot be empty."); + + if (value.Length > PartnerInvariants.NameMaxLength) + throw new ArgumentException($"Length of value ({value.Length}) exceeds maximum name length ({ProductInvariants.NameMaxLength})."); + + Name = value; + } + + public void UpdateAddress(Address address) + { + Address = address ?? throw new ArgumentNullException(nameof(address)); + } + + private Transaction CreateTransaction(IEnumerable<(Product product, int quantity)> items, TransactionType transactionType) + { + if (items == null) + throw new ArgumentNullException(nameof(items)); + + if (!items.Any() || items.Any(x => x.product == null || x.quantity < 1)) + throw new ArgumentException("List of items must be a non-empty list of non-null products and quantities of at least 1.", nameof(items)); + + var transaction = new Transaction( + type: transactionType, + partner: this + ); + + foreach (var (product, quantity) in items) + { + transaction.AddTransactionLine(product, quantity); + } + + _transactions.Add(transaction); + + return transaction; + } + } +} \ No newline at end of file diff --git a/src/Domain/Partners/PartnerInvariants.cs b/src/Domain/Partners/PartnerInvariants.cs new file mode 100644 index 0000000..b14f1f8 --- /dev/null +++ b/src/Domain/Partners/PartnerInvariants.cs @@ -0,0 +1,7 @@ +namespace MyWarehouse.Domain.Partners +{ + public static class PartnerInvariants + { + public const int NameMaxLength = 100; + } +} diff --git a/src/Domain/Products/Product.cs b/src/Domain/Products/Product.cs new file mode 100644 index 0000000..cc31432 --- /dev/null +++ b/src/Domain/Products/Product.cs @@ -0,0 +1,116 @@ +using MyWarehouse.Domain.Common; +using MyWarehouse.Domain.Common.ValueObjects.Mass; +using MyWarehouse.Domain.Common.ValueObjects.Money; +using MyWarehouse.Domain.Exceptions; +using MyWarehouse.Domain.Transactions; +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Domain.Products +{ + public class Product : MyEntity + { + [Required] + [StringLength(ProductInvariants.NameMaxLength)] + public string Name { get; private set; } + + [Required] + [StringLength(ProductInvariants.DescriptionMaxLength)] + public string Description { get; private set; } + + [Required] + public Money Price { get; private set; } + + [Required] + public Mass Mass { get; private set; } + + public int NumberInStock { get; private set; } + + private Product() // EF + {} + + public Product(string name, string description, Money price, Mass mass) + { + UpdateName(name); + UpdateDescription(description); + + CheckMass(mass?.Value ?? throw new ArgumentNullException(nameof(mass))); + CheckPrice(price?.Amount ?? throw new ArgumentNullException(nameof(price))); + + Mass = mass; + Price = price; + + NumberInStock = 0; + } + + public void UpdateMass(float value) + { + CheckMass(value); + Mass = new Mass(value, Mass?.Unit ?? ProductInvariants.DefaultMassUnit); + } + + public void UpdatePrice(decimal amount) + { + CheckPrice(amount); + Price = new Money(amount, Price?.Currency ?? ProductInvariants.DefaultPriceCurrency); + } + + public void UpdateName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Name cannot be empty."); + + if (value.Length > ProductInvariants.NameMaxLength) + throw new ArgumentException($"Length of value ({value.Length}) exceeds maximum name length ({ProductInvariants.NameMaxLength})."); + + Name = value; + } + + public void UpdateDescription(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Description cannot be empty."); + + if (value.Length > ProductInvariants.DescriptionMaxLength) + throw new ArgumentException($"Length of value ({value.Length}) exceeds maximum description length ({ProductInvariants.NameMaxLength})."); + + Description = value; + } + + /// + /// Adjust product stock based on a transaction occurred. + /// + internal void RecordTransaction(TransactionLine transactionLine) + { + if (transactionLine.Quantity < 1) + throw new ArgumentException("Product quantity in transaction must be 1 or greater."); + + switch (transactionLine.Transaction.TransactionType) + { + case TransactionType.Sales: + if (transactionLine.Quantity > NumberInStock) + throw new InsufficientStockException(this, transactionLine.Quantity, NumberInStock); + NumberInStock -= transactionLine.Quantity; + break; + case TransactionType.Procurement: + NumberInStock += transactionLine.Quantity; + break; + default: + throw new InvalidEnumArgumentException($"Unexpected {nameof(TransactionType)}: '{transactionLine.Transaction.TransactionType}'."); + } + } + + private static void CheckMass(float value) + { + if (value < ProductInvariants.MassMinimum) + throw new ArgumentException($"Value '{value}' is smaller than the minimum required mass of {ProductInvariants.MassMinimum}."); + } + + private static void CheckPrice(decimal amount) + { + if (amount < ProductInvariants.PriceMinimum) + throw new ArgumentException($"Amount '{amount}' is smaller than the minimum required price of {ProductInvariants.MassMinimum}."); + } + } +} \ No newline at end of file diff --git a/src/Domain/Products/ProductInvariants.cs b/src/Domain/Products/ProductInvariants.cs new file mode 100644 index 0000000..3fc15a3 --- /dev/null +++ b/src/Domain/Products/ProductInvariants.cs @@ -0,0 +1,17 @@ +using MyWarehouse.Domain.Common.ValueObjects.Mass; +using MyWarehouse.Domain.Common.ValueObjects.Money; + +namespace MyWarehouse.Domain.Products +{ + public static class ProductInvariants + { + public const int NameMaxLength = 100; + public const int DescriptionMaxLength = 1000; + + public const float MassMinimum = 0.1f; + public const decimal PriceMinimum = 0.1m; + + public static readonly MassUnit DefaultMassUnit = MassUnit.Kilogram; + public static readonly Currency DefaultPriceCurrency = Currency.USD; + } +} diff --git a/src/Domain/Transactions/Transaction.cs b/src/Domain/Transactions/Transaction.cs new file mode 100644 index 0000000..b0691c3 --- /dev/null +++ b/src/Domain/Transactions/Transaction.cs @@ -0,0 +1,66 @@ +using MyWarehouse.Domain.Common; +using MyWarehouse.Domain.Common.ValueObjects.Money; +using MyWarehouse.Domain.Partners; +using MyWarehouse.Domain.Products; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace MyWarehouse.Domain.Transactions +{ + /// + /// Represents inventory movement, with a total value calculated as + /// inventory movement total as the time of the transaction. + /// Does not model traditional purchase and sales orders. + /// + public class Transaction : MyEntity + { + public TransactionType TransactionType { get; private set; } + + [Required] + public Money Total { get; private set; } + + public int PartnerId { get; private set; } + public virtual Partner Partner { get; private set; } + + public virtual IReadOnlyCollection TransactionLines => _transactionLines.AsReadOnly(); + private List _transactionLines = new List(); + + private Transaction() // EF + {} + + internal Transaction(TransactionType type, Partner partner) + { + TransactionType = type; + Partner = partner; + } + + internal void AddTransactionLine(Product product, int quantity) + { + if (product == null) + throw new ArgumentNullException(nameof(product)); + + if (quantity < 1) + throw new ArgumentException("Value must be equal to or greater than 1.", nameof(quantity)); + + // Sales quantity vs Product stock validation is a Product responsibility; see RecordTransaction(). + + var transactionLine = new TransactionLine() + { + Transaction = this, + Product = product, + Quantity = quantity, + UnitPrice = product.Price.Copy() + }; + + product.RecordTransaction(transactionLine); + + _transactionLines.Add(transactionLine); + + var currency = _transactionLines.First().UnitPrice.Currency; + Total = TransactionLines.Aggregate(new Money(0, currency), + (total, line) => total + (line.UnitPrice * line.Quantity)); + } + } +} \ No newline at end of file diff --git a/src/Domain/Transactions/TransactionLine.cs b/src/Domain/Transactions/TransactionLine.cs new file mode 100644 index 0000000..692becd --- /dev/null +++ b/src/Domain/Transactions/TransactionLine.cs @@ -0,0 +1,31 @@ +using MyWarehouse.Domain.Common; +using MyWarehouse.Domain.Common.ValueObjects.Money; +using MyWarehouse.Domain.Products; +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Domain.Transactions +{ + /// + /// Simplified entity. In production context it would probably record more momentary data, + /// including partner name, address, etc., for reporting and historical purposes. + /// + public class TransactionLine : IEntity + { + internal TransactionLine() { } + + public int Id { get; private set; } + + [Required] + public int ProductId { get; init; } + public virtual Product Product { get; init; } + + [Required] + public Transaction Transaction { get; init; } + + [Range(1, int.MaxValue)] + public int Quantity { get; init; } + + [Required] + public Money UnitPrice { get; init; } + } +} \ No newline at end of file diff --git a/src/Domain/Transactions/TransactionType.cs b/src/Domain/Transactions/TransactionType.cs new file mode 100644 index 0000000..e43b731 --- /dev/null +++ b/src/Domain/Transactions/TransactionType.cs @@ -0,0 +1,8 @@ +namespace MyWarehouse.Domain +{ + public enum TransactionType + { + Sales = 0, + Procurement = 1 + } +} \ No newline at end of file diff --git a/src/Infrastructure/Authentication/Model/TokenModel.cs b/src/Infrastructure/Authentication/Model/TokenModel.cs new file mode 100644 index 0000000..87a2938 --- /dev/null +++ b/src/Infrastructure/Authentication/Model/TokenModel.cs @@ -0,0 +1,22 @@ +using System; + +namespace MyWarehouse.Infrastructure.Authentication.Model +{ + public class TokenModel + { + public string TokenType { get; } + public string AccessToken { get; } + public DateTime ExpiresAt {get;} + public string Username { get; set; } + + public TokenModel(string tokenType, string accessToken, DateTime expiresAt) + { + TokenType = tokenType; + AccessToken = accessToken; + ExpiresAt = expiresAt; + } + + public int GetRemainingLifetimeSeconds() + => Math.Max(0, (int)(ExpiresAt - DateTime.Now).TotalSeconds); + } +} diff --git a/src/Infrastructure/Authentication/Services/ITokenService.cs b/src/Infrastructure/Authentication/Services/ITokenService.cs new file mode 100644 index 0000000..bdd1206 --- /dev/null +++ b/src/Infrastructure/Authentication/Services/ITokenService.cs @@ -0,0 +1,9 @@ +using MyWarehouse.Infrastructure.Authentication.Model; + +namespace MyWarehouse.Infrastructure.Authentication.Services +{ + public interface ITokenService + { + TokenModel CreateAuthenticationToken(string userId, string userName); + } +} diff --git a/src/Infrastructure/Authentication/Services/IUserService.cs b/src/Infrastructure/Authentication/Services/IUserService.cs new file mode 100644 index 0000000..a0a490d --- /dev/null +++ b/src/Infrastructure/Authentication/Services/IUserService.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Identity; +using MyWarehouse.Infrastructure.Authentication.Model; +using System.Threading.Tasks; + +namespace MyWarehouse.Infrastructure.Authentication.Services +{ + public interface IUserService + { + Task<(SignInResult result, TokenModel token)> SignIn(string username, string password); + } +} diff --git a/src/Infrastructure/Authentication/Services/JwtTokenService.cs b/src/Infrastructure/Authentication/Services/JwtTokenService.cs new file mode 100644 index 0000000..43d08f7 --- /dev/null +++ b/src/Infrastructure/Authentication/Services/JwtTokenService.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using MyWarehouse.Infrastructure.Authentication.Model; +using MyWarehouse.Infrastructure.Authentication.Settings; +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace MyWarehouse.Infrastructure.Authentication.Services +{ + public class JwtTokenService : ITokenService + { + private readonly AuthenticationSettings _authSettings; + + public JwtTokenService(AuthenticationSettings authSettings) + { + _authSettings = authSettings; + } + + public TokenModel CreateAuthenticationToken(string userId, string userName) + { + var expiration = DateTime.UtcNow.AddDays(7); + var tokenHandler = new JwtSecurityTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new Claim[] + { + new Claim(JwtRegisteredClaimNames.Iss, _authSettings.JwtIssuer), + new Claim(JwtRegisteredClaimNames.Aud, _authSettings.JwtAudience), + new Claim(JwtRegisteredClaimNames.Sub, userId), + new Claim(JwtRegisteredClaimNames.UniqueName, userName) + }), + Expires = expiration, + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(_authSettings.JwtSigningKey), SecurityAlgorithms.HmacSha256Signature) + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + var tokenString = tokenHandler.WriteToken(token); + + return new TokenModel( + tokenType: "Bearer", + accessToken: tokenString, + expiresAt: expiration + ); + } + } +} diff --git a/src/Infrastructure/Authentication/Services/UserService.cs b/src/Infrastructure/Authentication/Services/UserService.cs new file mode 100644 index 0000000..28a2f59 --- /dev/null +++ b/src/Infrastructure/Authentication/Services/UserService.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Identity; +using MyWarehouse.Infrastructure.Authentication.Model; +using MyWarehouse.Infrastructure.Identity.Model; +using System.Threading.Tasks; + +namespace MyWarehouse.Infrastructure.Authentication.Services +{ + public class UserService : IUserService + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ITokenService _tokenService; + + public UserService(UserManager userManager, SignInManager signInManager, ITokenService tokenService) + { + this._userManager = userManager; + this._signInManager = signInManager; + this._tokenService = tokenService; + } + + public async Task<(SignInResult result, TokenModel token)> SignIn(string username, string password) + { + //var user = await _userManager.FindByEmailAsync(username); + var user = await _userManager.FindByNameAsync(username); + + if (user == null) + return (SignInResult.Failed, null); + + // Don't use SignInManager.PasswordSignInAsync(), because that sets useless cookies. + // But 'CheckPasswordSignInAsync' doesn't. Yep, it's confusing. Good thing we have access to the source code. :D + var result = await _signInManager.CheckPasswordSignInAsync(user, password, true); + + TokenModel token = null; + if (result.Succeeded) + { + token = _tokenService.CreateAuthenticationToken(user.Id, user.UserName); + token.Username = user.UserName; + } + + return (result, token); + } + } +} diff --git a/src/Infrastructure/Authentication/Settings/AuthenticationSettings.cs b/src/Infrastructure/Authentication/Settings/AuthenticationSettings.cs new file mode 100644 index 0000000..b08b0d9 --- /dev/null +++ b/src/Infrastructure/Authentication/Settings/AuthenticationSettings.cs @@ -0,0 +1,27 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Infrastructure.Authentication.Settings +{ + public class AuthenticationSettings + { + [Required, MinLength(10)] + public string JwtIssuer { get; init; } + + [Required, MinLength(1)] + public string JwtAudience { get; init; } + + [Required, MinLength(10)] + public string JwtSigningKeyBase64 + { + get => _jwtSigningKeyBase64; + init { _jwtSigningKeyBase64 = value; JwtSigningKey = Convert.FromBase64String(value); } + } + private string _jwtSigningKeyBase64; + + public byte[] JwtSigningKey { get; private set; } + + [Range(60, int.MaxValue)] + public int TokenExpirationSeconds { get; init; } + } +} diff --git a/src/Infrastructure/Authentication/Startup.cs b/src/Infrastructure/Authentication/Startup.cs new file mode 100644 index 0000000..fcae6f0 --- /dev/null +++ b/src/Infrastructure/Authentication/Startup.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using MyWarehouse.Infrastructure.Authentication.Settings; +using MyWarehouse.Infrastructure.Authentication.Services; +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Threading.Tasks; + +namespace MyWarehouse.Infrastructure.Authentication +{ + internal static class Startup + { + public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + services.AddScoped(); + + var authOptions = configuration.GetMyOptions(); + services.AddSingleton(authOptions); + + ConfigureLocalJwtAuthentication(services, authOptions); + } + + public static void Configure(IApplicationBuilder app) + { + app.UseAuthentication(); + app.UseAuthorization(); + } + + /// + /// Adds local JWT token based authentication. + /// Doesn't rely on any external identity provider or authority. + /// + private static void ConfigureLocalJwtAuthentication(IServiceCollection services, AuthenticationSettings authSettings) + { + // Prevents the mapping of sub claim into archaic SOAP NameIdentifier. + JwtSecurityTokenHandler.DefaultMapInboundClaims = false; + + services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { +#if DEBUG + options.Events = new JwtBearerEvents() + { + OnMessageReceived = ctx => + { + // Break here to debug JWT authentication. + return Task.FromResult(true); + } + }; +#endif + + options.RequireHttpsMetadata = false; + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = authSettings.JwtIssuer, + + ValidateAudience = true, + ValidAudience = authSettings.JwtIssuer, + + // Validate signing key instead of asking authority if signing is valid, + // since we're skipping on separate identity provider for the purpose of this simple showcase API. + // For the same reason we're using symmetric key, while in case of a separate identity provider - even if we wanted local key validation - we'd have only the public key of a public/private keypair. + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(authSettings.JwtSigningKey), + ClockSkew = TimeSpan.FromMinutes(5), + + RequireExpirationTime = true, + ValidateLifetime = true, + }; + }); + } + } +} diff --git a/src/Infrastructure/Authorization/Constants/KnownClaims.cs b/src/Infrastructure/Authorization/Constants/KnownClaims.cs new file mode 100644 index 0000000..a2665ea --- /dev/null +++ b/src/Infrastructure/Authorization/Constants/KnownClaims.cs @@ -0,0 +1,16 @@ +namespace MyWarehouse.Infrastructure.Authorization.Constants +{ + public static class KnownClaims + { + public static class ExampleClaim + { + public static string Name => nameof(ExampleClaim); + + public static class Values + { + public static string ExampleValue1 => nameof(ExampleValue1); + public static string ExampleValue2 => nameof(ExampleValue2); + } + } + } +} diff --git a/src/Infrastructure/Authorization/Constants/PolicyNames.cs b/src/Infrastructure/Authorization/Constants/PolicyNames.cs new file mode 100644 index 0000000..7e7501d --- /dev/null +++ b/src/Infrastructure/Authorization/Constants/PolicyNames.cs @@ -0,0 +1,7 @@ +namespace MyWarehouse.Infrastructure.Authorization.Constants +{ + public static class PolicyNames + { + public const string SamplePolicy = "SamplePolicy"; + } +} diff --git a/src/Infrastructure/Authorization/Startup.cs b/src/Infrastructure/Authorization/Startup.cs new file mode 100644 index 0000000..2bdb659 --- /dev/null +++ b/src/Infrastructure/Authorization/Startup.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MyWarehouse.Infrastructure.Authorization.Constants; + +namespace MyWarehouse.Infrastructure.Authorization +{ + internal static class Startup + { + public static void ConfigureServices(IServiceCollection services, IConfiguration _) + { + } + } +} diff --git a/src/Infrastructure/AzureKeyVault/Settings/AzureKeyVaultSettings.cs b/src/Infrastructure/AzureKeyVault/Settings/AzureKeyVaultSettings.cs new file mode 100644 index 0000000..11270a3 --- /dev/null +++ b/src/Infrastructure/AzureKeyVault/Settings/AzureKeyVaultSettings.cs @@ -0,0 +1,11 @@ +using MyWarehouse.Infrastructure.Common.Validation; + +namespace MyWarehouse.Infrastructure.AzureKeyVault.Settings +{ + internal class AzureKeyVaultSettings + { + [RequiredIf(nameof(AddToConfiguration), true)] + public string ServiceUrl { get; init; } + public bool AddToConfiguration { get; init; } + } +} diff --git a/src/Infrastructure/AzureKeyVault/Startup.cs b/src/Infrastructure/AzureKeyVault/Startup.cs new file mode 100644 index 0000000..500065e --- /dev/null +++ b/src/Infrastructure/AzureKeyVault/Startup.cs @@ -0,0 +1,28 @@ +using Microsoft.Azure.KeyVault; +using Microsoft.Azure.Services.AppAuthentication; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureKeyVault; +using Microsoft.Extensions.Hosting; +using MyWarehouse.Infrastructure.AzureKeyVault.Settings; + +namespace MyWarehouse.Infrastructure.AzureKeyVault +{ + internal static class Startup + { + public static void ConfigureAppConfiguration(HostBuilderContext _, IConfigurationBuilder configBuilder) + { + var settings = configBuilder.Build().GetMyOptions(); + + if (settings.AddToConfiguration) + { + configBuilder.AddAzureKeyVault( + settings.ServiceUrl, + new KeyVaultClient( + new KeyVaultClient.AuthenticationCallback( + new AzureServiceTokenProvider().KeyVaultTokenCallback)), + new DefaultKeyVaultSecretManager() + ); + } + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/Common/Extensions/ConfigurationExtensions.cs b/src/Infrastructure/Common/Extensions/ConfigurationExtensions.cs new file mode 100644 index 0000000..4cbdefb --- /dev/null +++ b/src/Infrastructure/Common/Extensions/ConfigurationExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Infrastructure +{ + public static class ConfigurationExtensions + { + /// + /// Binds a section of the configuration as a strongly typed configuration instance. + /// + /// The options type to bind. The name of this type is used as the section name as well. + public static T GetMyOptions (this IConfiguration configuration, bool required = false) where T : class + { + // TODO: Consider declaring the section name to bind via a settings interface or base class. It might be a problematic assumption that section name always equals the type name. + var bound = configuration.GetSection(typeof(T).Name).Get(); + + if (bound != null) + Validator.ValidateObject(bound, new ValidationContext(bound), validateAllProperties: true); + else if (required) + throw new InvalidOperationException($"Settings type of '{nameof(T)}' was requested as required, but was not found in configuration."); + + return bound; + } + + public static void RegisterMyOptions(this IServiceCollection services) where T : class + { + // TODO: Note that validation is late when resolved through resolver delegate. :/ Maybe ask for a configuration too, and bind it eagerly. + services.AddSingleton(resolver => + resolver.GetRequiredService().GetMyOptions()); + } + } +} diff --git a/src/Infrastructure/Common/Extensions/QueryableExtensions.cs b/src/Infrastructure/Common/Extensions/QueryableExtensions.cs new file mode 100644 index 0000000..357fbb4 --- /dev/null +++ b/src/Infrastructure/Common/Extensions/QueryableExtensions.cs @@ -0,0 +1,117 @@ +using StringToExpression.LanguageDefinitions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +namespace MyWarehouse.Infrastructure +{ + internal static class QueryableExtensions + { + /// + /// Applies filtering to a query by parsing the provided OData-standard filter string and translating it into expresions. + /// + /// Thrown when the filter string is incorrectly formatted. + public static IQueryable ApplyFilter(this IQueryable query, string oDataFilterString) + { + try + { + return ApplyFilterInternal(query, oDataFilterString); + } + catch(Exception e) + { + throw new FormatException($"The specified filter string '{oDataFilterString}' is invalid.", e); + } + } + + /// + /// Applies sorting to a query by parsing the provided OData-standard OrderBy string and translating it into expressions. + /// General expected string format is 'propertyName1, properyName2 asc, propertyName3 desc'. Specifying 'asc'/'desc' is optional. Nested property access is supported with '/' (e.g. Customer/Name). + /// + /// Thrown when the orderBy string is incorrectly formatted. + public static IQueryable ApplyOrder(this IQueryable query, string oDataOrderByString, int maximumNumberOfOrdering = 5) + { + try + { + return ApplyOrderInternal(query, oDataOrderByString, maximumNumberOfOrdering); + } + catch(Exception e) + { + throw new FormatException($"The specified orderBy string '{oDataOrderByString}' is invalid.", e); + } + } + + /// + /// Applies paging to a query expression, where index 1 is the first page. + /// + public static IQueryable ApplyPaging(this IQueryable query, int pageSize, int pageIndex) + => query + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize); + + private static IQueryable ApplyFilterInternal(IQueryable query, string oDataFilterString) + { + if (string.IsNullOrWhiteSpace(oDataFilterString)) + { + return query; + } + + var filterExpression = new ODataFilterLanguage().Parse(oDataFilterString); + return query.Where(filterExpression); + } + + private static IQueryable ApplyOrderInternal(IQueryable query, string oDataOrderByString, int maximumNumberOfOrdering) + { + if (string.IsNullOrWhiteSpace(oDataOrderByString)) + { + return query; + } + + bool firstOrdering = true; + foreach (var (propertyName, order) in GetOrderEntries(oDataOrderByString, maximumNumberOfOrdering)) + { + query = ApplyOrdering(query, propertyName, order, firstOrdering); + firstOrdering = false; + } + + //TODO: Look into the necessity (or lack thereof) of filtering out multiple orderBy on the same property. + return query; + + static IEnumerable<(string propertyPath, SortOrder order)> GetOrderEntries(string orderByString, int maxOrders) + { + return orderByString + .Split(',', count: maxOrders, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(orderStr => + { + var divider = orderStr.IndexOf(' '); + if (divider < 0) return (propertyPath: orderStr, order: SortOrder.Asc); + else return ( + propertyPath: orderStr[0..divider], + order: Enum.Parse(orderStr[divider..].Trim(), ignoreCase: true) + ); + }); + } + + static IQueryable ApplyOrdering(IQueryable query, string propertyPath, SortOrder order, bool firstOrdering) + { + var param = Expression.Parameter(typeof(T), "p"); + var member = (MemberExpression)propertyPath.Split('/').Aggregate((Expression)param, Expression.Property); //Expression.Property(param, propertyPath); + var exp = Expression.Lambda(member, param); + string methodName = order switch + { + SortOrder.Asc => firstOrdering ? "OrderBy" : "ThenBy", + SortOrder.Desc => firstOrdering ? "OrderByDescending" : "ThenByDescending" + }; + Type[] types = new Type[] { query.ElementType, exp.Body.Type }; + var orderByExpression = Expression.Call(typeof(Queryable), methodName, types, query.Expression, exp); + return query.Provider.CreateQuery(orderByExpression); + } + } + + private enum SortOrder + { + Asc, + Desc + } + } +} diff --git a/src/Infrastructure/Common/Validation/IValidatable.cs b/src/Infrastructure/Common/Validation/IValidatable.cs new file mode 100644 index 0000000..ffdf012 --- /dev/null +++ b/src/Infrastructure/Common/Validation/IValidatable.cs @@ -0,0 +1,7 @@ +namespace MyWarehouse.Infrastructure.Common.Validation +{ + internal interface IValidatable + { + public void Validate(); + } +} diff --git a/src/Infrastructure/Common/Validation/RequiredIfAttribute.cs b/src/Infrastructure/Common/Validation/RequiredIfAttribute.cs new file mode 100644 index 0000000..71a1f9c --- /dev/null +++ b/src/Infrastructure/Common/Validation/RequiredIfAttribute.cs @@ -0,0 +1,39 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Infrastructure.Common.Validation +{ + /// + /// Validates a property as required if a boolean flag has a specific value. + /// + [AttributeUsage(AttributeTargets.Property)] + public class RequiredIfAttribute : RequiredAttribute + { + private readonly string _flagName; + private readonly bool _condition; + + public RequiredIfAttribute(string flagName, bool condition) + { + _flagName = flagName; + _condition = condition; + } + + protected override ValidationResult IsValid(object value, ValidationContext context) + { + object instance = context.ObjectInstance; + Type type = instance.GetType(); + + if (!bool.TryParse(type.GetProperty(_flagName).GetValue(instance)?.ToString(), out bool flagValue)) + { + throw new InvalidOperationException($"{nameof(RequiredIfAttribute)} can be used only on bool properties."); + } + + if (flagValue == _condition && (value == null || (value is string s && string.IsNullOrEmpty(s)))) + { + return new ValidationResult($"Property {context.MemberName} must have a value when {_flagName} is {_condition}"); + } + + return ValidationResult.Success; + } + } +} diff --git a/src/Infrastructure/CoreDependencies/DataAccess/Repositories/Common/ListResponseModel.cs b/src/Infrastructure/CoreDependencies/DataAccess/Repositories/Common/ListResponseModel.cs new file mode 100644 index 0000000..177d964 --- /dev/null +++ b/src/Infrastructure/CoreDependencies/DataAccess/Repositories/Common/ListResponseModel.cs @@ -0,0 +1,35 @@ +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using System; +using System.Collections.Generic; + +namespace MyWarehouse.Infrastructure.CoreDependencies.DataAccess.Repositories.Common +{ + public class ListResponseModel : IListResponseModel + { + public int PageIndex { get; private set; } + public int PageSize { get; private set; } + + public int PageCount { get; private set; } + public int RowCount { get; private set; } + + public string ActiveFilter { get; private set; } + public string ActiveOrderBy { get; private set; } + + public int FirstRowOnPage => RowCount <= 0 ? 0 : ((PageIndex - 1) * PageSize) + 1; + public int LastRowOnPage => Math.Min(PageIndex * PageSize, RowCount); + + public IEnumerable Results { get; set; } = new List(); + + public ListResponseModel(ListQueryModel queryModel, int rowCount, IEnumerable results) + { + Results = results; + + PageIndex = queryModel.PageIndex; + PageSize = queryModel.PageSize; + ActiveOrderBy = queryModel.OrderBy; + ActiveFilter = queryModel.Filter; + RowCount = rowCount; + PageCount = (int)Math.Ceiling((double)rowCount / PageSize); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/CoreDependencies/DataAccess/Repositories/Common/RepositoryBase.cs b/src/Infrastructure/CoreDependencies/DataAccess/Repositories/Common/RepositoryBase.cs new file mode 100644 index 0000000..e6daf7b --- /dev/null +++ b/src/Infrastructure/CoreDependencies/DataAccess/Repositories/Common/RepositoryBase.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MyWarehouse.Domain.Common; +using Microsoft.EntityFrameworkCore; +using MyWarehouse.Infrastructure.Persistence.Context; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using MyWarehouse.Application.Common.Mapping; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using System; +using System.Linq.Expressions; + +namespace MyWarehouse.Infrastructure.CoreDependencies.DataAccess.Repositories.Common +{ + /// + /// Generic base repository with implementations of basic operations. + /// Concrete derived repositories should extend it with custom querying requirements for the given entity type. + /// + internal abstract class RepositoryBaseEF : IRepository where TEntity : class, IEntity + { + protected ApplicationDbContext _context; + protected DbSet _set; + private readonly IMapper _mapper; + + /// + /// Defines the base query for the given entity used by all operations. + /// Concrete implementations should apply all necessary includes and pre-filters here. + /// + protected abstract IQueryable BaseQuery { get; } + + public RepositoryBaseEF(ApplicationDbContext context, IMapper mapper) + { + _context = context; + _set = context.Set(); + _mapper = mapper; + } + + public virtual async Task GetByIdAsync(int id) + => await BaseQuery.SingleOrDefaultAsync(e => e.Id == id); + + public async Task> GetFiltered(Expression> filter, bool readOnly = false) + => await (readOnly ? BaseQuery.AsNoTracking() : BaseQuery).Where(filter).ToListAsync(); + + public virtual async Task GetProjectedAsync(int id, bool readOnly = false) where TDto : IMapFrom + => await (readOnly ? BaseQuery.AsNoTracking() : BaseQuery) + .Where(x => x.Id == id) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + + public virtual async Task> GetProjectedListAsync(ListQueryModel model, Expression> additionalFilter = null, bool readOnly = false) where TDto : IMapFrom + { + var query = readOnly ? BaseQuery.AsNoTracking() : BaseQuery; + + if (additionalFilter != null) + { + query = query.Where(additionalFilter); + } + + IQueryable filteredDtoQuery = default; + try + { + filteredDtoQuery = query + .ProjectTo(_mapper.ConfigurationProvider) + .ApplyFilter(model.Filter); + } + catch(FormatException fe) + { + model.ThrowFilterIncorrectException(fe.InnerException); + } + + var totalRowCount = await filteredDtoQuery.CountAsync(); + + IEnumerable resultPage = default; + try + { + resultPage = await filteredDtoQuery + .ApplyOrder(model.OrderBy) + .ApplyPaging(model.PageSize, model.PageIndex) + .ToListAsync(); + } + catch(FormatException fe) + { + model.ThrowOrderByIncorrectException(fe.InnerException); + } + + return new ListResponseModel(model, totalRowCount, resultPage); + } + + public virtual void Add(TEntity entity) + => _set.Add(entity); + + public virtual void AddRange(IEnumerable entities) + => _set.AddRange(entities); + + public virtual void StartTracking(TEntity detachedEntity) + => _set.Update(detachedEntity); + + /// + /// Removes the entity. Concrete implementations should decide if this is a hard removal or a deletion flag. + /// + public abstract void Remove(TEntity entityToDelete); + + /// + /// Removes the entities. Concrete implementations should decide if this is a hard removal or a deletion flag. + /// + public abstract void RemoveRange(IEnumerable entitiesToDelete); + } +} \ No newline at end of file diff --git a/src/Infrastructure/CoreDependencies/DataAccess/Repositories/PartnerRepositoryEF.cs b/src/Infrastructure/CoreDependencies/DataAccess/Repositories/PartnerRepositoryEF.cs new file mode 100644 index 0000000..354a1a2 --- /dev/null +++ b/src/Infrastructure/CoreDependencies/DataAccess/Repositories/PartnerRepositoryEF.cs @@ -0,0 +1,30 @@ +using AutoMapper; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories; +using MyWarehouse.Domain.Partners; +using MyWarehouse.Infrastructure.CoreDependencies.DataAccess.Repositories.Common; +using MyWarehouse.Infrastructure.Persistence.Context; +using System.Collections.Generic; +using System.Linq; + +namespace MyWarehouse.Infrastructure.CoreDependencies.DataAccess.Repositories +{ + internal class PartnerRepositoryEF : RepositoryBaseEF, IPartnerRepository + { + protected override IQueryable BaseQuery + => _context.Partners; + + public PartnerRepositoryEF(ApplicationDbContext context, IMapper mapper) : base(context, mapper) + { } + + public override void Remove(Partner entityToDelete) + { + _context.Remove(entityToDelete); + } + + public override void RemoveRange(IEnumerable entitiesToDelete) + { + foreach (var e in entitiesToDelete) + Remove(e); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/CoreDependencies/DataAccess/Repositories/ProductRepositoryEF.cs b/src/Infrastructure/CoreDependencies/DataAccess/Repositories/ProductRepositoryEF.cs new file mode 100644 index 0000000..d333880 --- /dev/null +++ b/src/Infrastructure/CoreDependencies/DataAccess/Repositories/ProductRepositoryEF.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using System.Data; +using System.Threading.Tasks; +using MyWarehouse.Domain.Products; +using Microsoft.EntityFrameworkCore; +using MyWarehouse.Infrastructure.Persistence.Context; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories; +using MyWarehouse.Infrastructure.CoreDependencies.DataAccess.Repositories.Common; +using AutoMapper; + +namespace MyWarehouse.Infrastructure.CoreDependencies.DataAccess.Repositories +{ + internal class ProductRepositoryEF : RepositoryBaseEF, IProductRepository + { + protected override IQueryable BaseQuery + => _context.Products.Include(x => x.Mass); + + public ProductRepositoryEF(ApplicationDbContext context, IMapper mapper) : base(context, mapper) + { } + + public Task> GetHeaviestProducts(int numberOfProducts) + => BaseQuery + .OrderByDescending(p => p.Mass) + .Take(numberOfProducts) + .ToListAsync(); + + public Task> GetMostStockedProducts(int numberOfProducts) + => BaseQuery + .OrderByDescending(p => p.NumberInStock) + .Take(numberOfProducts) + .ToListAsync(); + + public override void Remove(Product entityToDelete) + { + _context.Remove(entityToDelete); + } + + public override void RemoveRange(IEnumerable entitiesToDelete) + { + foreach (var e in entitiesToDelete) + Remove(e); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/CoreDependencies/DataAccess/Repositories/TransactionRepositoryEF.cs b/src/Infrastructure/CoreDependencies/DataAccess/Repositories/TransactionRepositoryEF.cs new file mode 100644 index 0000000..f82239d --- /dev/null +++ b/src/Infrastructure/CoreDependencies/DataAccess/Repositories/TransactionRepositoryEF.cs @@ -0,0 +1,39 @@ +using AutoMapper; +using Microsoft.EntityFrameworkCore; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories; +using MyWarehouse.Domain.Transactions; +using MyWarehouse.Infrastructure.CoreDependencies.DataAccess.Repositories.Common; +using MyWarehouse.Infrastructure.Persistence.Context; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace MyWarehouse.Infrastructure.CoreDependencies.DataAccess.Repositories +{ + internal class TransactionRepositoryEF : RepositoryBaseEF, ITransactionRepository + { + protected override IQueryable BaseQuery + => _context.Transactions.Include(e => e.TransactionLines) + // This is a crude way to make sure soft-deleted Partners and Products won't cause referencing Transactions to be hidden. + // Currently there is no way to disable only certain query filters. Fortunately, though, there are no side-effects in this case, because transactions cannot be deleted. + // This solution also breaks the encapsulation of soft-delete logic in DbContext... + // Hopefully they'll soon extend the global query filter functionality. + .IgnoreQueryFilters(); + + public TransactionRepositoryEF(ApplicationDbContext context, IMapper mapper) : base(context, mapper) + { } + + public override void Remove(Transaction entityToDelete) + => _set.Remove(entityToDelete); + + public override void RemoveRange(IEnumerable entitiesToDelete) + => _set.RemoveRange(entitiesToDelete); + + public async Task GetEntireTransaction(int id) + => await BaseQuery + .Include(x => x.Partner) + .Include(x => x.TransactionLines) + .ThenInclude(x => x.Product) + .FirstOrDefaultAsync(x => x.Id == id); + } +} \ No newline at end of file diff --git a/src/Infrastructure/CoreDependencies/DataAccess/UnitOfWork.cs b/src/Infrastructure/CoreDependencies/DataAccess/UnitOfWork.cs new file mode 100644 index 0000000..97f0169 --- /dev/null +++ b/src/Infrastructure/CoreDependencies/DataAccess/UnitOfWork.cs @@ -0,0 +1,30 @@ +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories; +using MyWarehouse.Infrastructure.Persistence.Context; +using System.Threading.Tasks; + +namespace MyWarehouse.Infrastructure.CoreDependencies.DataAccess +{ + internal class UnitOfWork : IUnitOfWork + { + private readonly ApplicationDbContext _dbContext; + + public IPartnerRepository Partners { get; } + public IProductRepository Products { get; } + public ITransactionRepository Transactions { get; } + + public UnitOfWork(ApplicationDbContext dbContext, IPartnerRepository partners, IProductRepository products, ITransactionRepository transactions) + { + _dbContext = dbContext; + Partners = partners; + Products = products; + Transactions = transactions; + } + + public void Dispose() + => _dbContext.Dispose(); + + public Task SaveChanges() + => _dbContext.SaveChangesAsync(); + } +} diff --git a/src/Infrastructure/CoreDependencies/Services/DateTimeService.cs b/src/Infrastructure/CoreDependencies/Services/DateTimeService.cs new file mode 100644 index 0000000..222699b --- /dev/null +++ b/src/Infrastructure/CoreDependencies/Services/DateTimeService.cs @@ -0,0 +1,10 @@ +using MyWarehouse.Application.Dependencies.Services; +using System; + +namespace MyWarehouse.Infrastructure.CoreDependencies.Services +{ + internal class DateTimeService : IDateTime + { + public DateTime Now => DateTime.Now; + } +} diff --git a/src/Infrastructure/CoreDependencies/Services/StockStatisticsService.cs b/src/Infrastructure/CoreDependencies/Services/StockStatisticsService.cs new file mode 100644 index 0000000..14a4e36 --- /dev/null +++ b/src/Infrastructure/CoreDependencies/Services/StockStatisticsService.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using MyWarehouse.Application.Dependencies.Services; +using MyWarehouse.Domain.Common.ValueObjects.Mass; +using MyWarehouse.Domain.Common.ValueObjects.Money; +using MyWarehouse.Infrastructure.Persistence.Context; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace MyWarehouse.Infrastructure.CoreDependencies.Services +{ + // It's rather questionable that this service is implemented here in the Infrastructure layer. + // One could argue that it should be in Application, but, on the other hand, + // it does depend on EF Core, and probably even specifically on SQL. + class StockStatisticsService : IStockStatisticsService + { + private readonly ApplicationDbContext _dbContext; + + public StockStatisticsService(ApplicationDbContext dbContext) + => _dbContext = dbContext; + + public async Task<(int ProductCount, int TotalStock)> GetProductStockCounts() + { + var res = await _dbContext.Products + .GroupBy(x => 1) + .Select(g => new { + productCount = g.Count(), + totalStock = g.Sum(p => p.NumberInStock) + }).SingleAsync(); + + return (res.productCount, res.totalStock); + } + + public async Task GetProductStockTotalMass(MassUnit unit) + { + var totalMassPerUnit = await _dbContext.Products + .GroupBy(x => x.Mass.Unit, p => new + { + p.Mass, + p.NumberInStock + }) + .Select(g => new + { + MassUnit = g.Key, + TotalMass = g.Sum(x => x.Mass.Value * x.NumberInStock) + }).ToListAsync(); + + var totalMass = totalMassPerUnit + .Select(x => new Mass(x.TotalMass, x.MassUnit)) + .Sum(mass => mass.ConvertTo(unit).Value); + + return new Mass(totalMass, unit); + } + + public async Task GetProductStockTotalValue() + { + var stockValuesPerCurrency = await _dbContext.Products + .GroupBy(x => x.Price.Currency, p => new + { + UnitPrice = p.Price.Amount, + Currency = p.Price.Currency, + NumberInStock = p.NumberInStock + }) + .Select(g => new + { + Currency = g.Key, + TotalValue = g.Sum(x => x.UnitPrice * x.NumberInStock) + }).ToListAsync(); + + if (stockValuesPerCurrency.Count > 1) + throw new InvalidOperationException( + $"Operation cannot be completed, because not all product prices use the same currency. Distinct currencies detected: " + + $"{string.Join(", ", stockValuesPerCurrency.Select(x => x.Currency.Code))}."); + + var stockValue = stockValuesPerCurrency.First(); + return new Money(stockValue.TotalValue, Currency.FromCode(stockValue.Currency.Code)); + } + } +} diff --git a/src/Infrastructure/CoreDependencies/Startup.cs b/src/Infrastructure/CoreDependencies/Startup.cs new file mode 100644 index 0000000..3acfe7f --- /dev/null +++ b/src/Infrastructure/CoreDependencies/Startup.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MyWarehouse.Application.Common.Dependencies.DataAccess; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories; +using MyWarehouse.Application.Dependencies.Services; +using MyWarehouse.Infrastructure.CoreDependencies.DataAccess; +using MyWarehouse.Infrastructure.CoreDependencies.DataAccess.Repositories; +using MyWarehouse.Infrastructure.CoreDependencies.Services; + +namespace MyWarehouse.Infrastructure.CoreDependencies +{ + public static class Startup + { + public static void ConfigureServices(this IServiceCollection services, IConfiguration _) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddTransient(); + services.AddTransient(); + } + } +} diff --git a/src/Infrastructure/Identity/Model/ApplicationUser.cs b/src/Infrastructure/Identity/Model/ApplicationUser.cs new file mode 100644 index 0000000..3630359 --- /dev/null +++ b/src/Infrastructure/Identity/Model/ApplicationUser.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Identity; + +namespace MyWarehouse.Infrastructure.Identity.Model +{ + public class ApplicationUser : IdentityUser + { + } +} diff --git a/src/Infrastructure/Identity/Startup.cs b/src/Infrastructure/Identity/Startup.cs new file mode 100644 index 0000000..55658ea --- /dev/null +++ b/src/Infrastructure/Identity/Startup.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MyWarehouse.Infrastructure.Identity.Model; +using MyWarehouse.Infrastructure.Persistence.Context; +using System.IdentityModel.Tokens.Jwt; + +namespace MyWarehouse.Infrastructure.Identity +{ + internal static class Startup + { + public static void ConfigureServices(IServiceCollection services, IConfiguration _) + { + services.AddIdentity(options => + { + options.User.RequireUniqueEmail = true; + options.ClaimsIdentity.UserIdClaimType = JwtRegisteredClaimNames.Sub; // JWT specific + }) + .AddDefaultTokenProviders() + + // Adding Roles is optional, and mostly exists for backwards-compatibility. + // Not needed if policy/claim based authorization is used (which is recommended). + // But, if AddRoles() is called, it must be before calling AddEntityFrameworkStores(), because otherwise IRoleStore won't be added (despite what the summary says).. + //.AddRoles() + + .AddEntityFrameworkStores(); // EF specific + } + } +} diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj new file mode 100644 index 0000000..179ca89 --- /dev/null +++ b/src/Infrastructure/Infrastructure.csproj @@ -0,0 +1,34 @@ + + + + net5.0 + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/Infrastructure/Persistence/Context/ApplicationDbContext.cs b/src/Infrastructure/Persistence/Context/ApplicationDbContext.cs new file mode 100644 index 0000000..2792949 --- /dev/null +++ b/src/Infrastructure/Persistence/Context/ApplicationDbContext.cs @@ -0,0 +1,192 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using MyWarehouse.Application.Dependencies.Services; +using MyWarehouse.Domain.Common; +using MyWarehouse.Domain.Partners; +using MyWarehouse.Domain.Products; +using MyWarehouse.Domain.Transactions; +using MyWarehouse.Infrastructure.Identity.Model; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MyWarehouse.Domain.Common.ValueObjects.Money; +using MyWarehouse.Domain.Common.ValueObjects.Mass; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace MyWarehouse.Infrastructure.Persistence.Context +{ + // In this particular architecture, AppDbContext only contains + // translation logic required for proper loading and saving of + // domain entities. Query-specific customizations and utility + // logic has been relegated higher, to repositories. + + /// + /// DB context implementation for Entity Framework. + /// + public class ApplicationDbContext : IdentityDbContext + { + public DbSet Products { get; set; } + public DbSet Partners { get; set; } + public DbSet Transactions { get; set; } + + private readonly ICurrentUserService _currentUser; + private readonly IDateTime _dateTime; + + public ApplicationDbContext(DbContextOptions options, ICurrentUserService currentUser, IDateTime dateTime) : base(options) + { + _currentUser = currentUser; + _dateTime = dateTime; + + // The default cascade delete, combined with the default Immediate cascade timing setting, + // can cause problems if we intend to manually revert the state of EntityEntries. + // This is because deletion state change cascades to navigation properties, even owned types, + // and reverting the state change on the root entity doesn't revert the cascaded changes. + // This is particularly problematic for owned types, where it seems EF incorrectly + // does a cascade and write nulls to the DB, even when the owned type is non-nullable, + // causing errors when saving entities. + // Here I effectively disabled the cascade for then-reverted (soft-deleted) entities. + // But in a production system the cascade behavior has to be properly designed. + ChangeTracker.CascadeDeleteTiming = CascadeTiming.OnSaveChanges; + } + + // Added for LinqPad. + public ApplicationDbContext(DbContextOptions options) : base(options) + { + } + + public override int SaveChanges() + => SaveChanges(acceptAllChangesOnSuccess: true); + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + ApplyMyEntityOverrides(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + => SaveChangesAsync(acceptAllChangesOnSuccess: true, cancellationToken); + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + ApplyMyEntityOverrides(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + protected override void OnModelCreating(ModelBuilder builder) + { + ConfigureValueObjects(builder); + ConfigureDecimalPrecision(builder); + ConfigureSoftDeleteFilter(builder); + + base.OnModelCreating(builder); + } + + /// + /// Configure the value object of the application domain as EF Core owned types. + /// + private static void ConfigureValueObjects(ModelBuilder builder) + { + builder.Owned(typeof(Money)); + builder.Owned(typeof(Currency)); + builder.Owned(typeof(Address)); + builder.Owned(typeof(Mass)); + builder.Owned(typeof(MassUnit)); + + // Unfortunately, as of EF Core 5.0, there is no way to centrally configure owned types. + // All references to the owned types have to be configured individually. + + // Store and restore mass unit as symbol. + builder.Entity().OwnsOne(p => p.Mass, StoreMassUnitAsSymbol); + + // Store and restore currency as currency code. + builder.Entity().OwnsOne(p => p.Price, StoreCurrencyAsCode); + builder.Entity().OwnsOne(p => p.Total, StoreCurrencyAsCode); + builder.Entity().OwnsOne(p => p.UnitPrice, StoreCurrencyAsCode); + + static void StoreCurrencyAsCode(OwnedNavigationBuilder onb) where T : class + => onb.Property(m => m.Currency) + .HasConversion( + c => c.Code, + c => Currency.FromCode(c)) + .HasMaxLength(3); + + static void StoreMassUnitAsSymbol(OwnedNavigationBuilder onb) where T : class + => onb.Property(m => m.Unit) + .HasConversion( + u => u.Symbol, + s => MassUnit.FromSymbol(s)) + .HasMaxLength(3); + } + + /// + /// Set all decimal properties to a custom uniform precision. + /// + private static void ConfigureDecimalPrecision(ModelBuilder builder) + { + foreach (var entityType in builder.Model.GetEntityTypes()) + { + foreach (var decimalProperty in entityType.GetProperties() + .Where(x => x.ClrType == typeof(decimal))) + { + decimalProperty.SetPrecision(18); + decimalProperty.SetScale(4); + } + } + } + + /// + /// Set global filter on all soft-deletable entities to exclude the ones which are 'deleted'. + /// + private static void ConfigureSoftDeleteFilter(ModelBuilder builder) + { + foreach (var softDeletableTypeBuilder in builder.Model.GetEntityTypes() + .Where(x => typeof(ISoftDeletable).IsAssignableFrom(x.ClrType))) + { + var parameter = Expression.Parameter(softDeletableTypeBuilder.ClrType, "p"); + + softDeletableTypeBuilder.SetQueryFilter( + Expression.Lambda( + Expression.Equal( + Expression.Property(parameter, nameof(ISoftDeletable.DeletedAt)), + Expression.Constant(null)), + parameter) + ); + } + } + + /// + /// Automatically stores metadata when entities are added, modified, or deleted. + /// + private void ApplyMyEntityOverrides() + { + foreach (var entry in ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Property(nameof(IAudited.CreatedBy)).CurrentValue = _currentUser.UserId; + entry.Property(nameof(IAudited.CreatedAt)).CurrentValue = _dateTime.Now; + break; + case EntityState.Modified: + entry.Property(nameof(IAudited.LastModifiedBy)).CurrentValue = _currentUser.UserId; + entry.Property(nameof(IAudited.LastModifiedAt)).CurrentValue = _dateTime.Now; + break; + } + } + + foreach (var entry in ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Deleted: + entry.State = EntityState.Unchanged; // Override removal. Better than Modified, because that flags ALL properties for update. + entry.Property(nameof(ISoftDeletable.DeletedBy)).CurrentValue = _currentUser.UserId; + entry.Property(nameof(ISoftDeletable.DeletedAt)).CurrentValue = _dateTime.Now; + break; + } + } + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Context/Migrations/20210206205027_AddDomain.Designer.cs b/src/Infrastructure/Persistence/Context/Migrations/20210206205027_AddDomain.Designer.cs new file mode 100644 index 0000000..8067ffa --- /dev/null +++ b/src/Infrastructure/Persistence/Context/Migrations/20210206205027_AddDomain.Designer.cs @@ -0,0 +1,605 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyWarehouse.Infrastructure.Persistence.Context; + +namespace MyWarehouse.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20210206205027_AddDomain")] + partial class AddDomain + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .UseIdentityColumns() + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("ProductVersion", "5.0.2"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Partners.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModifiedAt") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Partners"); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Products.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LastModifiedAt") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NumberInStock") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Transactions.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModifiedAt") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("TransactionType") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PartnerId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Transactions.TransactionLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("TransactionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("TransactionId"); + + b.ToTable("TransactionLine"); + }); + + modelBuilder.Entity("MyWarehouse.Infrastructure.Identity.Model.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("MyWarehouse.Infrastructure.Identity.Model.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("MyWarehouse.Infrastructure.Identity.Model.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MyWarehouse.Infrastructure.Identity.Model.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("MyWarehouse.Infrastructure.Identity.Model.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Partners.Partner", b => + { + b.OwnsOne("MyWarehouse.Domain.Partners.Address", "Address", b1 => + { + b1.Property("PartnerId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b1.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("Country") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("Street") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("ZipCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.HasKey("PartnerId"); + + b1.ToTable("Partners"); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + }); + + b.Navigation("Address") + .IsRequired(); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Products.Product", b => + { + b.OwnsOne("MyWarehouse.Domain.Common.ValueObjects.Mass.Mass", "Mass", b1 => + { + b1.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b1.Property("Unit") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b1.Property("Value") + .HasColumnType("real"); + + b1.HasKey("ProductId"); + + b1.ToTable("Products"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.OwnsOne("MyWarehouse.Domain.Common.ValueObjects.Money.Money", "Price", b1 => + { + b1.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b1.HasKey("ProductId"); + + b1.ToTable("Products"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.Navigation("Mass") + .IsRequired(); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Transactions.Transaction", b => + { + b.HasOne("MyWarehouse.Domain.Partners.Partner", "Partner") + .WithMany("Transactions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("MyWarehouse.Domain.Common.ValueObjects.Money.Money", "Total", b1 => + { + b1.Property("TransactionId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b1.HasKey("TransactionId"); + + b1.ToTable("Transactions"); + + b1.WithOwner() + .HasForeignKey("TransactionId"); + }); + + b.Navigation("Partner"); + + b.Navigation("Total") + .IsRequired(); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Transactions.TransactionLine", b => + { + b.HasOne("MyWarehouse.Domain.Products.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MyWarehouse.Domain.Transactions.Transaction", "Transaction") + .WithMany("TransactionLines") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("MyWarehouse.Domain.Common.ValueObjects.Money.Money", "UnitPrice", b1 => + { + b1.Property("TransactionLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b1.HasKey("TransactionLineId"); + + b1.ToTable("TransactionLine"); + + b1.WithOwner() + .HasForeignKey("TransactionLineId"); + }); + + b.Navigation("Product"); + + b.Navigation("Transaction"); + + b.Navigation("UnitPrice") + .IsRequired(); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Partners.Partner", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Transactions.Transaction", b => + { + b.Navigation("TransactionLines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistence/Context/Migrations/20210206205027_AddDomain.cs b/src/Infrastructure/Persistence/Context/Migrations/20210206205027_AddDomain.cs new file mode 100644 index 0000000..e3a0f23 --- /dev/null +++ b/src/Infrastructure/Persistence/Context/Migrations/20210206205027_AddDomain.cs @@ -0,0 +1,351 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace MyWarehouse.Infrastructure.Migrations +{ + public partial class AddDomain : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Partners", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Address_Street = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Address_City = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Address_Country = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Address_ZipCode = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + LastModifiedBy = table.Column(type: "nvarchar(max)", nullable: true), + LastModifiedAt = table.Column(type: "datetime2", nullable: true), + DeletedBy = table.Column(type: "nvarchar(max)", nullable: true), + DeletedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Partners", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + Price_Amount = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + Price_Currency = table.Column(type: "nvarchar(3)", maxLength: 3, nullable: false), + Mass_Value = table.Column(type: "real", nullable: false), + Mass_Unit = table.Column(type: "nvarchar(3)", maxLength: 3, nullable: false), + NumberInStock = table.Column(type: "int", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + LastModifiedBy = table.Column(type: "nvarchar(max)", nullable: true), + LastModifiedAt = table.Column(type: "datetime2", nullable: true), + DeletedBy = table.Column(type: "nvarchar(max)", nullable: true), + DeletedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Transactions", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TransactionType = table.Column(type: "int", nullable: false), + Total_Amount = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + Total_Currency = table.Column(type: "nvarchar(3)", maxLength: 3, nullable: false), + PartnerId = table.Column(type: "int", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + LastModifiedBy = table.Column(type: "nvarchar(max)", nullable: true), + LastModifiedAt = table.Column(type: "datetime2", nullable: true), + DeletedBy = table.Column(type: "nvarchar(max)", nullable: true), + DeletedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Transactions", x => x.Id); + table.ForeignKey( + name: "FK_Transactions_Partners_PartnerId", + column: x => x.PartnerId, + principalTable: "Partners", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TransactionLine", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ProductId = table.Column(type: "int", nullable: false), + TransactionId = table.Column(type: "int", nullable: false), + Quantity = table.Column(type: "int", nullable: false), + UnitPrice_Amount = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + UnitPrice_Currency = table.Column(type: "nvarchar(3)", maxLength: 3, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TransactionLine", x => x.Id); + table.ForeignKey( + name: "FK_TransactionLine_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TransactionLine_Transactions_TransactionId", + column: x => x.TransactionId, + principalTable: "Transactions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLine_ProductId", + table: "TransactionLine", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLine_TransactionId", + table: "TransactionLine", + column: "TransactionId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_PartnerId", + table: "Transactions", + column: "PartnerId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "TransactionLine"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "Products"); + + migrationBuilder.DropTable( + name: "Transactions"); + + migrationBuilder.DropTable( + name: "Partners"); + } + } +} diff --git a/src/Infrastructure/Persistence/Context/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Persistence/Context/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..10d1d08 --- /dev/null +++ b/src/Infrastructure/Persistence/Context/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,603 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyWarehouse.Infrastructure.Persistence.Context; + +namespace MyWarehouse.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .UseIdentityColumns() + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("ProductVersion", "5.0.2"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Partners.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModifiedAt") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Partners"); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Products.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LastModifiedAt") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NumberInStock") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Transactions.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModifiedAt") + .HasColumnType("datetime2"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("TransactionType") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PartnerId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Transactions.TransactionLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("TransactionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("TransactionId"); + + b.ToTable("TransactionLine"); + }); + + modelBuilder.Entity("MyWarehouse.Infrastructure.Identity.Model.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("MyWarehouse.Infrastructure.Identity.Model.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("MyWarehouse.Infrastructure.Identity.Model.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MyWarehouse.Infrastructure.Identity.Model.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("MyWarehouse.Infrastructure.Identity.Model.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Partners.Partner", b => + { + b.OwnsOne("MyWarehouse.Domain.Partners.Address", "Address", b1 => + { + b1.Property("PartnerId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b1.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("Country") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("Street") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("ZipCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.HasKey("PartnerId"); + + b1.ToTable("Partners"); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + }); + + b.Navigation("Address") + .IsRequired(); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Products.Product", b => + { + b.OwnsOne("MyWarehouse.Domain.Common.ValueObjects.Mass.Mass", "Mass", b1 => + { + b1.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b1.Property("Unit") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b1.Property("Value") + .HasColumnType("real"); + + b1.HasKey("ProductId"); + + b1.ToTable("Products"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.OwnsOne("MyWarehouse.Domain.Common.ValueObjects.Money.Money", "Price", b1 => + { + b1.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b1.HasKey("ProductId"); + + b1.ToTable("Products"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.Navigation("Mass") + .IsRequired(); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Transactions.Transaction", b => + { + b.HasOne("MyWarehouse.Domain.Partners.Partner", "Partner") + .WithMany("Transactions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("MyWarehouse.Domain.Common.ValueObjects.Money.Money", "Total", b1 => + { + b1.Property("TransactionId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b1.HasKey("TransactionId"); + + b1.ToTable("Transactions"); + + b1.WithOwner() + .HasForeignKey("TransactionId"); + }); + + b.Navigation("Partner"); + + b.Navigation("Total") + .IsRequired(); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Transactions.TransactionLine", b => + { + b.HasOne("MyWarehouse.Domain.Products.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MyWarehouse.Domain.Transactions.Transaction", "Transaction") + .WithMany("TransactionLines") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("MyWarehouse.Domain.Common.ValueObjects.Money.Money", "UnitPrice", b1 => + { + b1.Property("TransactionLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .UseIdentityColumn(); + + b1.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b1.HasKey("TransactionLineId"); + + b1.ToTable("TransactionLine"); + + b1.WithOwner() + .HasForeignKey("TransactionLineId"); + }); + + b.Navigation("Product"); + + b.Navigation("Transaction"); + + b.Navigation("UnitPrice") + .IsRequired(); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Partners.Partner", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("MyWarehouse.Domain.Transactions.Transaction", b => + { + b.Navigation("TransactionLines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistence/Seed/DbInitializer.cs b/src/Infrastructure/Persistence/Seed/DbInitializer.cs new file mode 100644 index 0000000..4523f6a --- /dev/null +++ b/src/Infrastructure/Persistence/Seed/DbInitializer.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MyWarehouse.Domain.Partners; +using MyWarehouse.Domain.Products; +using MyWarehouse.Infrastructure.Identity.Model; +using MyWarehouse.Infrastructure.Persistence.Context; +using MyWarehouse.Infrastructure.Persistence.Settings; +using MyWarehouse.TestData; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MyWarehouse.Infrastructure.Persistence.Seed +{ + static class DbInitializer + { + public static void SeedDatabase(IApplicationBuilder appBuilder, IConfiguration configuration) + { + using (var scope = appBuilder.ApplicationServices.CreateScope()) + { + var services = scope.ServiceProvider; + var settings = configuration.GetMyOptions(); + + try + { + var context = services.GetRequiredService(); + + if (settings.AutoMigrate.Value && context.Database.IsSqlServer()) + { + context.Database.Migrate(); + } + + if (settings.AutoSeed.Value) + { + SeedDefaultUser(services, configuration.GetMyOptions()); + SeedSampleData(context); + } + } + catch (Exception exception) + { + var logger = scope.ServiceProvider.GetRequiredService>(); + + logger.LogError(exception, "An error occurred while migrating or seeding the database."); + + throw; + } + } + } + + private static void SeedDefaultUser(IServiceProvider services, UserSeedSettings settings) + { + if (!settings.SeedDefaultUser) + return; + + using (var userManager = services.GetRequiredService>()) + { + if (!userManager.Users.Any(u => u.UserName == settings.DefaultUsername)) + { + var defaultUser = new ApplicationUser { UserName = settings.DefaultUsername, Email = settings.DefaultEmail }; + userManager.CreateAsync(defaultUser, settings.DefaultPassword).GetAwaiter().GetResult(); + } + } + } + + private static void SeedSampleData(ApplicationDbContext context) + { + List products = null; + List partners = null; + + if (!context.Partners.Any() && !context.Products.Any()) + { + (products, partners) = DataGenerator.GenerateBaseEntities(); + + context.Partners.AddRange(partners); + context.Products.AddRange(products); + context.SaveChanges(); + } + + if (!context.Transactions.Any()) + { + products ??= context.Products.ToList(); + partners ??= context.Partners.ToList(); + + // This is the best way to add transactions; to save after each one. + // Trust me, I tried. Batch saving causes them to get 'grouped' by partners via their nav property. + // Then, when you list transactions by ID, you get a bunch for the same partner one after each other. + // And trying to solve it via AsNoTracking(), changetracker.Clear(), Reflection, etc. is a nightmare. + var transactionsToGenerate = 103; + for (int i = 0; i < transactionsToGenerate; i++) + { + context.Transactions.Add( + DataGenerator.GenerateTransaction(partners, products)); + context.SaveChanges(); + } + } + } + } +} diff --git a/src/Infrastructure/Persistence/Settings/ApplicationDbSettings.cs b/src/Infrastructure/Persistence/Settings/ApplicationDbSettings.cs new file mode 100644 index 0000000..8c8e09e --- /dev/null +++ b/src/Infrastructure/Persistence/Settings/ApplicationDbSettings.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Infrastructure.Persistence.Settings +{ + class ApplicationDbSettings + { + /// + /// Specifies if migration should be attempted automatically during configuration. + /// + [Required] + public bool? AutoMigrate { get; init; } + + /// + /// Specifies if seeding should be attempted automatically during configuration. + /// + [Required] + public bool? AutoSeed { get; init; } + } +} diff --git a/src/Infrastructure/Persistence/Settings/ConnectionStrings.cs b/src/Infrastructure/Persistence/Settings/ConnectionStrings.cs new file mode 100644 index 0000000..fcc8bf0 --- /dev/null +++ b/src/Infrastructure/Persistence/Settings/ConnectionStrings.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Infrastructure.Persistence.Settings +{ + public class ConnectionStrings + { + [Required, MinLength(1)] + public string DefaultConnection { get; init; } + } +} diff --git a/src/Infrastructure/Persistence/Settings/UserSeedSettings.cs b/src/Infrastructure/Persistence/Settings/UserSeedSettings.cs new file mode 100644 index 0000000..2c3239f --- /dev/null +++ b/src/Infrastructure/Persistence/Settings/UserSeedSettings.cs @@ -0,0 +1,17 @@ +using MyWarehouse.Infrastructure.Common.Validation; + +namespace MyWarehouse.Infrastructure.Persistence.Settings +{ + class UserSeedSettings + { + public bool SeedDefaultUser { get; init; } + + [RequiredIf(nameof(SeedDefaultUser), true)] + public string DefaultUsername { get; init; } + + [RequiredIf(nameof(SeedDefaultUser), true)] + public string DefaultPassword { get; init; } + + public string DefaultEmail { get; init; } + } +} diff --git a/src/Infrastructure/Persistence/Startup.cs b/src/Infrastructure/Persistence/Startup.cs new file mode 100644 index 0000000..0a0338b --- /dev/null +++ b/src/Infrastructure/Persistence/Startup.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MyWarehouse.Infrastructure.Persistence.Context; +using MyWarehouse.Infrastructure.Persistence.Settings; + +namespace MyWarehouse.Infrastructure.Persistence +{ + internal static class Startup + { + public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment env) + { + services.AddSingleton(configuration.GetMyOptions()); + + services.AddDbContext(options => { + options.UseSqlServer( + configuration.GetMyOptions().DefaultConnection, + opts => opts.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName)); + + // Allows log messages to contain the normally masked + // SQL statement parameters sent to DB. + if (env.IsDevelopment()) + options.EnableSensitiveDataLogging(); + }); + } + + public static void Configure(IApplicationBuilder app, IConfiguration configuration) + { + Seed.DbInitializer.SeedDatabase(app, configuration); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/Startup.cs b/src/Infrastructure/Startup.cs new file mode 100644 index 0000000..f810bb0 --- /dev/null +++ b/src/Infrastructure/Startup.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MyWarehouse.Infrastructure.UnitTests")] + +namespace MyWarehouse.Infrastructure +{ + // This class implements a rather crude modular configuration of subcomponents, without any ceremony or meta-structure. + // Proper abstractions can be added later if modularization would seem to benefit from them. + + public static class Startup + { + public static void ConfigureAppConfiguration(HostBuilderContext context, IConfigurationBuilder configBuilder) + { + configBuilder.AddJsonFile("infrastructureSettings.json", optional: true); + + AzureKeyVault.Startup.ConfigureAppConfiguration(context, configBuilder); + } + + public static IServiceCollection ConfigureServices(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment env) + { + Swagger.Startup.ConfigureServices(services, configuration); + Identity.Startup.ConfigureServices(services, configuration); + Authentication.Startup.ConfigureServices(services, configuration); + Persistence.Startup.ConfigureServices(services, configuration, env); + CoreDependencies.Startup.ConfigureServices(services, configuration); + + return services; + } + + public static void Configure(IApplicationBuilder app, IConfiguration configuration, IWebHostEnvironment env) + { + Authentication.Startup.Configure(app); + Persistence.Startup.Configure(app, configuration); + Swagger.Startup.Configure(app, configuration); + } + } +} diff --git a/src/Infrastructure/Swagger/Configuration/SecuritySchemeNames.cs b/src/Infrastructure/Swagger/Configuration/SecuritySchemeNames.cs new file mode 100644 index 0000000..f56ebb2 --- /dev/null +++ b/src/Infrastructure/Swagger/Configuration/SecuritySchemeNames.cs @@ -0,0 +1,7 @@ +namespace MyWarehouse.Infrastructure.Swagger.Configuration +{ + public static class SecuritySchemeNames + { + public const string ApiLogin = "ApiLogin"; + } +} diff --git a/src/Infrastructure/Swagger/Configuration/SwaggerSettings.cs b/src/Infrastructure/Swagger/Configuration/SwaggerSettings.cs new file mode 100644 index 0000000..b844cbc --- /dev/null +++ b/src/Infrastructure/Swagger/Configuration/SwaggerSettings.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.Infrastructure.Swagger.Configuration +{ + class SwaggerSettings + { + [Required, MinLength(1)] + public string ApiName { get; init; } + + [Required, MinLength(1)] + public string ApiVersion { get; init; } + + public bool UseSwagger { get; init; } + + [Required, MinLength(1)] + public string LoginPath { get; set; } + + [Required, MinLength(1)] + public string JsonEndpointPath { get; set; } + } +} diff --git a/src/Infrastructure/Swagger/Filters/SwaggerAuthorizeFilter.cs b/src/Infrastructure/Swagger/Filters/SwaggerAuthorizeFilter.cs new file mode 100644 index 0000000..4eecf42 --- /dev/null +++ b/src/Infrastructure/Swagger/Filters/SwaggerAuthorizeFilter.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.OpenApi.Models; +using MyWarehouse.Infrastructure.Swagger.Configuration; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Collections.Generic; +using System.Linq; + +namespace MyWarehouse.Infrastructure.Swagger.Filters +{ + /// + /// Configure Swagger to send Bearer token when calling actions that require authorization via Authorize + /// + public class SwaggerAuthorizeFilter : IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var filterDescriptor = context.ApiDescription.ActionDescriptor.FilterDescriptors; + + var hasAuthorizeFilter = filterDescriptor.Select(filterInfo => filterInfo.Filter).Any(filter => filter is AuthorizeFilter); + var allowAnonymous = filterDescriptor.Select(filterInfo => filterInfo.Filter).Any(filter => filter is IAllowAnonymousFilter); + var hasAuthorizeAttribute = context.MethodInfo.DeclaringType.GetCustomAttributes(true).Any(attr => attr is AuthorizeAttribute) + || context.MethodInfo.GetCustomAttributes(true).Any(attr => attr is AuthorizeAttribute); + + if ((hasAuthorizeFilter || hasAuthorizeAttribute) && !allowAnonymous) + { + operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" }); + operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" }); + + if (operation.Security == null) + { + operation.Security = new List(); + } + + operation.Security.Add(new OpenApiSecurityRequirement() + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = SecuritySchemeNames.ApiLogin + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header, + }, + new List() + } + } + ); + } + } + } +} diff --git a/src/Infrastructure/Swagger/Filters/SwaggerGroupAttribute.cs b/src/Infrastructure/Swagger/Filters/SwaggerGroupAttribute.cs new file mode 100644 index 0000000..d5d0678 --- /dev/null +++ b/src/Infrastructure/Swagger/Filters/SwaggerGroupAttribute.cs @@ -0,0 +1,18 @@ +using System; + +namespace MyWarehouse.Infrastructure.Swagger.Filters +{ + /// + /// Specifies a custom name that overrides Swagger's default group name for the actions in the given controller. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class SwaggerGroupAttribute : Attribute + { + public string GroupName { get; set; } + + public SwaggerGroupAttribute(string groupName) + { + GroupName = groupName; + } + } +} diff --git a/src/Infrastructure/Swagger/Filters/SwaggerGroupFilter.cs b/src/Infrastructure/Swagger/Filters/SwaggerGroupFilter.cs new file mode 100644 index 0000000..1c787f2 --- /dev/null +++ b/src/Infrastructure/Swagger/Filters/SwaggerGroupFilter.cs @@ -0,0 +1,24 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Collections.Generic; +using System.Linq; + +namespace MyWarehouse.Infrastructure.Swagger.Filters +{ + /// + /// Overrides the grouping name of actions in controllers which are decorated with . + /// + public class SwaggerGroupFilter : IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var customGroupAttribute = context.MethodInfo.DeclaringType.GetCustomAttributes(true) + .OfType()?.FirstOrDefault(); + + if (customGroupAttribute != null && !string.IsNullOrWhiteSpace(customGroupAttribute.GroupName)) + { + operation.Tags = new List { new OpenApiTag() { Name = customGroupAttribute.GroupName } }; + } + } + } +} diff --git a/src/Infrastructure/Swagger/Startup.cs b/src/Infrastructure/Swagger/Startup.cs new file mode 100644 index 0000000..cd6f1c5 --- /dev/null +++ b/src/Infrastructure/Swagger/Startup.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using MyWarehouse.Infrastructure.Swagger.Configuration; +using MyWarehouse.Infrastructure.Swagger.Filters; +using System; +using System.Collections.Generic; + +namespace MyWarehouse.Infrastructure.Swagger +{ + internal static class Startup + { + public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddSwaggerGen(c => + { + var swaggerSettings = configuration.GetMyOptions(); + + if (swaggerSettings.UseSwagger == false) + { + return; + } + + c.SwaggerDoc(swaggerSettings.ApiVersion, new OpenApiInfo { Title = swaggerSettings.ApiName, Version = swaggerSettings.ApiVersion }); + + { // Add Login capability to Swagger UI. + c.AddSecurityDefinition(SecuritySchemeNames.ApiLogin, new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + Password = new OpenApiOAuthFlow() + { + TokenUrl = new Uri(swaggerSettings.LoginPath, UriKind.Relative), + AuthorizationUrl = new Uri(swaggerSettings.LoginPath, UriKind.Relative), + } + } + }); + } + + // Prevent SwaggerGen from throwing exception when multiple DTOs from different namespaces have the same type name. + c.CustomSchemaIds(x => { + var lastNamespaceSection = x.Namespace[(x.Namespace.LastIndexOf('.') + 1)..]; + var genericParameters = string.Join(',', (IEnumerable)x.GetGenericArguments()); + + return $"{lastNamespaceSection}.{x.Name}{(string.IsNullOrEmpty(genericParameters) ? null : "<" + genericParameters + ">")}"; + }); + + c.OperationFilter(); + c.OperationFilter(); + }); + } + + public static void Configure(IApplicationBuilder app, IConfiguration configuration) + { + var swaggerSettings = configuration.GetMyOptions(); + + if (swaggerSettings.UseSwagger == true) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint( + url: swaggerSettings.JsonEndpointPath, + name: swaggerSettings.ApiName + swaggerSettings.ApiVersion + ); + }); + } + } + } +} diff --git a/src/Infrastructure/infrastructureSettings.json b/src/Infrastructure/infrastructureSettings.json new file mode 100644 index 0000000..0db3279 --- /dev/null +++ b/src/Infrastructure/infrastructureSettings.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/src/SampleData/DataGenerator.cs b/src/SampleData/DataGenerator.cs new file mode 100644 index 0000000..3ac6c60 --- /dev/null +++ b/src/SampleData/DataGenerator.cs @@ -0,0 +1,29 @@ +using MyWarehouse.Domain.Partners; +using MyWarehouse.Domain.Products; +using MyWarehouse.Domain.Transactions; +using MyWarehouse.TestData.Samples; +using System.Collections.Generic; + +namespace MyWarehouse.TestData +{ + public static class DataGenerator + { + /// + /// Generates Partner and Product entities. + /// + public static (List, List) GenerateBaseEntities() + { + var products = SampleProducts.GenerateSampleProducts(76); + var partners = SamplePartners.GetSamplePartners(); + + return (products, partners); + } + + /// + /// Generates a single random transaction by selecting one partner, one or multiple products, and one or multiple quantity for each product. + /// Note that transaction will be persisted at save even without using the return value, because transactions are created inside the Partner aggregate root. + /// + public static Transaction GenerateTransaction(IReadOnlyList partners, IReadOnlyList products) + => SampleTransactions.GenerateTransaction(partners, products); + } +} \ No newline at end of file diff --git a/src/SampleData/SampleData.csproj b/src/SampleData/SampleData.csproj new file mode 100644 index 0000000..a920653 --- /dev/null +++ b/src/SampleData/SampleData.csproj @@ -0,0 +1,11 @@ + + + + net5.0 + + + + + + + diff --git a/src/SampleData/Samples/SamplePartners.cs b/src/SampleData/Samples/SamplePartners.cs new file mode 100644 index 0000000..bbdf87f --- /dev/null +++ b/src/SampleData/Samples/SamplePartners.cs @@ -0,0 +1,62 @@ +using MyWarehouse.Domain.Partners; +using System.Collections.Generic; + +namespace MyWarehouse.TestData.Samples +{ + /// + /// Returns some sci-fi themed partners. + /// + internal static class SamplePartners + { + internal static List GetSamplePartners() + { + return new List() + { + new Partner( + name: "Weyland-Yutani Corp", + address: new Address("1 Weyland Way", "Tokyo", "Japan", "100-6007") + ), + new Partner( + name: "Microsoft", + address: new Address("One Microsoft Way", "Redmond", "USA", "WA 98052") + ), + new Partner( + name: "TranStar Corporation", + address: new Address("2 Arboretum Bay", "Talos I", "China", "TAB2") + ), + new Partner( + name: "Cyberdyne Systems", + address: new Address("18144 El Camino Real", "Sunnyvale", "USA", "CA 93960") + ), + new Partner( + name: "Darkbook", + address: new Address("1 Humans Are Data Way", "Serverside", "USA", "CA 95960") + ), + new Partner( + name: "Tesla Cryogenics", + address: new Address("126 Frozen Way", "Palo Alto", "USA", "CA 94304") + ), + new Partner( + name: "Blue Sun Corporation", + address: new Address("1 Blue Sun HQ", "New Cardiff", "Australia", "2020") + ), + new Partner( + name: "Spacely Space Sprockets", + address: new Address("94 Propellant Boulevard", "Core York", "USA", "SPRC-D92F4") + ), + new Partner( + name: "Yoyodyne Propulsion Sys.", + address: new Address("R. do Rossio 1", "Madrid", "Spain", "28320") + ), + new Partner( + name: "Virtucon", + address: new Address("Mint Park Woodway Lane", "Leicestershire", "UK", "LE17 5FB") + ), + new Partner( + name: "Tyrell Corp.", + address: new Address("8 Nexus Center", "Los Angeles", "USA", "NE/444") + ), + }; + } + } +} \ No newline at end of file diff --git a/src/SampleData/Samples/SampleProducts.cs b/src/SampleData/Samples/SampleProducts.cs new file mode 100644 index 0000000..26ee99d --- /dev/null +++ b/src/SampleData/Samples/SampleProducts.cs @@ -0,0 +1,55 @@ +using MyWarehouse.Domain.Common.ValueObjects.Mass; +using MyWarehouse.Domain.Common.ValueObjects.Money; +using MyWarehouse.Domain.Products; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MyWarehouse.TestData.Samples +{ + /// + /// Generates goofy sci-fi themed products. + /// + internal static class SampleProducts + { + private static readonly string[] _namePrefixes = { "Bio-electric", "Neural", "Isograted", "Isolinear", "Microdyne", "Phylum", "Matterstream", "Transwarp", "Plasma", "Holographic", "Temporal", "Antimatter", "Dark matter", "Quantum", "Hydrogen", "Biogel", "RNA", "Void" }; + private static readonly string[] _nameSuffixes = { "Cable", "Diode", "Transponder", "Inducer", "Coupler", "Relay", "Coil", "Scanner", "Vacillator", "Inhibitor", "Oscillator", "Generator", "Inducer", "Reductor", "Splicer", "Transmuter", "Orchestrator", "Analyzer", "Doodad" }; + private static int MaximumNumber => _namePrefixes.Length * _nameSuffixes.Length; + + private static readonly string[] _descriptionPrefixes = { "Manages the", "Controls the", "Enhances the", "Distributes the", "Transforms the", "Acts as a governor in the", "Experimental version. Ensures the", "Plays a stabilizing role pertaining to the", "Quantifiably transposes the" }; + private static readonly string[] _descriptionJoiners = { "interaction of", "flow of", "connections between", "transfusions of", "intricate interconnections within", "seaming reagents of", "surrogate gyroconnections over" }; + private static readonly string[] _descriptionSuffixes = { "advanced micro circuits", "superconductive neural agents", "parallel quantum particles", "manifold dermal quantifiers", "charged stellar remains", "vorachodric micro-fitted interval dischargers", "exometric and telokinetic nano-engines", "tubular and oxogenic micoplasmosis" }; + + private static readonly Random _rnd = new Random(); + + internal static List GenerateSampleProducts(int number) + { + if (number > MaximumNumber) + { + throw new ArgumentException($"Maximum {MaximumNumber} unique products can be generated.", nameof(number)); + } + + var uniqueNames = new HashSet(number); + while (uniqueNames.Count < number) + { + uniqueNames.Add(GetName()); // HashSet filters out non-unique. + } + + return uniqueNames.Select(name => new Product( + name: name, + description: GetDescription(), + price: new Money(_rnd.Next(10, 999) + 0.99M, Currency.Default), + mass: new Mass(_rnd.Next(10, 200) * 0.1f, MassUnit.Kilogram) + )).ToList(); + + string GetName() + => $"{GetRandom(_namePrefixes)} {GetRandom(_nameSuffixes)}"; + + string GetDescription() + => $"{GetRandom(_descriptionPrefixes)} {GetRandom(_descriptionJoiners)} {GetRandom(_descriptionSuffixes)}"; + + string GetRandom(string[] arr) + => arr[_rnd.Next(arr.Length)]; + } + } +} \ No newline at end of file diff --git a/src/SampleData/Samples/SampleTransactions.cs b/src/SampleData/Samples/SampleTransactions.cs new file mode 100644 index 0000000..47c8698 --- /dev/null +++ b/src/SampleData/Samples/SampleTransactions.cs @@ -0,0 +1,71 @@ +using MyWarehouse.Domain.Partners; +using MyWarehouse.Domain.Products; +using MyWarehouse.Domain.Transactions; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MyWarehouse.TestData.Samples +{ + internal static class SampleTransactions + { + // Determines the minimum & maximum number of transaction lines to include in each transaction. + private const int MinTransactionLines = 1; + private const int MaxTransactionLines = 8; + + // Determines the minimum & maximum number of products to include in each transaction line. + private const int MinProcurementQuantity = 1; + private const int MaxProcurementQuantity = 30; + + // Determines the maximum ratio of stock to sell for any given product while creating a sales transaction line. + private const float MaximumSellingRatio = 0.4f; + + // Determines how many products to procure initially before starting to sell. + private const int StockPreloadProductCount = 15; + + private static readonly Random _rnd = new Random(21395443); + + internal static Transaction GenerateTransaction(IReadOnlyList partners, IReadOnlyList allProducts) + { + var availableProducts = allProducts.Where(x => x.NumberInStock > 0); + var unavailableProducts = allProducts.Where(x => x.NumberInStock == 0); + + var shouldBeProcurement = availableProducts.Count() < StockPreloadProductCount || _rnd.Next(0, 2) == 0; + var randomPartner = partners[_rnd.Next(0, partners.Count)]; + + Func, Transaction> transactionMethod = shouldBeProcurement + ? randomPartner.ProcureFrom + : randomPartner.SellTo; + + var productSelection = shouldBeProcurement + ? unavailableProducts.Any() + ? unavailableProducts + : allProducts + : availableProducts; + var productSelectionCount = productSelection.Count(); + + // Select some random products, and project them to transaction lines with random quantities based on the transaction type. + var transactionLines = productSelection + .SelectRandom(_rnd.Next(MinTransactionLines, MaxTransactionLines + 1)) + .Select(p => (p, _rnd.Next(MinProcurementQuantity, shouldBeProcurement + ? MaxProcurementQuantity + 1 + : Math.Max(MinProcurementQuantity, (int)(p.NumberInStock * MaximumSellingRatio))))); + + return transactionMethod(transactionLines); + } + + internal static IEnumerable SelectRandom(this IEnumerable list, int needed) + { + var count = list.Count(); + if (needed >= count) + return list; + + var selectedItems = new HashSet(); + while (needed > 0) + if (selectedItems.Add(list.ElementAt(_rnd.Next(count)))) + needed--; + + return selectedItems; + } + } +} \ No newline at end of file diff --git a/src/WebApi/Authentication/Dtos/LoginDto.cs b/src/WebApi/Authentication/Dtos/LoginDto.cs new file mode 100644 index 0000000..df5856e --- /dev/null +++ b/src/WebApi/Authentication/Dtos/LoginDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.WebApi.Authentication.Models.Dtos +{ + public class LoginDto + { + [Required, MinLength(1)] + public string Username { get; set; } + + [Required, MinLength(1)] + public string Password { get; set; } + } +} diff --git a/src/WebApi/Authentication/Dtos/TokenResponseDto.cs b/src/WebApi/Authentication/Dtos/TokenResponseDto.cs new file mode 100644 index 0000000..7beb59f --- /dev/null +++ b/src/WebApi/Authentication/Dtos/TokenResponseDto.cs @@ -0,0 +1,30 @@ +namespace MyWarehouse.WebApi.Authentication.Models.Dtos +{ + /// + /// Standard token response for login. + /// + public class TokenResponseDto + { + /// + /// The generated access token. + /// + public string access_token { get; set; } + + /// + /// The stored refresh token. + /// + public string refresh_token { get; set; } + + /// + /// The type of the token. Usually "Bearer". + /// + public string token_type { get; set; } = "Bearer"; + + /// + /// The expiration of the token, set in minutes. + /// + public int expires_in { get; set; } + + public string username { get; set; } + } +} diff --git a/src/WebApi/Authentication/Services/CurrentUserService.cs b/src/WebApi/Authentication/Services/CurrentUserService.cs new file mode 100644 index 0000000..b25967c --- /dev/null +++ b/src/WebApi/Authentication/Services/CurrentUserService.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Http; +using MyWarehouse.Application.Dependencies.Services; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace MyWarehouse.WebApi.Authentication.Services +{ + public class CurrentUserService : ICurrentUserService + { + private const string DefaultNonUserMoniker = "System"; + + public CurrentUserService(IHttpContextAccessor httpContextAccessor) + { + if (httpContextAccessor.HttpContext == null) + { + UserId = DefaultNonUserMoniker; + } + else + { + UserId = httpContextAccessor.HttpContext.User?.FindFirstValue(JwtRegisteredClaimNames.UniqueName); + } + } + + public string UserId { get; } + } +} diff --git a/src/WebApi/CORS/CorsSettings.cs b/src/WebApi/CORS/CorsSettings.cs new file mode 100644 index 0000000..ce5842b --- /dev/null +++ b/src/WebApi/CORS/CorsSettings.cs @@ -0,0 +1,9 @@ +namespace MyWarehouse.WebApi.CORS +{ + // Used for binding allowed origins in a strongly typed manner + // from JSON configuration. + public class CorsSettings + { + public string[] AllowedOrigins { get; init; } + } +} diff --git a/src/WebApi/Controllers/AccountController.cs b/src/WebApi/Controllers/AccountController.cs new file mode 100644 index 0000000..ef60dec --- /dev/null +++ b/src/WebApi/Controllers/AccountController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MyWarehouse.Infrastructure.Authentication.Services; +using MyWarehouse.WebApi.Authentication.Models.Dtos; +using System.Threading.Tasks; + +namespace MyWarehouse.WebApi.Controllers +{ + [Authorize] + [ApiController] + [Route("account")] + public class AccountController : ControllerBase + { + private readonly IUserService _userService; + + public AccountController(IUserService userService) + { + this._userService = userService; + } + + [AllowAnonymous] + [HttpPost] + [Route("login")] + public Task> Login([FromBody]LoginDto login) + { + return LoginInternal(login); + } + + [AllowAnonymous] + [HttpPost] + [Route("loginForm")] + [ProducesResponseType(typeof(TokenResponseDto), StatusCodes.Status200OK)] + public Task> LoginForm([FromForm]LoginDto login) + { + return LoginInternal(login); + } + + private async Task> LoginInternal(LoginDto login) + { + var (result, token) = await _userService.SignIn(login.Username, login.Password); + + if (result.IsLockedOut) + return Forbid("User locked out."); + else if (result.IsNotAllowed) + return Forbid("User is not allowed to sign in."); + else if (!result.Succeeded) + return Unauthorized("Username or password incorrect."); + + return Ok(new TokenResponseDto() + { + access_token = token.AccessToken, + token_type = token.TokenType, + expires_in = token.GetRemainingLifetimeSeconds(), + username = token.Username + }); + } + } +} diff --git a/src/WebApi/Controllers/PartnerController.cs b/src/WebApi/Controllers/PartnerController.cs new file mode 100644 index 0000000..5e6d1f8 --- /dev/null +++ b/src/WebApi/Controllers/PartnerController.cs @@ -0,0 +1,53 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using MyWarehouse.Application.Partners.CreatePartner; +using MyWarehouse.Application.Partners.DeletePartner; +using MyWarehouse.Application.Partners.GetPartnerDetails; +using MyWarehouse.Application.Partners.GetPartners; +using MyWarehouse.Application.Partners.UpdatePartner; +using System.Threading.Tasks; + +namespace MyWarehouse.WebApi.Controllers +{ + [Authorize] + [ApiController] + [Route("partners")] + public class PartnerController : ControllerBase + { + private readonly IMediator _mediator; + + public PartnerController(IMediator mediator) => _mediator = mediator; + + [HttpPost] + public async Task> Create(CreatePartnerCommand command) + => Ok(await _mediator.Send(command)); + + [HttpGet] + public async Task>> GetList([FromQuery] ListQueryModel query) + => Ok(await _mediator.Send(query)); + + [HttpGet("{id}")] + public async Task> Get(int id) + => Ok(await _mediator.Send(new GetPartnerDetailsQuery() { Id = id })); + + [HttpDelete("{id}")] + public async Task Delete(int id) + { + await _mediator.Send(new DeletePartnerCommand() { Id = id }); + + return NoContent(); + } + + [HttpPut("{id}")] + public async Task Update(int id, UpdatePartnerCommand command) + { + if (id != command.Id) return BadRequest(); + + await _mediator.Send(command); + + return NoContent(); + } + } +} diff --git a/src/WebApi/Controllers/ProductController.cs b/src/WebApi/Controllers/ProductController.cs new file mode 100644 index 0000000..6af7f2e --- /dev/null +++ b/src/WebApi/Controllers/ProductController.cs @@ -0,0 +1,68 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using MyWarehouse.Application.Partners.DeletePartner; +using MyWarehouse.Application.Partners.UpdatePartner; +using MyWarehouse.Application.Products.CreateProduct; +using MyWarehouse.Application.Products.GetProduct; +using MyWarehouse.Application.Products.GetProducts; +using MyWarehouse.Application.Products.GetProductsSummary; +using MyWarehouse.Application.Products.ProductStockMass; +using MyWarehouse.Application.Products.ProductStockValue; +using System.Threading.Tasks; + +namespace MyWarehouse.WebApi.Controllers +{ + [Authorize] + [ApiController] + [Route("products")] + public class ProductController : ControllerBase + { + private readonly IMediator _mediator; + + public ProductController(IMediator mediator) => _mediator = mediator; + + [HttpPost] + public async Task> Create(CreateProductCommand command) + => Ok(await _mediator.Send(command)); + + [HttpGet] + public async Task>> GetList([FromQuery] GetProductsListQuery query) + => Ok(await _mediator.Send(query)); + + [HttpGet("{id}")] + public async Task> Get(int id) + => Ok(await _mediator.Send(new GetProductDetailsQuery() { Id = id })); + + [HttpDelete("{id}")] + public async Task Delete(int id) + { + await _mediator.Send(new DeleteProductCommand() { Id = id }); + + return NoContent(); + } + + [HttpPut("{id}")] + public async Task Update(int id, UpdateProductCommand command) + { + if (id != command.Id) return BadRequest(); + + await _mediator.Send(command); + + return NoContent(); + } + + [HttpGet("totalMass")] + public async Task> ProductStockMass() + => Ok(await _mediator.Send(new ProductStockMassQuery())); + + [HttpGet("totalValue")] + public async Task> ProductStockValue() + => Ok(await _mediator.Send(new ProductStockValueQuery())); + + [HttpGet("stockCount")] + public async Task> ProductStockCount() + => Ok(await _mediator.Send(new ProductStockCountQuery())); + } +} diff --git a/src/WebApi/Controllers/TransactionController.cs b/src/WebApi/Controllers/TransactionController.cs new file mode 100644 index 0000000..5ae26b1 --- /dev/null +++ b/src/WebApi/Controllers/TransactionController.cs @@ -0,0 +1,33 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MyWarehouse.Application.Common.Dependencies.DataAccess.Repositories.Common; +using MyWarehouse.Application.Transactions.CreateTransaction; +using MyWarehouse.Application.Transactions.GetTransactionDetails; +using MyWarehouse.Application.Transactions.GetTransactionsList; +using System.Threading.Tasks; + +namespace MyWarehouse.WebApi.Controllers +{ + [Authorize] + [ApiController] + [Route("transactions")] + public class TransactionController : ControllerBase + { + private readonly IMediator _mediator; + + public TransactionController(IMediator mediator) => _mediator = mediator; + + [HttpPost] + public async Task> Create(CreateTransactionCommand command) + => Ok(await _mediator.Send(command)); + + [HttpGet] + public async Task>> GetList([FromQuery] GetTransactionListQuery query) + => Ok(await _mediator.Send(query)); + + [HttpGet("{id}")] + public async Task> Get(int id) + => Ok(await _mediator.Send(new GetTransactionDetailsQuery() { Id = id })); + } +} diff --git a/src/WebApi/ErrorHandling/ApiExceptionFilter.cs b/src/WebApi/ErrorHandling/ApiExceptionFilter.cs new file mode 100644 index 0000000..2ee14c5 --- /dev/null +++ b/src/WebApi/ErrorHandling/ApiExceptionFilter.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; +using MyWarehouse.Application.Common.Exceptions; +using System; + +namespace MyWarehouse.WebApi.ErrorHandling +{ + /// + /// Maps exceptions occurred in lower layers into HTTP responses with appropriate HTTP code. + /// Make sure to register this filter globally. + /// + public class ApiExceptionFilter : ExceptionFilterAttribute + { + private readonly ILogger _logger; + + public ApiExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public override void OnException(ExceptionContext context) + { + //TODO: Push logging deeper, into Application layer, and dedicate this component to response mapping. + _logger.LogError($"Exception: [{context.Exception.Message}]\r\n\r\nStack Trace:\r\n{context.Exception.StackTrace}"); + context.Result = ExecuteHandler(context.Exception); + context.ExceptionHandled = true; + + base.OnException(context); + } + + private static IActionResult ExecuteHandler(Exception exception) + => exception switch + { + InputValidationException e => HandleValidationException(e), + EntityNotFoundException e => HandleNotFoundException(e), + _ => HandleUnknownException(exception) + }; + + private static IActionResult HandleUnknownException(Exception _) + { + var details = new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "An error occurred while processing your request.", + Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1" + }; + + return new ObjectResult(details) { StatusCode = StatusCodes.Status500InternalServerError }; + } + + private static IActionResult HandleValidationException(InputValidationException exception) + { + var details = new ValidationProblemDetails(exception.Errors) + { + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1" + }; + + return new BadRequestObjectResult(details); + } + + private static IActionResult HandleNotFoundException(EntityNotFoundException exception) + { + var details = new ProblemDetails() + { + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4", + Title = "The specified resource was not found.", + Detail = exception.Message + }; + + return new NotFoundObjectResult(details); + } + } +} diff --git a/src/WebApi/Logging/Helper/LogHelper.cs b/src/WebApi/Logging/Helper/LogHelper.cs new file mode 100644 index 0000000..af26aab --- /dev/null +++ b/src/WebApi/Logging/Helper/LogHelper.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; +using Serilog.Events; +using System; + +namespace MyWarehouse.WebApi.Logging.Helper +{ + internal static class LogHelper + { + /// + /// Replacement of the default implementation of RequestLoggingOptions.GetLevel. + /// Excludes health check endpoints from logging by decreasing their log level. + /// + public static LogEventLevel ExcludeHealthChecks(HttpContext ctx, double _, Exception ex) => + ex != null + ? LogEventLevel.Error + : ctx.Response.StatusCode > 499 + ? LogEventLevel.Error + : IsHealthCheckEndpoint(ctx) // Not an error, check if it was a health check + ? LogEventLevel.Verbose // Was a health check, use Verbose + : LogEventLevel.Information; + + private static bool IsHealthCheckEndpoint(HttpContext ctx) + { + var endpoint = ctx.GetEndpoint(); + if (endpoint is object) + { + return string.Equals( + endpoint.DisplayName, + "Health checks", + StringComparison.Ordinal); + } + // No endpoint, so not a health check endpoint + return false; + } + } +} \ No newline at end of file diff --git a/src/WebApi/Logging/LoggingExtensions.cs b/src/WebApi/Logging/LoggingExtensions.cs new file mode 100644 index 0000000..21f7606 --- /dev/null +++ b/src/WebApi/Logging/LoggingExtensions.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using MyWarehouse.WebApi.Logging.Helper; +using MyWarehouse.WebApi.Logging.Settings; +using MyWarehouse.Infrastructure; +using Serilog; +using Serilog.Events; + +namespace MyWarehouse.WebApi.Logging +{ + public static class LoggingExtensions + { + public static IWebHostBuilder AddMySerilogLogging(this IWebHostBuilder webBuilder) + { + return webBuilder.UseSerilog((context, loggerCfg) => + { + loggerCfg + .MinimumLevel.Information() + .Enrich.FromLogContext() + .Enrich.WithProperty("EnvironmentName", context.HostingEnvironment.EnvironmentName) + .Enrich.WithMachineName(); + + if (context.HostingEnvironment.IsDevelopment()) + { + loggerCfg + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Event} - {Message}{NewLine}{Exception}") + .WriteTo.Debug(); + } + else + { + loggerCfg + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Error); + } + + var logglySettings = context.Configuration.GetMyOptions(); + if (logglySettings.WriteToLoggly.GetValueOrDefault() == true) + { + loggerCfg.WriteTo.Loggly( + customerToken: logglySettings.CustomerToken); + } + }); + } + + /// + /// Adds Serilog request logging to the request processing pipeline. + /// Call it early in the pipeline to capture as much as possible. + /// + public static IApplicationBuilder AddMyRequestLogging(this IApplicationBuilder appBuilder) + { + return appBuilder + // Log requests + .UseSerilogRequestLogging( + // Don't log health check endpoints + opts => opts.GetLevel = LogHelper.ExcludeHealthChecks); + } + } +} \ No newline at end of file diff --git a/src/WebApi/Logging/Settings/LogglySettings.cs b/src/WebApi/Logging/Settings/LogglySettings.cs new file mode 100644 index 0000000..842e449 --- /dev/null +++ b/src/WebApi/Logging/Settings/LogglySettings.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyWarehouse.WebApi.Logging.Settings +{ + public class LogglySettings + { + [Required] + public bool? WriteToLoggly { get; init; } + + [Required, MinLength(1)] + public string CustomerToken { get; init; } + } +} diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs new file mode 100644 index 0000000..1d87a2b --- /dev/null +++ b/src/WebApi/Program.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using MyWarehouse.WebApi.Logging; +using System.Reflection; + +namespace MyWarehouse +{ + public static class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) + => Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults( + webBuilder => { + // Notice: Logging overrides. + webBuilder.AddMySerilogLogging(); + + webBuilder.UseStartup(); + }) + .ConfigureAppConfiguration((context, config) => + { + // ConfigureWebHostDefaults only adds secrets if environment is Develop. + // This ensures they're always added, for local testing of Production setting. + config.AddUserSecrets(Assembly.GetEntryAssembly(), optional: true); + + // Notice: Infrastructure hook. + Infrastructure.Startup.ConfigureAppConfiguration(context, config); + }); + } +} \ No newline at end of file diff --git a/src/WebApi/Properties/launchSettings.json b/src/WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..76a58b9 --- /dev/null +++ b/src/WebApi/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:62564", + "sslPort": 44346 + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "MyWarehouseDev": { + "commandName": "Project", + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + }, + "MyWarehouseProd": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "health", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Production" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } +} \ No newline at end of file diff --git a/src/WebApi/Startup.cs b/src/WebApi/Startup.cs new file mode 100644 index 0000000..412dd41 --- /dev/null +++ b/src/WebApi/Startup.cs @@ -0,0 +1,121 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Mvc; +using MyWarehouse.Application.Dependencies.Services; +using MyWarehouse.WebApi.Authentication.Services; +using MyWarehouse.WebApi.Logging; +using MyWarehouse.WebApi.ErrorHandling; +using System.Text.Json; +using MyWarehouse.WebApi.CORS; +using MyWarehouse.Infrastructure; + +namespace MyWarehouse +{ + public class Startup + { + protected IConfiguration Configuration { get; } + public IWebHostEnvironment Environment { get; } + + public Startup(IConfiguration configuration, IWebHostEnvironment environment) + { + Configuration = configuration; + Environment = environment; + } + + public void ConfigureServices(IServiceCollection services) + { + AddMyOptions(services); + AddMyControllers(services); + AddMyApiServices(services); + + services.AddHealthChecks(); + + Infrastructure.Startup.ConfigureServices(services, Configuration, Environment); + Core.Startup.ConfigureServices(services); + + AddMyCorsConfig(services); + } + + public void Configure(IApplicationBuilder app) + { + if (Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + +#if DEBUG + app.Use(async (ctx, next) =>{ + + // Break here to debug HttpContext, Request, or Response. + await next(); + }); +#endif + + app.AddMyRequestLogging(); + + app.UseHttpsRedirection(); + app.UseRouting(); + + app.UseCors(); + + Infrastructure.Startup.Configure(app, Configuration, Environment); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHealthChecks("/health"); + }); + } + + private void AddMyCorsConfig(IServiceCollection services) + { + services.AddCors(options => + { + options.AddDefaultPolicy(builder => + { + builder + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .SetIsOriginAllowedToAllowWildcardSubdomains() + .WithOrigins( + Configuration.GetMyOptions().AllowedOrigins) + .Build(); + }); + }); + } + + /// + /// Binds strongly typed option classes to the aggregate configuration. + /// Classes configured here are injectable into components by the IoC container via IOptions. + /// + protected virtual void AddMyOptions(IServiceCollection services) + { + services.AddOptions(); + + // Add API-related strongly typed options here. + services.RegisterMyOptions(); + } + + protected virtual void AddMyControllers(IServiceCollection services) + { + services.AddControllers( + options => options.Filters.Add() + ) + .AddControllersAsServices() + .SetCompatibilityVersion(CompatibilityVersion.Version_3_0) + .AddJsonOptions(c => + c.JsonSerializerOptions.PropertyNamingPolicy + = JsonNamingPolicy.CamelCase); // Supposed to be default, but just to make sure. + } + + protected virtual void AddMyApiServices(IServiceCollection services) + { + services.AddHttpContextAccessor(); + services.AddScoped(); + } + } +} diff --git a/src/WebApi/WebApi.csproj b/src/WebApi/WebApi.csproj new file mode 100644 index 0000000..ff2822b --- /dev/null +++ b/src/WebApi/WebApi.csproj @@ -0,0 +1,33 @@ + + + + net5.0 + aspnet-MyWarehouse-E99EEC75-3176-485B-AAB4-5452F05E7109 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/WebApi/appsettings.Development.json b/src/WebApi/appsettings.Development.json new file mode 100644 index 0000000..628f3a9 --- /dev/null +++ b/src/WebApi/appsettings.Development.json @@ -0,0 +1,24 @@ +{ + "CorsSettings": { + "AllowedOrigins": [ "http://localhost:4200", "https://localhost:4200" ] + }, + "UserSeedSettings": { + "SeedDefaultUser": true + }, + "SwaggerSettings": { + "UseSwagger": true + }, + "AzureKeyVaultSettings": { + "AddToConfiguration": false + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "LogglySettings": { + "WriteToLoggly": false + } +} \ No newline at end of file diff --git a/src/WebApi/appsettings.json b/src/WebApi/appsettings.json new file mode 100644 index 0000000..0d00e2d --- /dev/null +++ b/src/WebApi/appsettings.json @@ -0,0 +1,47 @@ +{ + "CorsSettings": { + "AllowedOrigins": [] + }, + "SwaggerSettings": { + "ApiName": "MyWarehouse", + "ApiVersion": "v1", + "UseSwagger": false, + "LoginPath": "/account/loginForm", + "JsonEndpointPath": "/swagger/v1/swagger.json" + }, + "AzureKeyVaultSettings": { + "ServiceUrl": null, // Secret + "AddToConfiguration": false + }, + "ApplicationDbSettings": { + "AutoMigrate": true, + "AutoSeed": true + }, + "UserSeedSettings": { + "SeedDefaultUser": false, + "DefaultUsername": null, // Secret + "DefaultPassword": null, // Secret + "DefaultEmail": null // Secret + }, + "AuthenticationSettings": { + "JwtIssuer": "MyWarehouse", + "JwtAudience": "MyWarehouse", + "TokenExpirationSeconds": 86400, + "JwtSigningKeyBase64": null // Secret + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "LogglySettings": { + "WriteToLoggly": true, + "CustomerToken": null // Secret + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": null // Secret + } +} \ No newline at end of file