initial commit

This commit is contained in:
cuqmbr 2024-05-20 18:26:23 +03:00
commit 954e1f707b
Signed by: cuqmbr
GPG Key ID: 0AA446880C766199
210 changed files with 7174 additions and 0 deletions

489
.gitignore vendored Normal file
View File

@ -0,0 +1,489 @@
run.sh
notes.md
dev-scripts/
ExpenseTracker.Api/appsettings.Development.json
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

View File

@ -0,0 +1,155 @@
using Microsoft.AspNetCore.Mvc;
using ExpenseTracker.Application.Common.Models;
using ExpenseTracker.Application.Accounts;
using ExpenseTracker.Application.Accounts.Commands.Create;
using ExpenseTracker.Application.Accounts.Queries.GetWithPagination;
using ExpenseTracker.Application.Accounts.Queries.Get;
using ExpenseTracker.Application.Accounts.Commands.Delete;
using ExpenseTracker.Application.Accounts.Commands.Update;
using ExpenseTracker.Application.Accounts.Queries.Charts;
using ExpenseTracker.Application.Accounts.Queries.Charts.GetExpenseBarChart;
using ExpenseTracker.Application.Accounts.Queries.Charts.GetIncomeBarChart;
using ExpenseTracker.Application.Accounts.Queries.Charts.GetProfitBarChart;
using ExpenseTracker.Application.Accounts.Queries.Charts.GetExpenseByCategoryPieChart;
using ExpenseTracker.Application.Accounts.Queries.Export.Json.One;
using ExpenseTracker.Application.Accounts.Queries.Export.Json.Many;
using ExpenseTracker.Application.Accounts.Queries.Export.Csv.One;
using ExpenseTracker.Application.Accounts.Commands.Import.Json.One;
using ExpenseTracker.Application.Accounts.Commands.Import.Json.Many;
namespace ExpenseTracker.Api.Controllers;
[Route("accounts")]
public class AccountController : BaseController
{
[HttpPost]
public async Task<AccountDto> Create([FromBody] CreateAccountCommand command, CancellationToken cancellationToken)
{
return await Mediator.Send(command, cancellationToken);
}
[HttpGet]
public async Task<PaginatedList<AccountDto>> GetPage([FromQuery] GetAccountsWithPaginationQuery query, CancellationToken cancellationToken)
{
return await Mediator.Send(query, cancellationToken);
}
[HttpGet("{id}")]
public async Task<AccountDto> Get(string id, CancellationToken cancellationToken)
{
var query = new GetAccountQuery() { Id = id };
return await Mediator.Send(query, cancellationToken);
}
[HttpPut]
public async Task<AccountDto> Update([FromBody] UpdateAccountCommand command, CancellationToken cancellationToken)
{
return await Mediator.Send(command, cancellationToken);
}
[HttpDelete]
public async Task Delete([FromBody] DeleteAccountCommand command, CancellationToken cancellationToken)
{
await Mediator.Send(command, cancellationToken);
}
[HttpGet("{id}/export/json")]
public async Task<FileContentResult> ExportJsonOne(string id, CancellationToken cancellationToken)
{
var query = new ExportJsonAccountQuery() { Id = id };
var fileBytes = await Mediator.Send(query, cancellationToken);
var mimeType = "application/json";
var fileName = $"account_{id}.json";
return new FileContentResult(fileBytes, mimeType)
{
FileDownloadName = fileName
};
}
[HttpGet("export/json")]
public async Task<FileContentResult> ExportJsonMany(CancellationToken cancellationToken)
{
var query = new ExportJsonAccountsQuery();
var fileBytes = await Mediator.Send(query, cancellationToken);
var mimeType = "application/json";
var fileName = $"accounts.json";
return new FileContentResult(fileBytes, mimeType)
{
FileDownloadName = fileName
};
}
[HttpGet("{id}/export/csv")]
public async Task<FileContentResult> ExportCsvOne(string id, CancellationToken cancellationToken)
{
var query = new ExportCsvAccountQuery() { Id = id };
var fileBytes = await Mediator.Send(query, cancellationToken);
var mimeType = "text/json";
var fileName = $"account_transactions_{id}.csv";
return new FileContentResult(fileBytes, mimeType)
{
FileDownloadName = fileName
};
}
[HttpPost("import/json/one")]
public async Task ImportOneJson(IFormFile file, CancellationToken cancellationToken)
{
byte[] fileBytes;
using (var binaryReader = new BinaryReader(file.OpenReadStream()))
{
fileBytes = binaryReader.ReadBytes((int)file.Length);
}
var query = new ImportJsonAccountQuery() { File = fileBytes };
await Mediator.Send(query, cancellationToken);
}
[HttpPost("import/json/many")]
public async Task ImportManyJson(IFormFile file, CancellationToken cancellationToken)
{
byte[] fileBytes;
using (var binaryReader = new BinaryReader(file.OpenReadStream()))
{
fileBytes = binaryReader.ReadBytes((int)file.Length);
}
var query = new ImportJsonAccountsQuery() { File = fileBytes };
await Mediator.Send(query, cancellationToken);
}
[HttpGet("{id}/expenseBarChart")]
public async Task<DailyTransactionsBarChart> GetExpenseBarChart(string id, [FromQuery] GetExpenseBarChartQuery query, CancellationToken cancellationToken)
{
query.AccountId = id;
return await Mediator.Send(query, cancellationToken);
}
[HttpGet("{id}/incomeBarChart")]
public async Task<DailyTransactionsBarChart> GetIncomeBarChart(string id, [FromQuery] GetIncomeBarChartQuery query, CancellationToken cancellationToken)
{
query.AccountId = id;
return await Mediator.Send(query, cancellationToken);
}
[HttpGet("{id}/profitBarChart")]
public async Task<DailyTransactionsBarChart> GetProfitBarChart(string id, [FromQuery] GetProfitBarChartQuery query, CancellationToken cancellationToken)
{
query.AccountId = id;
return await Mediator.Send(query, cancellationToken);
}
[HttpGet("{id}/expenseByCategoryPieChart")]
public async Task<Dictionary<string, double>> GetExpenseByCategoryPieChart(string id, [FromQuery] GetExpenseByCategoryPieChartQuery query, CancellationToken cancellationToken)
{
query.AccountId = id;
var pieChart = await Mediator.Send(query, cancellationToken);
var result = pieChart.ToDictionary(item => item.Key.ToString(), item => item.Value);
return result;
}
}

View File

@ -0,0 +1,88 @@
using ExpenseTracker.Application.Authentication;
using ExpenseTracker.Application.Authentication.Commands.RegisterWithEmail;
using ExpenseTracker.Application.Authentication.Commands.RegisterWithEmailAndPassword;
using ExpenseTracker.Application.Authentication.Queries.Login;
using ExpenseTracker.Application.Authentication.Commands.RenewAccessTokenWithBody;
using ExpenseTracker.Application.Authentication.Commands.RenewAccessTokenWithCookie;
using ExpenseTracker.Application.Authentication.Commands.RevokeRefreshTokenWithCookie;
using ExpenseTracker.Application.Authentication.Commands.RevokeRefreshTokenWithBody;
using Microsoft.AspNetCore.Mvc;
namespace ExpenseTracker.Api.Controllers;
[Route("authentication")]
public class AuthenticationController : BaseController
{
[HttpPost("registerWithEmail")]
public async Task RegisterWithEmail([FromBody] RegisterWithEmailCommand command, CancellationToken cancellationToken)
{
await Mediator.Send(command, cancellationToken);
}
[HttpPost("registerWithEmailAndPassword")]
public async Task RegisterWithEmailAndPassword([FromBody] RegisterWithEmailAndPasswordCommand command, CancellationToken cancellationToken)
{
await Mediator.Send(command, cancellationToken);
}
[HttpPost("loginWithBody")]
public async Task<TokensModel> LoginWithBody([FromBody] LoginQuery query, CancellationToken cancellationToken)
{
return await Mediator.Send(query, cancellationToken);
}
[HttpPost("loginWithCookie")]
public async Task<TokensModel> LoginWithCookie([FromBody] LoginQuery query, CancellationToken cancellationToken)
{
var tokens = await Mediator.Send(query, cancellationToken);
HttpContext.Response.Cookies.Delete("accessToken");
HttpContext.Response.Cookies.Delete("refreshToken");
var cookieOptions = new CookieOptions() { Path = "/", Expires = DateTimeOffset.MaxValue, HttpOnly = true };
HttpContext.Response.Cookies.Append("accessToken", tokens.AccessToken, cookieOptions);
HttpContext.Response.Cookies.Append("refreshToken", tokens.RefreshToken, cookieOptions);
return tokens;
}
[HttpPost("renewAccessTokenWithBody")]
public async Task<TokensModel> RenewAccessTokenWithBody([FromBody] RenewAccessTokenWithBodyCommand command, CancellationToken cancellationToken)
{
var tokens = await Mediator.Send(command, cancellationToken);
HttpContext.Response.Cookies.Delete("accessToken");
var cookieOptions = new CookieOptions() { Path = "/", Expires = DateTimeOffset.MaxValue, HttpOnly = true };
HttpContext.Response.Cookies.Append("accessToken", tokens.AccessToken, cookieOptions);
return tokens;
}
[HttpPost("renewAccessTokenWithCookie")]
public async Task<TokensModel> RenewAccessTokenWithCookie([FromBody] RenewAccessTokenWithCookieCommand command, CancellationToken cancellationToken)
{
var tokens = await Mediator.Send(command, cancellationToken);
HttpContext.Response.Cookies.Delete("accessToken");
var cookieOptions = new CookieOptions() { Path = "/", Expires = DateTimeOffset.MaxValue, HttpOnly = true };
HttpContext.Response.Cookies.Append("accessToken", tokens.AccessToken, cookieOptions);
return tokens;
}
[HttpPost("revokeRefreshTokenWithBody")]
public async Task RevokeRefreshTokenWithBody([FromBody] RevokeRefreshTokenWithBodyCommand command, CancellationToken cancellationToken)
{
await Mediator.Send(command, cancellationToken);
}
[HttpPost("revokeRefreshTokenWithCookie")]
public async Task RevokeRefreshTokenWithCookie([FromBody] RevokeRefreshTokenWithCookieCommand command, CancellationToken cancellationToken)
{
await Mediator.Send(command, cancellationToken);
HttpContext.Response.Cookies.Delete("accessToken");
HttpContext.Response.Cookies.Delete("refreshToken");
}
}

View File

@ -0,0 +1,11 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ExpenseTracker.Api.Controllers;
[ApiController]
public class BaseController : ControllerBase
{
private IMediator _mediator;
protected IMediator Mediator => _mediator ??= HttpContext.RequestServices.GetService<IMediator>();
}

View File

@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Mvc;
using ExpenseTracker.Application.Transactions;
using ExpenseTracker.Application.Transactions.Commands.Create;
using ExpenseTracker.Application.Transactions.Commands.Delete;
using ExpenseTracker.Application.Transactions.Commands.Update;
using ExpenseTracker.Application.Transactions.Queries.Get;
using ExpenseTracker.Application.Common.Models;
using ExpenseTracker.Application.Transactions.Queries.GetWithPagination;
namespace ExpenseTracker.Api.Controllers;
[Route("transactions")]
public class TransactionController : BaseController
{
[HttpPost]
public async Task<TransactionDto> Create([FromBody] CreateTransactionCommand command, CancellationToken cancellationToken)
{
return await Mediator.Send(command, cancellationToken);
}
[HttpGet]
public async Task<PaginatedList<TransactionDto>> GetPage([FromQuery] GetTransactionsWithPaginationQuery query, CancellationToken cancellationToken)
{
return await Mediator.Send(query, cancellationToken);
}
[HttpGet("{id}")]
public async Task<TransactionDto> Get(string id, CancellationToken cancellationToken)
{
var query = new GetTransactionQuery() { Id = id };
return await Mediator.Send(query, cancellationToken);
}
[HttpPut]
public async Task<TransactionDto> Update([FromBody] UpdateTransactionCommand command, CancellationToken cancellationToken)
{
return await Mediator.Send(command, cancellationToken);
}
[HttpDelete]
public async Task Delete([FromBody] DeleteTransactionCommand command, CancellationToken cancellationToken)
{
await Mediator.Send(command, cancellationToken);
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\ExpenseTracker.Application\ExpenseTracker.Application.csproj" />
<ProjectReference Include="..\ExpenseTracker.Persistence\ExpenseTracker.Persistence.csproj" />
<ProjectReference Include="..\ExpenseTracker.Infrastructure\ExpenseTracker.Infrastructure.csproj" />
<ProjectReference Include="..\ExpenseTracker.Domain\ExpenseTracker.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,212 @@
using ExpenseTracker.Application.Common.Exceptions;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
namespace ExpenseTracker.Api.Middlewares;
public class GlobalExceptionHandlerMiddleware : IMiddleware
{
class ProblemDetailsWithTraceId : ProblemDetails
{
public string TraceId { get; init; } = Activity.Current?.TraceId.ToString();
}
private readonly Dictionary<Type, Func<HttpContext, Exception, Task>> _exceptionHandlers;
private readonly ILogger _logger;
public GlobalExceptionHandlerMiddleware(ILogger<GlobalExceptionHandlerMiddleware> logger)
{
// Register known exception types and handlers.
_exceptionHandlers = new()
{
{ typeof(ValidationException), HandleValidationException },
{ typeof(RegistrationException), HandleRegistrationException },
{ typeof(LoginException), HandleLoginException },
{ typeof(RenewAccessTokenException), HandleRenewAccessTokenException },
{ typeof(RevokeRefreshTokenException), HandleRevokeRefreshTokenException },
{ typeof(NotFoundException), HandleNotFoundException },
{ typeof(UnAuthorizedException), HandleUnAuthorizedException },
{ typeof(ForbiddenException), HandleForbiddenException },
{ typeof(CurrencyConverterException), HandleCurrencyConverterException },
};
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (Exception exception)
{
var exceptionType = exception.GetType();
_logger.LogInformation(
"{@DateUtc} {@TimeUtc} {@TraceId} {@SpanId} Interrupted with {@ExceptionType}.",
DateTime.UtcNow.ToString("yyyy-MM-dd"),
DateTime.UtcNow.ToString("HH:mm:ss.FFF"),
Activity.Current?.TraceId.ToString(),
Activity.Current?.SpanId.ToString(),
exceptionType);
if (_exceptionHandlers.ContainsKey(exceptionType))
{
await _exceptionHandlers[exceptionType].Invoke(context, exception);
return;
}
await HandleUnhandledExceptionException(context, exception);
}
}
private async Task HandleValidationException(HttpContext context, Exception exception)
{
var ex = (ValidationException)exception;
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new HttpValidationProblemDetails(ex.Errors)
{
Status = StatusCodes.Status400BadRequest,
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
Title = "One or more validation errors occurred.",
Detail = "Provided data doesn't satisfy validation requirements."
});
}
private async Task HandleRegistrationException(HttpContext context, Exception exception)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId()
{
Status = StatusCodes.Status400BadRequest,
Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request",
Title = "Registration failed.",
Detail = "Check your credentials."
});
}
private async Task HandleLoginException(HttpContext context, Exception exception)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId()
{
Status = StatusCodes.Status400BadRequest,
Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request",
Title = "Login failed.",
Detail = "Provided email and/or password are invalid."
});
}
private async Task HandleRenewAccessTokenException(HttpContext context, Exception exception)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId()
{
Status = StatusCodes.Status400BadRequest,
Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request",
Title = "Access token renewal failed.",
Detail = "Check validity of your refresh token."
});
}
private async Task HandleRevokeRefreshTokenException(HttpContext context, Exception exception)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId()
{
Status = StatusCodes.Status400BadRequest,
Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request",
Title = "Refresh token revocation failed.",
Detail = "Check validity of your refresh token."
});
}
private async Task HandleNotFoundException(HttpContext context, Exception exception)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId()
{
Status = StatusCodes.Status404NotFound,
Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-404-not-found",
Title = "One or more resources was not found.",
Detail = "Check validity of input data."
});
}
private async Task HandleUnAuthorizedException(HttpContext context, Exception exception)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId()
{
Status = StatusCodes.Status401Unauthorized,
Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-401-unauthorized",
Title = "Request lacks valid authentication credentials for the target resource.",
});
}
private async Task HandleForbiddenException(HttpContext context, Exception exception)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId()
{
Status = StatusCodes.Status403Forbidden,
Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-403-forbidden",
Title = "Server refuses to fulfill the request.",
});
}
private async Task HandleCurrencyConverterException(HttpContext context, Exception exception)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId()
{
Status = StatusCodes.Status500InternalServerError,
Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-500-internal-server-error",
Title = "Unable to communicate with currency exchage api.",
Detail = "Use default currency to query items without conversion."
});
}
private async Task HandleUnhandledExceptionException(HttpContext context, Exception exception)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId()
{
Status = StatusCodes.Status500InternalServerError,
Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-500-internal-server-error",
Title = "One or more internal server errors occured.",
Detail = "Report this error to service's support team.",
});
_logger.LogError(
"{@DateUtc} {@TimeUtc} {@TraceId} {@SpanId} Unhandled exception.\n{@ExceptionMessage}.\n{@ExceptionStackTrace}.",
DateTime.UtcNow.ToString("yyyy-MM-dd"),
DateTime.UtcNow.ToString("HH:mm:ss.FFF"),
Activity.Current?.TraceId.ToString(),
Activity.Current?.SpanId.ToString(),
exception.Message,
exception.StackTrace);
}
}

View File

@ -0,0 +1,71 @@
using Microsoft.OpenApi.Models;
using ExpenseTracker.Api.Middlewares;
using ExpenseTracker.Infrastructure;
using ExpenseTracker.Application;
using ExpenseTracker.Persistence;
using ExpenseTracker.Infrastructure.Identity;
using ExpenseTracker.Api.Services;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Api.Swashbuckle.OperationFilters;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPersistence(builder.Configuration);
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication();
builder.Services.AddAuthorization();
builder.Services.AddControllers();
builder.Services.AddSwaggerGen(options =>
{
options.OperationFilter<AcceptCurrencyHeaderFilter>();
options.EnableAnnotations();
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Scheme = "Bearer",
BearerFormat = "Json Web Token",
In = ParameterLocation.Header,
Name = "Authorization",
Description = "Bearer Authentication With Json Web Token",
Type = SecuritySchemeType.Http
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Id = "Bearer",
Type = ReferenceType.SecurityScheme
}
},
new List<string>()
}
});
});
builder.Services.AddTransient<GlobalExceptionHandlerMiddleware>();
builder.Services.AddScoped<ISessionUserService, SessionUserService>();
builder.Services.AddScoped<IInternationalizationService, InternationalizationService>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<GlobalExceptionHandlerMiddleware>();
app.MapControllers();
app.UseSwagger();
app.UseSwaggerUI();
var scope = app.Services.CreateScope();
IdentitySeeder.SeedIdentity(scope);
app.Run();

View File

@ -0,0 +1,18 @@
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Domain.Enums;
namespace ExpenseTracker.Api.Services;
public class InternationalizationService : IInternationalizationService
{
private readonly HttpContext _httpContext;
public InternationalizationService(IHttpContextAccessor httpContextAccessor)
{
_httpContext = httpContextAccessor.HttpContext!;
Currency = Currency.FromName(_httpContext.Request.Headers["Accept-Currency"]) ?? Currency.Default;
}
public Currency Currency { get; init; }
}

View File

@ -0,0 +1,38 @@
using System.IdentityModel.Tokens.Jwt;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Api.Services;
public class SessionUserService : ISessionUserService
{
private readonly HttpContext _httpContext;
public SessionUserService(IHttpContextAccessor httpContextAccessor)
{
_httpContext = httpContextAccessor.HttpContext!;
}
public string? Id => _httpContext.User.Claims
.FirstOrDefault(c => c.Properties
.Any(p => p.Value == JwtRegisteredClaimNames.Sub))
?.Value;
public string? Email => _httpContext.User.Claims
.FirstOrDefault(c => c.Properties
.Any(p => p.Value == JwtRegisteredClaimNames.Email))
?.Value;
public ICollection<string> Roles => _httpContext.User.Claims
.Where(c => c.Properties
.Any(p => p.Value == "roles"))
.Select(c => c.Value)
.ToArray();
public bool IsAdministrator => Roles.Contains(IdentityRoles.Administrator.ToString());
public bool IsAuthenticated => Id != null;
public string? AccessToken => _httpContext.Request.Cookies["accessToken"];
public string? RefreshToken => _httpContext.Request.Cookies["refreshToken"];
}

View File

@ -0,0 +1,21 @@
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace ExpenseTracker.Api.Swashbuckle.OperationFilters;
public class AcceptCurrencyHeaderFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (operation.Parameters == null)
operation.Parameters = new List<OpenApiParameter>();
operation.Parameters.Add(new OpenApiParameter
{
Name = "Accept-Currency",
In = ParameterLocation.Header,
Schema = new OpenApiSchema { Type = "String" },
Required = false
});
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,27 @@
using AutoMapper;
using ExpenseTracker.Application.Common.Mappings;
using ExpenseTracker.Domain.Entities;
namespace ExpenseTracker.Application.Accounts;
public class AccountDto : IMapFrom<Account>
{
public string Id { get; set; }
public string Name { get; set; }
public string? Description { get; set; }
public string Currency { get; set; }
public string UserId { get; set; }
public void Mapping(Profile profile)
{
profile.CreateMap<Account, AccountDto>()
.ForMember(d => d.Currency, opt => opt.MapFrom(s => s.Currency.Name));
profile.CreateMap<AccountDto, Account>()
.ForMember(d => d.Currency, opt => opt.MapFrom(s => Domain.Enums.Currency.FromName(s.Currency)));
}
}

View File

@ -0,0 +1,26 @@
using AutoMapper;
using ExpenseTracker.Application.Common.Mappings;
using ExpenseTracker.Application.Transactions;
using ExpenseTracker.Domain.Entities;
namespace ExpenseTracker.Application.Accounts;
public class AccountJsonDto : IMapFrom<Account>
{
public string Name { get; set; }
public string? Description { get; set; }
public string Currency { get; set; }
public IEnumerable<TransactionJsonDto>? Transactions { get; set; }
public void Mapping(Profile profile)
{
profile.CreateMap<Account, AccountJsonDto>()
.ForMember(d => d.Currency, opt => opt.MapFrom(s => s.Currency.Name));
profile.CreateMap<AccountJsonDto, Account>()
.ForMember(d => d.Currency, opt => opt.MapFrom(s => Domain.Enums.Currency.FromName(s.Currency)));
}
}

View File

@ -0,0 +1,16 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Commands.Create;
public record CreateAccountCommand : IRequest<AccountDto>
{
public string Name { get; set; }
public string? Description { get; set; }
public double InitialBalance { get; set; }
public string Currency { get; set; }
public string? UserId { get; set; }
}

View File

@ -0,0 +1,32 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Commands.Create;
public class CreateAccountCommandAuthorizer : AbstractRequestAuthorizer<CreateAccountCommand>
{
private readonly ISessionUserService _sessionUserService;
public CreateAccountCommandAuthorizer(ISessionUserService currentUserService)
{
_sessionUserService = currentUserService;
}
public override void BuildPolicy(CreateAccountCommand request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement
{
UserId = _sessionUserService.Id,
UserRoles = _sessionUserService.Roles,
RequiredUserId = request.UserId ?? _sessionUserService.Id,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}

View File

@ -0,0 +1,59 @@
using AutoMapper;
using MediatR;
using ExpenseTracker.Domain.Entities;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Domain.Enums;
using ExpenseTracker.Application.Common.Interfaces;
namespace ExpenseTracker.Application.Accounts.Commands.Create;
public class CreateAccountCommandHandler : IRequestHandler<CreateAccountCommand, AccountDto>
{
private readonly IMapper _mapper;
private readonly IUnitOfWork _unitOfWork;
private readonly ISessionUserService _sessionUserService;
public CreateAccountCommandHandler(
IMapper mapper,
IUnitOfWork unitOfWork,
ISessionUserService sessionUserService)
{
_mapper = mapper;
_unitOfWork = unitOfWork;
_sessionUserService = sessionUserService;
}
public async Task<AccountDto> Handle(CreateAccountCommand request, CancellationToken cancellationToken)
{
// TODO: Add UserId validation. Throw ValidationException when there is no user with given UserId
var parentUuid = Guid.NewGuid().ToString();
var childUuid = Guid.NewGuid().ToString();
var newAccount = new Account()
{
Id = parentUuid,
Name = request.Name,
Description = request.Description,
Currency = Currency.FromName(request.Currency),
UserId = request.UserId ?? _sessionUserService.Id
};
var databaseAccount = await _unitOfWork.AccountRepository.AddOneAsync(newAccount, cancellationToken);
var newTransaction = new Transaction()
{
Id = childUuid,
Amount = request.InitialBalance,
Category = Domain.Enums.Category.InitialBalance,
Time = DateTimeOffset.UtcNow,
AccountId = parentUuid
};
var databaseTransaction = await _unitOfWork.TransactionRepository.AddOneAsync(newTransaction, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken);
return _mapper.Map<AccountDto>(databaseAccount);
}
}

View File

@ -0,0 +1,20 @@
using FluentValidation;
using ExpenseTracker.Domain.Enums;
namespace ExpenseTracker.Application.Accounts.Commands.Create;
public class CreateAccountCommandValidator : AbstractValidator<CreateAccountCommand>
{
public CreateAccountCommandValidator()
{
RuleFor(e => e.Name)
.NotEmpty();
RuleFor(e => e.InitialBalance)
.NotNull();
RuleFor(e => e.Currency)
.Must(c => Currency.FromName(c) is not null)
.WithMessage(c => $"'{nameof(c.Currency)}' must be one of the following: '{String.Join("', ", Currency.Enumerations.Values.Select(v => v.Name))}'.");
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Commands.Delete;
public record DeleteAccountCommand : IRequest
{
public string Id { get; set; }
}

View File

@ -0,0 +1,37 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Commands.Delete;
public class DeleteAccountCommandAuthorizer : AbstractRequestAuthorizer<DeleteAccountCommand>
{
private readonly ISessionUserService _sessionUserService;
private readonly IAccountRepository _repository;
public DeleteAccountCommandAuthorizer(ISessionUserService currentUserService, IAccountRepository repository)
{
_sessionUserService = currentUserService;
_repository = repository;
}
public override void BuildPolicy(DeleteAccountCommand request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
var requiredUserId = _repository.Queryable.FirstOrDefault(e => e.Id == request.Id)?.UserId;
UseRequirement(new MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement
{
UserId = _sessionUserService.Id,
UserRoles = _sessionUserService.Roles,
RequiredUserId = requiredUserId,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}

View File

@ -0,0 +1,31 @@
using MediatR;
using ExpenseTracker.Application.Common.Exceptions;
using ExpenseTracker.Application.Accounts.Commands.Delete;
using ExpenseTracker.Application.Common.Interfaces;
namespace ExpenseTracker.Application.Accountes.Commands.Delete;
public class DeleteAccountCommandHandler : IRequestHandler<DeleteAccountCommand>
{
private readonly IUnitOfWork _unitOfWork;
public DeleteAccountCommandHandler(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task Handle(DeleteAccountCommand request, CancellationToken cancellationToken)
{
var isEntityPresentInDatabase = _unitOfWork.AccountRepository.Queryable.Any(e => e.Id == request.Id);
if (!isEntityPresentInDatabase)
{
throw new NotFoundException();
}
await _unitOfWork.AccountRepository.DeleteOneAsync(request.Id, cancellationToken);
await _unitOfWork.TransactionRepository.DeleteManyAsync(e => e.AccountId.Equals(request.Id), cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken);
}
}

View File

@ -0,0 +1,12 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Commands.Delete;
public class DeleteAccountCommandValidator : AbstractValidator<DeleteAccountCommand>
{
public DeleteAccountCommandValidator()
{
RuleFor(e => e.Id)
.NotEmpty();
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Commands.Import.Json.Many;
public record ImportJsonAccountsQuery : IRequest
{
public byte[] File { get; set; }
}

View File

@ -0,0 +1,23 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Services;
namespace ExpenseTracker.Application.Accounts.Commands.Import.Json.Many;
public class ImportJsonAccountsQueryAuthorizer : AbstractRequestAuthorizer<ImportJsonAccountsQuery>
{
private readonly ISessionUserService _sessionUserService;
public ImportJsonAccountsQueryAuthorizer(ISessionUserService currentUserService)
{
_sessionUserService = currentUserService;
}
public override void BuildPolicy(ImportJsonAccountsQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
}
}

View File

@ -0,0 +1,67 @@
using System.Text;
using AutoMapper;
using MediatR;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using ExpenseTracker.Application.Common.Interfaces;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Domain.Entities;
namespace ExpenseTracker.Application.Accounts.Commands.Import.Json.Many;
public class ImportJsonAccountsQueryHandler : IRequestHandler<ImportJsonAccountsQuery>
{
private readonly IMapper _mapper;
private readonly IUnitOfWork _unitOfWork;
private readonly ISessionUserService _sessionUserService;
public ImportJsonAccountsQueryHandler(
IMapper mapper,
IUnitOfWork unitOfWork,
ISessionUserService sessionUserService)
{
_mapper = mapper;
_unitOfWork = unitOfWork;
_sessionUserService = sessionUserService;
}
public async Task Handle(ImportJsonAccountsQuery request, CancellationToken cancellationToken)
{
string json;
using (var memoryStream = new MemoryStream(request.File))
{
using (var streamReader = new StreamReader(memoryStream, Encoding.UTF8))
{
json = await streamReader.ReadToEndAsync(cancellationToken);
}
}
var jsonSerializerSettings = new JsonSerializerSettings()
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
var accountDtos = JsonConvert.DeserializeObject<IEnumerable<AccountJsonDto>>(json, jsonSerializerSettings);
var accounts = _mapper.ProjectTo<Account>(accountDtos.AsQueryable());
foreach (var account in accounts)
{
account.Id = Guid.NewGuid().ToString();
account.UserId = _sessionUserService.Id;
foreach (var transaction in account.Transactions)
{
transaction.Id = Guid.NewGuid().ToString();
transaction.AccountId = account.Id;
await _unitOfWork.TransactionRepository.AddOneAsync(transaction, cancellationToken);
}
await _unitOfWork.AccountRepository.AddOneAsync(account, cancellationToken);
}
await _unitOfWork.SaveAsync(cancellationToken);
}
}

View File

@ -0,0 +1,11 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Commands.Import.Json.Many;
public class ImportJsonAccountsQueryValidator : AbstractValidator<ImportJsonAccountsQuery>
{
public ImportJsonAccountsQueryValidator()
{
RuleFor(e => e.File.Length).GreaterThan(0);
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Commands.Import.Json.One;
public record ImportJsonAccountQuery : IRequest
{
public byte[] File { get; set; }
}

View File

@ -0,0 +1,23 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Services;
namespace ExpenseTracker.Application.Accounts.Commands.Import.Json.One;
public class ImportJsonAccountQueryAuthorizer : AbstractRequestAuthorizer<ImportJsonAccountQuery>
{
private readonly ISessionUserService _sessionUserService;
public ImportJsonAccountQueryAuthorizer(ISessionUserService currentUserService)
{
_sessionUserService = currentUserService;
}
public override void BuildPolicy(ImportJsonAccountQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
}
}

View File

@ -0,0 +1,71 @@
using System.Text;
using AutoMapper;
using MediatR;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using ExpenseTracker.Application.Common.Interfaces;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Domain.Entities;
namespace ExpenseTracker.Application.Accounts.Commands.Import.Json.One;
public class ImportJsonAccountQueryHandler : IRequestHandler<ImportJsonAccountQuery>
{
private readonly IMapper _mapper;
private readonly IUnitOfWork _unitOfWork;
private readonly ISessionUserService _sessionUserService;
public ImportJsonAccountQueryHandler(
IMapper mapper,
IUnitOfWork unitOfWork,
ISessionUserService sessionUserService)
{
_mapper = mapper;
_unitOfWork = unitOfWork;
_sessionUserService = sessionUserService;
}
public async Task Handle(ImportJsonAccountQuery request, CancellationToken cancellationToken)
{
string json;
using (var memoryStream = new MemoryStream(request.File))
{
using (var streamReader = new StreamReader(memoryStream, Encoding.UTF8))
{
json = await streamReader.ReadToEndAsync(cancellationToken);
}
}
var jsonSerializerSettings = new JsonSerializerSettings()
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
var accountDto = JsonConvert.DeserializeObject<AccountJsonDto>(json, jsonSerializerSettings);
var account = _mapper.Map<Account>(accountDto);
account.Id = Guid.NewGuid().ToString();
account.UserId = _sessionUserService.Id;
foreach (var transaction in account.Transactions)
{
transaction.Id = Guid.NewGuid().ToString();
transaction.AccountId = account.Id;
}
var tasks = new List<Task>();
tasks.Add(_unitOfWork.AccountRepository.AddOneAsync(account, cancellationToken));
if (account.Transactions.Count() != 0)
{
tasks.Add(_unitOfWork.TransactionRepository.AddManyAsync(account.Transactions, cancellationToken));
}
await Task.WhenAll(tasks);
await _unitOfWork.SaveAsync(cancellationToken);
}
}

View File

@ -0,0 +1,11 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Commands.Import.Json.One;
public class ImportJsonAccountQueryValidator : AbstractValidator<ImportJsonAccountQuery>
{
public ImportJsonAccountQueryValidator()
{
RuleFor(e => e.File.Length).GreaterThan(0);
}
}

View File

@ -0,0 +1,14 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Commands.Update;
public record UpdateAccountCommand : IRequest<AccountDto>
{
public string Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string UserId { get; set; }
}

View File

@ -0,0 +1,32 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Commands.Update;
public class UpdateAccountCommandAuthorizer : AbstractRequestAuthorizer<UpdateAccountCommand>
{
private readonly ISessionUserService _sessionUserService;
public UpdateAccountCommandAuthorizer(ISessionUserService currentUserService)
{
_sessionUserService = currentUserService;
}
public override void BuildPolicy(UpdateAccountCommand request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement
{
UserId = _sessionUserService.Id,
UserRoles = _sessionUserService.Roles,
RequiredUserId = request.UserId,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}

View File

@ -0,0 +1,45 @@
using AutoMapper;
using MediatR;
using ExpenseTracker.Application.Common.Exceptions;
using ExpenseTracker.Application.Common.Interfaces;
using ExpenseTracker.Domain.Entities;
namespace ExpenseTracker.Application.Accounts.Commands.Update;
public class UpdateAccountCommandHandler : IRequestHandler<UpdateAccountCommand, AccountDto>
{
private readonly IMapper _mapper;
private readonly IUnitOfWork _unitOfWork;
public UpdateAccountCommandHandler(IMapper mapper, IUnitOfWork unitOfWork)
{
_mapper = mapper;
_unitOfWork = unitOfWork;
}
public async Task<AccountDto> Handle(UpdateAccountCommand request, CancellationToken cancellationToken)
{
var databaseEntity = _unitOfWork.AccountRepository.Queryable.FirstOrDefault(e => e.Id == request.Id);
if (databaseEntity == null)
{
throw new NotFoundException();
}
// TODO: Add UserId validation. Throw NotFoundException when there is no user with given UserId
var updatedEntity = new Account()
{
Id = request.Id,
Name = request.Name ?? databaseEntity.Name,
Description = request.Description,
UserId = request.UserId
};
databaseEntity = await _unitOfWork.AccountRepository.UpdateOneAsync(updatedEntity, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken);
return _mapper.Map<AccountDto>(databaseEntity);
}
}

View File

@ -0,0 +1,17 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Commands.Update;
public class UpdateAccountCommandValidator : AbstractValidator<UpdateAccountCommand>
{
public UpdateAccountCommandValidator()
{
RuleFor(e => e.Id)
.NotEmpty();
RuleFor(e => e.Name);
RuleFor(e => e.UserId)
.NotEmpty();
}
}

View File

@ -0,0 +1,39 @@
namespace ExpenseTracker.Application.Accounts.Queries.Charts;
public class DailyTransactionsBarChart
{
public DailyTransactionsBarChart(IEnumerable<IGrouping<DateTime, double>> groupings, DateOnly fromDate, DateOnly toDate)
{
var days = toDate.DayNumber - fromDate.DayNumber;
var dateAmount = new Dictionary<DateOnly, double>(days);
for (int i = 0; i < days; i++)
{
dateAmount.Add(fromDate.AddDays(i), 0);
}
foreach (var group in groupings)
{
var date = DateOnly.FromDateTime(group.Key);
var amount = group.Aggregate(0.0, (a, x) => a + x);
dateAmount[date] = amount;
}
Dates = dateAmount.Keys.ToList();
Amounts = dateAmount.Values.ToList();
}
public double MinimumAmount => Amounts.Min();
public double MaximumAmount => Amounts.Max();
public double AverageAmount => Double.Round(TotalAmount / Amounts.Count(), 4);
public double TotalAmount => Amounts.Aggregate(0.0, (a, x) => a +x);
public IEnumerable<DateOnly> Dates { get; set; }
public IEnumerable<double> Amounts { get; set; }
}

View File

@ -0,0 +1,12 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetExpenseBarChart;
public record GetExpenseBarChartQuery : IRequest<DailyTransactionsBarChart>
{
public string? AccountId { get; set; }
public DateOnly FromDate { get; set; }
public DateOnly ToDate { get; set; }
}

View File

@ -0,0 +1,37 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetExpenseBarChart;
public class GetExpenseBarChartQueryAuthorizer : AbstractRequestAuthorizer<GetExpenseBarChartQuery>
{
private readonly ISessionUserService _sessionUserService;
private readonly IAccountRepository _repository;
public GetExpenseBarChartQueryAuthorizer(ISessionUserService currentUserService, IAccountRepository repository)
{
_sessionUserService = currentUserService;
_repository = repository;
}
public override void BuildPolicy(GetExpenseBarChartQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
var requiredUserId = _repository.Queryable.FirstOrDefault(e => e.Id == request.AccountId)?.UserId;
UseRequirement(new MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement
{
UserId = _sessionUserService.Id,
UserRoles = _sessionUserService.Roles,
RequiredUserId = requiredUserId,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}

View File

@ -0,0 +1,43 @@
using AutoMapper;
using MediatR;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetExpenseBarChart;
public class GetExpenseBarChartQueryHandler : IRequestHandler<GetExpenseBarChartQuery, DailyTransactionsBarChart>
{
private readonly IMapper _mapper;
private readonly IAccountRepository _accountRepository;
private readonly ITransactionRepository _transactionRepository;
private readonly ISessionUserService _sessionUserService;
public GetExpenseBarChartQueryHandler(
IMapper mapper,
IAccountRepository repository,
ITransactionRepository transactionRepository,
ISessionUserService sessionUserService)
{
_mapper = mapper;
_accountRepository = repository;
_sessionUserService = sessionUserService;
_transactionRepository = transactionRepository;
}
public async Task<DailyTransactionsBarChart> Handle(GetExpenseBarChartQuery request, CancellationToken cancellationToken)
{
var entities = _transactionRepository.Queryable
.Where(e =>
e.AccountId == request.AccountId &&
e.Amount < 0)
.ToList()
.Where(e =>
DateOnly.FromDateTime(e.Time.DateTime) >= request.FromDate &&
DateOnly.FromDateTime(e.Time.DateTime) < request.ToDate)
.OrderBy(e => e.Time);
var groupsByDate = entities.GroupBy(e => e.Time.Date, t => t.Amount);
return new DailyTransactionsBarChart(groupsByDate, request.FromDate, request.ToDate);
}
}

View File

@ -0,0 +1,15 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetExpenseBarChart;
public class GetExpenseBarChartQueryValidator : AbstractValidator<GetExpenseBarChartQuery>
{
public GetExpenseBarChartQueryValidator()
{
// RuleFor(v => v.AccountId).NotEmpty();
RuleFor(v => v.FromDate).NotEmpty();
RuleFor(v => v.ToDate).GreaterThan(v => v.FromDate).NotEmpty();
}
}

View File

@ -0,0 +1,13 @@
using MediatR;
using ExpenseTracker.Domain.Enums;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetExpenseByCategoryPieChart;
public record GetExpenseByCategoryPieChartQuery : IRequest<Dictionary<Category, double>>
{
public string? AccountId { get; set; }
public DateOnly FromDate { get; set; }
public DateOnly ToDate { get; set; }
}

View File

@ -0,0 +1,37 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetExpenseByCategoryPieChart;
public class GetExpenseByCategoryPieChartQueryAuthorizer : AbstractRequestAuthorizer<GetExpenseByCategoryPieChartQuery>
{
private readonly ISessionUserService _sessionUserService;
private readonly IAccountRepository _repository;
public GetExpenseByCategoryPieChartQueryAuthorizer(ISessionUserService currentUserService, IAccountRepository repository)
{
_sessionUserService = currentUserService;
_repository = repository;
}
public override void BuildPolicy(GetExpenseByCategoryPieChartQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
var requiredUserId = _repository.Queryable.FirstOrDefault(e => e.Id == request.AccountId)?.UserId;
UseRequirement(new MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement
{
UserId = _sessionUserService.Id,
UserRoles = _sessionUserService.Roles,
RequiredUserId = requiredUserId,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}

View File

@ -0,0 +1,57 @@
using AutoMapper;
using MediatR;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Domain.Enums;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetExpenseByCategoryPieChart;
public class GetExpenseByCategoryPieChartQueryHandler : IRequestHandler<GetExpenseByCategoryPieChartQuery, Dictionary<Category, double>>
{
private readonly IMapper _mapper;
private readonly IAccountRepository _accountRepository;
private readonly ITransactionRepository _transactionRepository;
private readonly ISessionUserService _sessionUserService;
public GetExpenseByCategoryPieChartQueryHandler(
IMapper mapper,
IAccountRepository repository,
ITransactionRepository transactionRepository,
ISessionUserService sessionUserService)
{
_mapper = mapper;
_accountRepository = repository;
_sessionUserService = sessionUserService;
_transactionRepository = transactionRepository;
}
public async Task<Dictionary<Category, double>> Handle(GetExpenseByCategoryPieChartQuery request, CancellationToken cancellationToken)
{
var entities = _transactionRepository.Queryable
.Where(e =>
e.AccountId == request.AccountId &&
e.Amount < 0)
.ToList()
.Where(e =>
DateOnly.FromDateTime(e.Time.DateTime) >= request.FromDate &&
DateOnly.FromDateTime(e.Time.DateTime) < request.ToDate)
.OrderBy(e => e.Time);
var groupsByCategory = entities.GroupBy(e => e.Category, t => t.Amount);
var pieChart = new Dictionary<Category, double>();
var totalAmount = groupsByCategory.Aggregate(0.0, (a, x) => a + x.Aggregate(0.0, (b, y) => b + y));
foreach (var group in groupsByCategory)
{
var category = group.Key;
var amount = group.Aggregate(0.0, (a, x) => a + x);
var percent = Double.Round(amount / totalAmount, 4);
pieChart.Add(category, percent);
}
return pieChart;
}
}

View File

@ -0,0 +1,15 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetExpenseByCategoryPieChart;
public class GetExpenseByCategoryPieChartQueryValidator : AbstractValidator<GetExpenseByCategoryPieChartQuery>
{
public GetExpenseByCategoryPieChartQueryValidator()
{
// RuleFor(v => v.AccountId).NotEmpty();
RuleFor(v => v.FromDate).NotEmpty();
RuleFor(v => v.ToDate).GreaterThan(v => v.FromDate).NotEmpty();
}
}

View File

@ -0,0 +1,12 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetIncomeBarChart;
public record GetIncomeBarChartQuery : IRequest<DailyTransactionsBarChart>
{
public string? AccountId { get; set; }
public DateOnly FromDate { get; set; }
public DateOnly ToDate { get; set; }
}

View File

@ -0,0 +1,37 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetIncomeBarChart;
public class GetIncomeBarChartQueryAuthorizer : AbstractRequestAuthorizer<GetIncomeBarChartQuery>
{
private readonly ISessionUserService _sessionUserService;
private readonly IAccountRepository _repository;
public GetIncomeBarChartQueryAuthorizer(ISessionUserService currentUserService, IAccountRepository repository)
{
_sessionUserService = currentUserService;
_repository = repository;
}
public override void BuildPolicy(GetIncomeBarChartQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
var requiredUserId = _repository.Queryable.FirstOrDefault(e => e.Id == request.AccountId)?.UserId;
UseRequirement(new MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement
{
UserId = _sessionUserService.Id,
UserRoles = _sessionUserService.Roles,
RequiredUserId = requiredUserId,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}

View File

@ -0,0 +1,43 @@
using AutoMapper;
using MediatR;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetIncomeBarChart;
public class GetIncomeBarChartQueryHandler : IRequestHandler<GetIncomeBarChartQuery, DailyTransactionsBarChart>
{
private readonly IMapper _mapper;
private readonly IAccountRepository _accountRepository;
private readonly ITransactionRepository _transactionRepository;
private readonly ISessionUserService _sessionUserService;
public GetIncomeBarChartQueryHandler(
IMapper mapper,
IAccountRepository repository,
ITransactionRepository transactionRepository,
ISessionUserService sessionUserService)
{
_mapper = mapper;
_accountRepository = repository;
_sessionUserService = sessionUserService;
_transactionRepository = transactionRepository;
}
public async Task<DailyTransactionsBarChart> Handle(GetIncomeBarChartQuery request, CancellationToken cancellationToken)
{
var entities = _transactionRepository.Queryable
.Where(e =>
e.AccountId == request.AccountId &&
e.Amount > 0)
.ToList()
.Where(e =>
DateOnly.FromDateTime(e.Time.DateTime) >= request.FromDate &&
DateOnly.FromDateTime(e.Time.DateTime) < request.ToDate)
.OrderBy(e => e.Time);
var groupsByDate = entities.GroupBy(e => e.Time.Date, t => t.Amount);
return new DailyTransactionsBarChart(groupsByDate, request.FromDate, request.ToDate);
}
}

View File

@ -0,0 +1,15 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetIncomeBarChart;
public class GetIncomeBarChartQueryValidator : AbstractValidator<GetIncomeBarChartQuery>
{
public GetIncomeBarChartQueryValidator()
{
// RuleFor(v => v.AccountId).NotEmpty();
RuleFor(v => v.FromDate).NotEmpty();
RuleFor(v => v.ToDate).NotEmpty();
}
}

View File

@ -0,0 +1,12 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetProfitBarChart;
public record GetProfitBarChartQuery : IRequest<DailyTransactionsBarChart>
{
public string? AccountId { get; set; }
public DateOnly FromDate { get; set; }
public DateOnly ToDate { get; set; }
}

View File

@ -0,0 +1,37 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetProfitBarChart;
public class GetProfitBarChartQueryAuthorizer : AbstractRequestAuthorizer<GetProfitBarChartQuery>
{
private readonly ISessionUserService _sessionUserService;
private readonly IAccountRepository _repository;
public GetProfitBarChartQueryAuthorizer(ISessionUserService currentUserService, IAccountRepository repository)
{
_sessionUserService = currentUserService;
_repository = repository;
}
public override void BuildPolicy(GetProfitBarChartQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
var requiredUserId = _repository.Queryable.FirstOrDefault(e => e.Id == request.AccountId)?.UserId;
UseRequirement(new MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement
{
UserId = _sessionUserService.Id,
UserRoles = _sessionUserService.Roles,
RequiredUserId = requiredUserId,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}

View File

@ -0,0 +1,41 @@
using AutoMapper;
using MediatR;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetProfitBarChart;
public class GetProfitBarChartQueryHandler : IRequestHandler<GetProfitBarChartQuery, DailyTransactionsBarChart>
{
private readonly IMapper _mapper;
private readonly IAccountRepository _accountRepository;
private readonly ITransactionRepository _transactionRepository;
private readonly ISessionUserService _sessionUserService;
public GetProfitBarChartQueryHandler(
IMapper mapper,
IAccountRepository repository,
ITransactionRepository transactionRepository,
ISessionUserService sessionUserService)
{
_mapper = mapper;
_accountRepository = repository;
_sessionUserService = sessionUserService;
_transactionRepository = transactionRepository;
}
public async Task<DailyTransactionsBarChart> Handle(GetProfitBarChartQuery request, CancellationToken cancellationToken)
{
var entities = _transactionRepository.Queryable
.Where(e => e.AccountId == request.AccountId)
.ToList()
.Where(e =>
DateOnly.FromDateTime(e.Time.DateTime) >= request.FromDate &&
DateOnly.FromDateTime(e.Time.DateTime) < request.ToDate)
.OrderBy(e => e.Time);
var groupsByDate = entities.GroupBy(e => e.Time.Date, t => t.Amount);
return new DailyTransactionsBarChart(groupsByDate, request.FromDate, request.ToDate);
}
}

View File

@ -0,0 +1,15 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Queries.Charts.GetProfitBarChart;
public class GetProfitBarChartQueryValidator : AbstractValidator<GetProfitBarChartQuery>
{
public GetProfitBarChartQueryValidator()
{
// RuleFor(v => v.AccountId).NotEmpty();
RuleFor(v => v.FromDate).NotEmpty();
RuleFor(v => v.ToDate).GreaterThan(v => v.FromDate).NotEmpty();
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Csv.One;
public record ExportCsvAccountQuery : IRequest<byte[]>
{
public string Id { get; set; }
}

View File

@ -0,0 +1,37 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Csv.One;
public class ExportCsvAccountQueryAuthorizer : AbstractRequestAuthorizer<ExportCsvAccountQuery>
{
private readonly ISessionUserService _sessionUserService;
private readonly IAccountRepository _repository;
public ExportCsvAccountQueryAuthorizer(ISessionUserService currentUserService, IAccountRepository repository)
{
_sessionUserService = currentUserService;
_repository = repository;
}
public override void BuildPolicy(ExportCsvAccountQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
var requiredUserId = _repository.Queryable.FirstOrDefault(e => e.Id == request.Id)?.UserId;
UseRequirement(new MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement
{
UserId = _sessionUserService.Id,
UserRoles = _sessionUserService.Roles,
RequiredUserId = requiredUserId,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}

View File

@ -0,0 +1,67 @@
using System.Globalization;
using System.Text;
using AutoMapper;
using CsvHelper;
using MediatR;
using ExpenseTracker.Application.Common.Exceptions;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Domain.Entities;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Csv.One;
public class ExportCsvAccountQueryHandler : IRequestHandler<ExportCsvAccountQuery, byte[]>
{
private readonly IMapper _mapper;
private readonly IAccountRepository _accountRepository;
private readonly ITransactionRepository _transactionRepository;
public ExportCsvAccountQueryHandler(
IMapper mapper,
IAccountRepository repository,
ITransactionRepository transactionRepository)
{
_mapper = mapper;
_accountRepository = repository;
_transactionRepository = transactionRepository;
}
public async Task<byte[]> Handle(ExportCsvAccountQuery request, CancellationToken cancellationToken)
{
var entity = _accountRepository.Queryable
.GroupJoin(
_transactionRepository.Queryable,
b => b.Id,
t => t.AccountId,
(account, transactions) =>
new Account
{
Id = account.Id,
Name = account.Name,
Description = account.Description,
UserId = account.UserId,
Transactions = transactions
}
)
.FirstOrDefault(e => e.Id == request.Id);
// TODO: Mapping to DTO creates new objects therefore using more resources
// Create custom csv serializer to avoid mapping
var entityDto = _mapper.Map<AccountJsonDto>(entity);
using (var memoryStream = new MemoryStream())
{
using (var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8))
{
using (var csvWriter = new CsvWriter(streamWriter, CultureInfo.InvariantCulture))
{
csvWriter.Context.RegisterClassMap<TransactionCsvMap>();
await csvWriter.WriteRecordsAsync(entityDto.Transactions);
await csvWriter.FlushAsync();
}
}
return memoryStream.ToArray();
}
}
}

View File

@ -0,0 +1,11 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Csv.One;
public class ExportCsvAccountQueryValidator : AbstractValidator<ExportCsvAccountQuery>
{
public ExportCsvAccountQueryValidator()
{
RuleFor(e => e.Id).NotEmpty();
}
}

View File

@ -0,0 +1,5 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Json.Many;
public record ExportJsonAccountsQuery : IRequest<byte[]> { }

View File

@ -0,0 +1,24 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Services;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Json.Many;
public class ExportJsonAccountsQueryAuthorizer : AbstractRequestAuthorizer<ExportJsonAccountsQuery>
{
private readonly ISessionUserService _sessionUserService;
public ExportJsonAccountsQueryAuthorizer(ISessionUserService currentUserService)
{
_sessionUserService = currentUserService;
}
public override void BuildPolicy(ExportJsonAccountsQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
}
}

View File

@ -0,0 +1,72 @@
using System.Text;
using AutoMapper;
using MediatR;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Domain.Entities;
using ExpenseTracker.Domain.Enums;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Json.Many;
public class ExportJsonAccountsQueryHandler : IRequestHandler<ExportJsonAccountsQuery, byte[]>
{
private readonly IMapper _mapper;
private readonly ISessionUserService _sessionUserService;
private readonly IAccountRepository _accountRepository;
private readonly ITransactionRepository _transactionRepository;
public ExportJsonAccountsQueryHandler(
IMapper mapper,
ISessionUserService sessionUserService,
IAccountRepository repository,
ITransactionRepository transactionRepository)
{
_mapper = mapper;
_sessionUserService = sessionUserService;
_accountRepository = repository;
_transactionRepository = transactionRepository;
}
public async Task<byte[]> Handle(ExportJsonAccountsQuery request, CancellationToken cancellationToken)
{
var entities = _accountRepository.Queryable
.Where(e => e.UserId == _sessionUserService.Id)
.GroupJoin(
_transactionRepository.Queryable,
b => b.Id,
t => t.AccountId,
(account, transactions) =>
new Account
{
Id = account.Id,
Name = account.Name,
Description = account.Description,
Currency = account.Currency,
UserId = account.UserId,
Transactions = transactions
}
)
.ToArray();
var entityDtos = _mapper.ProjectTo<AccountJsonDto>(entities.AsQueryable());
var jsonSerializerSettings = new JsonSerializerSettings()
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
var json = JsonConvert.SerializeObject(entityDtos, jsonSerializerSettings);
using (var memoryStream = new MemoryStream())
{
using (var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8))
{
await streamWriter.WriteAsync(json);
await streamWriter.FlushAsync();
}
return memoryStream.ToArray();
}
}
}

View File

@ -0,0 +1,10 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Json.Many;
public class ExportJsonAccountsQueryValidator : AbstractValidator<ExportJsonAccountsQuery>
{
public ExportJsonAccountsQueryValidator()
{
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Json.One;
public record ExportJsonAccountQuery : IRequest<byte[]>
{
public string Id { get; set; }
}

View File

@ -0,0 +1,37 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Json.One;
public class ExportJsonAccountQueryAuthorizer : AbstractRequestAuthorizer<ExportJsonAccountQuery>
{
private readonly ISessionUserService _sessionUserService;
private readonly IAccountRepository _repository;
public ExportJsonAccountQueryAuthorizer(ISessionUserService currentUserService, IAccountRepository repository)
{
_sessionUserService = currentUserService;
_repository = repository;
}
public override void BuildPolicy(ExportJsonAccountQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
var requiredUserId = _repository.Queryable.FirstOrDefault(e => e.Id == request.Id)?.UserId;
UseRequirement(new MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement
{
UserId = _sessionUserService.Id,
UserRoles = _sessionUserService.Roles,
RequiredUserId = requiredUserId,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}

View File

@ -0,0 +1,69 @@
using System.Text;
using AutoMapper;
using MediatR;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Domain.Entities;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Json.One;
public class ExportJsonAccountQueryHandler : IRequestHandler<ExportJsonAccountQuery, byte[]>
{
private readonly IMapper _mapper;
private readonly IAccountRepository _accountRepository;
private readonly ITransactionRepository _transactionRepository;
public ExportJsonAccountQueryHandler(
IMapper mapper,
IAccountRepository repository,
ITransactionRepository transactionRepository)
{
_mapper = mapper;
_accountRepository = repository;
_transactionRepository = transactionRepository;
}
public async Task<byte[]> Handle(ExportJsonAccountQuery request, CancellationToken cancellationToken)
{
var entity = _accountRepository.Queryable
.GroupJoin(
_transactionRepository.Queryable,
b => b.Id,
t => t.AccountId,
(account, transactions) =>
new Account
{
Id = account.Id,
Name = account.Name,
Description = account.Description,
Currency = account.Currency,
UserId = account.UserId,
Transactions = transactions
}
)
.FirstOrDefault(e => e.Id == request.Id);
// TODO: Mapping to DTO creates new objects therefore using more resources
// Create custom json serializer to avoid mapping
var entityDto = _mapper.Map<AccountJsonDto>(entity);
var jsonSerializerSettings = new JsonSerializerSettings()
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
var json = JsonConvert.SerializeObject(entityDto, jsonSerializerSettings);
using (var memoryStream = new MemoryStream())
{
using (var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8))
{
await streamWriter.WriteAsync(json);
await streamWriter.FlushAsync();
}
return memoryStream.ToArray();
}
}
}

View File

@ -0,0 +1,11 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Queries.Export.Json.One;
public class ExportJsonAccountQueryValidator : AbstractValidator<ExportJsonAccountQuery>
{
public ExportJsonAccountQueryValidator()
{
RuleFor(e => e.Id).NotEmpty();
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace ExpenseTracker.Application.Accounts.Queries.Get;
public record GetAccountQuery : IRequest<AccountDto>
{
public string Id { get; set; }
}

View File

@ -0,0 +1,37 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Queries.Get;
public class GetAccountQueryAuthorizer : AbstractRequestAuthorizer<GetAccountQuery>
{
private readonly ISessionUserService _sessionUserService;
private readonly IAccountRepository _repository;
public GetAccountQueryAuthorizer(ISessionUserService currentUserService, IAccountRepository repository)
{
_sessionUserService = currentUserService;
_repository = repository;
}
public override void BuildPolicy(GetAccountQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
var requiredUserId = _repository.Queryable.FirstOrDefault(e => e.Id == request.Id)?.UserId;
UseRequirement(new MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement
{
UserId = _sessionUserService.Id,
UserRoles = _sessionUserService.Roles,
RequiredUserId = requiredUserId,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}

View File

@ -0,0 +1,31 @@
using AutoMapper;
using MediatR;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Domain.Entities;
namespace ExpenseTracker.Application.Accounts.Queries.Get;
public class GetAccountQueryHandler : IRequestHandler<GetAccountQuery, AccountDto>
{
private readonly IMapper _mapper;
private readonly IAccountRepository _accountRepository;
private readonly ITransactionRepository _transactionRepository;
public GetAccountQueryHandler(
IMapper mapper,
IAccountRepository repository,
ITransactionRepository transactionRepository)
{
_mapper = mapper;
_accountRepository = repository;
_transactionRepository = transactionRepository;
}
public async Task<AccountDto> Handle(GetAccountQuery request, CancellationToken cancellationToken)
{
var entity = _accountRepository.Queryable
.FirstOrDefault(e => e.Id == request.Id);
return _mapper.Map<AccountDto>(entity);
}
}

View File

@ -0,0 +1,12 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Queries.Get;
public class GetAccountQueryValidator : AbstractValidator<GetAccountQuery>
{
public GetAccountQueryValidator()
{
RuleFor(e => e.Id)
.NotEmpty();
}
}

View File

@ -0,0 +1,13 @@
using ExpenseTracker.Application.Common.Models;
using MediatR;
namespace ExpenseTracker.Application.Accounts.Queries.GetWithPagination;
public record GetAccountsWithPaginationQuery : IRequest<PaginatedList<AccountDto>>
{
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
public bool GetAll { get; set; } = false;
}

View File

@ -0,0 +1,33 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Authorization;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
namespace ExpenseTracker.Application.Accounts.Queries.GetWithPagination;
public class GetAccountsWithPaginationQueryAuthorizer : AbstractRequestAuthorizer<GetAccountsWithPaginationQuery>
{
private readonly ISessionUserService _sessionUserService;
public GetAccountsWithPaginationQueryAuthorizer(ISessionUserService currentUserService)
{
_sessionUserService = currentUserService;
}
public override void BuildPolicy(GetAccountsWithPaginationQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated = _sessionUserService.IsAuthenticated
});
if (request.GetAll)
{
UseRequirement(new MustBeInRolesRequirement
{
UserRoles = _sessionUserService.Roles,
RequiredRoles = new[] { IdentityRoles.Administrator.ToString() }
});
}
}
}

View File

@ -0,0 +1,42 @@
using AutoMapper;
using MediatR;
using ExpenseTracker.Application.Common.Extensions;
using ExpenseTracker.Application.Common.Interfaces.Repositories;
using ExpenseTracker.Application.Common.Interfaces.Services;
using ExpenseTracker.Application.Common.Models;
using ExpenseTracker.Domain.Entities;
namespace ExpenseTracker.Application.Accounts.Queries.GetWithPagination;
public class GetAccountssWithPaginationQueryHandler : IRequestHandler<GetAccountsWithPaginationQuery, PaginatedList<AccountDto>>
{
private readonly IMapper _mapper;
private readonly IAccountRepository _accountRepository;
private readonly ITransactionRepository _transactionRepository;
private readonly ISessionUserService _sessionUserService;
public GetAccountssWithPaginationQueryHandler(
IMapper mapper,
IAccountRepository repository,
ITransactionRepository transactionRepository,
ISessionUserService sessionUserService)
{
_mapper = mapper;
_accountRepository = repository;
_sessionUserService = sessionUserService;
_transactionRepository = transactionRepository;
}
public async Task<PaginatedList<AccountDto>> Handle(GetAccountsWithPaginationQuery request, CancellationToken cancellationToken)
{
var entities = _accountRepository.Queryable;
if (!request.GetAll)
{
entities = entities.Where(e => e.UserId == _sessionUserService.Id);
}
return entities
.ProjectToPaginatedList<Account, AccountDto>(request.PageNumber, request.PageSize, _mapper.ConfigurationProvider);
}
}

View File

@ -0,0 +1,16 @@
using FluentValidation;
namespace ExpenseTracker.Application.Accounts.Queries.GetWithPagination;
public class GetAccountsWithPaginationQueryValidator : AbstractValidator<GetAccountsWithPaginationQuery>
{
public GetAccountsWithPaginationQueryValidator()
{
RuleFor(v => v.PageNumber)
.GreaterThanOrEqualTo(1);
RuleFor(v => v.PageSize)
.GreaterThanOrEqualTo(1)
.LessThanOrEqualTo(50);
}
}

View File

@ -0,0 +1,20 @@
using System.Globalization;
using CsvHelper.Configuration;
using ExpenseTracker.Application.Common.Extensions;
using ExpenseTracker.Application.Transactions;
namespace ExpenseTracker.Application.Accounts;
public class TransactionCsvMap : ClassMap<TransactionDto>
{
public TransactionCsvMap()
{
AutoMap(CultureInfo.InvariantCulture);
Map(e => e.Id).Ignore();
Map(e => e.Amount).Name(nameof(TransactionDto.Amount).ToString().FirstCharacterToLower());
Map(e => e.Category).Name(nameof(TransactionDto.Category).ToString().FirstCharacterToLower());
Map(e => e.Time).Name(nameof(TransactionDto.Time).ToString().FirstCharacterToLower());
Map(e => e.Description).Name(nameof(TransactionDto.Description).ToString().FirstCharacterToLower());
Map(e => e.AccountId).Ignore();
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RegisterWithEmail;
public record RegisterWithEmailCommand : IRequest
{
public required string Email { get; set; }
}

View File

@ -0,0 +1,19 @@
using ExpenseTracker.Application.Common.Interfaces.Services;
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RegisterWithEmail;
public class RegisterWithEmailCommandHandler : IRequestHandler<RegisterWithEmailCommand>
{
private readonly IAuthenticationService _authenticationService;
public RegisterWithEmailCommandHandler(IAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
}
public async Task Handle(RegisterWithEmailCommand request, CancellationToken cancellationToken)
{
await _authenticationService.RegisterWithEmailAsync(request.Email, cancellationToken);
}
}

View File

@ -0,0 +1,16 @@
using FluentValidation;
namespace ExpenseTracker.Application.Authentication.Commands.RegisterWithEmail;
public class RegisterWithEmailCommandValidator : AbstractValidator<RegisterWithEmailCommand>
{
public RegisterWithEmailCommandValidator()
{
// https://regexr.com/2ri2c
RuleFor(v => v.Email)
.NotEmpty()
.WithMessage("Email address is required.")
.Matches(@"\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b")
.WithMessage("Email address is invalid.");
}
}

View File

@ -0,0 +1,10 @@
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RegisterWithEmailAndPassword;
public record RegisterWithEmailAndPasswordCommand : IRequest
{
public required string Email { get; set; }
public required string Password { get; set; }
}

View File

@ -0,0 +1,19 @@
using ExpenseTracker.Application.Common.Interfaces.Services;
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RegisterWithEmailAndPassword;
public class RegisterWithEmailAndPasswordCommandHandler : IRequestHandler<RegisterWithEmailAndPasswordCommand>
{
private readonly IAuthenticationService _authenticationService;
public RegisterWithEmailAndPasswordCommandHandler(IAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
}
public async Task Handle(RegisterWithEmailAndPasswordCommand request, CancellationToken cancellationToken)
{
await _authenticationService.RegisterWithEmailAndPasswordAsync(request.Email, request.Password, cancellationToken);
}
}

View File

@ -0,0 +1,23 @@
using FluentValidation;
namespace ExpenseTracker.Application.Authentication.Commands.RegisterWithEmailAndPassword;
public class RegisterWithEmailAndPasswordCommandValidator : AbstractValidator<RegisterWithEmailAndPasswordCommand>
{
public RegisterWithEmailAndPasswordCommandValidator()
{
// https://regexr.com/2ri2c
RuleFor(v => v.Email)
.NotEmpty().WithMessage("Email address is required.")
.Matches(@"\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b").WithMessage("Email address is invalid.");
RuleFor(v => v.Password)
.NotEmpty().WithMessage("Password is required.")
.MinimumLength(8).WithMessage("Password must be at least 8 characters long.")
.MaximumLength(64).WithMessage("Password must be at most 64 characters long.")
.Matches(@"(?=.*[A-Z]).*").WithMessage("Password must contain at least one uppercase letter.")
.Matches(@"(?=.*[a-z]).*").WithMessage("Password must contain at least one lowercase letter.")
.Matches(@"(?=.*[\d]).*").WithMessage("Password must contain at least one digit.")
.Matches(@"(?=.*[!@#$%^&*()]).*").WithMessage("Password must contain at least one of the following special charactters: !@#$%^&*().");
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RenewAccessTokenWithBody;
public record RenewAccessTokenWithBodyCommand : IRequest<TokensModel>
{
public required string RefreshToken { get; set; }
}

View File

@ -0,0 +1,19 @@
using ExpenseTracker.Application.Common.Interfaces.Services;
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RenewAccessTokenWithBody;
public class RenewAccessTokenWithBodyCommandHandler : IRequestHandler<RenewAccessTokenWithBodyCommand, TokensModel>
{
private readonly IAuthenticationService _authenticationService;
public RenewAccessTokenWithBodyCommandHandler(IAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
}
public async Task<TokensModel> Handle(RenewAccessTokenWithBodyCommand request, CancellationToken cancellationToken)
{
return await _authenticationService.RenewAccessTokenAsync(request.RefreshToken, cancellationToken);
}
}

View File

@ -0,0 +1,11 @@
using FluentValidation;
namespace ExpenseTracker.Application.Authentication.Commands.RenewAccessTokenWithBody;
public class RenewAccessTokenWithBodyCommandValidator : AbstractValidator<RenewAccessTokenWithBodyCommand>
{
public RenewAccessTokenWithBodyCommandValidator()
{
RuleFor(v => v.RefreshToken).NotEmpty();
}
}

View File

@ -0,0 +1,5 @@
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RenewAccessTokenWithCookie;
public record RenewAccessTokenWithCookieCommand : IRequest<TokensModel> { }

View File

@ -0,0 +1,23 @@
using ExpenseTracker.Application.Common.Interfaces.Services;
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RenewAccessTokenWithCookie;
public class RenewAccessTokenWithCookieCommandHandler : IRequestHandler<RenewAccessTokenWithCookieCommand, TokensModel>
{
private readonly ISessionUserService _sessionUserService;
private readonly IAuthenticationService _authenticationService;
public RenewAccessTokenWithCookieCommandHandler(
ISessionUserService sessionUserService,
IAuthenticationService authenticationService)
{
_sessionUserService = sessionUserService;
_authenticationService = authenticationService;
}
public async Task<TokensModel> Handle(RenewAccessTokenWithCookieCommand request, CancellationToken cancellationToken)
{
return await _authenticationService.RenewAccessTokenAsync(_sessionUserService.RefreshToken, cancellationToken);
}
}

View File

@ -0,0 +1,17 @@
using ExpenseTracker.Application.Common.Interfaces.Services;
using FluentValidation;
namespace ExpenseTracker.Application.Authentication.Commands.RenewAccessTokenWithCookie;
public class RenewAccessTokenWithCookieCommandValidator : AbstractValidator<RenewAccessTokenWithCookieCommand>
{
private readonly ISessionUserService _sessionUserService;
public RenewAccessTokenWithCookieCommandValidator(ISessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
RuleFor(e => e)
.Must(_ => _sessionUserService.RefreshToken != null);
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RevokeRefreshTokenWithBody;
public record RevokeRefreshTokenWithBodyCommand : IRequest
{
public required string RefreshToken { get; set; }
}

View File

@ -0,0 +1,19 @@
using ExpenseTracker.Application.Common.Interfaces.Services;
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RevokeRefreshTokenWithBody;
public class RevokeRefreshTokenWithBodyCommandHandler : IRequestHandler<RevokeRefreshTokenWithBodyCommand>
{
private readonly IAuthenticationService _authenticationService;
public RevokeRefreshTokenWithBodyCommandHandler(IAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
}
public async Task Handle(RevokeRefreshTokenWithBodyCommand request, CancellationToken cancellationToken)
{
await _authenticationService.RevokeRefreshTokenAsync(request.RefreshToken, cancellationToken);
}
}

View File

@ -0,0 +1,11 @@
using FluentValidation;
namespace ExpenseTracker.Application.Authentication.Commands.RevokeRefreshTokenWithBody;
public class RevokeRefreshTokenWithBodyCommandValidator : AbstractValidator<RevokeRefreshTokenWithBodyCommand>
{
public RevokeRefreshTokenWithBodyCommandValidator()
{
RuleFor(v => v.RefreshToken).NotEmpty();
}
}

View File

@ -0,0 +1,5 @@
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RevokeRefreshTokenWithCookie;
public record RevokeRefreshTokenWithCookieCommand : IRequest { }

View File

@ -0,0 +1,23 @@
using ExpenseTracker.Application.Common.Interfaces.Services;
using MediatR;
namespace ExpenseTracker.Application.Authentication.Commands.RevokeRefreshTokenWithCookie;
public class RevokeRefreshTokenWithCookieCommandHandler : IRequestHandler<RevokeRefreshTokenWithCookieCommand>
{
private readonly ISessionUserService _sessionUserService;
private readonly IAuthenticationService _authenticationService;
public RevokeRefreshTokenWithCookieCommandHandler(
ISessionUserService sessionUserService,
IAuthenticationService authenticationService)
{
_sessionUserService = sessionUserService;
_authenticationService = authenticationService;
}
public async Task Handle(RevokeRefreshTokenWithCookieCommand request, CancellationToken cancellationToken)
{
await _authenticationService.RevokeRefreshTokenAsync(_sessionUserService.RefreshToken, cancellationToken);
}
}

View File

@ -0,0 +1,17 @@
using ExpenseTracker.Application.Common.Interfaces.Services;
using FluentValidation;
namespace ExpenseTracker.Application.Authentication.Commands.RevokeRefreshTokenWithCookie;
public class RevokeRefreshTokenWithBodyCommandValidator : AbstractValidator<RevokeRefreshTokenWithCookieCommand>
{
private readonly ISessionUserService _sessionUserService;
public RevokeRefreshTokenWithBodyCommandValidator(ISessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
RuleFor(e => e)
.Must(_ => _sessionUserService.RefreshToken != null);
}
}

View File

@ -0,0 +1,10 @@
using MediatR;
namespace ExpenseTracker.Application.Authentication.Queries.Login;
public record LoginQuery : IRequest<TokensModel>
{
public required string Email { get; set; }
public required string Password { get; set; }
}

View File

@ -0,0 +1,19 @@
using ExpenseTracker.Application.Common.Interfaces.Services;
using MediatR;
namespace ExpenseTracker.Application.Authentication.Queries.Login;
public class LoginQueryHandler : IRequestHandler<LoginQuery, TokensModel>
{
private readonly IAuthenticationService _authenticationService;
public LoginQueryHandler(IAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
}
public async Task<TokensModel> Handle(LoginQuery request, CancellationToken cancellationToken)
{
return await _authenticationService.LoginAsync(request.Email, request.Password, cancellationToken);
}
}

View File

@ -0,0 +1,16 @@
using FluentValidation;
namespace ExpenseTracker.Application.Authentication.Queries.Login;
public class LoginQueryValidator : AbstractValidator<LoginQuery>
{
public LoginQueryValidator()
{
RuleFor(v => v.Email)
.NotEmpty().WithMessage("Email address is required.");
// .EmailAddress().WithMessage("Email address is invalid.");
RuleFor(v => v.Password)
.NotEmpty().WithMessage("Password is required.");
}
}

View File

@ -0,0 +1,14 @@
namespace ExpenseTracker.Application.Authentication;
public class TokensModel
{
public TokensModel(string accessToken, string refreshToken)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
}
public string AccessToken { get; protected set; }
public string RefreshToken { get; protected set; }
}

View File

@ -0,0 +1,13 @@
using MediatR.Behaviors.Authorization;
using MediatR.Behaviors.Authorization.Interfaces;
using ExpenseTracker.Application.Common.Exceptions;
namespace ExpenseTracker.Application.Common.Authorization;
public class CustomUnauthorizedResultHandler : IUnauthorizedResultHandler
{
public Task<TResponse> Invoke<TResponse>(AuthorizationResult result)
{
throw new ForbiddenException(result.FailureMessage);
}
}

View File

@ -0,0 +1,22 @@
using MediatR.Behaviors.Authorization;
using ExpenseTracker.Application.Common.Exceptions;
namespace ExpenseTracker.Application.Common.Authorization;
public class MustBeAuthenticatedRequirement : IAuthorizationRequirement
{
public required bool IsAuthenticated { get; init; } = default!;
class MustBeAuthenticatedRequirementHandler : IAuthorizationHandler<MustBeAuthenticatedRequirement>
{
public async Task<AuthorizationResult> Handle(MustBeAuthenticatedRequirement request, CancellationToken cancellationToken)
{
if (!request.IsAuthenticated)
{
throw new UnAuthorizedException();
}
return AuthorizationResult.Succeed();
}
}
}

View File

@ -0,0 +1,24 @@
using MediatR.Behaviors.Authorization;
namespace ExpenseTracker.Application.Common.Authorization;
public class MustBeInRolesRequirement : IAuthorizationRequirement
{
public required ICollection<string> UserRoles { get; init; } = default!;
public required ICollection<string> RequiredRoles { get; init; } = default!;
class MustBeInAdministratorRoleRequirementHandler : IAuthorizationHandler<MustBeInRolesRequirement>
{
public async Task<AuthorizationResult> Handle(MustBeInRolesRequirement request, CancellationToken cancellationToken)
{
var isUserInRequiredRoles = request.UserRoles.Any(ur => request.RequiredRoles.Contains(ur));
if (isUserInRequiredRoles)
{
return AuthorizationResult.Succeed();
}
return AuthorizationResult.Fail($"You must be in one of the following roles: '{String.Join("', ", request.RequiredRoles)}'.");
}
}
}

View File

@ -0,0 +1,28 @@
using MediatR.Behaviors.Authorization;
namespace ExpenseTracker.Application.Common.Authorization;
public class MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement : IAuthorizationRequirement
{
public required string UserId { get; init; } = default!;
public required ICollection<string> UserRoles { get; init; } = default!;
public required string RequiredUserId { get; init; } = default!;
public required ICollection<string> RequiredRoles { get; init; } = default!;
class MustBeInAdministratorRoleRequirementHandler : IAuthorizationHandler<MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement>
{
public async Task<AuthorizationResult> Handle(MustBeInRolesWhenInteractingWithUnOwnedEntityRequirement request, CancellationToken cancellationToken)
{
var isUserOwner = request.UserId == request.RequiredUserId;
var isUserInRequiredRoles = request.UserRoles.Any(ur => request.RequiredRoles.Contains(ur));
if (isUserOwner || isUserInRequiredRoles)
{
return AuthorizationResult.Succeed();
}
return AuthorizationResult.Fail($"You must be the entity owner or be in one of the following roles: '{String.Join("', ", request.RequiredRoles)}'.");
}
}
}

View File

@ -0,0 +1,46 @@
using System.Diagnostics;
using MediatR;
using Microsoft.Extensions.Logging;
using ExpenseTracker.Application.Common.Interfaces.Services;
namespace ExpenseTracker.Application.Common.Behaviours;
public class LoggingBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ILogger _logger;
private readonly ISessionUserService _sessionUserService;
public LoggingBehaviour(ILogger<TRequest> logger, ISessionUserService sessionUserService)
{
_logger = logger;
_sessionUserService = sessionUserService;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
_logger.LogInformation(
"{@DateUtc} {@TimeUtc} {@TraceId} {@SpanId} Handling {@RequestName} by user with Email: {@UserEmail} and UserId: {@UserId}.",
DateTime.UtcNow.ToString("yyyy-MM-dd"),
DateTime.UtcNow.ToString("HH:mm:ss.FFF"),
Activity.Current?.TraceId.ToString(),
Activity.Current?.SpanId.ToString(),
typeof(TRequest).Name,
_sessionUserService.Email,
_sessionUserService.Id);
var response = await next();
_logger.LogInformation(
"{@DateUtc} {@TimeUtc} {@TraceId} {@SpanId} Handled {@RequestName} by user with Email: {@UserEmail} and UserId: {@UserId}.",
DateTime.UtcNow.ToString("yyyy-MM-dd"),
DateTime.UtcNow.ToString("HH:mm:ss.FFF"),
Activity.Current?.TraceId.ToString(),
Activity.Current?.SpanId.ToString(),
typeof(TRequest).Name,
_sessionUserService.Email,
_sessionUserService.Id);
return response;
}
}

View File

@ -0,0 +1,37 @@
using FluentValidation;
using MediatR;
using ValidationException = ExpenseTracker.Application.Common.Exceptions.ValidationException;
namespace ExpenseTracker.Application.Common.Behaviours;
public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (_validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Any())
{
throw new ValidationException(failures);
}
}
return await next();
}
}

Some files were not shown because too many files have changed in this diff Show More