diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3729ff0c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1ff0c423 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..d3e7244f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +patreon: eduardopires diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd84ea78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 00000000..1b06b9ba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + +**ONLY IN ENGLISH** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml new file mode 100644 index 00000000..e8608446 --- /dev/null +++ b/.github/workflows/dotnet-core.yml @@ -0,0 +1,25 @@ +name: .NET Core + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 6.0.x + - name: Install dependencies + run: dotnet restore + - name: Build + run: dotnet build --configuration Release --no-restore + - name: Test + run: dotnet test --no-restore --verbosity normal diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1c9a181a --- /dev/null +++ b/.gitignore @@ -0,0 +1,242 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +[Xx]64/ +[Xx]86/ +[Bb]uild/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# 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 + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# 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 + +# 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 + +# TODO: Un-comment the next line if you do not want to checkin +# your web deploy settings because they may include unencrypted +# passwords +#*.pubxml +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directory +AppPackages/ +BundleArtifacts/ + +# 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/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# 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 + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# LightSwitch generated files +GeneratedArtifacts/ +ModelManifest.xml + +# Paket dependency manager +.paket/paket.exe + +# FAKE - F# Make +.fake/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..b1535c81 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at falecom@eduardopires.net.br. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..c6112993 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +If want contribute start opening an issue to discuss first diff --git a/Equinox.sln b/Equinox.sln new file mode 100644 index 00000000..91228308 --- /dev/null +++ b/Equinox.sln @@ -0,0 +1,100 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31025.218 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 - Presentation", "1 - Presentation", "{BA4C44CC-65BC-4CE0-9B44-68BA231FAC73}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 - Services", "2 - Services", "{A5DF9A6E-87DC-49E9-A437-04C399572BD4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 - Application", "3 - Application", "{FD1B5301-2A07-47B3-8773-839429800B2F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "4 - Domain", "4 - Domain", "{0C6FAB88-2741-4295-B6B6-F397DBA958F0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "5 - Infra", "5 - Infra", "{BAC12BD1-13B1-416E-A4B8-D889C6FFACCC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "5.1 - Data", "5.1 - Data", "{0CA11832-7E0F-4038-9DB6-FF1E6D14D0DF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "5.2 - CrossCutting", "5.2 - CrossCutting", "{DF6C4BDE-F3C5-4E53-A5D5-9D27B2D3E38F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equinox.UI.Web", "src\Equinox.UI.Web\Equinox.UI.Web.csproj", "{490517BA-F3C3-44B4-82F8-1E3A4ED6777A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equinox.Services.Api", "src\Equinox.Services.Api\Equinox.Services.Api.csproj", "{EA966EC3-85A6-4B57-82C1-5120E3390243}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equinox.Application", "src\Equinox.Application\Equinox.Application.csproj", "{851E7338-2397-4E8D-8199-FD1EAD109418}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equinox.Domain", "src\Equinox.Domain\Equinox.Domain.csproj", "{BF28C988-9C1B-41F5-BD58-D2FCAD7E80BA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equinox.Domain.Core", "src\Equinox.Domain.Core\Equinox.Domain.Core.csproj", "{9CC884B7-FA70-49E1-92A6-2B566E00FAB9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equinox.Infra.Data", "src\Equinox.Infra.Data\Equinox.Infra.Data.csproj", "{F0DDF87D-98A4-4237-91C9-FD865ED78ABB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equinox.Infra.CrossCutting.Bus", "src\Equinox.Infra.CrossCutting.Bus\Equinox.Infra.CrossCutting.Bus.csproj", "{91F0D76D-2BEA-43D2-B123-DC29F2E87792}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equinox.Infra.CrossCutting.Identity", "src\Equinox.Infra.CrossCutting.Identity\Equinox.Infra.CrossCutting.Identity.csproj", "{788030D0-561B-4136-96B4-D5ABFB5CFD07}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equinox.Infra.CrossCutting.IoC", "src\Equinox.Infra.CrossCutting.IoC\Equinox.Infra.CrossCutting.IoC.csproj", "{B3000AD2-5EAA-49A2-8FC4-10DF329B15B0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {490517BA-F3C3-44B4-82F8-1E3A4ED6777A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {490517BA-F3C3-44B4-82F8-1E3A4ED6777A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {490517BA-F3C3-44B4-82F8-1E3A4ED6777A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {490517BA-F3C3-44B4-82F8-1E3A4ED6777A}.Release|Any CPU.Build.0 = Release|Any CPU + {EA966EC3-85A6-4B57-82C1-5120E3390243}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA966EC3-85A6-4B57-82C1-5120E3390243}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA966EC3-85A6-4B57-82C1-5120E3390243}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA966EC3-85A6-4B57-82C1-5120E3390243}.Release|Any CPU.Build.0 = Release|Any CPU + {851E7338-2397-4E8D-8199-FD1EAD109418}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {851E7338-2397-4E8D-8199-FD1EAD109418}.Debug|Any CPU.Build.0 = Debug|Any CPU + {851E7338-2397-4E8D-8199-FD1EAD109418}.Release|Any CPU.ActiveCfg = Release|Any CPU + {851E7338-2397-4E8D-8199-FD1EAD109418}.Release|Any CPU.Build.0 = Release|Any CPU + {BF28C988-9C1B-41F5-BD58-D2FCAD7E80BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF28C988-9C1B-41F5-BD58-D2FCAD7E80BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF28C988-9C1B-41F5-BD58-D2FCAD7E80BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF28C988-9C1B-41F5-BD58-D2FCAD7E80BA}.Release|Any CPU.Build.0 = Release|Any CPU + {9CC884B7-FA70-49E1-92A6-2B566E00FAB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CC884B7-FA70-49E1-92A6-2B566E00FAB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CC884B7-FA70-49E1-92A6-2B566E00FAB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CC884B7-FA70-49E1-92A6-2B566E00FAB9}.Release|Any CPU.Build.0 = Release|Any CPU + {F0DDF87D-98A4-4237-91C9-FD865ED78ABB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0DDF87D-98A4-4237-91C9-FD865ED78ABB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0DDF87D-98A4-4237-91C9-FD865ED78ABB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0DDF87D-98A4-4237-91C9-FD865ED78ABB}.Release|Any CPU.Build.0 = Release|Any CPU + {91F0D76D-2BEA-43D2-B123-DC29F2E87792}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91F0D76D-2BEA-43D2-B123-DC29F2E87792}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91F0D76D-2BEA-43D2-B123-DC29F2E87792}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91F0D76D-2BEA-43D2-B123-DC29F2E87792}.Release|Any CPU.Build.0 = Release|Any CPU + {788030D0-561B-4136-96B4-D5ABFB5CFD07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {788030D0-561B-4136-96B4-D5ABFB5CFD07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {788030D0-561B-4136-96B4-D5ABFB5CFD07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {788030D0-561B-4136-96B4-D5ABFB5CFD07}.Release|Any CPU.Build.0 = Release|Any CPU + {B3000AD2-5EAA-49A2-8FC4-10DF329B15B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3000AD2-5EAA-49A2-8FC4-10DF329B15B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3000AD2-5EAA-49A2-8FC4-10DF329B15B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3000AD2-5EAA-49A2-8FC4-10DF329B15B0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0CA11832-7E0F-4038-9DB6-FF1E6D14D0DF} = {BAC12BD1-13B1-416E-A4B8-D889C6FFACCC} + {DF6C4BDE-F3C5-4E53-A5D5-9D27B2D3E38F} = {BAC12BD1-13B1-416E-A4B8-D889C6FFACCC} + {490517BA-F3C3-44B4-82F8-1E3A4ED6777A} = {BA4C44CC-65BC-4CE0-9B44-68BA231FAC73} + {EA966EC3-85A6-4B57-82C1-5120E3390243} = {A5DF9A6E-87DC-49E9-A437-04C399572BD4} + {851E7338-2397-4E8D-8199-FD1EAD109418} = {FD1B5301-2A07-47B3-8773-839429800B2F} + {BF28C988-9C1B-41F5-BD58-D2FCAD7E80BA} = {0C6FAB88-2741-4295-B6B6-F397DBA958F0} + {9CC884B7-FA70-49E1-92A6-2B566E00FAB9} = {0C6FAB88-2741-4295-B6B6-F397DBA958F0} + {F0DDF87D-98A4-4237-91C9-FD865ED78ABB} = {0CA11832-7E0F-4038-9DB6-FF1E6D14D0DF} + {91F0D76D-2BEA-43D2-B123-DC29F2E87792} = {DF6C4BDE-F3C5-4E53-A5D5-9D27B2D3E38F} + {788030D0-561B-4136-96B4-D5ABFB5CFD07} = {DF6C4BDE-F3C5-4E53-A5D5-9D27B2D3E38F} + {B3000AD2-5EAA-49A2-8FC4-10DF329B15B0} = {DF6C4BDE-F3C5-4E53-A5D5-9D27B2D3E38F} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4050E145-6791-440A-A2E5-75B05ACD4AFE} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..1c3c394c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Eduardo Pires + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..df553620 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ + + + +What is the Equinox Project? +===================== +The Equinox Project is a open-source project written in .NET Core + +The goal of this project is implement the most common used technologies and share with the technical community the best way to develop great applications with .NET + +[![Build status](https://ci.appveyor.com/api/projects/status/rl2ja69994rt3ei6?svg=true)](https://ci.appveyor.com/project/EduardoPires/equinoxproject) +![.NET Core](https://github.com/EduardoPires/EquinoxProject/workflows/.NET%20Core/badge.svg) +[![License](https://img.shields.io/github/license/eduardopires/equinoxproject.svg)](LICENSE) +[![Issues open](https://img.shields.io/github/issues/eduardopires/equinoxproject.svg)](https://huboard.com/EduardoPires/EquinoxProject/) + +## Give a Star! :star: +If you liked the project or if Equinox helped you, please give a star ;) + +## Want to learn everything? :mortar_board: +Check my online courses at [desenvolvedor.io](https://desenvolvedor.io) + +## How to use: +- You will need the latest Visual Studio 2022 and the latest .NET Core SDK. +- ***Please check if you have installed the same runtime version (SDK) described in global.json*** +- The latest SDK and tools can be downloaded from https://dot.net/core. + +Also you can run the Equinox Project in Visual Studio Code (Windows, Linux or MacOS). + +To know more about how to setup your enviroment visit the [Microsoft .NET Download Guide](https://www.microsoft.com/net/download) + +## Technologies implemented: + +- ASP.NET 6.0 + - ASP.NET MVC Core + - ASP.NET WebApi Core with JWT Bearer Authentication + - ASP.NET Identity Core +- Entity Framework Core 6.0 +- .NET Core Native DI +- AutoMapper +- FluentValidator +- MediatR +- Swagger UI with JWT support +- .NET DevPack +- .NET DevPack.Identity + +## Architecture: + +- Full architecture with responsibility separation concerns, SOLID and Clean Code +- Domain Driven Design (Layers and Domain Model Pattern) +- Domain Events +- Domain Notification +- Domain Validations +- CQRS (Imediate Consistency) +- Event Sourcing +- Unit of Work +- Repository + +## News + +**v1.8 - 03/22/2022** +- Migrated for .NET 6.0 +- All dependencies is up to date + +**v1.7 - 04/06/2021** +- Migrated for .NET 5.0 +- All dependencies is up to date + +**v1.6 - 06/09/2020** +- Full Refactoring (consistency, events, validation, identity) +- Added [NetDevPack](https://github.com/NetDevPack) and saving a hundreds of code lines +- All dependencies is up to date + +**v1.5 - 01/22/2020** +- Migrated for .NET Core 3.1.1 +- All dependencies is up to date +- Added JWT (Bearer) authentication for WebAPI +- Added JWT support in Swagger + +**v1.4 - 02/14/2019** +- Migrated for .NET Core 2.2.1 +- All dependencies is up to date +- Improvements for last version of MediatR (Notifications and Request) + +**v1.3 - 05/22/2018** +- Migrated for .NET Core 2.1.2 +- All dependencies is up to date +- Improvements in Automapper Setup +- Improvements for last version of MediatR (Notifications and Request) +- Code improvements in general + +**v1.2 - 08/15/2017** +- Migrated for .NET Core 2.0 and ASP.NET Core 2.0 +- Adaptations for the new Identity Authentication Model + +**v1.1 - 08/09/2017** +- Adding WebAPI service exposing the application features +- Adding Swagger UI for better viewing and testing +- Adding MediatR for Memory Bus Messaging + +## Disclaimer: +- **NOT** intended to be a definitive solution +- Beware to use in production way +- Maybe you don't need a lot of implementations that is included, try avoid the **over engineering** + +## Pull-Requests +Make a contact! Don't submit PRs for extra features, all the new features are planned + +## Why Equinox? +The Equinox is an astronomical event in which the plane of Earth's equator passes through the center of the Sun, which occurs twice each year, around 20 March and 23 September. [Wikipedia](https://en.wikipedia.org/wiki/Equinox) + +Equinox is also a series of publications (subtitle: "The Review of Scientific Illuminism") in book form that serves as the official organ of the A∴A∴, a magical order founded by Aleister Crowley :) [Wikipedia](https://en.wikipedia.org/wiki/The_Equinox) + +## We are Online: +See the project running on Azure + +## About: +The Equinox Project was developed by [Eduardo Pires](http://eduardopires.net.br) under the [MIT license](LICENSE). diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000..23a80e66 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,18 @@ +version: 1.0.{build} +image: Visual Studio 2022 +dotnet_csproj: + patch: true + file: '**\*.csproj' + version: '{version}' + version_prefix: '{version}' + package_version: '{version}' + assembly_version: '{version}' + file_version: '{version}' + informational_version: '{version}' +before_build: +- cmd: >- + dotnet restore + + dotnet build +build: + verbosity: minimal diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 00000000..0eb24365 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,8 @@ +title: The Equinox Project +description: Full ASP.NET Core 3.1 application with DDD, CQRS and Event Sourcing +google_analytics: +show_downloads: true +theme: jekyll-theme-cayman + +gems: + - jekyll-mentions diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..2155c8b1 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,101 @@ + + + +What is the Equinox Project? +===================== +The Equinox Project is a open-source project written in .NET Core + +The goal of this project is implement the most common used technologies and share with the technical community the best way to develop great applications with .NET + +[![License](https://img.shields.io/github/license/eduardopires/equinoxproject.svg)](LICENSE) +[![Issues open](https://img.shields.io/github/issues/eduardopires/equinoxproject.svg)](https://huboard.com/EduardoPires/EquinoxProject/) + +## Give a Star! :star: +If you liked the project or if Equinox helped you, please give a star ;) + +## Want to learn everything? :mortar_board: +Check my online courses at [desenvolvedor.io](https://desenvolvedor.io) + +## How to use: +- You will need the latest Visual Studio 2019 and the latest .NET Core SDK. +- ***Please check if you have installed the same runtime version (SDK) described in global.json*** +- The latest SDK and tools can be downloaded from https://dot.net/core. + +Also you can run the Equinox Project in Visual Studio Code (Windows, Linux or MacOS). + +To know more about how to setup your enviroment visit the [Microsoft .NET Download Guide](https://www.microsoft.com/net/download) + +## Technologies implemented: + +- ASP.NET Core 3.1 (with .NET Core 3.1) + - ASP.NET MVC Core + - ASP.NET WebApi Core with JWT Bearer Authentication + - ASP.NET Identity Core +- Entity Framework Core 3.1 +- .NET Core Native DI +- AutoMapper +- FluentValidator +- MediatR +- Swagger UI with JWT support + +## Architecture: + +- Full architecture with responsibility separation concerns, SOLID and Clean Code +- Domain Driven Design (Layers and Domain Model Pattern) +- Domain Events +- Domain Notification +- CQRS (Imediate Consistency) +- Event Sourcing +- Unit of Work +- Repository and Generic Repository + +## News + +**v1.5 - 01/22/2020** +- Migrated for .NET Core 3.1.1 +- All dependencies is up to date +- Added JWT (Bearer) authentication for WebAPI +- Added JWT support in Swagger + +**v1.4 - 02/14/2019** +- Migrated for .NET Core 2.2.1 +- All dependencies is up to date +- Improvements for last version of MediatR (Notifications and Request) + +**v1.3 - 05/22/2018** +- Migrated for .NET Core 2.1.2 +- All dependencies is up to date +- Improvements in Automapper Setup +- Improvements for last version of MediatR (Notifications and Request) +- Code improvements in general + +**v1.2 - 08/15/2017** +- Migrated for .NET Core 2.0 and ASP.NET Core 2.0 +- Adaptations for the new Identity Authentication Model + +**v1.1 - 08/09/2017** +- Adding WebAPI service exposing the application features +- Adding Swagger UI for better viewing and testing +- Adding MediatR for Memory Bus Messaging + +## Disclaimer: +- **NOT** intended to be a definitive solution +- Beware to use in production way +- Maybe you don't need a lot of implementations that is included, try avoid the **over engineering** + +## About the next versions +Watch our [RoadMap](https://github.com/EduardoPires/EquinoxProject/wiki/RoadMap) to know the new changes + +## Pull-Requests +Make a contact! Don't submit PRs for extra features, all new features is coming in V2 + +## Why Equinox? +The Equinox is an astronomical event in which the plane of Earth's equator passes through the center of the Sun, which occurs twice each year, around 20 March and 23 September. [Wikipedia](https://en.wikipedia.org/wiki/Equinox) + +Equinox is also a series of publications (subtitle: "The Review of Scientific Illuminism") in book form that serves as the official organ of the A∴A∴, a magical order founded by Aleister Crowley :) [Wikipedia](https://en.wikipedia.org/wiki/The_Equinox) + +## We are Online: +See the project running on Azure + +## About: +The Equinox Project was developed by [Eduardo Pires](http://eduardopires.net.br) under the [MIT license](LICENSE). diff --git a/global.json b/global.json new file mode 100644 index 00000000..cbc6e731 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "projects": ["src"], + "sdk": { + "version": "^6.0.201" + } +} diff --git a/sql/GenerateDataBase.sql b/sql/GenerateDataBase.sql new file mode 100644 index 00000000..cec3c67c Binary files /dev/null and b/sql/GenerateDataBase.sql differ diff --git a/src/Equinox.Application/AutoMapper/DomainToViewModelMappingProfile.cs b/src/Equinox.Application/AutoMapper/DomainToViewModelMappingProfile.cs new file mode 100644 index 00000000..08f8a0d4 --- /dev/null +++ b/src/Equinox.Application/AutoMapper/DomainToViewModelMappingProfile.cs @@ -0,0 +1,14 @@ +using AutoMapper; +using Equinox.Application.ViewModels; +using Equinox.Domain.Models; + +namespace Equinox.Application.AutoMapper +{ + public class DomainToViewModelMappingProfile : Profile + { + public DomainToViewModelMappingProfile() + { + CreateMap(); + } + } +} diff --git a/src/Equinox.Application/AutoMapper/ViewModelToDomainMappingProfile.cs b/src/Equinox.Application/AutoMapper/ViewModelToDomainMappingProfile.cs new file mode 100644 index 00000000..4d1e73ae --- /dev/null +++ b/src/Equinox.Application/AutoMapper/ViewModelToDomainMappingProfile.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using Equinox.Application.ViewModels; +using Equinox.Domain.Commands; + +namespace Equinox.Application.AutoMapper +{ + public class ViewModelToDomainMappingProfile : Profile + { + public ViewModelToDomainMappingProfile() + { + CreateMap() + .ConstructUsing(c => new RegisterNewCustomerCommand(c.Name, c.Email, c.BirthDate)); + CreateMap() + .ConstructUsing(c => new UpdateCustomerCommand(c.Id, c.Name, c.Email, c.BirthDate)); + } + } +} diff --git a/src/Equinox.Application/Equinox.Application.csproj b/src/Equinox.Application/Equinox.Application.csproj new file mode 100644 index 00000000..3a5b59ed --- /dev/null +++ b/src/Equinox.Application/Equinox.Application.csproj @@ -0,0 +1,12 @@ + + + net6.0 + + + + + + + + + \ No newline at end of file diff --git a/src/Equinox.Application/EventSourcedNormalizers/CustomerHistory.cs b/src/Equinox.Application/EventSourcedNormalizers/CustomerHistory.cs new file mode 100644 index 00000000..16f25865 --- /dev/null +++ b/src/Equinox.Application/EventSourcedNormalizers/CustomerHistory.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Equinox.Domain.Core.Events; + +namespace Equinox.Application.EventSourcedNormalizers +{ + public static class CustomerHistory + { + public static IList HistoryData { get; set; } + + public static IList ToJavaScriptCustomerHistory(IList storedEvents) + { + HistoryData = new List(); + CustomerHistoryDeserializer(storedEvents); + + var sorted = HistoryData.OrderBy(c => c.Timestamp); + var list = new List(); + var last = new CustomerHistoryData(); + + foreach (var change in sorted) + { + var jsSlot = new CustomerHistoryData + { + Id = change.Id == Guid.Empty.ToString() || change.Id == last.Id + ? "" + : change.Id, + Name = string.IsNullOrWhiteSpace(change.Name) || change.Name == last.Name + ? "" + : change.Name, + Email = string.IsNullOrWhiteSpace(change.Email) || change.Email == last.Email + ? "" + : change.Email, + BirthDate = string.IsNullOrWhiteSpace(change.BirthDate) || change.BirthDate == last.BirthDate + ? "" + : change.BirthDate.Substring(0,10), + Action = string.IsNullOrWhiteSpace(change.Action) ? "" : change.Action, + Timestamp = change.Timestamp, + Who = change.Who + }; + + list.Add(jsSlot); + last = change; + } + return list; + } + + private static void CustomerHistoryDeserializer(IEnumerable storedEvents) + { + foreach (var e in storedEvents) + { + var historyData = JsonSerializer.Deserialize(e.Data); + historyData.Timestamp = DateTime.Parse(historyData.Timestamp).ToString("yyyy'-'MM'-'dd' - 'HH':'mm':'ss"); + + switch (e.MessageType) + { + case "CustomerRegisteredEvent": + historyData.Action = "Registered"; + historyData.Who = e.User; + break; + case "CustomerUpdatedEvent": + historyData.Action = "Updated"; + historyData.Who = e.User; + break; + case "CustomerRemovedEvent": + historyData.Action = "Removed"; + historyData.Who = e.User; + break; + default: + historyData.Action = "Unrecognized"; + historyData.Who = e.User ?? "Anonymous"; + break; + + } + HistoryData.Add(historyData); + } + } + } +} \ No newline at end of file diff --git a/src/Equinox.Application/EventSourcedNormalizers/CustomerHistoryData.cs b/src/Equinox.Application/EventSourcedNormalizers/CustomerHistoryData.cs new file mode 100644 index 00000000..5ed07349 --- /dev/null +++ b/src/Equinox.Application/EventSourcedNormalizers/CustomerHistoryData.cs @@ -0,0 +1,13 @@ +namespace Equinox.Application.EventSourcedNormalizers +{ + public class CustomerHistoryData + { + public string Action { get; set; } + public string Id { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string BirthDate { get; set; } + public string Timestamp { get; set; } + public string Who { get; set; } + } +} \ No newline at end of file diff --git a/src/Equinox.Application/Interfaces/ICustomerAppService.cs b/src/Equinox.Application/Interfaces/ICustomerAppService.cs new file mode 100644 index 00000000..903e5e1e --- /dev/null +++ b/src/Equinox.Application/Interfaces/ICustomerAppService.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Equinox.Application.EventSourcedNormalizers; +using Equinox.Application.ViewModels; +using FluentValidation.Results; + +namespace Equinox.Application.Interfaces +{ + public interface ICustomerAppService : IDisposable + { + Task> GetAll(); + Task GetById(Guid id); + + Task Register(CustomerViewModel customerViewModel); + Task Update(CustomerViewModel customerViewModel); + Task Remove(Guid id); + + Task> GetAllHistory(Guid id); + } +} diff --git a/src/Equinox.Application/Services/CustomerAppService.cs b/src/Equinox.Application/Services/CustomerAppService.cs new file mode 100644 index 00000000..3cb6e36d --- /dev/null +++ b/src/Equinox.Application/Services/CustomerAppService.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutoMapper; +using Equinox.Application.EventSourcedNormalizers; +using Equinox.Application.Interfaces; +using Equinox.Application.ViewModels; +using Equinox.Domain.Commands; +using Equinox.Domain.Interfaces; +using Equinox.Infra.Data.Repository.EventSourcing; +using FluentValidation.Results; +using NetDevPack.Mediator; + +namespace Equinox.Application.Services +{ + public class CustomerAppService : ICustomerAppService + { + private readonly IMapper _mapper; + private readonly ICustomerRepository _customerRepository; + private readonly IEventStoreRepository _eventStoreRepository; + private readonly IMediatorHandler _mediator; + + public CustomerAppService(IMapper mapper, + ICustomerRepository customerRepository, + IMediatorHandler mediator, + IEventStoreRepository eventStoreRepository) + { + _mapper = mapper; + _customerRepository = customerRepository; + _mediator = mediator; + _eventStoreRepository = eventStoreRepository; + } + + public async Task> GetAll() + { + return _mapper.Map>(await _customerRepository.GetAll()); + } + + public async Task GetById(Guid id) + { + return _mapper.Map(await _customerRepository.GetById(id)); + } + + public async Task Register(CustomerViewModel customerViewModel) + { + var registerCommand = _mapper.Map(customerViewModel); + return await _mediator.SendCommand(registerCommand); + } + + public async Task Update(CustomerViewModel customerViewModel) + { + var updateCommand = _mapper.Map(customerViewModel); + return await _mediator.SendCommand(updateCommand); + } + + public async Task Remove(Guid id) + { + var removeCommand = new RemoveCustomerCommand(id); + return await _mediator.SendCommand(removeCommand); + } + + public async Task> GetAllHistory(Guid id) + { + return CustomerHistory.ToJavaScriptCustomerHistory(await _eventStoreRepository.All(id)); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } + } +} diff --git a/src/Equinox.Application/ViewModels/CustomerViewModel.cs b/src/Equinox.Application/ViewModels/CustomerViewModel.cs new file mode 100644 index 00000000..102a57a6 --- /dev/null +++ b/src/Equinox.Application/ViewModels/CustomerViewModel.cs @@ -0,0 +1,29 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Equinox.Application.ViewModels +{ + public class CustomerViewModel + { + [Key] + public Guid Id { get; set; } + + [Required(ErrorMessage = "The Name is Required")] + [MinLength(2)] + [MaxLength(100)] + [DisplayName("Name")] + public string Name { get; set; } + + [Required(ErrorMessage = "The E-mail is Required")] + [EmailAddress] + [DisplayName("E-mail")] + public string Email { get; set; } + + [Required(ErrorMessage = "The BirthDate is Required")] + [DisplayFormat(ApplyFormatInEditMode = true, DataFormatString = "{0:yyyy-MM-dd}")] + [DataType(DataType.Date, ErrorMessage = "Data em formato inválido")] + [DisplayName("Birth Date")] + public DateTime BirthDate { get; set; } + } +} diff --git a/src/Equinox.Domain.Core/Equinox.Domain.Core.csproj b/src/Equinox.Domain.Core/Equinox.Domain.Core.csproj new file mode 100644 index 00000000..8e37f651 --- /dev/null +++ b/src/Equinox.Domain.Core/Equinox.Domain.Core.csproj @@ -0,0 +1,8 @@ + + + net6.0 + + + + + \ No newline at end of file diff --git a/src/Equinox.Domain.Core/Events/IEventStore.cs b/src/Equinox.Domain.Core/Events/IEventStore.cs new file mode 100644 index 00000000..d098d073 --- /dev/null +++ b/src/Equinox.Domain.Core/Events/IEventStore.cs @@ -0,0 +1,9 @@ +using NetDevPack.Messaging; + +namespace Equinox.Domain.Core.Events +{ + public interface IEventStore + { + void Save(T theEvent) where T : Event; + } +} \ No newline at end of file diff --git a/src/Equinox.Domain.Core/Events/StoredEvent.cs b/src/Equinox.Domain.Core/Events/StoredEvent.cs new file mode 100644 index 00000000..8dd3c0a3 --- /dev/null +++ b/src/Equinox.Domain.Core/Events/StoredEvent.cs @@ -0,0 +1,26 @@ +using System; +using NetDevPack.Messaging; + +namespace Equinox.Domain.Core.Events +{ + public class StoredEvent : Event + { + public StoredEvent(Event theEvent, string data, string user) + { + Id = Guid.NewGuid(); + AggregateId = theEvent.AggregateId; + MessageType = theEvent.MessageType; + Data = data; + User = user; + } + + // EF Constructor + protected StoredEvent() { } + + public Guid Id { get; private set; } + + public string Data { get; private set; } + + public string User { get; private set; } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain.Core/Models/ValueObject.cs b/src/Equinox.Domain.Core/Models/ValueObject.cs new file mode 100644 index 00000000..5ea96d73 --- /dev/null +++ b/src/Equinox.Domain.Core/Models/ValueObject.cs @@ -0,0 +1,36 @@ +namespace Equinox.Domain.Core.Models +{ + public abstract class ValueObject where T : ValueObject + { + public override bool Equals(object obj) + { + var valueObject = obj as T; + return EqualsCore(valueObject); + } + + protected abstract bool EqualsCore(T other); + + public override int GetHashCode() + { + return GetHashCodeCore(); + } + + protected abstract int GetHashCodeCore(); + + public static bool operator ==(ValueObject a, ValueObject b) + { + if (a is null && b is null) + return true; + + if (a is null || b is null) + return false; + + return a.Equals(b); + } + + public static bool operator !=(ValueObject a, ValueObject b) + { + return !(a == b); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Commands/CustomerCommand.cs b/src/Equinox.Domain/Commands/CustomerCommand.cs new file mode 100644 index 00000000..da74b750 --- /dev/null +++ b/src/Equinox.Domain/Commands/CustomerCommand.cs @@ -0,0 +1,16 @@ +using System; +using NetDevPack.Messaging; + +namespace Equinox.Domain.Commands +{ + public abstract class CustomerCommand : Command + { + public Guid Id { get; protected set; } + + public string Name { get; protected set; } + + public string Email { get; protected set; } + + public DateTime BirthDate { get; protected set; } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Commands/CustomerCommandHandler.cs b/src/Equinox.Domain/Commands/CustomerCommandHandler.cs new file mode 100644 index 00000000..ad2ead57 --- /dev/null +++ b/src/Equinox.Domain/Commands/CustomerCommandHandler.cs @@ -0,0 +1,91 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Equinox.Domain.Events; +using Equinox.Domain.Interfaces; +using Equinox.Domain.Models; +using FluentValidation.Results; +using MediatR; +using NetDevPack.Messaging; + +namespace Equinox.Domain.Commands +{ + public class CustomerCommandHandler : CommandHandler, + IRequestHandler, + IRequestHandler, + IRequestHandler + { + private readonly ICustomerRepository _customerRepository; + + public CustomerCommandHandler(ICustomerRepository customerRepository) + { + _customerRepository = customerRepository; + } + + public async Task Handle(RegisterNewCustomerCommand message, CancellationToken cancellationToken) + { + if (!message.IsValid()) return message.ValidationResult; + + var customer = new Customer(Guid.NewGuid(), message.Name, message.Email, message.BirthDate); + + if (await _customerRepository.GetByEmail(customer.Email) != null) + { + AddError("The customer e-mail has already been taken."); + return ValidationResult; + } + + customer.AddDomainEvent(new CustomerRegisteredEvent(customer.Id, customer.Name, customer.Email, customer.BirthDate)); + + _customerRepository.Add(customer); + + return await Commit(_customerRepository.UnitOfWork); + } + + public async Task Handle(UpdateCustomerCommand message, CancellationToken cancellationToken) + { + if (!message.IsValid()) return message.ValidationResult; + + var customer = new Customer(message.Id, message.Name, message.Email, message.BirthDate); + var existingCustomer = await _customerRepository.GetByEmail(customer.Email); + + if (existingCustomer != null && existingCustomer.Id != customer.Id) + { + if (!existingCustomer.Equals(customer)) + { + AddError("The customer e-mail has already been taken."); + return ValidationResult; + } + } + + customer.AddDomainEvent(new CustomerUpdatedEvent(customer.Id, customer.Name, customer.Email, customer.BirthDate)); + + _customerRepository.Update(customer); + + return await Commit(_customerRepository.UnitOfWork); + } + + public async Task Handle(RemoveCustomerCommand message, CancellationToken cancellationToken) + { + if (!message.IsValid()) return message.ValidationResult; + + var customer = await _customerRepository.GetById(message.Id); + + if (customer is null) + { + AddError("The customer doesn't exists."); + return ValidationResult; + } + + customer.AddDomainEvent(new CustomerRemovedEvent(message.Id)); + + _customerRepository.Remove(customer); + + return await Commit(_customerRepository.UnitOfWork); + } + + public void Dispose() + { + _customerRepository.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Commands/RegisterNewCustomerCommand.cs b/src/Equinox.Domain/Commands/RegisterNewCustomerCommand.cs new file mode 100644 index 00000000..5e8cea67 --- /dev/null +++ b/src/Equinox.Domain/Commands/RegisterNewCustomerCommand.cs @@ -0,0 +1,21 @@ +using System; +using Equinox.Domain.Commands.Validations; + +namespace Equinox.Domain.Commands +{ + public class RegisterNewCustomerCommand : CustomerCommand + { + public RegisterNewCustomerCommand(string name, string email, DateTime birthDate) + { + Name = name; + Email = email; + BirthDate = birthDate; + } + + public override bool IsValid() + { + ValidationResult = new RegisterNewCustomerCommandValidation().Validate(this); + return ValidationResult.IsValid; + } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Commands/RemoveCustomerCommand.cs b/src/Equinox.Domain/Commands/RemoveCustomerCommand.cs new file mode 100644 index 00000000..8d405df0 --- /dev/null +++ b/src/Equinox.Domain/Commands/RemoveCustomerCommand.cs @@ -0,0 +1,20 @@ +using System; +using Equinox.Domain.Commands.Validations; + +namespace Equinox.Domain.Commands +{ + public class RemoveCustomerCommand : CustomerCommand + { + public RemoveCustomerCommand(Guid id) + { + Id = id; + AggregateId = id; + } + + public override bool IsValid() + { + ValidationResult = new RemoveCustomerCommandValidation().Validate(this); + return ValidationResult.IsValid; + } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Commands/UpdateCustomerCommand.cs b/src/Equinox.Domain/Commands/UpdateCustomerCommand.cs new file mode 100644 index 00000000..3d3189c5 --- /dev/null +++ b/src/Equinox.Domain/Commands/UpdateCustomerCommand.cs @@ -0,0 +1,22 @@ +using System; +using Equinox.Domain.Commands.Validations; + +namespace Equinox.Domain.Commands +{ + public class UpdateCustomerCommand : CustomerCommand + { + public UpdateCustomerCommand(Guid id, string name, string email, DateTime birthDate) + { + Id = id; + Name = name; + Email = email; + BirthDate = birthDate; + } + + public override bool IsValid() + { + ValidationResult = new UpdateCustomerCommandValidation().Validate(this); + return ValidationResult.IsValid; + } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Commands/Validations/CustomerValidation.cs b/src/Equinox.Domain/Commands/Validations/CustomerValidation.cs new file mode 100644 index 00000000..89863212 --- /dev/null +++ b/src/Equinox.Domain/Commands/Validations/CustomerValidation.cs @@ -0,0 +1,41 @@ +using System; +using FluentValidation; + +namespace Equinox.Domain.Commands.Validations +{ + public abstract class CustomerValidation : AbstractValidator where T : CustomerCommand + { + protected void ValidateName() + { + RuleFor(c => c.Name) + .NotEmpty().WithMessage("Please ensure you have entered the Name") + .Length(2, 150).WithMessage("The Name must have between 2 and 150 characters"); + } + + protected void ValidateBirthDate() + { + RuleFor(c => c.BirthDate) + .NotEmpty() + .Must(HaveMinimumAge) + .WithMessage("The customer must have 18 years or more"); + } + + protected void ValidateEmail() + { + RuleFor(c => c.Email) + .NotEmpty() + .EmailAddress(); + } + + protected void ValidateId() + { + RuleFor(c => c.Id) + .NotEqual(Guid.Empty); + } + + protected static bool HaveMinimumAge(DateTime birthDate) + { + return birthDate <= DateTime.Now.AddYears(-18); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Commands/Validations/RegisterNewCustomerCommandValidation.cs b/src/Equinox.Domain/Commands/Validations/RegisterNewCustomerCommandValidation.cs new file mode 100644 index 00000000..da677238 --- /dev/null +++ b/src/Equinox.Domain/Commands/Validations/RegisterNewCustomerCommandValidation.cs @@ -0,0 +1,12 @@ +namespace Equinox.Domain.Commands.Validations +{ + public class RegisterNewCustomerCommandValidation : CustomerValidation + { + public RegisterNewCustomerCommandValidation() + { + ValidateName(); + ValidateBirthDate(); + ValidateEmail(); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Commands/Validations/RemoveCustomerCommandValidation.cs b/src/Equinox.Domain/Commands/Validations/RemoveCustomerCommandValidation.cs new file mode 100644 index 00000000..1b28d072 --- /dev/null +++ b/src/Equinox.Domain/Commands/Validations/RemoveCustomerCommandValidation.cs @@ -0,0 +1,10 @@ +namespace Equinox.Domain.Commands.Validations +{ + public class RemoveCustomerCommandValidation : CustomerValidation + { + public RemoveCustomerCommandValidation() + { + ValidateId(); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Commands/Validations/UpdateCustomerCommandValidation.cs b/src/Equinox.Domain/Commands/Validations/UpdateCustomerCommandValidation.cs new file mode 100644 index 00000000..48704c41 --- /dev/null +++ b/src/Equinox.Domain/Commands/Validations/UpdateCustomerCommandValidation.cs @@ -0,0 +1,13 @@ +namespace Equinox.Domain.Commands.Validations +{ + public class UpdateCustomerCommandValidation : CustomerValidation + { + public UpdateCustomerCommandValidation() + { + ValidateId(); + ValidateName(); + ValidateBirthDate(); + ValidateEmail(); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Equinox.Domain.csproj b/src/Equinox.Domain/Equinox.Domain.csproj new file mode 100644 index 00000000..472d8dfb --- /dev/null +++ b/src/Equinox.Domain/Equinox.Domain.csproj @@ -0,0 +1,15 @@ + + + net6.0 + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Equinox.Domain/Events/CustomerEventHandler.cs b/src/Equinox.Domain/Events/CustomerEventHandler.cs new file mode 100644 index 00000000..34fa4d5f --- /dev/null +++ b/src/Equinox.Domain/Events/CustomerEventHandler.cs @@ -0,0 +1,33 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Equinox.Domain.Events +{ + public class CustomerEventHandler : + INotificationHandler, + INotificationHandler, + INotificationHandler + { + public Task Handle(CustomerUpdatedEvent message, CancellationToken cancellationToken) + { + // Send some notification e-mail + + return Task.CompletedTask; + } + + public Task Handle(CustomerRegisteredEvent message, CancellationToken cancellationToken) + { + // Send some greetings e-mail + + return Task.CompletedTask; + } + + public Task Handle(CustomerRemovedEvent message, CancellationToken cancellationToken) + { + // Send some see you soon e-mail + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Events/CustomerRegisteredEvent.cs b/src/Equinox.Domain/Events/CustomerRegisteredEvent.cs new file mode 100644 index 00000000..8ba4e0e0 --- /dev/null +++ b/src/Equinox.Domain/Events/CustomerRegisteredEvent.cs @@ -0,0 +1,24 @@ +using System; +using NetDevPack.Messaging; + +namespace Equinox.Domain.Events +{ + public class CustomerRegisteredEvent : Event + { + public CustomerRegisteredEvent(Guid id, string name, string email, DateTime birthDate) + { + Id = id; + Name = name; + Email = email; + BirthDate = birthDate; + AggregateId = id; + } + public Guid Id { get; set; } + + public string Name { get; private set; } + + public string Email { get; private set; } + + public DateTime BirthDate { get; private set; } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Events/CustomerRemovedEvent.cs b/src/Equinox.Domain/Events/CustomerRemovedEvent.cs new file mode 100644 index 00000000..f1aaa000 --- /dev/null +++ b/src/Equinox.Domain/Events/CustomerRemovedEvent.cs @@ -0,0 +1,16 @@ +using System; +using NetDevPack.Messaging; + +namespace Equinox.Domain.Events +{ + public class CustomerRemovedEvent : Event + { + public CustomerRemovedEvent(Guid id) + { + Id = id; + AggregateId = id; + } + + public Guid Id { get; set; } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Events/CustomerUpdatedEvent.cs b/src/Equinox.Domain/Events/CustomerUpdatedEvent.cs new file mode 100644 index 00000000..1b9ef47a --- /dev/null +++ b/src/Equinox.Domain/Events/CustomerUpdatedEvent.cs @@ -0,0 +1,24 @@ +using System; +using NetDevPack.Messaging; + +namespace Equinox.Domain.Events +{ + public class CustomerUpdatedEvent : Event + { + public CustomerUpdatedEvent(Guid id, string name, string email, DateTime birthDate) + { + Id = id; + Name = name; + Email = email; + BirthDate = birthDate; + AggregateId = id; + } + public Guid Id { get; set; } + + public string Name { get; private set; } + + public string Email { get; private set; } + + public DateTime BirthDate { get; private set; } + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Interfaces/ICustomerRepository.cs b/src/Equinox.Domain/Interfaces/ICustomerRepository.cs new file mode 100644 index 00000000..7e68ac1a --- /dev/null +++ b/src/Equinox.Domain/Interfaces/ICustomerRepository.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Equinox.Domain.Models; +using NetDevPack.Data; + +namespace Equinox.Domain.Interfaces +{ + public interface ICustomerRepository : IRepository + { + Task GetById(Guid id); + Task GetByEmail(string email); + Task> GetAll(); + + void Add(Customer customer); + void Update(Customer customer); + void Remove(Customer customer); + } +} \ No newline at end of file diff --git a/src/Equinox.Domain/Models/Customer.cs b/src/Equinox.Domain/Models/Customer.cs new file mode 100644 index 00000000..0d0308ab --- /dev/null +++ b/src/Equinox.Domain/Models/Customer.cs @@ -0,0 +1,25 @@ +using System; +using NetDevPack.Domain; + +namespace Equinox.Domain.Models +{ + public class Customer : Entity, IAggregateRoot + { + public Customer(Guid id, string name, string email, DateTime birthDate) + { + Id = id; + Name = name; + Email = email; + BirthDate = birthDate; + } + + // Empty constructor for EF + protected Customer() { } + + public string Name { get; private set; } + + public string Email { get; private set; } + + public DateTime BirthDate { get; private set; } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Bus/Equinox.Infra.CrossCutting.Bus.csproj b/src/Equinox.Infra.CrossCutting.Bus/Equinox.Infra.CrossCutting.Bus.csproj new file mode 100644 index 00000000..c1fad611 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Bus/Equinox.Infra.CrossCutting.Bus.csproj @@ -0,0 +1,11 @@ + + + net6.0 + + + + + + + + \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Bus/InMemoryBus.cs b/src/Equinox.Infra.CrossCutting.Bus/InMemoryBus.cs new file mode 100644 index 00000000..2bb5b517 --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Bus/InMemoryBus.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using Equinox.Domain.Core.Events; +using FluentValidation.Results; +using MediatR; +using NetDevPack.Mediator; +using NetDevPack.Messaging; + +namespace Equinox.Infra.CrossCutting.Bus +{ + public sealed class InMemoryBus : IMediatorHandler + { + private readonly IMediator _mediator; + private readonly IEventStore _eventStore; + + public InMemoryBus(IEventStore eventStore, IMediator mediator) + { + _eventStore = eventStore; + _mediator = mediator; + } + + public async Task PublishEvent(T @event) where T : Event + { + if (!@event.MessageType.Equals("DomainNotification")) + _eventStore?.Save(@event); + + await _mediator.Publish(@event); + } + + public async Task SendCommand(T command) where T : Command + { + return await _mediator.Send(command); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/ApiIdentityConfig.cs b/src/Equinox.Infra.CrossCutting.Identity/ApiIdentityConfig.cs new file mode 100644 index 00000000..99fda35b --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/ApiIdentityConfig.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NetDevPack.Identity; +using NetDevPack.Identity.Jwt; + +namespace Equinox.Infra.CrossCutting.Identity +{ + public static class ApiIdentityConfig + { + public static void AddApiIdentityConfiguration(this IServiceCollection services, IConfiguration configuration) + { + // Default EF Context for Identity (inside of the NetDevPack.Identity) + services.AddIdentityEntityFrameworkContextConfiguration(options => + options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"), + b => b.MigrationsAssembly("Equinox.Infra.CrossCutting.Identity"))); + + // Default Identity configuration from NetDevPack.Identity + services.AddIdentityConfiguration(); + + // Default JWT configuration from NetDevPack.Identity + services.AddJwtConfiguration(configuration, "AppSettings"); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/Equinox.Infra.CrossCutting.Identity.csproj b/src/Equinox.Infra.CrossCutting.Identity/Equinox.Infra.CrossCutting.Identity.csproj new file mode 100644 index 00000000..da9a8a5b --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/Equinox.Infra.CrossCutting.Identity.csproj @@ -0,0 +1,13 @@ + + + net6.0 + + + + + + + + + + \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.Identity/WebAppIdentityConfig.cs b/src/Equinox.Infra.CrossCutting.Identity/WebAppIdentityConfig.cs new file mode 100644 index 00000000..e2ca0b7b --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.Identity/WebAppIdentityConfig.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NetDevPack.Identity; + +namespace Equinox.Infra.CrossCutting.Identity +{ + public static class WebAppIdentityConfig + { + public static void AddWebAppIdentityConfiguration(this IServiceCollection services, IConfiguration configuration) + { + // Default EF Context for Identity (inside of the NetDevPack.Identity) + services.AddIdentityEntityFrameworkContextConfiguration(options => + SqlServerDbContextOptionsExtensions.UseSqlServer(options, configuration.GetConnectionString("DefaultConnection"), + b => b.MigrationsAssembly("Equinox.Infra.CrossCutting.Identity"))); + + // Default Identity configuration from NetDevPack.Identity + services.AddIdentityConfiguration(); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.CrossCutting.IoC/Equinox.Infra.CrossCutting.IoC.csproj b/src/Equinox.Infra.CrossCutting.IoC/Equinox.Infra.CrossCutting.IoC.csproj new file mode 100644 index 00000000..6de87bcb --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.IoC/Equinox.Infra.CrossCutting.IoC.csproj @@ -0,0 +1,14 @@ + + + + net6.0 + + + + + + + + + + diff --git a/src/Equinox.Infra.CrossCutting.IoC/NativeInjectorBootStrapper.cs b/src/Equinox.Infra.CrossCutting.IoC/NativeInjectorBootStrapper.cs new file mode 100644 index 00000000..885f9a7d --- /dev/null +++ b/src/Equinox.Infra.CrossCutting.IoC/NativeInjectorBootStrapper.cs @@ -0,0 +1,49 @@ +using Equinox.Application.Interfaces; +using Equinox.Application.Services; +using Equinox.Domain.Commands; +using Equinox.Domain.Core.Events; +using Equinox.Domain.Events; +using Equinox.Domain.Interfaces; +using Equinox.Infra.CrossCutting.Bus; +using Equinox.Infra.Data.Context; +using Equinox.Infra.Data.EventSourcing; +using Equinox.Infra.Data.Repository; +using Equinox.Infra.Data.Repository.EventSourcing; +using FluentValidation.Results; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using NetDevPack.Mediator; + +namespace Equinox.Infra.CrossCutting.IoC +{ + public static class NativeInjectorBootStrapper + { + public static void RegisterServices(IServiceCollection services) + { + // Domain Bus (Mediator) + services.AddScoped(); + + // Application + services.AddScoped(); + + // Domain - Events + services.AddScoped, CustomerEventHandler>(); + services.AddScoped, CustomerEventHandler>(); + services.AddScoped, CustomerEventHandler>(); + + // Domain - Commands + services.AddScoped, CustomerCommandHandler>(); + services.AddScoped, CustomerCommandHandler>(); + services.AddScoped, CustomerCommandHandler>(); + + // Infra - Data + services.AddScoped(); + services.AddScoped(); + + // Infra - Data EventSourcing + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.Data/Context/EquinoxContext.cs b/src/Equinox.Infra.Data/Context/EquinoxContext.cs new file mode 100644 index 00000000..b6dc17da --- /dev/null +++ b/src/Equinox.Infra.Data/Context/EquinoxContext.cs @@ -0,0 +1,82 @@ +using System.Linq; +using System.Threading.Tasks; +using Equinox.Domain.Models; +using Equinox.Infra.Data.Mappings; +using FluentValidation.Results; +using Microsoft.EntityFrameworkCore; +using NetDevPack.Data; +using NetDevPack.Domain; +using NetDevPack.Mediator; +using NetDevPack.Messaging; + +namespace Equinox.Infra.Data.Context +{ + public sealed class EquinoxContext : DbContext, IUnitOfWork + { + private readonly IMediatorHandler _mediatorHandler; + + public EquinoxContext(DbContextOptions options, IMediatorHandler mediatorHandler) : base(options) + { + _mediatorHandler = mediatorHandler; + ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + ChangeTracker.AutoDetectChangesEnabled = false; + } + + public DbSet Customers { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + foreach (var property in modelBuilder.Model.GetEntityTypes().SelectMany( + e => e.GetProperties().Where(p => p.ClrType == typeof(string)))) + property.SetColumnType("varchar(100)"); + + modelBuilder.ApplyConfiguration(new CustomerMap()); + + base.OnModelCreating(modelBuilder); + } + + public async Task Commit() + { + // Dispatch Domain Events collection. + // Choices: + // A) Right BEFORE committing data (EF SaveChanges) into the DB will make a single transaction including + // side effects from the domain event handlers which are using the same DbContext with "InstancePerLifetimeScope" or "scoped" lifetime + // B) Right AFTER committing data (EF SaveChanges) into the DB will make multiple transactions. + // You will need to handle eventual consistency and compensatory actions in case of failures in any of the Handlers. + await _mediatorHandler.PublishDomainEvents(this).ConfigureAwait(false); + + // After executing this line all the changes (from the Command Handler and Domain Event Handlers) + // performed through the DbContext will be committed + var success = await SaveChangesAsync() > 0; + + return success; + } + } + + public static class MediatorExtension + { + public static async Task PublishDomainEvents(this IMediatorHandler mediator, T ctx) where T : DbContext + { + var domainEntities = ctx.ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents != null && x.Entity.DomainEvents.Any()); + + var domainEvents = domainEntities + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + domainEntities.ToList() + .ForEach(entity => entity.Entity.ClearDomainEvents()); + + var tasks = domainEvents + .Select(async (domainEvent) => { + await mediator.PublishEvent(domainEvent); + }); + + await Task.WhenAll(tasks); + } + } +} diff --git a/src/Equinox.Infra.Data/Context/EventStoreSQLContext.cs b/src/Equinox.Infra.Data/Context/EventStoreSQLContext.cs new file mode 100644 index 00000000..893f9f7c --- /dev/null +++ b/src/Equinox.Infra.Data/Context/EventStoreSQLContext.cs @@ -0,0 +1,21 @@ +using Equinox.Domain.Core.Events; +using Equinox.Infra.Data.Mappings; +using Microsoft.EntityFrameworkCore; + + +namespace Equinox.Infra.Data.Context +{ + public class EventStoreSqlContext : DbContext + { + public EventStoreSqlContext(DbContextOptions options) : base(options) { } + + public DbSet StoredEvent { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new StoredEventMap()); + + base.OnModelCreating(modelBuilder); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.Data/Equinox.Infra.Data.csproj b/src/Equinox.Infra.Data/Equinox.Infra.Data.csproj new file mode 100644 index 00000000..1860cd2c --- /dev/null +++ b/src/Equinox.Infra.Data/Equinox.Infra.Data.csproj @@ -0,0 +1,15 @@ + + + net6.0 + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Equinox.Infra.Data/EventSourcing/SqlEventStore.cs b/src/Equinox.Infra.Data/EventSourcing/SqlEventStore.cs new file mode 100644 index 00000000..dac91b12 --- /dev/null +++ b/src/Equinox.Infra.Data/EventSourcing/SqlEventStore.cs @@ -0,0 +1,39 @@ +using Equinox.Domain.Core.Events; +using Equinox.Infra.Data.Repository.EventSourcing; +using NetDevPack.Identity.User; +using NetDevPack.Messaging; +using Newtonsoft.Json; + +namespace Equinox.Infra.Data.EventSourcing +{ + public class SqlEventStore : IEventStore + { + private readonly IEventStoreRepository _eventStoreRepository; + private readonly IAspNetUser _user; + + public SqlEventStore(IEventStoreRepository eventStoreRepository, IAspNetUser user) + { + _eventStoreRepository = eventStoreRepository; + _user = user; + } + + public void Save(T theEvent) where T : Event + { + // Using Newtonsoft.Json because System.Text.Json + // is a sad joke to be considered "Done" + + // The System.Text don't know how serialize a + // object with inherited properties, I said is sad... + // Yes! I tried: options = new JsonSerializerOptions { WriteIndented = true }; + + var serializedData = JsonConvert.SerializeObject(theEvent); + + var storedEvent = new StoredEvent( + theEvent, + serializedData, + _user.Name ?? _user.GetUserEmail()); + + _eventStoreRepository.Store(storedEvent); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.Data/Mappings/CustomerMap.cs b/src/Equinox.Infra.Data/Mappings/CustomerMap.cs new file mode 100644 index 00000000..a31d0b58 --- /dev/null +++ b/src/Equinox.Infra.Data/Mappings/CustomerMap.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Equinox.Domain.Models; + +namespace Equinox.Infra.Data.Mappings +{ + public class CustomerMap : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(c => c.Id) + .HasColumnName("Id"); + + builder.Property(c => c.Name) + .HasColumnType("varchar(100)") + .HasMaxLength(100) + .IsRequired(); + + builder.Property(c => c.Email) + .HasColumnType("varchar(100)") + .HasMaxLength(100) + .IsRequired(); + } + } +} diff --git a/src/Equinox.Infra.Data/Mappings/StoredEventMap.cs b/src/Equinox.Infra.Data/Mappings/StoredEventMap.cs new file mode 100644 index 00000000..631fb106 --- /dev/null +++ b/src/Equinox.Infra.Data/Mappings/StoredEventMap.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Equinox.Domain.Core.Events; + +namespace Equinox.Infra.Data.Mappings +{ + public class StoredEventMap : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(c => c.Timestamp) + .HasColumnName("CreationDate"); + + builder.Property(c => c.MessageType) + .HasColumnName("Action") + .HasColumnType("varchar(100)"); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.Data/Migrations/20161213130431_Initial.Designer.cs b/src/Equinox.Infra.Data/Migrations/20161213130431_Initial.Designer.cs new file mode 100644 index 00000000..6244cea2 --- /dev/null +++ b/src/Equinox.Infra.Data/Migrations/20161213130431_Initial.Designer.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Equinox.Infra.Data.Context; + +namespace Equinox.Infra.Data.Migrations +{ + [DbContext(typeof(EquinoxContext))] + [Migration("20161213130431_Initial")] + partial class Initial + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + modelBuilder + .HasAnnotation("ProductVersion", "1.1.0-rtm-22752") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Equinox.Domain.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("Id"); + + b.Property("BirthDate"); + + b.Property("Email") + .IsRequired() + .HasColumnType("varchar(100)") + .HasMaxLength(11); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + } + } +} diff --git a/src/Equinox.Infra.Data/Migrations/20161213130431_Initial.cs b/src/Equinox.Infra.Data/Migrations/20161213130431_Initial.cs new file mode 100644 index 00000000..c972c1d0 --- /dev/null +++ b/src/Equinox.Infra.Data/Migrations/20161213130431_Initial.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Equinox.Infra.Data.Migrations +{ + public partial class Initial : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Customers", + columns: table => new + { + Id = table.Column(nullable: false), + BirthDate = table.Column(nullable: false), + Email = table.Column(type: "varchar(100)", maxLength: 11, nullable: false), + Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Customers"); + } + } +} diff --git a/src/Equinox.Infra.Data/Migrations/EquinoxContextModelSnapshot.cs b/src/Equinox.Infra.Data/Migrations/EquinoxContextModelSnapshot.cs new file mode 100644 index 00000000..2d4e9a17 --- /dev/null +++ b/src/Equinox.Infra.Data/Migrations/EquinoxContextModelSnapshot.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Equinox.Infra.Data.Context; + +namespace Equinox.Infra.Data.Migrations +{ + [DbContext(typeof(EquinoxContext))] + partial class EquinoxContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { + modelBuilder + .HasAnnotation("ProductVersion", "1.1.0-rtm-22752") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Equinox.Domain.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("Id"); + + b.Property("BirthDate"); + + b.Property("Email") + .IsRequired() + .HasColumnType("varchar(100)") + .HasMaxLength(11); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + } + } +} diff --git a/src/Equinox.Infra.Data/Migrations/EventStoreSQL/20161213130520_Initial.Designer.cs b/src/Equinox.Infra.Data/Migrations/EventStoreSQL/20161213130520_Initial.Designer.cs new file mode 100644 index 00000000..9bed57c4 --- /dev/null +++ b/src/Equinox.Infra.Data/Migrations/EventStoreSQL/20161213130520_Initial.Designer.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Equinox.Infra.Data.Context; + +namespace Equinox.Infra.Data.Migrations.EventStoreSQL +{ + [DbContext(typeof(EventStoreSqlContext))] + [Migration("20161213130520_Initial")] + partial class Initial + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + modelBuilder + .HasAnnotation("ProductVersion", "1.1.0-rtm-22752") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Equinox.Domain.Core.Events.StoredEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("AggregateId"); + + b.Property("Data"); + + b.Property("MessageType") + .HasColumnName("Action") + .HasColumnType("varchar(100)"); + + b.Property("Timestamp") + .HasColumnName("CreationDate"); + + b.Property("User"); + + b.HasKey("Id"); + + b.ToTable("StoredEvent"); + }); + } + } +} diff --git a/src/Equinox.Infra.Data/Migrations/EventStoreSQL/20161213130520_Initial.cs b/src/Equinox.Infra.Data/Migrations/EventStoreSQL/20161213130520_Initial.cs new file mode 100644 index 00000000..843e8a4b --- /dev/null +++ b/src/Equinox.Infra.Data/Migrations/EventStoreSQL/20161213130520_Initial.cs @@ -0,0 +1,33 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Equinox.Infra.Data.Migrations.EventStoreSQL +{ + public partial class Initial : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "StoredEvent", + columns: table => new + { + Id = table.Column(nullable: false), + AggregateId = table.Column(nullable: false), + Data = table.Column(nullable: true), + Action = table.Column(type: "varchar(100)", nullable: true), + CreationDate = table.Column(nullable: false), + User = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_StoredEvent", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StoredEvent"); + } + } +} diff --git a/src/Equinox.Infra.Data/Migrations/EventStoreSQL/EventStoreSQLContextModelSnapshot.cs b/src/Equinox.Infra.Data/Migrations/EventStoreSQL/EventStoreSQLContextModelSnapshot.cs new file mode 100644 index 00000000..d156d92d --- /dev/null +++ b/src/Equinox.Infra.Data/Migrations/EventStoreSQL/EventStoreSQLContextModelSnapshot.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Equinox.Infra.Data.Context; + +namespace Equinox.Infra.Data.Migrations.EventStoreSQL +{ + [DbContext(typeof(EventStoreSqlContext))] + partial class EventStoreSQLContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { + modelBuilder + .HasAnnotation("ProductVersion", "1.1.0-rtm-22752") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Equinox.Domain.Core.Events.StoredEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("AggregateId"); + + b.Property("Data"); + + b.Property("MessageType") + .HasColumnName("Action") + .HasColumnType("varchar(100)"); + + b.Property("Timestamp") + .HasColumnName("CreationDate"); + + b.Property("User"); + + b.HasKey("Id"); + + b.ToTable("StoredEvent"); + }); + } + } +} diff --git a/src/Equinox.Infra.Data/Repository/CustomerRepository.cs b/src/Equinox.Infra.Data/Repository/CustomerRepository.cs new file mode 100644 index 00000000..cefefc5b --- /dev/null +++ b/src/Equinox.Infra.Data/Repository/CustomerRepository.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Equinox.Domain.Interfaces; +using Equinox.Domain.Models; +using Equinox.Infra.Data.Context; +using Microsoft.EntityFrameworkCore; +using NetDevPack.Data; + +namespace Equinox.Infra.Data.Repository +{ + public class CustomerRepository : ICustomerRepository + { + protected readonly EquinoxContext Db; + protected readonly DbSet DbSet; + + public CustomerRepository(EquinoxContext context) + { + Db = context; + DbSet = Db.Set(); + } + + public IUnitOfWork UnitOfWork => Db; + + public async Task GetById(Guid id) + { + return await DbSet.FindAsync(id); + } + + public async Task> GetAll() + { + return await DbSet.ToListAsync(); + } + + public async Task GetByEmail(string email) + { + return await DbSet.AsNoTracking().FirstOrDefaultAsync(c => c.Email == email); + } + + public void Add(Customer customer) + { + DbSet.Add(customer); + } + + public void Update(Customer customer) + { + DbSet.Update(customer); + } + + public void Remove(Customer customer) + { + DbSet.Remove(customer); + } + + public void Dispose() + { + Db.Dispose(); + } + } +} diff --git a/src/Equinox.Infra.Data/Repository/EventSourcing/EventStoreSQLRepository.cs b/src/Equinox.Infra.Data/Repository/EventSourcing/EventStoreSQLRepository.cs new file mode 100644 index 00000000..796cace1 --- /dev/null +++ b/src/Equinox.Infra.Data/Repository/EventSourcing/EventStoreSQLRepository.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading.Tasks; +using Equinox.Domain.Core.Events; +using Equinox.Infra.Data.Context; +using Microsoft.EntityFrameworkCore; + +namespace Equinox.Infra.Data.Repository.EventSourcing +{ + public class EventStoreSqlRepository : IEventStoreRepository + { + private readonly EventStoreSqlContext _context; + + public EventStoreSqlRepository(EventStoreSqlContext context) + { + _context = context; + } + + public async Task> All(Guid aggregateId) + { + return await (from e in _context.StoredEvent where e.AggregateId == aggregateId select e).ToListAsync(); + } + + public void Store(StoredEvent theEvent) + { + _context.StoredEvent.Add(theEvent); + _context.SaveChanges(); + } + + public void Dispose() + { + _context.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.Data/Repository/EventSourcing/IEventStoreRepository.cs b/src/Equinox.Infra.Data/Repository/EventSourcing/IEventStoreRepository.cs new file mode 100644 index 00000000..03aa90b7 --- /dev/null +++ b/src/Equinox.Infra.Data/Repository/EventSourcing/IEventStoreRepository.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Equinox.Domain.Core.Events; + +namespace Equinox.Infra.Data.Repository.EventSourcing +{ + public interface IEventStoreRepository : IDisposable + { + void Store(StoredEvent theEvent); + Task> All(Guid aggregateId); + } +} \ No newline at end of file diff --git a/src/Equinox.Infra.Data/appsettings.json b/src/Equinox.Infra.Data/appsettings.json new file mode 100644 index 00000000..865a1cb8 --- /dev/null +++ b/src/Equinox.Infra.Data/appsettings.json @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=Equinox;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/src/Equinox.Services.Api/Configurations/AutoMapperConfig.cs b/src/Equinox.Services.Api/Configurations/AutoMapperConfig.cs new file mode 100644 index 00000000..1d7efcba --- /dev/null +++ b/src/Equinox.Services.Api/Configurations/AutoMapperConfig.cs @@ -0,0 +1,16 @@ +using System; +using Equinox.Application.AutoMapper; +using Microsoft.Extensions.DependencyInjection; + +namespace Equinox.Services.Api.Configurations +{ + public static class AutoMapperConfig + { + public static void AddAutoMapperConfiguration(this IServiceCollection services) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + services.AddAutoMapper(typeof(DomainToViewModelMappingProfile), typeof(ViewModelToDomainMappingProfile)); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Services.Api/Configurations/DatabaseConfig.cs b/src/Equinox.Services.Api/Configurations/DatabaseConfig.cs new file mode 100644 index 00000000..adbafe60 --- /dev/null +++ b/src/Equinox.Services.Api/Configurations/DatabaseConfig.cs @@ -0,0 +1,22 @@ +using System; +using Equinox.Infra.Data.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Equinox.Services.Api.Configurations +{ + public static class DatabaseConfig + { + public static void AddDatabaseConfiguration(this IServiceCollection services, IConfiguration configuration) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + services.AddDbContext(options => + options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); + + services.AddDbContext(options => + options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Services.Api/Configurations/DependencyInjectionConfig.cs b/src/Equinox.Services.Api/Configurations/DependencyInjectionConfig.cs new file mode 100644 index 00000000..9b54a449 --- /dev/null +++ b/src/Equinox.Services.Api/Configurations/DependencyInjectionConfig.cs @@ -0,0 +1,16 @@ +using System; +using Equinox.Infra.CrossCutting.IoC; +using Microsoft.Extensions.DependencyInjection; + +namespace Equinox.Services.Api.Configurations +{ + public static class DependencyInjectionConfig + { + public static void AddDependencyInjectionConfiguration(this IServiceCollection services) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + NativeInjectorBootStrapper.RegisterServices(services); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Services.Api/Configurations/SwaggerConfig.cs b/src/Equinox.Services.Api/Configurations/SwaggerConfig.cs new file mode 100644 index 00000000..60fd1cdd --- /dev/null +++ b/src/Equinox.Services.Api/Configurations/SwaggerConfig.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +namespace Equinox.Services.Api.Configurations +{ + public static class SwaggerConfig + { + public static void AddSwaggerConfiguration(this IServiceCollection services) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + services.AddSwaggerGen(s => + { + s.SwaggerDoc("v1", new OpenApiInfo + { + Version = "v1", + Title = "Equinox Project", + Description = "Equinox API Swagger surface", + Contact = new OpenApiContact { Name = "Eduardo Pires", Email = "contato@eduardopires.net.br", Url = new Uri("http://www.eduardopires.net.br") }, + License = new OpenApiLicense { Name = "MIT", Url = new Uri("https://github.com/EduardoPires/EquinoxProject/blob/master/LICENSE") } + }); + + s.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = "Input the JWT like: Bearer {your token}", + Name = "Authorization", + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey + }); + + s.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + new string[] {} + } + }); + + }); + } + + public static void UseSwaggerSetup(this IApplicationBuilder app) + { + if (app == null) throw new ArgumentNullException(nameof(app)); + + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "v1"); + }); + } + } +} \ No newline at end of file diff --git a/src/Equinox.Services.Api/Controllers/AccountController.cs b/src/Equinox.Services.Api/Controllers/AccountController.cs new file mode 100644 index 00000000..07416c97 --- /dev/null +++ b/src/Equinox.Services.Api/Controllers/AccountController.cs @@ -0,0 +1,92 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using NetDevPack.Identity.Jwt; +using NetDevPack.Identity.Model; + +namespace Equinox.Services.Api.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class AccountController : ApiController + { + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly AppJwtSettings _appJwtSettings; + + public AccountController( + SignInManager signInManager, + UserManager userManager, + IOptions appJwtSettings) + { + _userManager = userManager; + _signInManager = signInManager; + _appJwtSettings = appJwtSettings.Value; + } + + [HttpPost] + [Route("register")] + public async Task Register(RegisterUser registerUser) + { + if (!ModelState.IsValid) return CustomResponse(ModelState); + + var user = new IdentityUser + { + UserName = registerUser.Email, + Email = registerUser.Email, + EmailConfirmed = true + }; + + var result = await _userManager.CreateAsync(user, registerUser.Password); + + if (result.Succeeded) + { + return CustomResponse(GetFullJwt(user.Email)); + } + + foreach (var error in result.Errors) + { + AddError(error.Description); + } + + return CustomResponse(); + } + + [HttpPost] + [Route("login")] + public async Task Login(LoginUser loginUser) + { + if (!ModelState.IsValid) return CustomResponse(ModelState); + + var result = await _signInManager.PasswordSignInAsync(loginUser.Email, loginUser.Password, false, true); + + if (result.Succeeded) + { + var fullJwt = GetFullJwt(loginUser.Email); + return CustomResponse(fullJwt); + } + + if (result.IsLockedOut) + { + AddError("This user is temporarily blocked"); + return CustomResponse(); + } + + AddError("Incorrect user or password"); + return CustomResponse(); + } + + private string GetFullJwt(string email) + { + return new JwtBuilder() + .WithUserManager(_userManager) + .WithJwtSettings(_appJwtSettings) + .WithEmail(email) + .WithJwtClaims() + .WithUserClaims() + .WithUserRoles() + .BuildToken(); + } + } +} diff --git a/src/Equinox.Services.Api/Controllers/ApiController.cs b/src/Equinox.Services.Api/Controllers/ApiController.cs new file mode 100644 index 00000000..6f518ae2 --- /dev/null +++ b/src/Equinox.Services.Api/Controllers/ApiController.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Equinox.Services.Api.Controllers +{ + [ApiController] + public abstract class ApiController : ControllerBase + { + private readonly ICollection _errors = new List(); + + protected ActionResult CustomResponse(object result = null) + { + if (IsOperationValid()) + { + return Ok(result); + } + + return BadRequest(new ValidationProblemDetails(new Dictionary + { + { "Messages", _errors.ToArray() } + })); + } + + protected ActionResult CustomResponse(ModelStateDictionary modelState) + { + var errors = modelState.Values.SelectMany(e => e.Errors); + foreach (var error in errors) + { + AddError(error.ErrorMessage); + } + + return CustomResponse(); + } + + protected ActionResult CustomResponse(ValidationResult validationResult) + { + foreach (var error in validationResult.Errors) + { + AddError(error.ErrorMessage); + } + + return CustomResponse(); + } + + protected bool IsOperationValid() + { + return !_errors.Any(); + } + + protected void AddError(string erro) + { + _errors.Add(erro); + } + + protected void ClearErrors() + { + _errors.Clear(); + } + } +} diff --git a/src/Equinox.Services.Api/Controllers/CustomerController.cs b/src/Equinox.Services.Api/Controllers/CustomerController.cs new file mode 100644 index 00000000..cc32d374 --- /dev/null +++ b/src/Equinox.Services.Api/Controllers/CustomerController.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Equinox.Application.EventSourcedNormalizers; +using Equinox.Application.Interfaces; +using Equinox.Application.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NetDevPack.Identity.Authorization; + +namespace Equinox.Services.Api.Controllers +{ + [Authorize] + public class CustomerController : ApiController + { + private readonly ICustomerAppService _customerAppService; + + public CustomerController(ICustomerAppService customerAppService) + { + _customerAppService = customerAppService; + } + + [AllowAnonymous] + [HttpGet("customer-management")] + public async Task> Get() + { + return await _customerAppService.GetAll(); + } + + [AllowAnonymous] + [HttpGet("customer-management/{id:guid}")] + public async Task Get(Guid id) + { + return await _customerAppService.GetById(id); + } + + [CustomAuthorize("Customers", "Write")] + [HttpPost("customer-management")] + public async Task Post([FromBody]CustomerViewModel customerViewModel) + { + return !ModelState.IsValid ? CustomResponse(ModelState) : CustomResponse(await _customerAppService.Register(customerViewModel)); + } + + [CustomAuthorize("Customers", "Write")] + [HttpPut("customer-management")] + public async Task Put([FromBody]CustomerViewModel customerViewModel) + { + return !ModelState.IsValid ? CustomResponse(ModelState) : CustomResponse(await _customerAppService.Update(customerViewModel)); + } + + [CustomAuthorize("Customers", "Remove")] + [HttpDelete("customer-management")] + public async Task Delete(Guid id) + { + return CustomResponse(await _customerAppService.Remove(id)); + } + + [AllowAnonymous] + [HttpGet("customer-management/history/{id:guid}")] + public async Task> History(Guid id) + { + return await _customerAppService.GetAllHistory(id); + } + } +} diff --git a/src/Equinox.Services.Api/Dockerfile b/src/Equinox.Services.Api/Dockerfile new file mode 100644 index 00000000..479315de --- /dev/null +++ b/src/Equinox.Services.Api/Dockerfile @@ -0,0 +1,22 @@ +#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +WORKDIR /src +COPY ["src/Equinox.Services.Api/Equinox.Services.Api.csproj", "src/Equinox.Services.Api/"] +RUN dotnet restore "src/Equinox.Services.Api/Equinox.Services.Api.csproj" +COPY . . +WORKDIR "/src/src/Equinox.Services.Api" +RUN dotnet build "Equinox.Services.Api.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Equinox.Services.Api.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Equinox.Services.Api.dll"] diff --git a/src/Equinox.Services.Api/Equinox.Services.Api.csproj b/src/Equinox.Services.Api/Equinox.Services.Api.csproj new file mode 100644 index 00000000..c632b61f --- /dev/null +++ b/src/Equinox.Services.Api/Equinox.Services.Api.csproj @@ -0,0 +1,29 @@ + + + net6.0 + b543be42-f7ab-48b6-b633-72d6fb529fb7 + enable + Linux + ..\.. + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + \ No newline at end of file diff --git a/src/Equinox.Services.Api/Program.cs b/src/Equinox.Services.Api/Program.cs new file mode 100644 index 00000000..d801f160 --- /dev/null +++ b/src/Equinox.Services.Api/Program.cs @@ -0,0 +1,69 @@ +using Equinox.Infra.CrossCutting.Identity; +using Equinox.Services.Api.Configurations; +using MediatR; +using NetDevPack.Identity; +using NetDevPack.Identity.User; + +var builder = WebApplication.CreateBuilder(args); + +builder.Configuration + .SetBasePath(builder.Environment.ContentRootPath) + .AddJsonFile("appsettings.json", true, true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true) + .AddEnvironmentVariables(); + +// ConfigureServices + +// WebAPI Config +builder.Services.AddControllers(); + +// Setting DBContexts +builder.Services.AddDatabaseConfiguration(builder.Configuration); + +// ASP.NET Identity Settings & JWT +builder.Services.AddApiIdentityConfiguration(builder.Configuration); + +// Interactive AspNetUser (logged in) +// NetDevPack.Identity dependency +builder.Services.AddAspNetUserConfiguration(); + +// AutoMapper Settings +builder.Services.AddAutoMapperConfiguration(); + +// Swagger Config +builder.Services.AddSwaggerConfiguration(); + +// Adding MediatR for Domain Events and Notifications +builder.Services.AddMediatR(AppDomain.CurrentDomain.GetAssemblies()); + +// .NET Native DI Abstraction +builder.Services.AddDependencyInjectionConfiguration(); + +var app = builder.Build(); + +// Configure + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} + +app.UseHttpsRedirection(); + +app.UseRouting(); + +app.UseCors(c => +{ + c.AllowAnyHeader(); + c.AllowAnyMethod(); + c.AllowAnyOrigin(); +}); + +// NetDevPack.Identity dependency +app.UseAuthConfiguration(); + +app.MapControllers(); + +app.UseSwaggerSetup(); + +app.Run(); \ No newline at end of file diff --git a/src/Equinox.Services.Api/Properties/launchSettings.json b/src/Equinox.Services.Api/Properties/launchSettings.json new file mode 100644 index 00000000..8acc38a9 --- /dev/null +++ b/src/Equinox.Services.Api/Properties/launchSettings.json @@ -0,0 +1,47 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:59223", + "sslPort": 44389 + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "IIS Express - Development": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express - Staging": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Staging" + } + }, + "IIS Express - Production": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Production" + } + }, + "Self Hosted": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": "true", + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } +} \ No newline at end of file diff --git a/src/Equinox.Services.Api/appsettings.Development.json b/src/Equinox.Services.Api/appsettings.Development.json new file mode 100644 index 00000000..0b627e65 --- /dev/null +++ b/src/Equinox.Services.Api/appsettings.Development.json @@ -0,0 +1,20 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=Equinox;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "AppSettings": { + // Always use strong keys like that + "SecretKey": "fmFGn5agHZkuG2N0e1zaEJIQtGVoNN5P", + "Expiration": 2, + "Issuer": "MyEnvironment", + "Audience": "https://localhost" + } +} diff --git a/src/Equinox.Services.Api/appsettings.Staging.json b/src/Equinox.Services.Api/appsettings.Staging.json new file mode 100644 index 00000000..0b627e65 --- /dev/null +++ b/src/Equinox.Services.Api/appsettings.Staging.json @@ -0,0 +1,20 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=Equinox;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "AppSettings": { + // Always use strong keys like that + "SecretKey": "fmFGn5agHZkuG2N0e1zaEJIQtGVoNN5P", + "Expiration": 2, + "Issuer": "MyEnvironment", + "Audience": "https://localhost" + } +} diff --git a/src/Equinox.Services.Api/appsettings.Testing.json b/src/Equinox.Services.Api/appsettings.Testing.json new file mode 100644 index 00000000..0b627e65 --- /dev/null +++ b/src/Equinox.Services.Api/appsettings.Testing.json @@ -0,0 +1,20 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=Equinox;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "AppSettings": { + // Always use strong keys like that + "SecretKey": "fmFGn5agHZkuG2N0e1zaEJIQtGVoNN5P", + "Expiration": 2, + "Issuer": "MyEnvironment", + "Audience": "https://localhost" + } +} diff --git a/src/Equinox.Services.Api/appsettings.json b/src/Equinox.Services.Api/appsettings.json new file mode 100644 index 00000000..d9d9a9bf --- /dev/null +++ b/src/Equinox.Services.Api/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Equinox.UI.Web/Areas/Identity/IdentityHostingStartup.cs b/src/Equinox.UI.Web/Areas/Identity/IdentityHostingStartup.cs new file mode 100644 index 00000000..d0cba12d --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/IdentityHostingStartup.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Hosting; + +[assembly: HostingStartup(typeof(Equinox.UI.Web.Areas.Identity.IdentityHostingStartup))] +namespace Equinox.UI.Web.Areas.Identity +{ + public class IdentityHostingStartup : IHostingStartup + { + public void Configure(IWebHostBuilder builder) + { + builder.ConfigureServices((context, services) => { + }); + } + } +} \ No newline at end of file diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Register.cshtml b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Register.cshtml new file mode 100644 index 00000000..d9b50aba --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Register.cshtml @@ -0,0 +1,67 @@ +@page +@model RegisterModel +@{ + ViewData["Title"] = "Register"; +} + +@ViewData["Title"] + + + + + Create a new account. + + + + + + + + + + + + + + + + + + Register + + + + + Use another service to register. + + @{ + if ((Model.ExternalLogins?.Count ?? 0) == 0) + { + + + There are no external authentication services configured. See this article + for details on setting up this ASP.NET application to support logging in via external services. + + + } + else + { + + + + @foreach (var provider in Model.ExternalLogins) + { + @provider.DisplayName + } + + + + } + } + + + + +@section Scripts { + +} diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Register.cshtml.cs new file mode 100644 index 00000000..52fcec43 --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; + +namespace Equinox.UI.Web.Areas.Identity.Pages.Account +{ + [AllowAnonymous] + public class RegisterModel : PageModel + { + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly ILogger _logger; + private readonly IEmailSender _emailSender; + + public RegisterModel( + UserManager userManager, + SignInManager signInManager, + ILogger logger, + IEmailSender emailSender) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + _emailSender = emailSender; + } + + [BindProperty] + public InputModel Input { get; set; } + + public string ReturnUrl { get; set; } + + public IList ExternalLogins { get; set; } + + public class InputModel + { + [Required] + [EmailAddress] + [Display(Name = "Email")] + public string Email { get; set; } + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "Password")] + public string Password { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm password")] + [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + } + + public async Task OnGetAsync(string returnUrl = null) + { + ReturnUrl = returnUrl; + ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + } + + public async Task OnPostAsync(string returnUrl = null) + { + returnUrl = returnUrl ?? Url.Content("~/"); + ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + if (ModelState.IsValid) + { + var user = new IdentityUser { UserName = Input.Email, Email = Input.Email }; + var result = await _userManager.CreateAsync(user, Input.Password); + if (result.Succeeded) + { + _logger.LogInformation("User created a new account with password."); + + // User claim for write customers data + await _userManager.AddClaimAsync(user, new Claim("Customers", "Write")); + + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ConfirmEmail", + pageHandler: null, + values: new { area = "Identity", userId = user.Id, code }, + protocol: Request.Scheme); + + await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", + $"Please confirm your account by clicking here."); + + if (_userManager.Options.SignIn.RequireConfirmedAccount) + { + return RedirectToPage("RegisterConfirmation", new { email = Input.Email }); + } + else + { + await _signInManager.SignInAsync(user, isPersistent: false); + return LocalRedirect(returnUrl); + } + } + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + } + + // If we got this far, something failed, redisplay form + return Page(); + } + } +} diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/Account/_ViewImports.cshtml b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/_ViewImports.cshtml new file mode 100644 index 00000000..d01d2da9 --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/Account/_ViewImports.cshtml @@ -0,0 +1 @@ +@using Equinox.UI.Web.Areas.Identity.Pages.Account \ No newline at end of file diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml b/src/Equinox.UI.Web/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml new file mode 100644 index 00000000..bacc0ae4 --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/_ViewImports.cshtml b/src/Equinox.UI.Web/Areas/Identity/Pages/_ViewImports.cshtml new file mode 100644 index 00000000..ae9afd46 --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Identity +@using Equinox.UI.Web.Areas.Identity +@using Equinox.UI.Web.Areas.Identity.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Equinox.UI.Web/Areas/Identity/Pages/_ViewStart.cshtml b/src/Equinox.UI.Web/Areas/Identity/Pages/_ViewStart.cshtml new file mode 100644 index 00000000..c4284f6c --- /dev/null +++ b/src/Equinox.UI.Web/Areas/Identity/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "/Views/Shared/_Layout.cshtml"; +} diff --git a/src/Equinox.UI.Web/Configurations/AutoMapperConfig.cs b/src/Equinox.UI.Web/Configurations/AutoMapperConfig.cs new file mode 100644 index 00000000..a62cc018 --- /dev/null +++ b/src/Equinox.UI.Web/Configurations/AutoMapperConfig.cs @@ -0,0 +1,16 @@ +using System; +using Equinox.Application.AutoMapper; +using Microsoft.Extensions.DependencyInjection; + +namespace Equinox.UI.Web.Configurations +{ + public static class AutoMapperConfig + { + public static void AddAutoMapperConfiguration(this IServiceCollection services) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + services.AddAutoMapper(typeof(DomainToViewModelMappingProfile), typeof(ViewModelToDomainMappingProfile)); + } + } +} \ No newline at end of file diff --git a/src/Equinox.UI.Web/Configurations/DatabaseConfig.cs b/src/Equinox.UI.Web/Configurations/DatabaseConfig.cs new file mode 100644 index 00000000..0e189cdc --- /dev/null +++ b/src/Equinox.UI.Web/Configurations/DatabaseConfig.cs @@ -0,0 +1,22 @@ +using System; +using Equinox.Infra.Data.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Equinox.UI.Web.Configurations +{ + public static class DatabaseConfig + { + public static void AddDatabaseConfiguration(this IServiceCollection services, IConfiguration configuration) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + services.AddDbContext(options => + options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); + + services.AddDbContext(options => + options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); + } + } +} \ No newline at end of file diff --git a/src/Equinox.UI.Web/Configurations/DependencyInjectionConfig.cs b/src/Equinox.UI.Web/Configurations/DependencyInjectionConfig.cs new file mode 100644 index 00000000..bdd4dfe0 --- /dev/null +++ b/src/Equinox.UI.Web/Configurations/DependencyInjectionConfig.cs @@ -0,0 +1,16 @@ +using System; +using Equinox.Infra.CrossCutting.IoC; +using Microsoft.Extensions.DependencyInjection; + +namespace Equinox.UI.Web.Configurations +{ + public static class DependencyInjectionConfig + { + public static void AddDependencyInjectionConfiguration(this IServiceCollection services) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + NativeInjectorBootStrapper.RegisterServices(services); + } + } +} \ No newline at end of file diff --git a/src/Equinox.UI.Web/Configurations/IdentityConfig.cs b/src/Equinox.UI.Web/Configurations/IdentityConfig.cs new file mode 100644 index 00000000..cc58b62f --- /dev/null +++ b/src/Equinox.UI.Web/Configurations/IdentityConfig.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Equinox.UI.Web.Configurations +{ + public static class IdentityConfig + { + + public static void AddSocialAuthenticationConfiguration(this IServiceCollection services, IConfiguration configuration) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + services.AddAuthentication() + .AddFacebook(o => + { + o.AppId = configuration["Authentication:Facebook:AppId"]; + o.AppSecret = configuration["Authentication:Facebook:AppSecret"]; + }) + .AddGoogle(googleOptions => + { + googleOptions.ClientId = configuration["Authentication:Google:ClientId"]; + googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"]; + }); + } + } +} \ No newline at end of file diff --git a/src/Equinox.UI.Web/Controllers/BaseController.cs b/src/Equinox.UI.Web/Controllers/BaseController.cs new file mode 100644 index 00000000..fa9f0d22 --- /dev/null +++ b/src/Equinox.UI.Web/Controllers/BaseController.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc; + +namespace Equinox.UI.Web.Controllers +{ + public class BaseController : Controller + { + private readonly ICollection _errors = new List(); + + protected bool ResponseHasErrors(ValidationResult result) + { + if (result == null || result.IsValid) return false; + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.ErrorMessage); + } + + return true; + } + + protected void AddProcessError(string erro) + { + _errors.Add(erro); + } + + public bool IsValidOperation() + { + return !_errors.Any(); + } + } +} diff --git a/src/Equinox.UI.Web/Controllers/CustomerController.cs b/src/Equinox.UI.Web/Controllers/CustomerController.cs new file mode 100644 index 00000000..a43ef1c3 --- /dev/null +++ b/src/Equinox.UI.Web/Controllers/CustomerController.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading.Tasks; +using Equinox.Application.Interfaces; +using Equinox.Application.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NetDevPack.Identity.Authorization; + +namespace Equinox.UI.Web.Controllers +{ + [Authorize] + public class CustomerController : BaseController + { + private readonly ICustomerAppService _customerAppService; + + public CustomerController(ICustomerAppService customerAppService) + { + _customerAppService = customerAppService; + } + [AllowAnonymous] + [HttpGet("customer-management/list-all")] + public async Task Index() + { + return View(await _customerAppService.GetAll()); + } + + [AllowAnonymous] + [HttpGet("customer-management/customer-details/{id:guid}")] + public async Task Details(Guid? id) + { + if (id == null) return NotFound(); + + var customerViewModel = await _customerAppService.GetById(id.Value); + + if (customerViewModel == null) return NotFound(); + + return View(customerViewModel); + } + + [CustomAuthorize("Customers", "Write")] + [HttpGet("customer-management/register-new")] + public IActionResult Create() + { + return View(); + } + + [CustomAuthorize("Customers", "Write")] + [HttpPost("customer-management/register-new")] + public async Task Create(CustomerViewModel customerViewModel) + { + if (!ModelState.IsValid) return View(customerViewModel); + + if (ResponseHasErrors(await _customerAppService.Register(customerViewModel))) + return View(customerViewModel); + + ViewBag.Sucesso = "Customer Registered!"; + + return View(customerViewModel); + } + + [CustomAuthorize("Customers", "Write")] + [HttpGet("customer-management/edit-customer/{id:guid}")] + public async Task Edit(Guid? id) + { + if (id == null) return NotFound(); + + var customerViewModel = await _customerAppService.GetById(id.Value); + + if (customerViewModel == null) return NotFound(); + + return View(customerViewModel); + } + + [CustomAuthorize("Customers", "Write")] + [HttpPost("customer-management/edit-customer/{id:guid}")] + public async Task Edit(CustomerViewModel customerViewModel) + { + if (!ModelState.IsValid) return View(customerViewModel); + + if (ResponseHasErrors(await _customerAppService.Update(customerViewModel))) + return View(customerViewModel); + + ViewBag.Sucesso = "Customer Updated!"; + + return View(customerViewModel); + } + + [CustomAuthorize("Customers", "Remove")] + [HttpGet("customer-management/remove-customer/{id:guid}")] + public async Task Delete(Guid? id) + { + if (id == null) return NotFound(); + + var customerViewModel = await _customerAppService.GetById(id.Value); + + if (customerViewModel == null) return NotFound(); + + return View(customerViewModel); + } + + [CustomAuthorize("Customers", "Remove")] + [HttpPost("customer-management/remove-customer/{id:guid}"), ActionName("Delete")] + public async Task DeleteConfirmed(Guid id) + { + if (ResponseHasErrors(await _customerAppService.Remove(id))) + return View(await _customerAppService.GetById(id)); + + ViewBag.Sucesso = "Customer Removed!"; + return RedirectToAction("Index"); + } + + [AllowAnonymous] + [HttpGet("customer-management/customer-history/{id:guid}")] + public async Task History(Guid id) + { + var customerHistoryData = await _customerAppService.GetAllHistory(id); + return Json(customerHistoryData); + } + } +} diff --git a/src/Equinox.UI.Web/Controllers/HomeController.cs b/src/Equinox.UI.Web/Controllers/HomeController.cs new file mode 100644 index 00000000..90ddc528 --- /dev/null +++ b/src/Equinox.UI.Web/Controllers/HomeController.cs @@ -0,0 +1,44 @@ +using Equinox.UI.Web.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Equinox.UI.Web.Controllers +{ + public class HomeController : Controller + { + public IActionResult Index() + { + return View(); + } + + [Route("error/{id:length(3,3)}")] + public IActionResult Errors(int id) + { + var modelErro = new ErrorViewModel(); + + if (id == 500) + { + modelErro.Message = "An error has occurred! Please try again later or contact our support."; + modelErro.Title = "An error has occurred!"; + modelErro.ErrorCode = id; + } + else if (id == 404) + { + modelErro.Message = "The page you are looking for does not exist! If you have any questions please contact our support"; + modelErro.Title = "Oops! Page not found."; + modelErro.ErrorCode = id; + } + else if (id == 403) + { + modelErro.Message = "You are not allowed to do this."; + modelErro.Title = "Access Denied"; + modelErro.ErrorCode = id; + } + else + { + return StatusCode(500); + } + + return View("Error", modelErro); + } + } +} diff --git a/src/Equinox.UI.Web/Data/ApplicationDbContext.cs b/src/Equinox.UI.Web/Data/ApplicationDbContext.cs new file mode 100644 index 00000000..62bda1d2 --- /dev/null +++ b/src/Equinox.UI.Web/Data/ApplicationDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Equinox.UI.Web.Data +{ + public class ApplicationDbContext : IdentityDbContext + { + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/src/Equinox.UI.Web/Data/MigrationExtension.cs b/src/Equinox.UI.Web/Data/MigrationExtension.cs new file mode 100644 index 00000000..1432ea28 --- /dev/null +++ b/src/Equinox.UI.Web/Data/MigrationExtension.cs @@ -0,0 +1,38 @@ +using Equinox.Infra.Data.Context; +using Microsoft.EntityFrameworkCore; + +namespace Equinox.UI.Web.Data +{ + public static class MigrationsExtension + { + /// + /// This contains the migration code for the application + /// + /// + /// A reference to app after the operation has completed + public static async Task Migrate(this WebApplication app) + { + using (var program = app.Services.CreateScope()) + { + var service = program.ServiceProvider; + + var logger = service.GetService>(); + try + { + var equinoxContext = service.GetRequiredService(); + var eventStoreSqlContext = service.GetRequiredService(); + + await equinoxContext.Database.MigrateAsync(); + await eventStoreSqlContext.Database.MigrateAsync(); + } + catch (Exception ex) + { + logger.LogError(ex.Message, ex); + + } + } + return app; + } + } + +} diff --git a/src/Equinox.UI.Web/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs b/src/Equinox.UI.Web/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs new file mode 100644 index 00000000..92d21b06 --- /dev/null +++ b/src/Equinox.UI.Web/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs @@ -0,0 +1,277 @@ +// +using Equinox.UI.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System; + +namespace Equinox.UI.Web.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("00000000000000_CreateIdentitySchema")] + partial class CreateIdentitySchema + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(256)") + .HasMaxLength(256); + + b.Property("NormalizedName") + .HasColumnType("nvarchar(256)") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + 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.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasColumnType("nvarchar(256)") + .HasMaxLength(256); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasColumnType("nvarchar(256)") + .HasMaxLength(256); + + b.Property("NormalizedUserName") + .HasColumnType("nvarchar(256)") + .HasMaxLength(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") + .HasColumnType("nvarchar(256)") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + 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(128)") + .HasMaxLength(128); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(128)") + .HasMaxLength(128); + + 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(128)") + .HasMaxLength(128); + + b.Property("Name") + .HasColumnType("nvarchar(128)") + .HasMaxLength(128); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + 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("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", 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("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Equinox.UI.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs b/src/Equinox.UI.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs new file mode 100644 index 00000000..27e670ea --- /dev/null +++ b/src/Equinox.UI.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs @@ -0,0 +1,220 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Equinox.UI.Web.Data.Migrations +{ + public partial class CreateIdentitySchema : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(nullable: false), + Name = table.Column(maxLength: 256, nullable: true), + NormalizedName = table.Column(maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(nullable: false), + UserName = table.Column(maxLength: 256, nullable: true), + NormalizedUserName = table.Column(maxLength: 256, nullable: true), + Email = table.Column(maxLength: 256, nullable: true), + NormalizedEmail = table.Column(maxLength: 256, nullable: true), + EmailConfirmed = table.Column(nullable: false), + PasswordHash = table.Column(nullable: true), + SecurityStamp = table.Column(nullable: true), + ConcurrencyStamp = table.Column(nullable: true), + PhoneNumber = table.Column(nullable: true), + PhoneNumberConfirmed = table.Column(nullable: false), + TwoFactorEnabled = table.Column(nullable: false), + LockoutEnd = table.Column(nullable: true), + LockoutEnabled = table.Column(nullable: false), + AccessFailedCount = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + RoleId = table.Column(nullable: false), + ClaimType = table.Column(nullable: true), + ClaimValue = table.Column(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(nullable: false) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + UserId = table.Column(nullable: false), + ClaimType = table.Column(nullable: true), + ClaimValue = table.Column(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(maxLength: 128, nullable: false), + ProviderKey = table.Column(maxLength: 128, nullable: false), + ProviderDisplayName = table.Column(nullable: true), + UserId = table.Column(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(nullable: false), + RoleId = table.Column(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(nullable: false), + LoginProvider = table.Column(maxLength: 128, nullable: false), + Name = table.Column(maxLength: 128, nullable: false), + Value = table.Column(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.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"); + } + + 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: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/src/Equinox.UI.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Equinox.UI.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 00000000..e3f14802 --- /dev/null +++ b/src/Equinox.UI.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,275 @@ +// +using Equinox.UI.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System; + +namespace Equinox.UI.Web.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(256)") + .HasMaxLength(256); + + b.Property("NormalizedName") + .HasColumnType("nvarchar(256)") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + 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.IdentityUser", b => + { + b.Property
+ There are no external authentication services configured. See this article + for details on setting up this ASP.NET application to support logging in via external services. +
+ @foreach (var provider in Model.ExternalLogins) + { + @provider.DisplayName + } +