Compare commits

...

9 Commits

Author SHA1 Message Date
120963f3cc
add optional ticket group binding to account
All checks were successful
/ tests (push) Successful in 26s
/ build (push) Successful in 10m28s
/ build-docker (push) Successful in 5m49s
2025-05-30 16:40:28 +03:00
4d1f6edc2e
add ticket group queries 2025-05-30 13:14:20 +03:00
e5b3180220
update uk-UA localization 2025-05-29 18:02:26 +03:00
6a9504d6ff
add payment email notifications 2025-05-29 18:02:02 +03:00
68a9e06eeb
add email sender service 2025-05-29 13:13:41 +03:00
41158b34c5
flatten configuration file structure 2025-05-29 12:24:13 +03:00
9ccd0bb68d
add account creation when adding a company 2025-05-29 11:56:34 +03:00
bb309d7c20
add account creation when adding an employee 2025-05-28 17:55:32 +03:00
7229a10ad5
add account management 2025-05-28 15:40:30 +03:00
110 changed files with 8833 additions and 655 deletions

View File

@ -9,7 +9,7 @@ public static class CustomValidators
{ {
return return
ruleBuilder ruleBuilder
.Matches(@"^[a-z0-9-_.]*$"); .Matches(@"^[a-z0-9-_\.]*$");
} }
// According to RFC 5321. // According to RFC 5321.
@ -18,7 +18,7 @@ public static class CustomValidators
{ {
return return
ruleBuilder ruleBuilder
.Matches(@"^[\w\.-]{1,64}@[\w\.-]{1,251}\.\w{2,4}$"); .Matches(@"^[a-z0-9-_\.]{1,64}@[a-z0-9-_\.]{1,251}\.[a-z0-9-_]{2,4}$");
} }
// According to ITU-T E.164, no spaces. // According to ITU-T E.164, no spaces.

View File

@ -0,0 +1,7 @@
namespace cuqmbr.TravelGuide.Application.Common.Services;
public interface EmailSenderService
{
Task SendAsync(string[] addresses, string subject, string body,
CancellationToken cancellationToken);
}

View File

@ -11,4 +11,11 @@ public record AddCompanyCommand : IRequest<CompanyDto>
public string ContactEmail { get; set; } public string ContactEmail { get; set; }
public string ContactPhoneNumber { get; set; } public string ContactPhoneNumber { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
} }

View File

@ -1,8 +1,12 @@
using MediatR; using MediatR;
using cuqmbr.TravelGuide.Application.Common.Persistence;
using cuqmbr.TravelGuide.Domain.Entities;
using AutoMapper; using AutoMapper;
using cuqmbr.TravelGuide.Application.Common.Persistence;
using cuqmbr.TravelGuide.Application.Common.Exceptions; using cuqmbr.TravelGuide.Application.Common.Exceptions;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Entities;
using cuqmbr.TravelGuide.Domain.Enums;
using System.Security.Cryptography;
using System.Text;
namespace cuqmbr.TravelGuide.Application.Companies.Commands.AddCompany; namespace cuqmbr.TravelGuide.Application.Companies.Commands.AddCompany;
@ -11,13 +15,14 @@ public class AddCompanyCommandHandler :
{ {
private readonly UnitOfWork _unitOfWork; private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly PasswordHasherService _passwordHasher;
public AddCompanyCommandHandler( public AddCompanyCommandHandler(UnitOfWork unitOfWork, IMapper mapper,
UnitOfWork unitOfWork, PasswordHasherService passwordHasher)
IMapper mapper)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
_passwordHasher = passwordHasher;
} }
public async Task<CompanyDto> Handle( public async Task<CompanyDto> Handle(
@ -33,12 +38,51 @@ public class AddCompanyCommandHandler :
"Company with given name already exists."); "Company with given name already exists.");
} }
// Create new account for employee
var account = await _unitOfWork.AccountRepository.GetOneAsync(
e => e.Email == request.Email,
cancellationToken);
if (account != null)
{
throw new DuplicateEntityException();
}
var role = (await _unitOfWork.RoleRepository.GetPageAsync(
1, IdentityRole.Enumerations.Count(), cancellationToken))
.Items
.First(r => r.Value.Equals(IdentityRole.CompanyOwner));
var salt = RandomNumberGenerator.GetBytes(128 / 8);
var hash = await _passwordHasher.HashAsync(
Encoding.UTF8.GetBytes(request.Password),
salt, cancellationToken);
var saltBase64 = Convert.ToBase64String(salt);
var hashBase64 = Convert.ToBase64String(hash);
account = new Account()
{
Username = request.Username,
Email = request.Email,
PasswordHash = hashBase64,
PasswordSalt = saltBase64,
AccountRoles = new AccountRole[] { new() { RoleId = role.Id } }
};
account = await _unitOfWork.AccountRepository.AddOneAsync(
account, cancellationToken);
entity = new Company() entity = new Company()
{ {
Name = request.Name, Name = request.Name,
LegalAddress = request.LegalAddress, LegalAddress = request.LegalAddress,
ContactEmail = request.ContactEmail, ContactEmail = request.ContactEmail,
ContactPhoneNumber = request.ContactPhoneNumber ContactPhoneNumber = request.ContactPhoneNumber,
Account = account
}; };
entity = await _unitOfWork.CompanyRepository.AddOneAsync( entity = await _unitOfWork.CompanyRepository.AddOneAsync(

View File

@ -54,5 +54,46 @@ public class AddCompanyCommandValidator : AbstractValidator<AddCompanyCommand>
cultureService.Culture, cultureService.Culture,
localizer["FluentValidation.MaximumLength"], localizer["FluentValidation.MaximumLength"],
64)); 64));
RuleFor(v => v.Username)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.MinimumLength(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MinimumLength"],
1))
.MaximumLength(32)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
32))
.IsUsername()
.WithMessage(localizer["FluentValidation.IsUsername"]);
RuleFor(v => v.Email)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.IsEmail()
.WithMessage(localizer["FluentValidation.IsEmail"]);
RuleFor(v => v.Password)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.MinimumLength(8)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MinimumLength"],
8))
.MaximumLength(64)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
64));
} }
} }

View File

@ -18,7 +18,7 @@ public class DeleteCompanyCommandHandler : IRequestHandler<DeleteCompanyCommand>
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var entity = await _unitOfWork.CompanyRepository.GetOneAsync( var entity = await _unitOfWork.CompanyRepository.GetOneAsync(
e => e.Guid == request.Guid, cancellationToken); e => e.Guid == request.Guid, e => e.Account, cancellationToken);
if (entity == null) if (entity == null)
{ {
@ -28,6 +28,9 @@ public class DeleteCompanyCommandHandler : IRequestHandler<DeleteCompanyCommand>
await _unitOfWork.CompanyRepository.DeleteOneAsync( await _unitOfWork.CompanyRepository.DeleteOneAsync(
entity, cancellationToken); entity, cancellationToken);
await _unitOfWork.AccountRepository.DeleteOneAsync(
entity.Account, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken); await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose(); _unitOfWork.Dispose();
} }

View File

@ -31,10 +31,14 @@ public class UpdateCompanyCommandHandler :
throw new NotFoundException(); throw new NotFoundException();
} }
var account = await _unitOfWork.AccountRepository.GetOneAsync(
a => a.Id == entity.AccountId, cancellationToken);
entity.Name = request.Name; entity.Name = request.Name;
entity.LegalAddress = request.LegalAddress; entity.LegalAddress = request.LegalAddress;
entity.ContactEmail = request.ContactEmail; entity.ContactEmail = request.ContactEmail;
entity.ContactPhoneNumber = request.ContactPhoneNumber; entity.ContactPhoneNumber = request.ContactPhoneNumber;
entity.Account = account;
entity = await _unitOfWork.CompanyRepository.UpdateOneAsync( entity = await _unitOfWork.CompanyRepository.UpdateOneAsync(
entity, cancellationToken); entity, cancellationToken);

View File

@ -0,0 +1,21 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.Companies;
public sealed class CompanyAccountDto : IMapFrom<Account>
{
public Guid Uuid { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<Account, CompanyAccountDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.Guid));
}
}

View File

@ -15,6 +15,8 @@ public sealed class CompanyDto : IMapFrom<Company>
public string ContactPhoneNumber { get; set; } public string ContactPhoneNumber { get; set; }
public CompanyAccountDto Account { get; set; }
public void Mapping(MappingProfile profile) public void Mapping(MappingProfile profile)
{ {
profile.CreateMap<Company, CompanyDto>() profile.CreateMap<Company, CompanyDto>()

View File

@ -33,6 +33,19 @@ public class GetCompaniesPageQueryHandler :
request.PageNumber, request.PageSize, request.PageNumber, request.PageSize,
cancellationToken); cancellationToken);
// Hydrate companies
var accountIds = paginatedList.Items.Select(e => e.AccountId);
var accounts = await _unitOfWork.AccountRepository.GetPageAsync(
e => accountIds.Contains(e.Id),
1, paginatedList.Items.Count, cancellationToken);
foreach (var company in paginatedList.Items)
{
company.Account =
accounts.Items.First(a => a.Id == company.AccountId);
}
var mappedItems = _mapper var mappedItems = _mapper
.ProjectTo<CompanyDto>(paginatedList.Items.AsQueryable()); .ProjectTo<CompanyDto>(paginatedList.Items.AsQueryable());

View File

@ -26,13 +26,18 @@ public class GetCompanyQueryHandler :
var entity = await _unitOfWork.CompanyRepository.GetOneAsync( var entity = await _unitOfWork.CompanyRepository.GetOneAsync(
e => e.Guid == request.Guid, cancellationToken); e => e.Guid == request.Guid, cancellationToken);
_unitOfWork.Dispose();
if (entity == null) if (entity == null)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var account = await _unitOfWork.AccountRepository.GetOneAsync(
e => e.Id == entity.AccountId, cancellationToken);
entity.Account = account;
_unitOfWork.Dispose();
return _mapper.Map<CompanyDto>(entity); return _mapper.Map<CompanyDto>(entity);
} }
} }

View File

@ -9,4 +9,11 @@ public sealed class AddCompanyViewModel
public string ContactEmail { get; set; } public string ContactEmail { get; set; }
public string ContactPhoneNumber { get; set; } public string ContactPhoneNumber { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
} }

View File

@ -2,7 +2,7 @@ namespace cuqmbr.TravelGuide.Application;
public sealed class ConfigurationOptions public sealed class ConfigurationOptions
{ {
public static string SectionName { get; } = "Application"; public static string SectionName { get; } = "";
public LocalizationConfigurationOptions Localization { get; set; } = new(); public LocalizationConfigurationOptions Localization { get; set; } = new();

View File

@ -20,4 +20,11 @@ public record AddEmployeeCommand : IRequest<EmployeeDto>
public Guid CompanyGuid { get; set; } public Guid CompanyGuid { get; set; }
public ICollection<EmployeeDocumentModel> Documents { get; set; } public ICollection<EmployeeDocumentModel> Documents { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
} }

View File

@ -4,6 +4,10 @@ using cuqmbr.TravelGuide.Domain.Entities;
using AutoMapper; using AutoMapper;
using cuqmbr.TravelGuide.Application.Common.Exceptions; using cuqmbr.TravelGuide.Application.Common.Exceptions;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using cuqmbr.TravelGuide.Domain.Enums;
using System.Security.Cryptography;
using cuqmbr.TravelGuide.Application.Common.Services;
using System.Text;
namespace cuqmbr.TravelGuide.Application.Employees.Commands.AddEmployee; namespace cuqmbr.TravelGuide.Application.Employees.Commands.AddEmployee;
@ -13,15 +17,15 @@ public class AddEmployeeCommandHandler :
private readonly UnitOfWork _unitOfWork; private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly IStringLocalizer _localizer; private readonly IStringLocalizer _localizer;
private readonly PasswordHasherService _passwordHasher;
public AddEmployeeCommandHandler( public AddEmployeeCommandHandler(UnitOfWork unitOfWork, IMapper mapper,
UnitOfWork unitOfWork, IStringLocalizer localizer, PasswordHasherService passwordHasher)
IMapper mapper,
IStringLocalizer localizer)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
_localizer = localizer; _localizer = localizer;
_passwordHasher = passwordHasher;
} }
public async Task<EmployeeDto> Handle( public async Task<EmployeeDto> Handle(
@ -52,6 +56,44 @@ public class AddEmployeeCommandHandler :
throw new DuplicateEntityException(); throw new DuplicateEntityException();
} }
// Create new account for employee
var account = await _unitOfWork.AccountRepository.GetOneAsync(
e => e.Email == request.Email,
cancellationToken);
if (account != null)
{
throw new DuplicateEntityException();
}
var role = (await _unitOfWork.RoleRepository.GetPageAsync(
1, IdentityRole.Enumerations.Count(), cancellationToken))
.Items
.First(r => r.Value.Equals(IdentityRole.CompanyEmployee));
var salt = RandomNumberGenerator.GetBytes(128 / 8);
var hash = await _passwordHasher.HashAsync(
Encoding.UTF8.GetBytes(request.Password),
salt, cancellationToken);
var saltBase64 = Convert.ToBase64String(salt);
var hashBase64 = Convert.ToBase64String(hash);
account = new Account()
{
Username = request.Username,
Email = request.Email,
PasswordHash = hashBase64,
PasswordSalt = saltBase64,
AccountRoles = new AccountRole[] { new() { RoleId = role.Id } }
};
account = await _unitOfWork.AccountRepository.AddOneAsync(
account, cancellationToken);
entity = new Employee() entity = new Employee()
{ {
FirstName = request.FirstName, FirstName = request.FirstName,
@ -66,12 +108,14 @@ public class AddEmployeeCommandHandler :
Information = d.Information Information = d.Information
}) })
.ToArray(), .ToArray(),
Company = parentEntity Company = parentEntity,
Account = account
}; };
entity = await _unitOfWork.EmployeeRepository.AddOneAsync( entity = await _unitOfWork.EmployeeRepository.AddOneAsync(
entity, cancellationToken); entity, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken); await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose(); _unitOfWork.Dispose();

View File

@ -1,3 +1,4 @@
using cuqmbr.TravelGuide.Application.Common.FluentValidation;
using cuqmbr.TravelGuide.Application.Common.Services; using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums; using cuqmbr.TravelGuide.Domain.Enums;
using FluentValidation; using FluentValidation;
@ -79,5 +80,46 @@ public class AddEmployeeCommandValidator : AbstractValidator<AddEmployeeCommand>
localizer["FluentValidation.MaximumLength"], localizer["FluentValidation.MaximumLength"],
256)); 256));
}); });
RuleFor(v => v.Username)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.MinimumLength(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MinimumLength"],
1))
.MaximumLength(32)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
32))
.IsUsername()
.WithMessage(localizer["FluentValidation.IsUsername"]);
RuleFor(v => v.Email)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.IsEmail()
.WithMessage(localizer["FluentValidation.IsEmail"]);
RuleFor(v => v.Password)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.MinimumLength(8)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MinimumLength"],
8))
.MaximumLength(64)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
64));
} }
} }

View File

@ -18,7 +18,7 @@ public class DeleteEmployeeCommandHandler : IRequestHandler<DeleteEmployeeComman
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var entity = await _unitOfWork.EmployeeRepository.GetOneAsync( var entity = await _unitOfWork.EmployeeRepository.GetOneAsync(
e => e.Guid == request.Guid, cancellationToken); e => e.Guid == request.Guid, e => e.Account, cancellationToken);
if (entity == null) if (entity == null)
{ {
@ -31,6 +31,9 @@ public class DeleteEmployeeCommandHandler : IRequestHandler<DeleteEmployeeComman
await _unitOfWork.EmployeeRepository.DeleteOneAsync( await _unitOfWork.EmployeeRepository.DeleteOneAsync(
entity, cancellationToken); entity, cancellationToken);
await _unitOfWork.AccountRepository.DeleteOneAsync(
entity.Account, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken); await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose(); _unitOfWork.Dispose();
} }

View File

@ -38,7 +38,7 @@ public class UpdateEmployeeCommandHandler :
} }
var entity = await _unitOfWork.EmployeeRepository.GetOneAsync( var employee = await _unitOfWork.EmployeeRepository.GetOneAsync(
e => e =>
e.FirstName == request.FirstName && e.FirstName == request.FirstName &&
e.LastName == request.LastName && e.LastName == request.LastName &&
@ -49,30 +49,34 @@ public class UpdateEmployeeCommandHandler :
e.Guid != request.Guid, e.Guid != request.Guid,
cancellationToken); cancellationToken);
if (entity != null) if (employee != null)
{ {
throw new DuplicateEntityException(); throw new DuplicateEntityException();
} }
entity = await _unitOfWork.EmployeeRepository.GetOneAsync( employee = await _unitOfWork.EmployeeRepository.GetOneAsync(
e => e.Guid == request.Guid, e => e.Documents, cancellationToken); e => e.Guid == request.Guid, e => e.Documents, cancellationToken);
if (entity == null) if (employee == null)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var account = await _unitOfWork.AccountRepository.GetOneAsync(
a => a.Id == employee.AccountId, cancellationToken);
entity.Guid = request.Guid;
entity.FirstName = request.FirstName;
entity.LastName = request.LastName;
entity.Patronymic = request.Patronymic;
entity.Sex = request.Sex;
entity.BirthDate = request.BirthDate;
entity.CompanyId = parentEntity.Id;
entity.Company = parentEntity; employee.Guid = request.Guid;
employee.FirstName = request.FirstName;
employee.LastName = request.LastName;
employee.Patronymic = request.Patronymic;
employee.Sex = request.Sex;
employee.BirthDate = request.BirthDate;
employee.CompanyId = parentEntity.Id;
employee.Company = parentEntity;
employee.Account = account;
var requestEmployeeDocuments = request.Documents.Select( var requestEmployeeDocuments = request.Documents.Select(
@ -82,27 +86,27 @@ public class UpdateEmployeeCommandHandler :
Information = d.Information Information = d.Information
}); });
var commonEmployeeDocuments = entity.Documents.IntersectBy( var commonEmployeeDocuments = employee.Documents.IntersectBy(
requestEmployeeDocuments.Select( requestEmployeeDocuments.Select(
ed => (ed.DocumentType, ed.Information)), ed => (ed.DocumentType, ed.Information)),
ed => (ed.DocumentType, ed.Information)); ed => (ed.DocumentType, ed.Information));
var newEmployeeDocuments = requestEmployeeDocuments.ExceptBy( var newEmployeeDocuments = requestEmployeeDocuments.ExceptBy(
entity.Documents.Select(ed => (ed.DocumentType, ed.Information)), employee.Documents.Select(ed => (ed.DocumentType, ed.Information)),
ed => (ed.DocumentType, ed.Information)); ed => (ed.DocumentType, ed.Information));
var combinedEmployeeDocuments = commonEmployeeDocuments.UnionBy( var combinedEmployeeDocuments = commonEmployeeDocuments.UnionBy(
newEmployeeDocuments, ed => (ed.DocumentType, ed.Information)); newEmployeeDocuments, ed => (ed.DocumentType, ed.Information));
entity.Documents = combinedEmployeeDocuments.ToList(); employee.Documents = combinedEmployeeDocuments.ToList();
entity = await _unitOfWork.EmployeeRepository.UpdateOneAsync( employee = await _unitOfWork.EmployeeRepository.UpdateOneAsync(
entity, cancellationToken); employee, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken); await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose(); _unitOfWork.Dispose();
return _mapper.Map<EmployeeDto>(entity); return _mapper.Map<EmployeeDto>(employee);
} }
} }

View File

@ -0,0 +1,21 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.Employees;
public sealed class EmployeeAccountDto : IMapFrom<Account>
{
public Guid Uuid { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<Account, EmployeeAccountDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.Guid));
}
}

View File

@ -22,6 +22,8 @@ public sealed class EmployeeDto : IMapFrom<Employee>
public ICollection<EmployeeDocumentDto> Documents { get; set; } public ICollection<EmployeeDocumentDto> Documents { get; set; }
public EmployeeAccountDto Account { get; set; }
public void Mapping(MappingProfile profile) public void Mapping(MappingProfile profile)
{ {
profile.CreateMap<Employee, EmployeeDto>() profile.CreateMap<Employee, EmployeeDto>()

View File

@ -33,13 +33,18 @@ public class GetEmployeeQueryHandler :
} }
// Hydrate employees with companies // Hydrate employee
var company = await _unitOfWork.CompanyRepository.GetOneAsync( var company = await _unitOfWork.CompanyRepository.GetOneAsync(
e => e.Id == entity.CompanyId, cancellationToken); e => e.Id == entity.CompanyId, cancellationToken);
entity.Company = company; entity.Company = company;
var account = await _unitOfWork.AccountRepository.GetOneAsync(
e => e.Id == entity.AccountId, cancellationToken);
entity.Account = account;
_unitOfWork.Dispose(); _unitOfWork.Dispose();

View File

@ -49,7 +49,7 @@ public class GetEmployeesPageQueryHandler :
cancellationToken); cancellationToken);
// Hydrate employees with companies // Hydrate employees
var companies = await _unitOfWork.CompanyRepository.GetPageAsync( var companies = await _unitOfWork.CompanyRepository.GetPageAsync(
e => paginatedList.Items.Select(e => e.CompanyId).Contains(e.Id), e => paginatedList.Items.Select(e => e.CompanyId).Contains(e.Id),
@ -61,6 +61,17 @@ public class GetEmployeesPageQueryHandler :
companies.Items.First(c => c.Id == employee.CompanyId); companies.Items.First(c => c.Id == employee.CompanyId);
} }
var accountIds = paginatedList.Items.Select(e => e.AccountId);
var accounts = await _unitOfWork.AccountRepository.GetPageAsync(
e => accountIds.Contains(e.Id),
1, paginatedList.Items.Count, cancellationToken);
foreach (var employee in paginatedList.Items)
{
employee.Account =
accounts.Items.First(a => a.Id == employee.AccountId);
}
var mappedItems = _mapper var mappedItems = _mapper
.ProjectTo<EmployeeDto>(paginatedList.Items.AsQueryable()); .ProjectTo<EmployeeDto>(paginatedList.Items.AsQueryable());

View File

@ -16,4 +16,11 @@ public sealed class AddEmployeeViewModel
public Guid CompanyUuid { get; set; } public Guid CompanyUuid { get; set; }
public ICollection<EmployeeDocumentViewModel> Documents { get; set; } public ICollection<EmployeeDocumentViewModel> Documents { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
} }

View File

@ -1,10 +1,13 @@
using cuqmbr.TravelGuide.Domain.Enums; using cuqmbr.TravelGuide.Domain.Enums;
using MediatR; using MediatR;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount; namespace cuqmbr.TravelGuide.Application.Identity
.Accounts.Commands.AddAccount;
public record AddAccountCommand : IRequest<AccountDto> public record AddAccountCommand : IRequest<AccountDto>
{ {
public string Username { get; set; }
public string Email { get; set; } public string Email { get; set; }
public string Password { get; set; } public string Password { get; set; }

View File

@ -3,7 +3,8 @@ using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums; using cuqmbr.TravelGuide.Domain.Enums;
using MediatR.Behaviors.Authorization; using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount; namespace cuqmbr.TravelGuide.Application
.Identity.Accounts.Commands.AddAccount;
public class AddAccountCommandAuthorizer : public class AddAccountCommandAuthorizer :
AbstractRequestAuthorizer<AddAccountCommand> AbstractRequestAuthorizer<AddAccountCommand>

View File

@ -7,34 +7,33 @@ using cuqmbr.TravelGuide.Application.Common.Services;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount; namespace cuqmbr.TravelGuide.Application
.Identity.Accounts.Commands.AddAccount;
public class AddAccountCommandHandler : public class AddAccountCommandHandler :
IRequestHandler<AddAccountCommand, AccountDto> IRequestHandler<AddAccountCommand, AccountDto>
{ {
private readonly UnitOfWork _unitOfWork; private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly PasswordHasherService _passwordHasherService; private readonly PasswordHasherService _passwordHasher;
public AddAccountCommandHandler( public AddAccountCommandHandler(UnitOfWork unitOfWork,
UnitOfWork unitOfWork, IMapper mapper, PasswordHasherService passwordHasher)
IMapper mapper,
PasswordHasherService passwordHasherService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
_passwordHasherService = passwordHasherService; _passwordHasher = passwordHasher;
} }
public async Task<AccountDto> Handle( public async Task<AccountDto> Handle(
AddAccountCommand request, AddAccountCommand request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var user = await _unitOfWork.AccountRepository.GetOneAsync( var account = await _unitOfWork.AccountRepository.GetOneAsync(
e => e.Email == request.Email, e => e.Email == request.Email,
cancellationToken); cancellationToken);
if (user != null) if (account != null)
{ {
throw new DuplicateEntityException(); throw new DuplicateEntityException();
} }
@ -47,15 +46,16 @@ public class AddAccountCommandHandler :
.Items; .Items;
var salt = RandomNumberGenerator.GetBytes(128 / 8); var salt = RandomNumberGenerator.GetBytes(128 / 8);
var hash = await _passwordHasherService.HashAsync( var hash = await _passwordHasher.HashAsync(
Encoding.UTF8.GetBytes(request.Password), Encoding.UTF8.GetBytes(request.Password),
salt, cancellationToken); salt, cancellationToken);
var saltBase64 = Convert.ToBase64String(salt); var saltBase64 = Convert.ToBase64String(salt);
var hashBase64 = Convert.ToBase64String(hash); var hashBase64 = Convert.ToBase64String(hash);
user = new Account() account = new Account()
{ {
Username = request.Username,
Email = request.Email, Email = request.Email,
PasswordHash = hashBase64, PasswordHash = hashBase64,
PasswordSalt = saltBase64, PasswordSalt = saltBase64,
@ -66,12 +66,12 @@ public class AddAccountCommandHandler :
.ToArray() .ToArray()
}; };
user = await _unitOfWork.AccountRepository.AddOneAsync( account = await _unitOfWork.AccountRepository.AddOneAsync(
user, cancellationToken); account, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken); await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose(); _unitOfWork.Dispose();
return _mapper.Map<AccountDto>(user); return _mapper.Map<AccountDto>(account);
} }
} }

View File

@ -1,16 +1,37 @@
using cuqmbr.TravelGuide.Application.Common.FluentValidation; using cuqmbr.TravelGuide.Application.Common.FluentValidation;
using cuqmbr.TravelGuide.Application.Common.Services; using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using FluentValidation; using FluentValidation;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount; namespace cuqmbr.TravelGuide.Application
.Identity.Accounts.Commands.AddAccount;
public class AddAccountCommandValidator : AbstractValidator<AddAccountCommand> public class AddAccountCommandValidator :
AbstractValidator<AddAccountCommand>
{ {
public AddAccountCommandValidator( public AddAccountCommandValidator(
IStringLocalizer localizer, IStringLocalizer localizer,
SessionCultureService cultureService) SessionCultureService cultureService)
{ {
RuleFor(v => v.Username)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.MinimumLength(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MinimumLength"],
1))
.MaximumLength(32)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
32))
.IsUsername()
.WithMessage(localizer["FluentValidation.IsUsername"]);
RuleFor(v => v.Email) RuleFor(v => v.Email)
.NotEmpty() .NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]) .WithMessage(localizer["FluentValidation.NotEmpty"])
@ -32,5 +53,18 @@ public class AddAccountCommandValidator : AbstractValidator<AddAccountCommand>
cultureService.Culture, cultureService.Culture,
localizer["FluentValidation.MaximumLength"], localizer["FluentValidation.MaximumLength"],
64)); 64));
RuleFor(v => v.Roles ?? new IdentityRole[0])
.IsUnique(r => r)
.WithMessage(localizer["FluentValidation.IsUnique"]);
RuleForEach(v => v.Roles)
.Must(r => IdentityRole.Enumerations.ContainsValue(r))
.WithMessage(
String.Format(
localizer["FluentValidation.MustBeInEnum"],
String.Join(
", ",
IdentityRole.Enumerations.Values.Select(e => e.Name))));
} }
} }

View File

@ -0,0 +1,9 @@
using MediatR;
namespace cuqmbr.TravelGuide.Application.Identity
.Accounts.Commands.DeleteAccount;
public record DeleteAccountCommand : IRequest
{
public Guid Guid { get; set; }
}

View File

@ -0,0 +1,32 @@
using cuqmbr.TravelGuide.Application.Common.Authorization;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.Identity
.Accounts.Commands.DeleteAccount;
public class DeleteAccountCommandAuthorizer :
AbstractRequestAuthorizer<DeleteAccountCommand>
{
private readonly SessionUserService _sessionUserService;
public DeleteAccountCommandAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(DeleteAccountCommand request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,34 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Persistence;
using cuqmbr.TravelGuide.Application.Common.Exceptions;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.DeleteAccount;
public class DeleteAccountCommandHandler : IRequestHandler<DeleteAccountCommand>
{
private readonly UnitOfWork _unitOfWork;
public DeleteAccountCommandHandler(UnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task Handle(
DeleteAccountCommand request,
CancellationToken cancellationToken)
{
var account = await _unitOfWork.AccountRepository.GetOneAsync(
e => e.Guid == request.Guid, cancellationToken);
if (account == null)
{
throw new NotFoundException();
}
await _unitOfWork.AccountRepository.DeleteOneAsync(
account, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose();
}
}

View File

@ -0,0 +1,14 @@
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.DeleteAccount;
public class DeleteAccountCommandValidator : AbstractValidator<DeleteAccountCommand>
{
public DeleteAccountCommandValidator(IStringLocalizer localizer)
{
RuleFor(v => v.Guid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
}
}

View File

@ -0,0 +1,18 @@
using MediatR;
using cuqmbr.TravelGuide.Domain.Enums;
namespace cuqmbr.TravelGuide.Application
.Identity.Accounts.Commands.UpdateAccount;
public record UpdateAccountCommand : IRequest<AccountDto>
{
public Guid Guid { get; set; }
public string? Username { get; set; }
public string? Email { get; set; }
public string? Password { get; set; }
public ICollection<IdentityRole>? Roles { get; set; }
}

View File

@ -0,0 +1,31 @@
using cuqmbr.TravelGuide.Application.Common.Authorization;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.UpdateAccount;
public class UpdateAccountCommandAuthorizer :
AbstractRequestAuthorizer<UpdateAccountCommand>
{
private readonly SessionUserService _sessionUserService;
public UpdateAccountCommandAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(UpdateAccountCommand request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,109 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Persistence;
using AutoMapper;
using cuqmbr.TravelGuide.Application.Common.Exceptions;
using System.Security.Cryptography;
using System.Text;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application
.Identity.Accounts.Commands.UpdateAccount;
public class UpdateAccountCommandHandler :
IRequestHandler<UpdateAccountCommand, AccountDto>
{
private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly PasswordHasherService _passwordHasher;
public UpdateAccountCommandHandler(UnitOfWork unitOfWork,
IMapper mapper, PasswordHasherService passwordHasher)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_passwordHasher = passwordHasher;
}
public async Task<AccountDto> Handle(
UpdateAccountCommand request,
CancellationToken cancellationToken)
{
var account = await _unitOfWork.AccountRepository
.GetOneAsync(e => e.Guid == request.Guid,
e => e.AccountRoles, cancellationToken);
if (account == null)
{
throw new NotFoundException();
}
account.Username = request.Username ?? account.Username;
account.Email = request.Email ?? account.Email;
if (request.Password != null)
{
var salt = RandomNumberGenerator.GetBytes(128 / 8);
var hash = await _passwordHasher.HashAsync(
Encoding.UTF8.GetBytes(request.Password),
salt, cancellationToken);
var saltBase64 = Convert.ToBase64String(salt);
var hashBase64 = Convert.ToBase64String(hash);
account.PasswordHash = hashBase64;
account.PasswordSalt = saltBase64;
}
if (request.Roles != null)
{
var requestRoleIds = (await _unitOfWork.RoleRepository
.GetPageAsync(
r => request.Roles.Contains(r.Value),
1, request.Roles.Count, cancellationToken))
.Items
.Select(r => r.Id);
var accountRoles = account.AccountRoles;
var accountRoleIds = accountRoles.Select(ar => ar.RoleId);
var commonRoleIds = requestRoleIds.Intersect(accountRoleIds);
var newRoleIds = requestRoleIds.Except(accountRoleIds);
var combinedRoleIds = commonRoleIds.Union(newRoleIds);
account.AccountRoles = combinedRoleIds.Select(rId =>
new AccountRole()
{
Id = accountRoles.FirstOrDefault(ar =>
ar.RoleId == rId)?.Id ?? default,
RoleId = rId
})
.ToList();
}
else
{
var accountRoleIds = account.AccountRoles.Select(ar => ar.RoleId);
var accountRoles = (await _unitOfWork.AccountRoleRepository
.GetPageAsync(
ar => accountRoleIds.Contains(ar.RoleId),
ar => ar.Role,
1, accountRoleIds.Count(), cancellationToken))
.Items;
account.AccountRoles = accountRoles.ToList();
}
account = await _unitOfWork.AccountRepository.UpdateOneAsync(
account, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose();
return _mapper.Map<AccountDto>(account);
}
}

View File

@ -0,0 +1,68 @@
using cuqmbr.TravelGuide.Application.Common.FluentValidation;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application
.Identity.Accounts.Commands.UpdateAccount;
public class UpdateAccountCommandValidator :
AbstractValidator<UpdateAccountCommand>
{
public UpdateAccountCommandValidator(
IStringLocalizer localizer,
SessionCultureService cultureService)
{
RuleFor(v => v.Guid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
RuleFor(v => v.Username)
.MinimumLength(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MinimumLength"],
1))
.MaximumLength(32)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
32))
.IsUsername()
.WithMessage(localizer["FluentValidation.IsUsername"]);
RuleFor(v => v.Email)
.IsEmail()
.WithMessage(localizer["FluentValidation.IsEmail"]);
RuleFor(v => v.Password)
.MinimumLength(8)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MinimumLength"],
8))
.MaximumLength(64)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
64));
RuleFor(v => v.Roles ?? new IdentityRole[0])
.IsUnique(r => r)
.WithMessage(localizer["FluentValidation.IsUnique"]);
RuleForEach(v => v.Roles)
.Must(r => IdentityRole.Enumerations.ContainsValue(r))
.WithMessage(
String.Format(
localizer["FluentValidation.MustBeInEnum"],
String.Join(
", ",
IdentityRole.Enumerations.Values.Select(e => e.Name))));
}
}

View File

@ -0,0 +1,8 @@
using MediatR;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccount;
public record GetAccountQuery : IRequest<AccountDto>
{
public Guid Guid { get; set; }
}

View File

@ -0,0 +1,31 @@
using cuqmbr.TravelGuide.Application.Common.Authorization;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccount;
public class GetAccountQueryAuthorizer :
AbstractRequestAuthorizer<GetAccountQuery>
{
private readonly SessionUserService _sessionUserService;
public GetAccountQueryAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(GetAccountQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,51 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Persistence;
using cuqmbr.TravelGuide.Application.Common.Exceptions;
using AutoMapper;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccount;
public class GetAccountQueryHandler :
IRequestHandler<GetAccountQuery, AccountDto>
{
private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public GetAccountQueryHandler(
UnitOfWork unitOfWork,
IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<AccountDto> Handle(
GetAccountQuery request,
CancellationToken cancellationToken)
{
var account = await _unitOfWork.AccountRepository.GetOneAsync(
e => e.Guid == request.Guid, e => e.AccountRoles,
cancellationToken);
if (account == null)
{
throw new NotFoundException();
}
var accountRoleIds = account.AccountRoles.Select(ar => ar.RoleId);
var accountRoles = (await _unitOfWork.AccountRoleRepository
.GetPageAsync(
ar => accountRoleIds.Contains(ar.RoleId),
ar => ar.Role,
1, accountRoleIds.Count(), cancellationToken))
.Items;
account.AccountRoles = accountRoles.ToList();
_unitOfWork.Dispose();
return _mapper.Map<AccountDto>(account);
}
}

View File

@ -0,0 +1,14 @@
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccount;
public class GetAccountQueryValidator : AbstractValidator<GetAccountQuery>
{
public GetAccountQueryValidator(IStringLocalizer localizer)
{
RuleFor(v => v.Guid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
}
}

View File

@ -0,0 +1,18 @@
using cuqmbr.TravelGuide.Application.Common.Models;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccountsPage;
public record GetAccountsPageQuery : IRequest<PaginatedList<AccountDto>>
{
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
public string Search { get; set; } = String.Empty;
public string Sort { get; set; } = String.Empty;
public ICollection<IdentityRole>? Roles { get; set; }
}

View File

@ -0,0 +1,31 @@
using cuqmbr.TravelGuide.Application.Common.Authorization;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccountsPage;
public class GetAccountsPageQueryAuthorizer :
AbstractRequestAuthorizer<GetAccountsPageQuery>
{
private readonly SessionUserService _sessionUserService;
public GetAccountsPageQueryAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(GetAccountsPageQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,81 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Persistence;
using AutoMapper;
using cuqmbr.TravelGuide.Application.Common.Models;
using cuqmbr.TravelGuide.Application.Common.Extensions;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccountsPage;
public class GetAccountsPageQueryHandler :
IRequestHandler<GetAccountsPageQuery, PaginatedList<AccountDto>>
{
private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public GetAccountsPageQueryHandler(
UnitOfWork unitOfWork,
IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<PaginatedList<AccountDto>> Handle(
GetAccountsPageQuery request,
CancellationToken cancellationToken)
{
var paginatedList = await _unitOfWork.AccountRepository.GetPageAsync(
a =>
(a.Username.ToLower().Contains(request.Search.ToLower()) ||
a.Email.ToLower().Contains(request.Search.ToLower())) &&
(request.Roles != null
? request.Roles.All(r => a.AccountRoles.Any(ar => ar.Role.Value == r))
: true),
a => a.AccountRoles,
request.PageNumber, request.PageSize, cancellationToken);
var accounts = paginatedList.Items;
var accountsRoleIds = accounts
.SelectMany(a => a.AccountRoles)
.Select(ar => ar.RoleId)
.Distinct();
var roles = (await _unitOfWork.RoleRepository
.GetPageAsync(
r => accountsRoleIds.Contains(r.Id),
1, accountsRoleIds.Count(), cancellationToken))
.Items;
foreach (var account in accounts)
{
account.AccountRoles = account.AccountRoles.Select(ar =>
new AccountRole()
{
RoleId = ar.RoleId,
Role = roles.Single(r => r.Id == ar.RoleId),
AccountId = account.Id,
Account = account
})
.ToArray();
}
var mappedItems = _mapper
.ProjectTo<AccountDto>(accounts.AsQueryable());
mappedItems = QueryableExtension<AccountDto>
.ApplySort(mappedItems, request.Sort);
_unitOfWork.Dispose();
return new PaginatedList<AccountDto>(
mappedItems.ToList(),
paginatedList.TotalCount, request.PageNumber,
request.PageSize);
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,43 @@
using cuqmbr.TravelGuide.Application.Common.Services;
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccountsPage;
public class GetAccountsPageQueryValidator : AbstractValidator<GetAccountsPageQuery>
{
public GetAccountsPageQueryValidator(
IStringLocalizer localizer,
SessionCultureService cultureService)
{
RuleFor(v => v.PageNumber)
.GreaterThanOrEqualTo(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"],
1));
RuleFor(v => v.PageSize)
.GreaterThanOrEqualTo(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"],
1))
.LessThanOrEqualTo(50)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.LessThanOrEqualTo"],
50));
RuleFor(v => v.Search)
.MaximumLength(64)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
64));
}
}

View File

@ -2,6 +2,8 @@ namespace cuqmbr.TravelGuide.Application.Identity.Accounts.ViewModels;
public sealed class AddAccountViewModel public sealed class AddAccountViewModel
{ {
public string Username { get; set; }
public string Email { get; set; } public string Email { get; set; }
public string Password { get; set; } public string Password { get; set; }

View File

@ -0,0 +1,6 @@
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.ViewModels;
public sealed class GetAccountsPageFilterViewModel
{
public ICollection<string>? Roles { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace cuqmbr.TravelGuide.Application.Identity.Accounts.ViewModels;
public sealed class UpdateAccountViewModel
{
public Guid Uuid { get; set; }
public string? Username { get; set; }
public string? Email { get; set; }
public string? Password { get; set; }
public ICollection<string>? Roles { get; set; }
}

View File

@ -17,6 +17,8 @@ public record GetPaymentLinkCommand : IRequest<PaymentLinkDto>
public DateOnly PassangerBirthDate { get; set; } public DateOnly PassangerBirthDate { get; set; }
public string? PassangerEmail { get; set; }
public ICollection<TicketGroupPaymentTicketModel> Tickets { get; set; } public ICollection<TicketGroupPaymentTicketModel> Tickets { get; set; }

View File

@ -1,6 +1,4 @@
using cuqmbr.TravelGuide.Application.Common.Authorization; using cuqmbr.TravelGuide.Application.Common.Authorization;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR.Behaviors.Authorization; using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
@ -9,24 +7,8 @@ namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
public class GetPaymentLinkCommandAuthorizer : public class GetPaymentLinkCommandAuthorizer :
AbstractRequestAuthorizer<GetPaymentLinkCommand> AbstractRequestAuthorizer<GetPaymentLinkCommand>
{ {
private readonly SessionUserService _sessionUserService;
public GetPaymentLinkCommandAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(GetPaymentLinkCommand request) public override void BuildPolicy(GetPaymentLinkCommand request)
{ {
UseRequirement(new MustBeAuthenticatedRequirement UseRequirement(new AllowAllRequirement());
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
} }
} }

View File

@ -21,16 +21,30 @@ public class GetPaymentLinkCommandHandler :
private readonly IStringLocalizer _localizer; private readonly IStringLocalizer _localizer;
private readonly EmailSenderService _emailSender;
private readonly SessionTimeZoneService _sessionTimeZoneService;
private readonly SessionCultureService _sessionCultureService;
private readonly SessionUserService _sessionUserService;
public GetPaymentLinkCommandHandler( public GetPaymentLinkCommandHandler(
UnitOfWork unitOfWork, UnitOfWork unitOfWork,
CurrencyConverterService currencyConverterService, CurrencyConverterService currencyConverterService,
LiqPayPaymentService liqPayPaymentService, LiqPayPaymentService liqPayPaymentService,
IStringLocalizer localizer) IStringLocalizer localizer,
EmailSenderService emailSender,
SessionTimeZoneService SessionTimeZoneService,
SessionCultureService sessionCultureService,
SessionUserService sessionUserService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_currencyConverterService = currencyConverterService; _currencyConverterService = currencyConverterService;
_liqPayPaymentService = liqPayPaymentService; _liqPayPaymentService = liqPayPaymentService;
_localizer = localizer; _localizer = localizer;
_emailSender = emailSender;
_sessionTimeZoneService = SessionTimeZoneService;
_sessionCultureService = sessionCultureService;
_sessionUserService = sessionUserService;
} }
public async Task<PaymentLinkDto> Handle( public async Task<PaymentLinkDto> Handle(
@ -322,7 +336,7 @@ public class GetPaymentLinkCommandHandler :
// TODO: This counts departure address stop time which is // TODO: This counts departure address stop time which is
// not wrong but may be not desired. // not wrong but may be not desired.
var timeToDeparture = verad var timeToDeparture = verad
.TakeWhile(rad => rad.Id != departureRouteAddressId) .TakeWhile(rad => rad.RouteAddressId != departureRouteAddressId)
.Aggregate(TimeSpan.Zero, (sum, next) => .Aggregate(TimeSpan.Zero, (sum, next) =>
sum + next.TimeToNextAddress + next.CurrentAddressStopTime); sum + next.TimeToNextAddress + next.CurrentAddressStopTime);
@ -336,7 +350,7 @@ public class GetPaymentLinkCommandHandler :
var costToDeparture = verad var costToDeparture = verad
.TakeWhile(rad => rad.Id != departureRouteAddressId) .TakeWhile(rad => rad.RouteAddressId != departureRouteAddressId)
.Aggregate((decimal)0, (sum, next) => .Aggregate((decimal)0, (sum, next) =>
sum + next.CostToNextAddress); sum + next.CostToNextAddress);
@ -398,6 +412,10 @@ public class GetPaymentLinkCommandHandler :
.Items; .Items;
var account = await _unitOfWork.AccountRepository.GetOneAsync(
e => e.Guid == _sessionUserService.Guid, cancellationToken);
var travelTime = var travelTime =
ticketsDetails.OrderBy(td => td.order).Last().arrivalTime - ticketsDetails.OrderBy(td => td.order).Last().arrivalTime -
ticketsDetails.OrderBy(td => td.order).First().departureTime; ticketsDetails.OrderBy(td => td.order).First().departureTime;
@ -412,6 +430,7 @@ public class GetPaymentLinkCommandHandler :
PurchaseTime = DateTimeOffset.UtcNow, PurchaseTime = DateTimeOffset.UtcNow,
Status = TicketStatus.Reserved, Status = TicketStatus.Reserved,
TravelTime = travelTime, TravelTime = travelTime,
PassangerEmail = request.PassangerEmail,
Tickets = request.Tickets.Select( Tickets = request.Tickets.Select(
t => t =>
{ {
@ -428,12 +447,6 @@ public class GetPaymentLinkCommandHandler :
var detail = ticketsDetails var detail = ticketsDetails
.SingleOrDefault(td => td.order == t.Order); .SingleOrDefault(td => td.order == t.Order);
var currency = Currency.UAH;
var cost = _currencyConverterService
.ConvertAsync(
detail.cost, detail.currency, currency,
cancellationToken).Result;
return new Ticket() return new Ticket()
{ {
DepartureRouteAddressId = departureRouteAddress.Id, DepartureRouteAddressId = departureRouteAddress.Id,
@ -441,12 +454,12 @@ public class GetPaymentLinkCommandHandler :
ArrivalRouteAddressId = arrivalRouteAddress.Id, ArrivalRouteAddressId = arrivalRouteAddress.Id,
ArrivalRouteAddress = arrivalRouteAddress, ArrivalRouteAddress = arrivalRouteAddress,
Order = t.Order, Order = t.Order,
Cost = cost, Cost = detail.cost,
Currency = currency, Currency = detail.currency,
VehicleEnrollmentId = ve.Id VehicleEnrollmentId = ve.Id
}; };
}) }).ToArray(),
.ToArray() AccountId = account?.Id
}; };
entity = await _unitOfWork.TicketGroupRepository.AddOneAsync( entity = await _unitOfWork.TicketGroupRepository.AddOneAsync(
@ -456,7 +469,11 @@ public class GetPaymentLinkCommandHandler :
_unitOfWork.Dispose(); _unitOfWork.Dispose();
var amount = entity.Tickets.Sum(e => e.Cost); var amount = entity.Tickets.Sum(e =>
_currencyConverterService
.ConvertAsync(
e.Cost, e.Currency, Currency.UAH,
cancellationToken).Result);
var guid = entity.Guid; var guid = entity.Guid;
var validity = TimeSpan.FromMinutes(10); var validity = TimeSpan.FromMinutes(10);
var resultPath = request.ResultPath; var resultPath = request.ResultPath;
@ -465,9 +482,31 @@ public class GetPaymentLinkCommandHandler :
var paymentLink = _liqPayPaymentService var paymentLink = _liqPayPaymentService
.GetPaymentLink( .GetPaymentLink(
amount, Currency.UAH, guid.ToString(), validity, amount, Currency.UAH, guid.ToString(), validity,
_localizer["PaymentProcessing.TicketPaymentDescription"], _localizer["PaymentProcessing.Ticket.PaymentDescription"],
resultPath, callbackPath); resultPath, callbackPath);
if (request.PassangerEmail != null)
{
var validUntil = DateTimeOffset.UtcNow
.Add(validity)
.ToOffset(_sessionTimeZoneService.TimeZone.BaseUtcOffset);
var subject =
_localizer["PaymentProcessing.Ticket" +
".Email.PaymentCreated.Subject"];
var body = String.Format(
_sessionCultureService.Culture,
_localizer["PaymentProcessing.Ticket" +
".Email.PaymentCreated.Body"],
Currency.UAH.Round(amount), Currency.UAH.Name,
validUntil, paymentLink);
await _emailSender.SendAsync(
new[] { request.PassangerEmail }, subject,
body, cancellationToken);
}
return new PaymentLinkDto() { PaymentLink = paymentLink }; return new PaymentLinkDto() { PaymentLink = paymentLink };
} }
} }

View File

@ -60,6 +60,15 @@ public class GetPaymentLinkCommandValidator :
cultureService.Culture, cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"], localizer["FluentValidation.GreaterThanOrEqualTo"],
DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-100)))); DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-100))));
When(tg => tg.PassangerEmail != null, () =>
{
RuleFor(v => v.PassangerEmail)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.IsEmail()
.WithMessage(localizer["FluentValidation.IsEmail"]);
});
RuleFor(tg => tg.Tickets) RuleFor(tg => tg.Tickets)
.IsUnique(t => t.VehicleEnrollmentGuid) .IsUnique(t => t.VehicleEnrollmentGuid)

View File

@ -5,6 +5,8 @@ using cuqmbr.TravelGuide.Application.Common.Exceptions;
using System.Text; using System.Text;
using Newtonsoft.Json; using Newtonsoft.Json;
using cuqmbr.TravelGuide.Domain.Enums; using cuqmbr.TravelGuide.Domain.Enums;
using Microsoft.Extensions.Localization;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.ProcessCallback; .TicketGroups.Commands.ProcessCallback;
@ -13,20 +15,31 @@ public class ProcessCallbackCommandHandler :
IRequestHandler<ProcessCallbackCommand> IRequestHandler<ProcessCallbackCommand>
{ {
private readonly UnitOfWork _unitOfWork; private readonly UnitOfWork _unitOfWork;
private readonly LiqPayPaymentService _liqPayPaymentService; private readonly LiqPayPaymentService _liqPayPaymentService;
private readonly IStringLocalizer _localizer;
private readonly EmailSenderService _emailSender;
public ProcessCallbackCommandHandler( public ProcessCallbackCommandHandler(
UnitOfWork unitOfWork, UnitOfWork unitOfWork,
LiqPayPaymentService liqPayPaymentService) LiqPayPaymentService liqPayPaymentService,
IStringLocalizer localizer,
EmailSenderService emailSender)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_liqPayPaymentService = liqPayPaymentService; _liqPayPaymentService = liqPayPaymentService;
_localizer = localizer;
_emailSender = emailSender;
} }
public async Task Handle( public async Task Handle(
ProcessCallbackCommand request, ProcessCallbackCommand request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// Validate signature.
var isSignatureValid = _liqPayPaymentService var isSignatureValid = _liqPayPaymentService
.IsValidSignature(request.Data, request.Signature); .IsValidSignature(request.Data, request.Signature);
@ -35,6 +48,9 @@ public class ProcessCallbackCommandHandler :
throw new ForbiddenException(); throw new ForbiddenException();
} }
// Parse request data.
var dataBytes = Convert.FromBase64String(request.Data); var dataBytes = Convert.FromBase64String(request.Data);
var dataJson = Encoding.UTF8.GetString(dataBytes); var dataJson = Encoding.UTF8.GetString(dataBytes);
@ -42,9 +58,11 @@ public class ProcessCallbackCommandHandler :
string status = data.status; string status = data.status;
var ticketGroupGuid = Guid.Parse((string)data.order_id); var ticketGroupGuid = Guid.Parse((string)data.order_id);
var ticketGroup = await _unitOfWork.TicketGroupRepository var ticketGroup = await _unitOfWork.TicketGroupRepository
.GetOneAsync(e => e.Guid == ticketGroupGuid, cancellationToken); .GetOneAsync(e => e.Guid == ticketGroupGuid,
e => e.Tickets, cancellationToken);
if (ticketGroup == null || if (ticketGroup == null ||
ticketGroup.Status == TicketStatus.Purchased) ticketGroup.Status == TicketStatus.Purchased)
@ -52,6 +70,9 @@ public class ProcessCallbackCommandHandler :
throw new ForbiddenException(); throw new ForbiddenException();
} }
// Process callback status
if (status.Equals("error") || status.Equals("failure")) if (status.Equals("error") || status.Equals("failure"))
{ {
await _unitOfWork.TicketGroupRepository await _unitOfWork.TicketGroupRepository
@ -59,12 +80,228 @@ public class ProcessCallbackCommandHandler :
} }
else if (status.Equals("success")) else if (status.Equals("success"))
{ {
// Update ticket status
ticketGroup.Status = TicketStatus.Purchased; ticketGroup.Status = TicketStatus.Purchased;
await _unitOfWork.TicketGroupRepository await _unitOfWork.TicketGroupRepository
.UpdateOneAsync(ticketGroup, cancellationToken); .UpdateOneAsync(ticketGroup, cancellationToken);
// Hydrate ticket group
var vehicleEnrollmentIds =
ticketGroup.Tickets.Select(t => t.VehicleEnrollmentId);
var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository
.GetPageAsync(
ve => vehicleEnrollmentIds.Contains(ve.Id),
ve => ve.Route.RouteAddresses,
1, vehicleEnrollmentIds.Count(), cancellationToken))
.Items;
var routeAddressIds = vehicleEnrollments
.SelectMany(ve => ve.Route.RouteAddresses)
.Select(ra => ra.Id);
var routeAddressDetails = (await _unitOfWork.RouteAddressDetailRepository
.GetPageAsync(
rad => routeAddressIds.Contains(rad.RouteAddressId),
1, routeAddressIds.Count(), cancellationToken))
.Items;
var addressIds = vehicleEnrollments
.SelectMany(ve => ve.Route.RouteAddresses)
.Select(ra => ra.AddressId);
var addresses = (await _unitOfWork.AddressRepository
.GetPageAsync(
a => addressIds.Contains(a.Id),
a => a.City.Region.Country,
1, addressIds.Count(), cancellationToken))
.Items;
var vehicleIds = vehicleEnrollments
.Select(ve => ve.VehicleId);
var vehicles = (await _unitOfWork.VehicleRepository
.GetPageAsync(
v => vehicleIds.Contains(v.Id),
v => v.Company,
1, vehicleIds.Count(), cancellationToken))
.Items;
foreach (var ve in vehicleEnrollments)
{
ve.Vehicle = vehicles.Single(v => v.Id == ve.VehicleId);
foreach (var ra in ve.Route.RouteAddresses)
{
ra.Address = addresses.Single(a => a.Id == ra.AddressId);
ra.Details = routeAddressDetails
.Where(rad => rad.RouteAddressId == ra.Id)
.ToArray();
}
}
foreach (var t in ticketGroup.Tickets)
{
t.VehicleEnrollment = vehicleEnrollments
.Single(ve => ve.Id == t.VehicleEnrollmentId);
}
// Send email
if (ticketGroup.PassangerEmail != null)
{
var subject =
_localizer["PaymentProcessing.Ticket" +
".Email.PaymentCompleted.Subject"];
var ticketDetails = GetTicketDetails(ticketGroup);
var body = String.Format(
_localizer["PaymentProcessing.Ticket" +
".Email.PaymentCompleted.Body"],
ticketDetails);
await _emailSender.SendAsync(
new[] { ticketGroup.PassangerEmail }, subject,
body, cancellationToken);
}
} }
await _unitOfWork.SaveAsync(cancellationToken); await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose(); _unitOfWork.Dispose();
} }
private string GetTicketDetails(TicketGroup ticketGroup)
{
var sb = new StringBuilder();
sb.AppendLine("General:");
sb.AppendLine();
sb.AppendLine($"Ticket uuid: {ticketGroup.Guid}");
sb.AppendLine($"Purchase Time: {ticketGroup.PurchaseTime}");
sb.AppendLine();
var departureRouteAddressId =
ticketGroup.Tickets.First().DepartureRouteAddressId;
var arrivalRouteAddressId =
ticketGroup.Tickets.Last().ArrivalRouteAddressId;
var departureTime =
ticketGroup.Tickets.First()
.VehicleEnrollment.GetDepartureTime(departureRouteAddressId);
var arrivalTime =
ticketGroup.Tickets.Last()
.VehicleEnrollment.GetArrivalTime(arrivalRouteAddressId);
var departureAddress =
ticketGroup.Tickets.First()
.VehicleEnrollment.Route.RouteAddresses
.Single(ra => ra.Id == departureRouteAddressId)
.Address;
var arrivalAddress =
ticketGroup.Tickets.Last()
.VehicleEnrollment.Route.RouteAddresses
.Single(ra => ra.Id == arrivalRouteAddressId)
.Address;
var departureAddressName =
$"{departureAddress.City.Region.Country.Name}, " +
$"{departureAddress.City.Region.Name}, " +
$"{departureAddress.City.Name}, " +
$"{departureAddress.Name}";
var arrivalAddressName =
$"{arrivalAddress.City.Region.Country.Name}, " +
$"{arrivalAddress.City.Region.Name}, " +
$"{arrivalAddress.City.Name}, " +
$"{arrivalAddress.Name}";
sb.AppendLine($"Departure: {departureAddressName} at {departureTime}.");
sb.AppendLine($"Arrival: {arrivalAddressName} at {arrivalTime}.");
sb.AppendLine();
sb.AppendLine();
sb.AppendLine($"Passanger details:");
sb.AppendLine();
sb.AppendLine($"First Name: {ticketGroup.PassangerFirstName}.");
sb.AppendLine($"Last Name: {ticketGroup.PassangerLastName}.");
sb.AppendLine($"Patronymic: {ticketGroup.PassangerPatronymic}.");
sb.AppendLine($"Sex: {ticketGroup.PassangerSex}.");
sb.AppendLine($"Birth Date: {ticketGroup.PassangerBirthDate}.");
sb.AppendLine($"Email: {ticketGroup.PassangerEmail}.");
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("Vehicle enrollments' details:");
sb.AppendLine();
foreach (var t in ticketGroup.Tickets)
{
departureRouteAddressId = t.DepartureRouteAddressId;
arrivalRouteAddressId = t.ArrivalRouteAddressId;
departureTime =
t.VehicleEnrollment.GetDepartureTime(departureRouteAddressId);
arrivalTime =
t.VehicleEnrollment.GetArrivalTime(arrivalRouteAddressId);
departureAddress =
t.VehicleEnrollment.Route.RouteAddresses
.Single(ra => ra.Id == departureRouteAddressId)
.Address;
arrivalAddress =
t.VehicleEnrollment.Route.RouteAddresses
.Single(ra => ra.Id == arrivalRouteAddressId)
.Address;
departureAddressName =
$"{departureAddress.City.Region.Country.Name}, " +
$"{departureAddress.City.Region.Name}, " +
$"{departureAddress.City.Name}, " +
$"{departureAddress.Name}";
arrivalAddressName =
$"{arrivalAddress.City.Region.Country.Name}, " +
$"{arrivalAddress.City.Region.Name}, " +
$"{arrivalAddress.City.Name}, " +
$"{arrivalAddress.Name}";
var vehicle = t.VehicleEnrollment.Vehicle;
var company = vehicle.Company;
sb.AppendLine($"Departure: {departureAddressName} at {departureTime}.");
sb.AppendLine($"Arrival: {arrivalAddressName} at {arrivalTime}.");
if (vehicle is Bus)
{
sb.AppendLine($"Vehicle: Bus, {((Bus)vehicle).Model}, " +
$"{((Bus)vehicle).Number}.");
}
else if (vehicle is Aircraft)
{
sb.AppendLine($"Vehicle: Aircraft, {((Aircraft)vehicle).Model}, " +
$"{((Aircraft)vehicle).Number}.");
}
else if (vehicle is Train)
{
sb.AppendLine($"Vehicle: Train, {((Train)vehicle).Model}, " +
$"{((Train)vehicle).Number}.");
}
else
{
throw new NotImplementedException();
}
sb.AppendLine($"Company: {company.Name}, ({company.ContactEmail}, " +
$"{company.ContactPhoneNumber}).");
var cost = t.Currency.Round(
t.VehicleEnrollment
.GetCost(departureRouteAddressId,arrivalRouteAddressId));
sb.AppendLine($"Cost: {cost} {t.Currency.Name}");
sb.AppendLine();
}
return sb.ToString();
}
} }

View File

@ -13,6 +13,8 @@ public sealed class TicketGroupPaymentViewModel
public DateOnly PassangerBirthDate { get; set; } public DateOnly PassangerBirthDate { get; set; }
public string? PassangerEmail { get; set; }
public ICollection<TicketPaymentViewModel> Tickets { get; set; } public ICollection<TicketPaymentViewModel> Tickets { get; set; }

View File

@ -62,6 +62,18 @@
} }
}, },
"PaymentProcessing": { "PaymentProcessing": {
"TicketPaymentDescription": "Ticket purchase." "Ticket": {
"PaymentDescription": "Ticket purchase.",
"Email": {
"PaymentCreated": {
"Subject": "Ticket purchase payment link.",
"Body": "You have reserved a ticket. Payment amount is {0} {1} Payment link is valid until {2}.\n\nLink: {3}"
},
"PaymentCompleted": {
"Subject": "Ticket purchase complete.",
"Body": "Payment is succeeded.\n\n\nTicket details:\n\n{0}"
}
}
}
} }
} }

View File

@ -1,46 +1,79 @@
{ {
"FluentValidation": { "FluentValidation": {
"MaximumLength": "Повинно бути менше ніж {0:G} символів.",
"NotEmpty": "Не повинно бути порожнім.", "NotEmpty": "Не повинно бути порожнім.",
"GreaterThanOrEqualTo": "Повинно бути більше або дорівнювати {0:G}.", "GreaterThanOrEqualTo": "Повинно бути більше або дорівнювати {0}.",
"LessThanOrEqualTo": "Повинно бути менше або дорівнювати {0:G}." "LessThanOrEqualTo": "Повинно бути менше або дорівнювати {0}.",
"MinimumLength": "Довжина повинна бути більшою або дорівнювати {0} символам.",
"MaximumLength": "Довжина повинна бути меншою або дорівнювати {0} символам.",
"MustBeInEnum": "Повинно бути одним із наступних: {0}.",
"IsUsername": "Може містити латинські літери у нижньому регістрі (a-z), цифри (0-9), дефіси (-), підкреслення (_) та крапки (.).",
"IsEmail": "Повинно бути дійсною електронною адресою відповідно до RFC 5321.",
"IsPhoneNumber": "Повинно бути дійсним номером телефону відповідно до ITU-T E.164 без роздільних символів.",
"IsUnique": "Елементи колекції повинні бути унікальними."
},
"Validation": {
"DistinctOrder": "Повинно мати унікальні значення порядку.",
"SameVehicleType": "Повинно мати однаковий тип транспортного засобу.",
"DateTimeOffset": {
"GreaterThanOrEqualTo": "Повинно бути більше або дорівнювати {0:U}"
},
"VehicleEnrollments": {
"OverlapWithOther": "Наданий запис транспортного засобу перетинається за розкладом з іншим.",
"NegativeTime": "Вказаний час повинен бути додатнім проміжком часу.",
"NegativeCost": "Вказана вартість повинна бути додатнім значенням."
}
}, },
"ExceptionHandling": { "ExceptionHandling": {
"ValidationException": { "ValidationException": {
"Title": "Виникла одна або декілька помилок валідації.", "Title": "Виникла одна або кілька помилок валідації.",
"Detail": "Надані дані не задовольняють вимогам валідації." "Detail": "Надані дані не відповідають вимогам валідації."
}, },
"RegistrationException": { "RegistrationException": {
"Title": "Реєстрація не вдалася.", "Title": "Реєстрація не вдалася.",
"Detail": "Електронна пошта вже зареєстрована." "Detail": "Електронна пошта вже зареєстрована."
}, },
"UnAuthorizedException": { "UnAuthorizedException": {
"Title": "Доступ без автентифікації заблоковано.", "Title": "Неавтентифікований доступ заблоковано.",
"Detail": "Запит не містить дійсних автентифікаційних даних для цільового ресурсу." "Detail": "Запит не містить дійсних автентифікаційних даних для цільового ресурсу."
}, },
"AithenticationException": { "AuthenticationException": {
"Title": "Автентифікація не вдалася.", "Title": "Автентифікація не вдалася.",
"Detail": "Перевірте правильність наданих облікових даних." "Detail": "Перевірте правильність наданих облікових даних."
}, },
"LoginException": { "LoginException": {
"Title": "Вхід не вдалий.", "Title": "Вхід не вдалий.",
"Detail": "Надані електронна пошта та/або пароль недійсні." "Detail": "Надана електронна пошта та/або пароль недійсні."
}, },
"ForbiddenException": { "ForbiddenException": {
"Title": "Доступ заборонено.", "Title": "Неавторизований доступ заблоковано.",
"Detail": "У вас недостатньо прав для виконання запиту." "Detail": "У вас недостатньо прав для виконання запиту."
}, },
"DuplicateEntityException": { "DuplicateEntityException": {
"Title": "Обєкт вже існує.", "Title": "Об'єкт вже існує.",
"Detail": "Дублювання не дозволяється." "Detail": "Дублікати заборонені."
}, },
"NotFoundException": { "NotFoundException": {
"Title": "Один або декілька ресурсів не знайдено.", "Title": "Один або кілька ресурсів не знайдено.",
"Detail": "Перевірте правильність вхідних даних." "Detail": "Перевірте правильність вхідних даних."
}, },
"UnhandledException": { "UnhandledException": {
"Title": "Виникла одна або декілька внутрішніх помилок сервера.", "Title": "Виникла одна або кілька внутрішніх помилок сервера.",
"Detail": "Повідомте про цю помилку службі підтримки сервісу." "Detail": "Повідомте про цю помилку команді підтримки сервісу."
}
},
"PaymentProcessing": {
"Ticket": {
"PaymentDescription": "Придбання квитка.",
"Email": {
"PaymentCreated": {
"Subject": "Посилання для оплати придбання квитка.",
"Body": "Ви забронювали квиток. Сума оплати становить {0} {1}. Посилання дійсне до {2}.\n\nПосилання: {3}"
},
"PaymentCompleted": {
"Subject": "Придбання квитка завершено.",
"Body": "Оплата пройшла успішно.\n\n\nОсь деталі вашого квитка:\n\n{0}"
}
}
} }
} }
} }

View File

@ -148,7 +148,7 @@ public class AddTicketGroupCommandHandler :
var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository
.GetPageAsync( .GetPageAsync(
e => vehicleEnrollmentGuids.Contains(e.Guid), e => vehicleEnrollmentGuids.Contains(e.Guid),
e => e.Vehicle, e => e.Vehicle.Company,
1, vehicleEnrollmentGuids.Count(), cancellationToken)) 1, vehicleEnrollmentGuids.Count(), cancellationToken))
.Items; .Items;
@ -325,7 +325,7 @@ public class AddTicketGroupCommandHandler :
// TODO: This counts departure address stop time which is // TODO: This counts departure address stop time which is
// not wrong but may be not desired. // not wrong but may be not desired.
var timeToDeparture = verad var timeToDeparture = verad
.TakeWhile(rad => rad.Id != departureRouteAddressId) .TakeWhile(rad => rad.RouteAddressId != departureRouteAddressId)
.Aggregate(TimeSpan.Zero, (sum, next) => .Aggregate(TimeSpan.Zero, (sum, next) =>
sum + next.TimeToNextAddress + next.CurrentAddressStopTime); sum + next.TimeToNextAddress + next.CurrentAddressStopTime);
@ -339,7 +339,7 @@ public class AddTicketGroupCommandHandler :
var costToDeparture = verad var costToDeparture = verad
.TakeWhile(rad => rad.Id != departureRouteAddressId) .TakeWhile(rad => rad.RouteAddressId != departureRouteAddressId)
.Aggregate((decimal)0, (sum, next) => .Aggregate((decimal)0, (sum, next) =>
sum + next.CostToNextAddress); sum + next.CostToNextAddress);
@ -416,7 +416,6 @@ public class AddTicketGroupCommandHandler :
1, vehicleEnrollmentGuids.Count(), cancellationToken)) 1, vehicleEnrollmentGuids.Count(), cancellationToken))
.Items; .Items;
var routeAddressGuids = var routeAddressGuids =
request.Tickets.Select(t => t.DepartureRouteAddressGuid).Concat( request.Tickets.Select(t => t.DepartureRouteAddressGuid).Concat(
request.Tickets.Select(t => t.ArrivalRouteAddressGuid)); request.Tickets.Select(t => t.ArrivalRouteAddressGuid));

View File

@ -0,0 +1,8 @@
using MediatR;
namespace cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroup;
public record GetTicketGroupQuery : IRequest<TicketGroupDto>
{
public Guid Guid { get; set; }
}

View File

@ -0,0 +1,31 @@
using cuqmbr.TravelGuide.Application.Common.Authorization;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroup;
public class GetTicketGroupQueryAuthorizer :
AbstractRequestAuthorizer<GetTicketGroupQuery>
{
private readonly SessionUserService _sessionUserService;
public GetTicketGroupQueryAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(GetTicketGroupQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,183 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Persistence;
using cuqmbr.TravelGuide.Application.Common.Exceptions;
using AutoMapper;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
namespace cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroup;
public class GetTicketGroupQueryHandler :
IRequestHandler<GetTicketGroupQuery, TicketGroupDto>
{
private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly CurrencyConverterService _currencyConverter;
private readonly SessionCurrencyService _sessionCurrencyService;
private readonly SessionTimeZoneService _sessionTimeZoneService;
private readonly object _lock = new();
public GetTicketGroupQueryHandler(UnitOfWork unitOfWork,
IMapper mapper, CurrencyConverterService currencyConverterService,
SessionCurrencyService sessionCurrencyService,
SessionTimeZoneService sessionTimeZoneService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_currencyConverter = currencyConverterService;
_sessionCurrencyService = sessionCurrencyService;
_sessionTimeZoneService = sessionTimeZoneService;
}
public async Task<TicketGroupDto> Handle(
GetTicketGroupQuery request,
CancellationToken cancellationToken)
{
var ticketGroup = await _unitOfWork.TicketGroupRepository.GetOneAsync(
e => e.Guid == request.Guid, e => e.Tickets,
cancellationToken);
if (ticketGroup == null)
{
throw new NotFoundException();
}
// Hydrate
var vehicleEnrollmentIds =
ticketGroup.Tickets.Select(t => t.VehicleEnrollmentId);
var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository
.GetPageAsync(
ve => vehicleEnrollmentIds.Contains(ve.Id),
ve => ve.Route.RouteAddresses,
1, vehicleEnrollmentIds.Count(), cancellationToken))
.Items;
var routeAddressIds = vehicleEnrollments
.SelectMany(ve => ve.Route.RouteAddresses)
.Select(ra => ra.Id);
var routeAddressDetails = (await _unitOfWork.RouteAddressDetailRepository
.GetPageAsync(
rad => routeAddressIds.Contains(rad.RouteAddressId),
1, routeAddressIds.Count(), cancellationToken))
.Items;
var addressIds = vehicleEnrollments
.SelectMany(ve => ve.Route.RouteAddresses)
.Select(ra => ra.AddressId);
var addresses = (await _unitOfWork.AddressRepository
.GetPageAsync(
a => addressIds.Contains(a.Id),
a => a.City.Region.Country,
1, addressIds.Count(), cancellationToken))
.Items;
var vehicleIds = vehicleEnrollments
.Select(ve => ve.VehicleId);
var vehicles = (await _unitOfWork.VehicleRepository
.GetPageAsync(
v => vehicleIds.Contains(v.Id),
v => v.Company,
1, vehicleIds.Count(), cancellationToken))
.Items;
var account = await _unitOfWork.AccountRepository.GetOneAsync(
a => a.Id == ticketGroup.AccountId, cancellationToken);
if (ticketGroup.AccountId != null)
{
ticketGroup.Account = account;
}
foreach (var ve in vehicleEnrollments)
{
ve.Vehicle = vehicles.Single(v => v.Id == ve.VehicleId);
foreach (var ra in ve.Route.RouteAddresses)
{
ra.Address = addresses.Single(a => a.Id == ra.AddressId);
ra.Details = routeAddressDetails
.Where(rad => rad.RouteAddressId == ra.Id)
.ToArray();
}
}
// TODO: Replace with AutoMapper resolvers
// Convert currency and apply session time zone
var convertTasks = new List<Task>();
foreach (var t in ticketGroup.Tickets)
{
convertTasks.Add(Task.Factory.StartNew(() =>
{
t.VehicleEnrollment.DepartureTime =
TimeZoneInfo.ConvertTime(t.VehicleEnrollment.DepartureTime,
_sessionTimeZoneService.TimeZone);
}));
if (_sessionCurrencyService.Currency.Equals(Currency.Default))
{
break;
}
convertTasks.Add(Task.Factory.StartNew(() =>
{
lock (_lock)
{
var convertedCost = _currencyConverter.ConvertAsync(t.Cost,
t.Currency, _sessionCurrencyService.Currency,
cancellationToken)
.Result;
t.Cost = _sessionCurrencyService
.Currency.Round(convertedCost);
}
}));
foreach (var rad in t.VehicleEnrollment.RouteAddressDetails)
{
convertTasks.Add(Task.Factory.StartNew(() =>
{
lock (_lock)
{
var convertedCost = _currencyConverter.ConvertAsync(
rad.CostToNextAddress, t.VehicleEnrollment.Currency,
_sessionCurrencyService.Currency, cancellationToken)
.Result;
rad.CostToNextAddress = _sessionCurrencyService
.Currency.Round(convertedCost);
}
}));
}
}
Task.WaitAll(convertTasks);
foreach (var t in ticketGroup.Tickets)
{
if (_sessionCurrencyService.Currency.Equals(Currency.Default))
{
break;
}
t.Currency = _sessionCurrencyService.Currency;
t.VehicleEnrollment.Currency = _sessionCurrencyService.Currency;
}
_unitOfWork.Dispose();
var dto = _mapper.Map<TicketGroupDto>(ticketGroup);
dto.Currency = _sessionCurrencyService.Currency.Name;
return dto;
}
}

View File

@ -0,0 +1,14 @@
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroup;
public class GetTicketGroupQueryValidator : AbstractValidator<GetTicketGroupQuery>
{
public GetTicketGroupQueryValidator(IStringLocalizer localizer)
{
RuleFor(v => v.Guid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
}
}

View File

@ -0,0 +1,55 @@
using cuqmbr.TravelGuide.Application.Common.Models;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR;
namespace cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroupsPage;
public record GetTicketGroupsPageQuery : IRequest<PaginatedList<TicketGroupDto>>
{
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
public string Search { get; set; } = String.Empty;
public string Sort { get; set; } = String.Empty;
public HashSet<Sex>? PassangerSex { get; set; }
public DateOnly? PassangerBirthDateGreaterThanOrEqualTo { get; set; }
public DateOnly? PassangerBirthDateLessThanOrEqualTo { get; set; }
public DateTimeOffset? PurchaseTimeGreaterThanOrEqualTo { get; set; }
public DateTimeOffset? PurchaseTimeLessThanOrEqualTo { get; set; }
public HashSet<TicketStatus>? Statuses { get; set; }
public HashSet<VehicleType>? VehicleTypes { get; set; }
public TimeSpan? TravelTimeGreaterThanOrEqualTo { get; set; }
public TimeSpan? TravelTimeLessThanOrEqualTo { get; set; }
public Guid? AccountGuid { get; set; }
// TODO: Add filtering parametetrs listed below. It is hard to
// be done because of pagination.
// public decimal? CostGreaterThanOrEqualTo { get; set; }
//
// public decimal? CostLessThanOrEqualTo { get; set; }
//
// public short? NumberOfTransfersGreaterThanOrEqualTo { get; set; }
//
// public short? NumberOfTransfersLessThanOrEqualTo { get; set; }
//
// public DateTimeOffset? DepartureTimeGreaterThanOrEqualTo { get; set; }
//
// public DateTimeOffset? DepartureTimeLessThanOrEqualTo { get; set; }
//
// public DateTimeOffset? ArrivalTimeGreaterThanOrEqualTo { get; set; }
//
// public DateTimeOffset? ArrivalTimeLessThanOrEqualTo { get; set; }
}

View File

@ -0,0 +1,31 @@
using cuqmbr.TravelGuide.Application.Common.Authorization;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroupsPage;
public class GetTicketGroupsPageQueryAuthorizer :
AbstractRequestAuthorizer<GetTicketGroupsPageQuery>
{
private readonly SessionUserService _sessionUserService;
public GetTicketGroupsPageQueryAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(GetTicketGroupsPageQuery request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,251 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Persistence;
using AutoMapper;
using cuqmbr.TravelGuide.Application.Common.Models;
using cuqmbr.TravelGuide.Application.Common.Extensions;
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
namespace cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroupsPage;
public class GetTicketGroupsPageQueryHandler :
IRequestHandler<GetTicketGroupsPageQuery, PaginatedList<TicketGroupDto>>
{
private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly CurrencyConverterService _currencyConverter;
private readonly SessionCurrencyService _sessionCurrencyService;
private readonly SessionTimeZoneService _sessionTimeZoneService;
private readonly object _lock = new();
public GetTicketGroupsPageQueryHandler(UnitOfWork unitOfWork,
IMapper mapper, CurrencyConverterService currencyConverterService,
SessionCurrencyService sessionCurrencyService,
SessionTimeZoneService sessionTimeZoneService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_currencyConverter = currencyConverterService;
_sessionCurrencyService = sessionCurrencyService;
_sessionTimeZoneService = sessionTimeZoneService;
}
public async Task<PaginatedList<TicketGroupDto>> Handle(
GetTicketGroupsPageQuery request,
CancellationToken cancellationToken)
{
var paginatedList = await _unitOfWork.TicketGroupRepository.GetPageAsync(
e =>
(e.PassangerFirstName.ToLower().Contains(request.Search.ToLower()) ||
e.PassangerLastName.ToLower().Contains(request.Search.ToLower()) ||
e.PassangerPatronymic.ToLower().Contains(request.Search.ToLower()) ||
(e.PassangerEmail != null ?
e.PassangerEmail.ToLower().Contains(request.Search.ToLower()) :
false)) &&
(request.PassangerSex != null
? request.PassangerSex.Contains(e.PassangerSex)
: true) &&
(request.PassangerBirthDateGreaterThanOrEqualTo != null
? e.PassangerBirthDate >= request.PassangerBirthDateGreaterThanOrEqualTo
: true) &&
(request.PassangerBirthDateLessThanOrEqualTo != null
? e.PassangerBirthDate <= request.PassangerBirthDateLessThanOrEqualTo
: true) &&
(request.PurchaseTimeGreaterThanOrEqualTo != null
? e.PurchaseTime >= request.PurchaseTimeGreaterThanOrEqualTo
: true) &&
(request.PurchaseTimeLessThanOrEqualTo != null
? e.PurchaseTime <= request.PurchaseTimeLessThanOrEqualTo
: true) &&
(request.PassangerSex != null
? request.PassangerSex.Contains(e.PassangerSex)
: true) &&
(request.Statuses != null
? request.Statuses.Contains(e.Status)
: true) &&
(request.VehicleTypes != null
? e.Tickets
.Select(t => t.VehicleEnrollment.Vehicle.VehicleType)
.Any(vt => request.VehicleTypes.Contains(vt))
: true) &&
(request.TravelTimeGreaterThanOrEqualTo != null
? e.TravelTime >= request.TravelTimeGreaterThanOrEqualTo
: true) &&
(request.TravelTimeLessThanOrEqualTo != null
? e.TravelTime <= request.TravelTimeLessThanOrEqualTo
: true) &&
(request.AccountGuid != null
? e.Account.Guid == request.AccountGuid
: true),
e => e.Tickets,
request.PageNumber, request.PageSize, cancellationToken);
var ticketGroups = paginatedList.Items;
// Hydrate
var vehicleEnrollmentIds =
ticketGroups.SelectMany(tg => tg.Tickets)
.Select(t => t.VehicleEnrollmentId);
var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository
.GetPageAsync(
ve => vehicleEnrollmentIds.Contains(ve.Id),
ve => ve.Route.RouteAddresses,
1, vehicleEnrollmentIds.Count(), cancellationToken))
.Items;
var routeAddressIds = vehicleEnrollments
.SelectMany(ve => ve.Route.RouteAddresses)
.Select(ra => ra.Id);
var routeAddressDetails = (await _unitOfWork.RouteAddressDetailRepository
.GetPageAsync(
rad => routeAddressIds.Contains(rad.RouteAddressId),
1, routeAddressIds.Count(), cancellationToken))
.Items;
var addressIds = vehicleEnrollments
.SelectMany(ve => ve.Route.RouteAddresses)
.Select(ra => ra.AddressId);
var addresses = (await _unitOfWork.AddressRepository
.GetPageAsync(
a => addressIds.Contains(a.Id),
a => a.City.Region.Country,
1, addressIds.Count(), cancellationToken))
.Items;
var vehicleIds = vehicleEnrollments
.Select(ve => ve.VehicleId);
var vehicles = (await _unitOfWork.VehicleRepository
.GetPageAsync(
v => vehicleIds.Contains(v.Id),
v => v.Company,
1, vehicleIds.Count(), cancellationToken))
.Items;
var accountIds =
ticketGroups.Select(tg => tg.AccountId);
var accounts = (await _unitOfWork.AccountRepository
.GetPageAsync(
a => accountIds.Contains(a.Id),
1, accountIds.Count(), cancellationToken))
.Items;
foreach (var tg in ticketGroups)
{
if (tg.AccountId != null)
{
tg.Account = accounts.Single(a => a.Id == tg.AccountId);
}
}
foreach (var ve in vehicleEnrollments)
{
ve.Vehicle = vehicles.Single(v => v.Id == ve.VehicleId);
foreach (var ra in ve.Route.RouteAddresses)
{
ra.Address = addresses.Single(a => a.Id == ra.AddressId);
ra.Details = routeAddressDetails
.Where(rad => rad.RouteAddressId == ra.Id)
.ToArray();
}
}
// TODO: Replace with AutoMapper resolvers.
// Convert currency and apply session time zone.
var convertTasks = new List<Task>();
var processedRouteAddressDetailIds = new HashSet<long>();
foreach (var t in ticketGroups.SelectMany(tg => tg.Tickets))
{
convertTasks.Add(Task.Factory.StartNew(() =>
{
t.VehicleEnrollment.DepartureTime =
TimeZoneInfo.ConvertTime(t.VehicleEnrollment.DepartureTime,
_sessionTimeZoneService.TimeZone);
}));
if (_sessionCurrencyService.Currency.Equals(Currency.Default))
{
break;
}
convertTasks.Add(Task.Factory.StartNew(() =>
{
lock (_lock)
{
var convertedCost = _currencyConverter.ConvertAsync(t.Cost,
t.Currency, _sessionCurrencyService.Currency,
t.TicketGroup.PurchaseTime, cancellationToken)
.Result;
t.Cost = _sessionCurrencyService
.Currency.Round(convertedCost);
}
}));
foreach (var rad in t.VehicleEnrollment.RouteAddressDetails)
{
convertTasks.Add(Task.Factory.StartNew(() =>
{
lock (_lock)
{
if (processedRouteAddressDetailIds.Contains(rad.Id))
{
return;
}
var convertedCost = _currencyConverter.ConvertAsync(
rad.CostToNextAddress, t.VehicleEnrollment.Currency,
_sessionCurrencyService.Currency,
t.TicketGroup.PurchaseTime, cancellationToken)
.Result;
rad.CostToNextAddress = _sessionCurrencyService
.Currency.Round(convertedCost);
processedRouteAddressDetailIds.Add(rad.Id);
}
}));
}
}
Task.WaitAll(convertTasks);
foreach (var t in ticketGroups.SelectMany(tg => tg.Tickets))
{
if (_sessionCurrencyService.Currency.Equals(Currency.Default))
{
break;
}
t.Currency = _sessionCurrencyService.Currency;
t.VehicleEnrollment.Currency = _sessionCurrencyService.Currency;
}
var mappedItems =
_mapper.Map<IEnumerable<TicketGroupDto>>(ticketGroups);
foreach (var item in mappedItems)
{
item.Currency = _sessionCurrencyService.Currency.Name;
}
mappedItems = QueryableExtension<TicketGroupDto>
.ApplySort(mappedItems.AsQueryable(), request.Sort);
_unitOfWork.Dispose();
return new PaginatedList<TicketGroupDto>(
mappedItems.ToList(),
paginatedList.TotalCount, request.PageNumber,
request.PageSize);
}
}

View File

@ -0,0 +1,80 @@
using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroupsPage;
public class GetTicketGroupsPageQueryValidator : AbstractValidator<GetTicketGroupsPageQuery>
{
public GetTicketGroupsPageQueryValidator(
IStringLocalizer localizer,
SessionCultureService cultureService)
{
RuleFor(v => v.PageNumber)
.GreaterThanOrEqualTo(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"],
1));
RuleFor(v => v.PageSize)
.GreaterThanOrEqualTo(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"],
1))
.LessThanOrEqualTo(50)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.LessThanOrEqualTo"],
50));
RuleFor(v => v.Search)
.MaximumLength(64)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
64));
When(v => v.PassangerSex != null, () =>
{
RuleForEach(v => v.PassangerSex)
.Must((v, s) => Sex.Enumerations.ContainsValue(s))
.WithMessage(
String.Format(
localizer["FluentValidation.MustBeInEnum"],
String.Join(
", ",
Sex.Enumerations.Values.Select(e => e.Name))));
});
When(v => v.Statuses != null, () =>
{
RuleForEach(v => v.Statuses)
.Must((v, s) => TicketStatus.Enumerations.ContainsValue(s))
.WithMessage(
String.Format(
localizer["FluentValidation.MustBeInEnum"],
String.Join(
", ",
TicketStatus.Enumerations.Values.Select(e => e.Name))));
});
When(v => v.VehicleTypes != null, () =>
{
RuleForEach(v => v.VehicleTypes)
.Must((v, s) => VehicleType.Enumerations.ContainsValue(s))
.WithMessage(
String.Format(
localizer["FluentValidation.MustBeInEnum"],
String.Join(
", ",
VehicleType.Enumerations.Values.Select(e => e.Name))));
});
}
}

View File

@ -1,53 +0,0 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.TicketGroups;
public sealed class TicketAddressDto : IMapFrom<Address>
{
public Guid Uuid { get; set; }
public string Name { get; set; }
public double Longitude { get; set; }
public double Latitude { get; set; }
public Guid CountryUuid { get; set; }
public string CountryName { get; set; }
public Guid RegionUuid { get; set; }
public string RegionName { get; set; }
public Guid CityUuid { get; set; }
public string CityName { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<Address, TicketAddressDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.Guid))
.ForMember(
d => d.CountryUuid,
opt => opt.MapFrom(s => s.City.Region.Country.Guid))
.ForMember(
d => d.CountryName,
opt => opt.MapFrom(s => s.City.Region.Country.Name))
.ForMember(
d => d.RegionUuid,
opt => opt.MapFrom(s => s.City.Region.Guid))
.ForMember(
d => d.RegionName,
opt => opt.MapFrom(s => s.City.Region.Name))
.ForMember(
d => d.CityUuid,
opt => opt.MapFrom(s => s.City.Guid))
.ForMember(
d => d.CityName,
opt => opt.MapFrom(s => s.City.Name));
}
}

View File

@ -1,50 +0,0 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.TicketGroups;
public sealed class TicketDto : IMapFrom<Ticket>
{
public Guid Uuid { get; set; }
public Guid DepartureRouteAddressUuid { get; set; }
public Guid ArrivalRouteAddressUuid { get; set; }
public TicketAddressDto DepartureAddress { get; set; }
public TicketAddressDto ArrivalAddress { get; set; }
public short Order { get; set; }
public Guid VehicleEnrollmentUuid { get; set; }
// TODO: Add VehicleEnrollment model
public string Currency { get; set; }
public decimal Cost { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<Ticket, TicketDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.Guid))
.ForMember(
d => d.DepartureRouteAddressUuid,
opt => opt.MapFrom(s => s.DepartureRouteAddress.Guid))
.ForMember(
d => d.ArrivalRouteAddressUuid,
opt => opt.MapFrom(s => s.ArrivalRouteAddress.Guid))
.ForMember(
d => d.DepartureAddress,
opt => opt.MapFrom(s => s.DepartureRouteAddress.Address))
.ForMember(
d => d.ArrivalAddress,
opt => opt.MapFrom(s => s.ArrivalRouteAddress.Address))
.ForMember(
d => d.VehicleEnrollmentUuid,
opt => opt.MapFrom(s => s.VehicleEnrollment.Guid));
}
}

View File

@ -0,0 +1,21 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.TicketGroups;
public sealed class TicketGroupAccountDto : IMapFrom<Account>
{
public Guid Uuid { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<Account, TicketGroupAccountDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.Guid));
}
}

View File

@ -0,0 +1,87 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.TicketGroups;
public sealed class TicketGroupAddressDto : IMapFrom<RouteAddressDetail>
{
public Guid Uuid { get; set; }
public string Name { get; set; }
public double Longitude { get; set; }
public double Latitude { get; set; }
public Guid CountryUuid { get; set; }
public string CountryName { get; set; }
public Guid RegionUuid { get; set; }
public string RegionName { get; set; }
public Guid CityUuid { get; set; }
public string CityName { get; set; }
public TimeSpan TimeToNextAddress { get; set; }
public decimal CostToNextAddress { get; set; }
public TimeSpan CurrentAddressStopTime { get; set; }
public short Order { get; set; }
public Guid RouteAddressUuid { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<RouteAddressDetail, TicketGroupAddressDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.RouteAddress.Address.Guid))
.ForMember(
d => d.Name,
opt => opt.MapFrom(s => s.RouteAddress.Address.Name))
.ForMember(
d => d.Longitude,
opt => opt.MapFrom(s => s.RouteAddress.Address.Longitude))
.ForMember(
d => d.Latitude,
opt => opt.MapFrom(s => s.RouteAddress.Address.Latitude))
.ForMember(
d => d.CountryUuid,
opt => opt.MapFrom(s => s.RouteAddress.Address.City.Region.Country.Guid))
.ForMember(
d => d.CountryName,
opt => opt.MapFrom(s => s.RouteAddress.Address.City.Region.Country.Name))
.ForMember(
d => d.RegionUuid,
opt => opt.MapFrom(s => s.RouteAddress.Address.City.Region.Guid))
.ForMember(
d => d.RegionName,
opt => opt.MapFrom(s => s.RouteAddress.Address.City.Region.Name))
.ForMember(
d => d.CityUuid,
opt => opt.MapFrom(s => s.RouteAddress.Address.City.Guid))
.ForMember(
d => d.CityName,
opt => opt.MapFrom(s => s.RouteAddress.Address.City.Name))
.ForMember(
d => d.TimeToNextAddress,
opt => opt.MapFrom(s => s.TimeToNextAddress))
.ForMember(
d => d.CostToNextAddress,
opt => opt.MapFrom(s => s.CostToNextAddress))
.ForMember(
d => d.CurrentAddressStopTime,
opt => opt.MapFrom(s => s.CurrentAddressStopTime))
.ForMember(
d => d.Order,
opt => opt.MapFrom(s => s.RouteAddress.Order))
.ForMember(
d => d.RouteAddressUuid,
opt => opt.MapFrom(s => s.RouteAddress.Guid));
}
}

View File

@ -0,0 +1,25 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.TicketGroups;
public sealed class TicketGroupCompanyDto : IMapFrom<Company>
{
public string Uuid { get; set; }
public string Name { get; set; }
public string LegalAddress { get; set; }
public string ContactEmail { get; set; }
public string ContactPhoneNumber { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<Company, TicketGroupCompanyDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.Guid));
}
}

View File

@ -18,14 +18,30 @@ public sealed class TicketGroupDto : IMapFrom<TicketGroup>
public DateOnly PassangerBirthDate { get; set; } public DateOnly PassangerBirthDate { get; set; }
public string? PassangerEmail { get; set; }
public DateTimeOffset PurchaseTime { get; set; } public DateTimeOffset PurchaseTime { get; set; }
public bool Returned { get; set; } public string Status { get; set; }
public DateTimeOffset DepartureTime { get; set; }
public DateTimeOffset ArrivalTime { get; set; }
public TimeSpan TravelTime { get; set; } public TimeSpan TravelTime { get; set; }
public TimeSpan TimeInStops { get; set; }
public ICollection<TicketDto> Tickets { get; set; } public int NumberOfTransfers { get; set; }
public string Currency { get; set; }
public decimal Cost { get; set; }
public TicketGroupAccountDto? Account { get; set; }
public ICollection<TicketGroupVehicleEnrollmentDto> Enrollments { get; set; }
public void Mapping(MappingProfile profile) public void Mapping(MappingProfile profile)
{ {
@ -40,6 +56,108 @@ public sealed class TicketGroupDto : IMapFrom<TicketGroup>
d => d.PurchaseTime, d => d.PurchaseTime,
opt => opt opt => opt
.MapFrom<DateTimeOffsetToLocalResolver, DateTimeOffset>( .MapFrom<DateTimeOffsetToLocalResolver, DateTimeOffset>(
s => s.PurchaseTime)); s => s.PurchaseTime))
.ForMember(
d => d.Status,
opt => opt.MapFrom(s => s.Status.Name))
.ForMember(
d => d.DepartureTime,
opt => opt.MapFrom(
(s, d) =>
{
var departureRouteAddressId =
s.Tickets
.OrderBy(t => t.Order)
.First()
.DepartureRouteAddressId;
return
s.Tickets
.OrderBy(t => t.Order)
.First().VehicleEnrollment
.GetDepartureTime(departureRouteAddressId);
}))
.ForMember(
d => d.ArrivalTime,
opt => opt.MapFrom(
(s, d) =>
{
var arrivalRouteAddressId =
s.Tickets
.OrderBy(t => t.Order)
.First()
.ArrivalRouteAddressId;
return
s.Tickets
.OrderBy(t => t.Order)
.First().VehicleEnrollment
.GetArrivalTime(arrivalRouteAddressId);
}))
.ForMember(
d => d.TravelTime,
opt => opt.MapFrom(
(s, d) =>
{
var departureRouteAddressId =
s.Tickets
.OrderBy(t => t.Order)
.First()
.DepartureRouteAddressId;
var arrivalRouteAddressId =
s.Tickets
.OrderBy(t => t.Order)
.First()
.ArrivalRouteAddressId;
var departureTime =
s.Tickets
.OrderBy(t => t.Order)
.First().VehicleEnrollment
.GetDepartureTime(departureRouteAddressId);
var arrivalTime =
s.Tickets
.OrderBy(t => t.Order)
.First().VehicleEnrollment
.GetArrivalTime(departureRouteAddressId);
return arrivalTime - departureTime;
}))
.ForMember(
d => d.TimeInStops,
opt => opt.MapFrom(
(s, d) =>
{
var timePeriodsInStops =
s.Tickets.Select(t =>
{
var departureRouteAddressId =
t.DepartureRouteAddressId;
var arrivalRouteAddressId =
t.ArrivalRouteAddressId;
return
t.VehicleEnrollment.GetTimeInStops(
departureRouteAddressId,
arrivalRouteAddressId);
});
return
timePeriodsInStops
.Aggregate(TimeSpan.Zero,
(sum, next) => sum += next);
}))
.ForMember(
d => d.NumberOfTransfers,
opt => opt.MapFrom(s => s.Tickets.Count() - 1))
.ForMember(
d => d.Cost,
opt => opt.MapFrom(
(s, d) =>
{
var costs =
s.Tickets.Select(t => t.Currency.Round(t.Cost));
return
costs
.Aggregate((decimal)0,
(sum, next) => sum += next);
}))
.ForMember(
d => d.Enrollments,
opt => opt.MapFrom(s => s.Tickets));
} }
} }

View File

@ -0,0 +1,95 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.TicketGroups;
public sealed class TicketGroupVehicleDto : IMapFrom<Vehicle>
{
public string Uuid { get; set; }
public string Type { get; set; }
public string Number { get; set; }
public string Model { get; set; }
public short Capacity { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<Vehicle, TicketGroupVehicleDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.Guid))
.ForMember(
d => d.Type,
opt => opt.MapFrom(s => s.VehicleType.Name))
.ForMember(
d => d.Number,
opt => opt.MapFrom(
(s, d) =>
{
if (s is Bus)
{
return ((Bus)s).Number;
}
else if (s is Aircraft)
{
return ((Aircraft)s).Number;
}
else if (s is Train)
{
return ((Train)s).Number;
}
else
{
throw new NotImplementedException();
}
}))
.ForMember(
d => d.Model,
opt => opt.MapFrom(
(s, d) =>
{
if (s is Bus)
{
return ((Bus)s).Model;
}
else if (s is Aircraft)
{
return ((Aircraft)s).Model;
}
else if (s is Train)
{
return ((Train)s).Model;
}
else
{
throw new NotImplementedException();
}
}))
.ForMember(
d => d.Capacity,
opt => opt.MapFrom(
(s, d) =>
{
if (s is Bus)
{
return ((Bus)s).Capacity;
}
else if (s is Aircraft)
{
return ((Aircraft)s).Capacity;
}
else if (s is Train)
{
return ((Train)s).Capacity;
}
else
{
throw new NotImplementedException();
}
}));
}
}

View File

@ -0,0 +1,99 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.TicketGroups;
public sealed class TicketGroupVehicleEnrollmentDto : IMapFrom<Ticket>
{
public DateTimeOffset DepartureTime { get; set; }
public DateTimeOffset ArrivalTime { get; set; }
public TimeSpan TravelTime { get; set; }
public TimeSpan TimeMoving { get; set; }
public TimeSpan TimeInStops { get; set; }
public int NumberOfStops { get; set; }
public string Currency { get; set; }
public decimal Cost { get; set; }
public Guid Uuid { get; set; }
public short Order { get; set; }
public TicketGroupCompanyDto Company { get; set; }
public TicketGroupVehicleDto Vehicle { get; set; }
public ICollection<TicketGroupAddressDto> Addresses { get; set; }
public void Mapping(MappingProfile profile)
{
profile.CreateMap<Ticket, TicketGroupVehicleEnrollmentDto>()
.ForMember(
d => d.Uuid,
opt => opt.MapFrom(s => s.VehicleEnrollment.Guid))
.ForMember(
d => d.DepartureTime,
opt => opt.MapFrom(
(s, d) =>
{
return s.VehicleEnrollment
.GetDepartureTime(s.DepartureRouteAddressId);
}))
.ForMember(
d => d.ArrivalTime,
opt => opt.MapFrom(
(s, d) =>
{
return s.VehicleEnrollment
.GetArrivalTime(s.ArrivalRouteAddressId);
}))
.ForMember(
d => d.TravelTime,
opt => opt.MapFrom(
(s, d) =>
{
var departureTime = s.VehicleEnrollment
.GetDepartureTime(s.DepartureRouteAddressId);
var arrivalTime = s.VehicleEnrollment
.GetArrivalTime(s.ArrivalRouteAddressId);
return arrivalTime - departureTime;
}))
.ForMember(
d => d.TimeInStops,
opt => opt.MapFrom(
(s, d) =>
{
return s.VehicleEnrollment.GetTimeInStops(
s.DepartureRouteAddressId, s.ArrivalRouteAddressId);
}))
.ForMember(
d => d.NumberOfStops,
opt => opt.MapFrom(
(s, d) =>
{
return s.VehicleEnrollment.GetNumberOfStops(
s.DepartureRouteAddressId, s.ArrivalRouteAddressId);
}))
.ForMember(
d => d.Currency,
opt => opt.MapFrom(s => s.Currency))
.ForMember(
d => d.Cost,
opt => opt.MapFrom(s => s.Currency.Round(s.Cost)))
.ForMember(
d => d.Company,
opt => opt.MapFrom(s => s.VehicleEnrollment.Vehicle.Company))
.ForMember(
d => d.Vehicle,
opt => opt.MapFrom(s => s.VehicleEnrollment.Vehicle))
.ForMember(
d => d.Addresses,
opt => opt.MapFrom(s => s.VehicleEnrollment.RouteAddressDetails));
}
}

View File

@ -2,11 +2,23 @@ namespace cuqmbr.TravelGuide.Application.TicketGroups.ViewModels;
public sealed class GetTicketGroupsPageFilterViewModel public sealed class GetTicketGroupsPageFilterViewModel
{ {
public string? Sex { get; set; } public HashSet<string>? PassangerSex { get; set; }
public DateOnly? BirthDateGreaterThanOrEqualTo { get; set; } public DateOnly? PassangerBirthDateGreaterThanOrEqualTo { get; set; }
public DateOnly? BirthDateLessThanOrEqualTo { get; set; } public DateOnly? PassangerBirthDateLessThanOrEqualTo { get; set; }
public Guid? CompanyUuid { get; set; } public DateTimeOffset? PurchaseTimeGreaterThanOrEqualTo { get; set; }
public DateTimeOffset? PurchaseTimeLessThanOrEqualTo { get; set; }
public HashSet<string>? Statuses { get; set; }
public HashSet<string>? VehicleTypes { get; set; }
public TimeSpan? TravelTimeGreaterThanOrEqualTo { get; set; }
public TimeSpan? TravelTimeLessThanOrEqualTo { get; set; }
public Guid? AccountUuid { get; set; }
} }

View File

@ -1,21 +0,0 @@
namespace cuqmbr.TravelGuide.Application.TicketGroups.ViewModels;
public sealed class UpdateTicketGroupViewModel
{
public string PassangerFirstName { get; set; }
public string PassangerLastName { get; set; }
public string PassangerPatronymic { get; set; }
public string PassangerSex { get; set; }
public DateOnly PassangerBirthDate { get; set; }
public DateTimeOffset PurchaseTime { get; set; }
public bool Returned { get; set; }
public ICollection<TicketViewModel> Tickets { get; set; }
}

View File

@ -357,16 +357,6 @@ public class SearchAllQueryHandler :
tag = path.Select(e => e.Tag).Last(); tag = path.Select(e => e.Tag).Last();
lastRouteAddressGuid = vehicleEnrollments
.Single(e => e.Id == tag.VehicleEnrollmentId)
.RouteAddressDetails
.Select(e => e.RouteAddress)
.OrderBy(e => e.Order)
.SkipWhile(e => e.Order != tag.RouteAddress.Order)
.Take(2)
.ElementAt(1)
.Guid;
costToNextAddress = await _currencyConverterService costToNextAddress = await _currencyConverterService
.ConvertAsync(tag.CostToNextAddress, .ConvertAsync(tag.CostToNextAddress,
tag.VehicleEnrollment.Currency, tag.VehicleEnrollment.Currency,
@ -388,7 +378,7 @@ public class SearchAllQueryHandler :
CostToNextAddress = 0, CostToNextAddress = 0,
CurrentAddressStopTime = tag.CurrentAddressStopTime, CurrentAddressStopTime = tag.CurrentAddressStopTime,
Order = addressOrder, Order = addressOrder,
RouteAddressUuid = lastRouteAddressGuid RouteAddressUuid = tag.RouteAddress.Guid
}); });

View File

@ -29,13 +29,11 @@ public static class Configuration
configuration.GetSection( configuration.GetSection(
PersistenceConfigurationOptions.SectionName)); PersistenceConfigurationOptions.SectionName));
services.AddOptions<ApplicationConfigurationOptions>().Bind( services.AddOptions<ApplicationConfigurationOptions>()
configuration.GetSection( .Bind(configuration);
ApplicationConfigurationOptions.SectionName));
services.AddOptions<InfrastructureConfigurationOptions>().Bind( services.AddOptions<InfrastructureConfigurationOptions>()
configuration.GetSection( .Bind(configuration);
InfrastructureConfigurationOptions.SectionName));
return services; return services;
} }

View File

@ -21,7 +21,10 @@ public static class Configuration
ExchangeApiCurrencyConverterService>() ExchangeApiCurrencyConverterService>()
.AddScoped< .AddScoped<
cuqmbr.TravelGuide.Application.Common.Services.LiqPayPaymentService, cuqmbr.TravelGuide.Application.Common.Services.LiqPayPaymentService,
cuqmbr.TravelGuide.Infrastructure.Services.LiqPayPaymentService>(); cuqmbr.TravelGuide.Infrastructure.Services.LiqPayPaymentService>()
.AddScoped<
EmailSenderService,
MailKitEmailSenderService>();
return services; return services;
} }

View File

@ -164,11 +164,25 @@
"Microsoft.Extensions.Options": "8.0.0" "Microsoft.Extensions.Options": "8.0.0"
} }
}, },
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.5.1",
"contentHash": "zy8TMeTP+1FH2NrLaNZtdRbBdq7u5MI+NFZQOBSM69u5RFkciinwzV2eveY6Kjf5MzgsYvvl6kTStsj3JrXqkg=="
},
"FluentValidation": { "FluentValidation": {
"type": "Transitive", "type": "Transitive",
"resolved": "11.11.0", "resolved": "11.11.0",
"contentHash": "cyIVdQBwSipxWG8MA3Rqox7iNbUNUTK5bfJi9tIdm4CAfH71Oo5ABLP4/QyrUwuakqpUEPGtE43BDddvEehuYw==" "contentHash": "cyIVdQBwSipxWG8MA3Rqox7iNbUNUTK5bfJi9tIdm4CAfH71Oo5ABLP4/QyrUwuakqpUEPGtE43BDddvEehuYw=="
}, },
"MailKit": {
"type": "Transitive",
"resolved": "4.12.1",
"contentHash": "rIqJm92qtHvk1hDchsJ95Hy7n46A7imE24ol++ikXBsjf3Bi1qDBu4H91FfY6LrYXJaxRlc2gIIpC8AOJrCbqg==",
"dependencies": {
"MimeKit": "4.12.0",
"System.Formats.Asn1": "8.0.1"
}
},
"MediatR": { "MediatR": {
"type": "Transitive", "type": "Transitive",
"resolved": "12.4.1", "resolved": "12.4.1",
@ -701,6 +715,15 @@
"System.Security.Principal.Windows": "4.5.0" "System.Security.Principal.Windows": "4.5.0"
} }
}, },
"MimeKit": {
"type": "Transitive",
"resolved": "4.12.0",
"contentHash": "PFUHfs6BZxKYM/QPJksAwXphbJf0SEfdSfsoQ6p6yvFRaJPofFJMBiotWhFRrdSUzfp6C6K49EjBIqIwZ2TJqA==",
"dependencies": {
"BouncyCastle.Cryptography": "2.5.1",
"System.Security.Cryptography.Pkcs": "8.0.1"
}
},
"Newtonsoft.Json": { "Newtonsoft.Json": {
"type": "Transitive", "type": "Transitive",
"resolved": "13.0.3", "resolved": "13.0.3",
@ -769,6 +792,11 @@
"resolved": "5.0.0", "resolved": "5.0.0",
"contentHash": "dMkqfy2el8A8/I76n2Hi1oBFEbG1SfxD2l5nhwXV3XjlnOmwxJlQbYpJH4W51odnU9sARCSAgv7S3CyAFMkpYg==" "contentHash": "dMkqfy2el8A8/I76n2Hi1oBFEbG1SfxD2l5nhwXV3XjlnOmwxJlQbYpJH4W51odnU9sARCSAgv7S3CyAFMkpYg=="
}, },
"System.Formats.Asn1": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A=="
},
"System.IdentityModel.Tokens.Jwt": { "System.IdentityModel.Tokens.Jwt": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.0.1", "resolved": "8.0.1",
@ -845,6 +873,7 @@
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
"Application": "[1.0.0, )", "Application": "[1.0.0, )",
"MailKit": "[4.12.1, )",
"Microsoft.Extensions.Http": "[9.0.4, )", "Microsoft.Extensions.Http": "[9.0.4, )",
"Newtonsoft.Json": "[13.0.3, )" "Newtonsoft.Json": "[13.0.3, )"
} }

View File

@ -13,4 +13,11 @@ public sealed class Account : EntityBase
public ICollection<AccountRole> AccountRoles { get; set; } public ICollection<AccountRole> AccountRoles { get; set; }
public ICollection<RefreshToken> RefreshTokens { get; set; } public ICollection<RefreshToken> RefreshTokens { get; set; }
public Employee? Employee { get; set; }
public Company? Company { get; set; }
public ICollection<TicketGroup> TicketGroups { get; set; }
} }

View File

@ -14,4 +14,9 @@ public sealed class Company : EntityBase
public ICollection<Employee> Employees { get; set; } public ICollection<Employee> Employees { get; set; }
public ICollection<Vehicle> Vehicles { get; set; } public ICollection<Vehicle> Vehicles { get; set; }
public long AccountId { get; set; }
public Account Account { get; set; }
} }

View File

@ -22,4 +22,9 @@ public sealed class Employee : EntityBase
public ICollection<EmployeeDocument> Documents { get; set; } public ICollection<EmployeeDocument> Documents { get; set; }
public ICollection<VehicleEnrollmentEmployee> VehicleEnrollmentEmployees { get; set; } public ICollection<VehicleEnrollmentEmployee> VehicleEnrollmentEmployees { get; set; }
public long AccountId { get; set; }
public Account Account { get; set; }
} }

View File

@ -14,6 +14,8 @@ public sealed class TicketGroup : EntityBase
public DateOnly PassangerBirthDate { get; set; } public DateOnly PassangerBirthDate { get; set; }
public string? PassangerEmail { get; set; }
public DateTimeOffset PurchaseTime { get; set; } public DateTimeOffset PurchaseTime { get; set; }
public TicketStatus Status { get; set; } public TicketStatus Status { get; set; }
@ -22,4 +24,9 @@ public sealed class TicketGroup : EntityBase
public ICollection<Ticket> Tickets { get; set; } public ICollection<Ticket> Tickets { get; set; }
public long? AccountId { get; set; }
public Account? Account { get; set; }
} }

View File

@ -121,7 +121,7 @@ public class VehicleEnrollment : EntityBase
.OrderBy(e => e.RouteAddress.Order); .OrderBy(e => e.RouteAddress.Order);
var departureRouteAddressDetail = orderedRouteAddressDetails var departureRouteAddressDetail = orderedRouteAddressDetails
.Single(e => e.Id == DepartureRouteAddressId); .Single(e => e.RouteAddressId == DepartureRouteAddressId);
var timeInStops = TimeSpan.Zero; var timeInStops = TimeSpan.Zero;
foreach (var routeAddressDetail in orderedRouteAddressDetails) foreach (var routeAddressDetail in orderedRouteAddressDetails)
@ -159,8 +159,8 @@ public class VehicleEnrollment : EntityBase
return return
RouteAddressDetails RouteAddressDetails
.OrderBy(e => e.RouteAddress.Order) .OrderBy(e => e.RouteAddress.Order)
.SkipWhile(e => e.Id != DepartureRouteAddressId) .SkipWhile(e => e.RouteAddressId != DepartureRouteAddressId)
.TakeWhile(e => e.Id != ArrivalRouteAddressId) .TakeWhile(e => e.RouteAddressId != ArrivalRouteAddressId)
.Count() - 1; .Count() - 1;
} }
@ -180,8 +180,8 @@ public class VehicleEnrollment : EntityBase
return return
RouteAddressDetails RouteAddressDetails
.OrderBy(e => e.RouteAddress.Order) .OrderBy(e => e.RouteAddress.Order)
.SkipWhile(e => e.Id != DepartureRouteAddressId) .SkipWhile(e => e.RouteAddressId != DepartureRouteAddressId)
.TakeWhile(e => e.Id != ArrivalRouteAddressId) .TakeWhile(e => e.RouteAddressId != ArrivalRouteAddressId)
.Aggregate(TimeSpan.Zero, .Aggregate(TimeSpan.Zero,
(sum, next) => sum += next.TimeToNextAddress); (sum, next) => sum += next.TimeToNextAddress);
} }
@ -202,8 +202,8 @@ public class VehicleEnrollment : EntityBase
return return
RouteAddressDetails RouteAddressDetails
.OrderBy(e => e.RouteAddress.Order) .OrderBy(e => e.RouteAddress.Order)
.SkipWhile(e => e.Id != DepartureRouteAddressId) .SkipWhile(e => e.RouteAddressId != DepartureRouteAddressId)
.TakeWhile(e => e.Id != ArrivalRouteAddressId) .TakeWhile(e => e.RouteAddressId != ArrivalRouteAddressId)
.Aggregate((decimal)0, .Aggregate((decimal)0,
(sum, next) => sum += next.CostToNextAddress); (sum, next) => sum += next.CostToNextAddress);
} }

View File

@ -14,24 +14,39 @@ public abstract class Currency : Enumeration<Currency>
protected Currency(int value, string name) : base(value, name) { } protected Currency(int value, string name) : base(value, name) { }
protected virtual byte DecimalDigits { get; } = byte.MaxValue;
public decimal Round(decimal amount)
{
return Math.Round(amount, DecimalDigits);
}
// When no currency is specified // When no currency is specified
private sealed class DefaultCurrency : Currency private sealed class DefaultCurrency : Currency
{ {
public DefaultCurrency() : base(Int32.MaxValue, "DEFAULT") { } public DefaultCurrency() : base(Int32.MaxValue, "DEFAULT") { }
protected override byte DecimalDigits => 2;
} }
private sealed class USDCurrency : Currency private sealed class USDCurrency : Currency
{ {
public USDCurrency() : base(840, "USD") { } public USDCurrency() : base(840, "USD") { }
protected override byte DecimalDigits => 2;
} }
private sealed class EURCurrency : Currency private sealed class EURCurrency : Currency
{ {
public EURCurrency() : base(978, "EUR") { } public EURCurrency() : base(978, "EUR") { }
protected override byte DecimalDigits => 2;
} }
private sealed class UAHCurrency : Currency private sealed class UAHCurrency : Currency
{ {
public UAHCurrency() : base(980, "UAH") { } public UAHCurrency() : base(980, "UAH") { }
protected override byte DecimalDigits => 2;
} }
} }

View File

@ -52,6 +52,9 @@ public class CompaniesController : ControllerBase
LegalAddress = viewModel.LegalAddress, LegalAddress = viewModel.LegalAddress,
ContactEmail = viewModel.ContactEmail, ContactEmail = viewModel.ContactEmail,
ContactPhoneNumber = viewModel.ContactPhoneNumber, ContactPhoneNumber = viewModel.ContactPhoneNumber,
Username = viewModel.Username,
Email = viewModel.Email,
Password = viewModel.Password
}, },
cancellationToken)); cancellationToken));
} }

View File

@ -59,7 +59,10 @@ public class EmployeesController : ControllerBase
Information = e.Information Information = e.Information
}).ToArray(), }).ToArray(),
CompanyGuid = viewModel.CompanyUuid CompanyGuid = viewModel.CompanyUuid,
Username = viewModel.Username,
Email = viewModel.Email,
Password = viewModel.Password
}, },
cancellationToken)); cancellationToken));
} }

View File

@ -7,12 +7,10 @@ using cuqmbr.TravelGuide.Application.Identity.Roles.Queries.GetRolesPage;
using cuqmbr.TravelGuide.Application.Identity.Accounts; using cuqmbr.TravelGuide.Application.Identity.Accounts;
using cuqmbr.TravelGuide.Application.Identity.Accounts.ViewModels; using cuqmbr.TravelGuide.Application.Identity.Accounts.ViewModels;
using cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount; using cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount;
// using cuqmbr.TravelGuide.Application.Identity.Commands.AddIdentity; using cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccountsPage;
// using cuqmbr.TravelGuide.Application.Identity.Queries.GetIdentityPage; using cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccount;
// using cuqmbr.TravelGuide.Application.Identity.Queries.GetIdentity; using cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.UpdateAccount;
// using cuqmbr.TravelGuide.Application.Identity.Commands.UpdateIdentity; using cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.DeleteAccount;
// using cuqmbr.TravelGuide.Application.Identity.Commands.DeleteIdentity;
// using cuqmbr.TravelGuide.Application.Identity.ViewModels;
namespace cuqmbr.TravelGuide.HttpApi.Controllers; namespace cuqmbr.TravelGuide.HttpApi.Controllers;
@ -78,7 +76,7 @@ public class IdentityController : ControllerBase
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error", StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))] typeof(ProblemDetails))]
public async Task<ActionResult<AccountDto>> Add( public async Task<ActionResult<AccountDto>> AddAccount(
[FromBody] AddAccountViewModel viewModel, [FromBody] AddAccountViewModel viewModel,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@ -87,6 +85,7 @@ public class IdentityController : ControllerBase
await Mediator.Send( await Mediator.Send(
new AddAccountCommand() new AddAccountCommand()
{ {
Username = viewModel.Username,
Email = viewModel.Email, Email = viewModel.Email,
Password = viewModel.Password, Password = viewModel.Password,
Roles = viewModel.Roles Roles = viewModel.Roles
@ -96,147 +95,144 @@ public class IdentityController : ControllerBase
cancellationToken)); cancellationToken));
} }
[HttpGet("accounts")]
[SwaggerOperation("Get a list of all accounts")]
[SwaggerResponse(
StatusCodes.Status200OK, "Request successful",
typeof(PaginatedList<AccountDto>))]
[SwaggerResponse(
StatusCodes.Status400BadRequest, "Input data validation error",
typeof(HttpValidationProblemDetails))]
[SwaggerResponse(
StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status403Forbidden,
"Not enough privileges to perform an action",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))]
public async Task<PaginatedList<AccountDto>> GetAccountsPage(
[FromQuery] PageQuery pageQuery, [FromQuery] SearchQuery searchQuery,
[FromQuery] SortQuery sortQuery,
[FromQuery] GetAccountsPageFilterViewModel filterQuery,
CancellationToken cancellationToken)
{
return await Mediator.Send(
new GetAccountsPageQuery()
{
PageNumber = pageQuery.PageNumber,
PageSize = pageQuery.PageSize,
Search = searchQuery.Search,
Sort = sortQuery.Sort,
Roles = filterQuery.Roles == null ? null :
filterQuery.Roles
.Select(s => IdentityRole.FromName(s))
.ToArray()
},
cancellationToken);
}
[HttpGet("accounts/{uuid:guid}")]
[SwaggerOperation("Get an account by uuid")]
[SwaggerResponse(
StatusCodes.Status200OK, "Request successful", typeof(AccountDto))]
[SwaggerResponse(
StatusCodes.Status400BadRequest, "Input data validation error",
typeof(HttpValidationProblemDetails))]
[SwaggerResponse(
StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status403Forbidden,
"Not enough privileges to perform an action",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status404NotFound, "Object not found", typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))]
public async Task<AccountDto> GetAccount(
[FromRoute] Guid uuid,
CancellationToken cancellationToken)
{
return await Mediator.Send(new GetAccountQuery() { Guid = uuid },
cancellationToken);
}
[HttpPut("accounts/{uuid:guid}")]
[SwaggerOperation("Update an account")]
[SwaggerResponse(
StatusCodes.Status200OK, "Request successful", typeof(AccountDto))]
[SwaggerResponse(
StatusCodes.Status400BadRequest, "Object already exists",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status400BadRequest, "Input data validation error",
typeof(HttpValidationProblemDetails))]
[SwaggerResponse(
StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status403Forbidden,
"Not enough privileges to perform an action",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status404NotFound, "Object not found",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status404NotFound, "Parent object not found",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))]
public async Task<AccountDto> UpdateAccount(
[FromRoute] Guid uuid,
[FromBody] UpdateAccountViewModel viewModel,
CancellationToken cancellationToken)
{
return await Mediator.Send(
new UpdateAccountCommand()
{
Guid = uuid,
Username = viewModel.Username,
Email = viewModel.Email,
Password = viewModel.Password,
Roles = viewModel.Roles == null ? null :
viewModel.Roles
.Select(s => IdentityRole.FromName(s))
.ToArray()
},
cancellationToken);
}
// [HttpPost] [HttpDelete("accounts/{uuid:guid}")]
// [SwaggerOperation("Add an identity")] [SwaggerOperation("Delete an account")]
// [SwaggerResponse( [SwaggerResponse(StatusCodes.Status204NoContent, "Request successful")]
// StatusCodes.Status201Created, "Object successfuly created", [SwaggerResponse(
// typeof(IdentityDto))] StatusCodes.Status400BadRequest, "Input data validation error",
// [SwaggerResponse( typeof(HttpValidationProblemDetails))]
// StatusCodes.Status400BadRequest, "Object already exists", [SwaggerResponse(
// typeof(ProblemDetails))] StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
// [SwaggerResponse( typeof(ProblemDetails))]
// StatusCodes.Status400BadRequest, "Input data validation error", [SwaggerResponse(
// typeof(HttpValidationProblemDetails))] StatusCodes.Status403Forbidden,
// [SwaggerResponse( "Not enough privileges to perform an action",
// StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", typeof(ProblemDetails))]
// typeof(ProblemDetails))] [SwaggerResponse(
// [SwaggerResponse( StatusCodes.Status404NotFound, "Object not found",
// StatusCodes.Status403Forbidden, typeof(ProblemDetails))]
// "Not enough privileges to perform an action", [SwaggerResponse(
// typeof(ProblemDetails))] StatusCodes.Status500InternalServerError, "Internal server error",
// [SwaggerResponse( typeof(ProblemDetails))]
// StatusCodes.Status404NotFound, "Parent object not found", public async Task<IActionResult> DeleteAccount(
// typeof(ProblemDetails))] [FromRoute] Guid uuid,
// [SwaggerResponse( CancellationToken cancellationToken)
// StatusCodes.Status500InternalServerError, "Internal server error", {
// typeof(ProblemDetails))] await Mediator.Send(
// public async Task<ActionResult<IdentityDto>> Add( new DeleteAccountCommand() { Guid = uuid },
// [FromBody] AddIdentityViewModel viewModel, cancellationToken);
// CancellationToken cancellationToken) return StatusCode(StatusCodes.Status204NoContent);
// { }
// return StatusCode(
// StatusCodes.Status201Created,
// await Mediator.Send(
// new AddIdentityCommand()
// {
// Name = viewModel.Name,
// Longitude = viewModel.Longitude,
// Latitude = viewModel.Latitude,
// VehicleType = VehicleType.FromName(viewModel.VehicleType),
// CityGuid = viewModel.CityUuid
// },
// cancellationToken));
// }
//
// [HttpGet("{uuid:guid}")]
// [SwaggerOperation("Get an identity by uuid")]
// [SwaggerResponse(
// StatusCodes.Status200OK, "Request successful", typeof(IdentityDto))]
// [SwaggerResponse(
// StatusCodes.Status400BadRequest, "Input data validation error",
// typeof(HttpValidationProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status403Forbidden,
// "Not enough privileges to perform an action",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status404NotFound, "Object not found", typeof(IdentityDto))]
// [SwaggerResponse(
// StatusCodes.Status500InternalServerError, "Internal server error",
// typeof(ProblemDetails))]
// public async Task<IdentityDto> Get(
// [FromRoute] Guid uuid,
// CancellationToken cancellationToken)
// {
// return await Mediator.Send(new GetIdentityQuery() { Guid = uuid },
// cancellationToken);
// }
//
// [HttpPut("{uuid:guid}")]
// [SwaggerOperation("Update an identity")]
// [SwaggerResponse(
// StatusCodes.Status200OK, "Request successful", typeof(IdentityDto))]
// [SwaggerResponse(
// StatusCodes.Status400BadRequest, "Object already exists",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status400BadRequest, "Input data validation error",
// typeof(HttpValidationProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status403Forbidden,
// "Not enough privileges to perform an action",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status404NotFound, "Object not found", typeof(IdentityDto))]
// [SwaggerResponse(
// StatusCodes.Status404NotFound, "Parent object not found",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status500InternalServerError, "Internal server error",
// typeof(ProblemDetails))]
// public async Task<IdentityDto> Update(
// [FromRoute] Guid uuid,
// [FromBody] UpdateIdentityViewModel viewModel,
// CancellationToken cancellationToken)
// {
// return await Mediator.Send(
// new UpdateIdentityCommand()
// {
// Guid = uuid,
// Name = viewModel.Name,
// Longitude = viewModel.Longitude,
// Latitude = viewModel.Latitude,
// VehicleType = VehicleType.FromName(viewModel.VehicleType),
// CityGuid = viewModel.CityUuid
// },
// cancellationToken);
// }
//
// [HttpDelete("{uuid:guid}")]
// [SwaggerOperation("Delete an identity")]
// [SwaggerResponse(StatusCodes.Status204NoContent, "Request successful")]
// [SwaggerResponse(
// StatusCodes.Status400BadRequest, "Input data validation error",
// typeof(HttpValidationProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status403Forbidden,
// "Not enough privileges to perform an action",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status404NotFound, "Object not found",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status500InternalServerError, "Internal server error",
// typeof(ProblemDetails))]
// public async Task<IActionResult> Delete(
// [FromRoute] Guid uuid,
// CancellationToken cancellationToken)
// {
// await Mediator.Send(
// new DeleteIdentityCommand() { Guid = uuid },
// cancellationToken);
// return StatusCode(StatusCodes.Status204NoContent);
// }
} }

View File

@ -48,6 +48,7 @@ public class PaymentController : ControllerBase
PassangerPatronymic = viewModel.PassangerPatronymic, PassangerPatronymic = viewModel.PassangerPatronymic,
PassangerSex = Sex.FromName(viewModel.PassangerSex), PassangerSex = Sex.FromName(viewModel.PassangerSex),
PassangerBirthDate = viewModel.PassangerBirthDate, PassangerBirthDate = viewModel.PassangerBirthDate,
PassangerEmail = viewModel.PassangerEmail,
Tickets = viewModel.Tickets.Select(e => Tickets = viewModel.Tickets.Select(e =>
new TicketGroupPaymentTicketModel() new TicketGroupPaymentTicketModel()
{ {

View File

@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using cuqmbr.TravelGuide.Application.Common.Services; using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Application.Common.Persistence;
namespace cuqmbr.TravelGuide.HttpApi.Controllers; namespace cuqmbr.TravelGuide.HttpApi.Controllers;
@ -9,15 +8,13 @@ namespace cuqmbr.TravelGuide.HttpApi.Controllers;
public class TestsController : ControllerBase public class TestsController : ControllerBase
{ {
private readonly IStringLocalizer _localizer; private readonly IStringLocalizer _localizer;
private readonly UnitOfWork _unitOfWork; private readonly EmailSenderService _emailSender;
public TestsController( public TestsController(SessionCultureService cultureService,
SessionCultureService cultureService, IStringLocalizer localizer, EmailSenderService emailSender)
IStringLocalizer localizer,
UnitOfWork unitOfWork)
{ {
_localizer = localizer; _localizer = localizer;
_unitOfWork = unitOfWork; _emailSender = emailSender;
} }
[HttpGet("getLocalizedString/{inputString}")] [HttpGet("getLocalizedString/{inputString}")]
@ -31,19 +28,15 @@ public class TestsController : ControllerBase
[HttpGet("trigger")] [HttpGet("trigger")]
public async Task Trigger(CancellationToken cancellationToken) public async Task Trigger(CancellationToken cancellationToken)
{ {
// await _unitOfWork.BusRepository.AddOneAsync( var body =
// new Domain.Entities.Bus() @"Hello, friend!
// {
// Number = "AB1234MK",
// Model = "This is a fancy bus model",
// Capacity = 40
// },
// cancellationToken);
//
// await _unitOfWork.SaveAsync(cancellationToken);
// _unitOfWork.Dispose();
var vehicles = await _unitOfWork.VehicleRepository This is my email message for you.
.GetPageAsync(1, 10, cancellationToken);
--
Travel Guide Service
";
await _emailSender.SendAsync(new string[] { "cuqmbr@ya.ru" }, "Test subject", body, cancellationToken);
} }
} }

View File

@ -3,8 +3,12 @@ using Swashbuckle.AspNetCore.Annotations;
using cuqmbr.TravelGuide.Domain.Enums; using cuqmbr.TravelGuide.Domain.Enums;
using cuqmbr.TravelGuide.Application.TicketGroups; using cuqmbr.TravelGuide.Application.TicketGroups;
using cuqmbr.TravelGuide.Application.TicketGroups.Commands.AddTicketGroup; using cuqmbr.TravelGuide.Application.TicketGroups.Commands.AddTicketGroup;
using cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroup;
using cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroupsPage;
using cuqmbr.TravelGuide.Application.TicketGroups.ViewModels; using cuqmbr.TravelGuide.Application.TicketGroups.ViewModels;
using cuqmbr.TravelGuide.Application.TicketGroups.Models; using cuqmbr.TravelGuide.Application.TicketGroups.Models;
using cuqmbr.TravelGuide.Application.Common.Models;
using cuqmbr.TravelGuide.Application.Common.ViewModels;
namespace cuqmbr.TravelGuide.HttpApi.Controllers; namespace cuqmbr.TravelGuide.HttpApi.Controllers;
@ -12,7 +16,7 @@ namespace cuqmbr.TravelGuide.HttpApi.Controllers;
public class TicketGroupsController : ControllerBase public class TicketGroupsController : ControllerBase
{ {
[HttpPost] [HttpPost]
[SwaggerOperation("Add a ticketGroup")] [SwaggerOperation("Add a ticket group")]
[SwaggerResponse( [SwaggerResponse(
StatusCodes.Status201Created, "Object successfuly created", StatusCodes.Status201Created, "Object successfuly created",
typeof(TicketGroupDto))] typeof(TicketGroupDto))]
@ -67,149 +71,85 @@ public class TicketGroupsController : ControllerBase
cancellationToken)); cancellationToken));
} }
// [HttpGet] [HttpGet]
// [SwaggerOperation("Get a list of all ticketGroups")] [SwaggerOperation("Get a list of all ticket groups")]
// [SwaggerResponse( [SwaggerResponse(
// StatusCodes.Status200OK, "Request successful", StatusCodes.Status200OK, "Request successful",
// typeof(PaginatedList<TicketGroupDto>))] typeof(PaginatedList<TicketGroupDto>))]
// [SwaggerResponse( [SwaggerResponse(
// StatusCodes.Status400BadRequest, "Input data validation error", StatusCodes.Status400BadRequest, "Input data validation error",
// typeof(HttpValidationProblemDetails))] typeof(HttpValidationProblemDetails))]
// [SwaggerResponse( [SwaggerResponse(
// StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
// typeof(ProblemDetails))] typeof(ProblemDetails))]
// [SwaggerResponse( [SwaggerResponse(
// StatusCodes.Status403Forbidden, StatusCodes.Status403Forbidden,
// "Not enough privileges to perform an action", "Not enough privileges to perform an action",
// typeof(ProblemDetails))] typeof(ProblemDetails))]
// [SwaggerResponse( [SwaggerResponse(
// StatusCodes.Status500InternalServerError, "Internal server error", StatusCodes.Status500InternalServerError, "Internal server error",
// typeof(ProblemDetails))] typeof(ProblemDetails))]
// public async Task<PaginatedList<TicketGroupDto>> GetPage( public async Task<PaginatedList<TicketGroupDto>> GetPage(
// [FromQuery] PageQuery pageQuery, [FromQuery] SearchQuery searchQuery, [FromQuery] PageQuery pageQuery, [FromQuery] SearchQuery searchQuery,
// [FromQuery] SortQuery sortQuery, [FromQuery] SortQuery sortQuery,
// [FromQuery] GetTicketGroupsPageFilterViewModel filterQuery, [FromQuery] GetTicketGroupsPageFilterViewModel filterQuery,
// CancellationToken cancellationToken) CancellationToken cancellationToken)
// { {
// return await Mediator.Send( return await Mediator.Send(
// new GetTicketGroupsPageQuery() new GetTicketGroupsPageQuery()
// { {
// PageNumber = pageQuery.PageNumber, PageNumber = pageQuery.PageNumber,
// PageSize = pageQuery.PageSize, PageSize = pageQuery.PageSize,
// Search = searchQuery.Search, Search = searchQuery.Search,
// Sort = sortQuery.Sort, Sort = sortQuery.Sort,
// LongitudeGreaterOrEqualThan = PassangerSex = filterQuery.PassangerSex?
// filterQuery.LongitudeGreaterOrEqualThan, .Select(s => Sex.FromName(s)).ToHashSet(),
// LongitudeLessOrEqualThan = PassangerBirthDateGreaterThanOrEqualTo =
// filterQuery.LongitudeLessOrEqualThan, filterQuery.PassangerBirthDateGreaterThanOrEqualTo,
// LatitudeGreaterOrEqualThan = PassangerBirthDateLessThanOrEqualTo =
// filterQuery.LatitudeGreaterOrEqualThan, filterQuery.PassangerBirthDateLessThanOrEqualTo,
// LatitudeLessOrEqualThan = PurchaseTimeGreaterThanOrEqualTo =
// filterQuery.LatitudeLessOrEqualThan, filterQuery.PurchaseTimeGreaterThanOrEqualTo,
// VehicleType = VehicleType.FromName(filterQuery.VehicleType), PurchaseTimeLessThanOrEqualTo =
// CountryGuid = filterQuery.CountryUuid, filterQuery.PurchaseTimeLessThanOrEqualTo,
// RegionGuid = filterQuery.RegionUuid, Statuses = filterQuery.Statuses?
// CityGuid = filterQuery.CityUuid .Select(s => TicketStatus.FromName(s)).ToHashSet(),
// }, VehicleTypes = filterQuery.VehicleTypes?
// cancellationToken); .Select(vt => VehicleType.FromName(vt)).ToHashSet(),
// } TravelTimeGreaterThanOrEqualTo =
// filterQuery.TravelTimeGreaterThanOrEqualTo,
// [HttpGet("{uuid:guid}")] TravelTimeLessThanOrEqualTo =
// [SwaggerOperation("Get a ticketGroup by uuid")] filterQuery.TravelTimeLessThanOrEqualTo,
// [SwaggerResponse( AccountGuid = filterQuery.AccountUuid
// StatusCodes.Status200OK, "Request successful", typeof(TicketGroupDto))] },
// [SwaggerResponse( cancellationToken);
// StatusCodes.Status400BadRequest, "Input data validation error", }
// typeof(HttpValidationProblemDetails))]
// [SwaggerResponse( [HttpGet("{uuid:guid}")]
// StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", [SwaggerOperation("Get a ticket group by uuid")]
// typeof(ProblemDetails))] [SwaggerResponse(
// [SwaggerResponse( StatusCodes.Status200OK, "Request successful", typeof(TicketGroupDto))]
// StatusCodes.Status403Forbidden, [SwaggerResponse(
// "Not enough privileges to perform an action", StatusCodes.Status400BadRequest, "Input data validation error",
// typeof(ProblemDetails))] typeof(HttpValidationProblemDetails))]
// [SwaggerResponse( [SwaggerResponse(
// StatusCodes.Status404NotFound, "Object not found", typeof(TicketGroupDto))] StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
// [SwaggerResponse( typeof(ProblemDetails))]
// StatusCodes.Status500InternalServerError, "Internal server error", [SwaggerResponse(
// typeof(ProblemDetails))] StatusCodes.Status403Forbidden,
// public async Task<TicketGroupDto> Get( "Not enough privileges to perform an action",
// [FromRoute] Guid uuid, typeof(ProblemDetails))]
// CancellationToken cancellationToken) [SwaggerResponse(
// { StatusCodes.Status404NotFound, "Object not found",
// return await Mediator.Send(new GetTicketGroupQuery() { Guid = uuid }, typeof(ProblemDetails))]
// cancellationToken); [SwaggerResponse(
// } StatusCodes.Status500InternalServerError, "Internal server error",
// typeof(ProblemDetails))]
// [HttpPut("{uuid:guid}")] public async Task<TicketGroupDto> Get(
// [SwaggerOperation("Update a ticketGroup")] [FromRoute] Guid uuid,
// [SwaggerResponse( CancellationToken cancellationToken)
// StatusCodes.Status200OK, "Request successful", typeof(TicketGroupDto))] {
// [SwaggerResponse( return await Mediator.Send(new GetTicketGroupQuery() { Guid = uuid },
// StatusCodes.Status400BadRequest, "Object already exists", cancellationToken);
// typeof(ProblemDetails))] }
// [SwaggerResponse(
// StatusCodes.Status400BadRequest, "Input data validation error",
// typeof(HttpValidationProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status403Forbidden,
// "Not enough privileges to perform an action",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status404NotFound, "Object not found", typeof(TicketGroupDto))]
// [SwaggerResponse(
// StatusCodes.Status404NotFound, "Parent object not found",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status500InternalServerError, "Internal server error",
// typeof(ProblemDetails))]
// public async Task<TicketGroupDto> Update(
// [FromRoute] Guid uuid,
// [FromBody] UpdateTicketGroupViewModel viewModel,
// CancellationToken cancellationToken)
// {
// return await Mediator.Send(
// new UpdateTicketGroupCommand()
// {
// Guid = uuid,
// Name = viewModel.Name,
// Longitude = viewModel.Longitude,
// Latitude = viewModel.Latitude,
// VehicleType = VehicleType.FromName(viewModel.VehicleType),
// CityGuid = viewModel.CityUuid
// },
// cancellationToken);
// }
//
// [HttpDelete("{uuid:guid}")]
// [SwaggerOperation("Delete a ticketGroup")]
// [SwaggerResponse(StatusCodes.Status204NoContent, "Request successful")]
// [SwaggerResponse(
// StatusCodes.Status400BadRequest, "Input data validation error",
// typeof(HttpValidationProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status403Forbidden,
// "Not enough privileges to perform an action",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status404NotFound, "Object not found",
// typeof(ProblemDetails))]
// [SwaggerResponse(
// StatusCodes.Status500InternalServerError, "Internal server error",
// typeof(ProblemDetails))]
// public async Task<IActionResult> Delete(
// [FromRoute] Guid uuid,
// CancellationToken cancellationToken)
// {
// await Mediator.Send(
// new DeleteTicketGroupCommand() { Guid = uuid },
// cancellationToken);
// return StatusCode(StatusCodes.Status204NoContent);
// }
} }

View File

@ -1,35 +1,42 @@
{ {
"Application": { "Logging": {
"Logging": { "Type": "SimpleConsole",
"Type": "SimpleConsole", "LogLevel": "Information",
"LogLevel": "Information", "TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffK",
"TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffK", "UseUtcTimestamp": true
"UseUtcTimestamp": true },
}, "Localization": {
"Datastore": { "DefaultCultureName": "en-US",
"Type": "postgresql", "CacheDuration": "00:30:00"
"ConnectionString": "Host=127.0.0.1:5432;Database=travel_guide;Username=postgres;Password=0000;Include Error Detail=true" },
}, "JsonWebToken": {
"Localization": { "Issuer": "https://api.travel-guide.cuqmbr.xyz",
"DefaultCultureName": "en-US", "Audience": "https://travel-guide.cuqmbr.xyz",
"CacheDuration": "00:30:00" "IssuerSigningKey": "a2c98dec80787a4e85ffb5bcbc24f7e4cc014d8a4fe43e9520480a50759164bc",
}, "AccessTokenValidity": "24:00:00",
"JsonWebToken": { "RefreshTokenValidity": "72:00:00"
"Issuer": "https://api.travel-guide.cuqmbr.xyz", },
"Audience": "https://travel-guide.cuqmbr.xyz", "Datastore": {
"IssuerSigningKey": "a2c98dec80787a4e85ffb5bcbc24f7e4cc014d8a4fe43e9520480a50759164bc", "Type": "postgresql",
"AccessTokenValidity": "24:00:00", "ConnectionString": "Host=127.0.0.1:5432;Database=travel_guide;Username=postgres;Password=0000;Include Error Detail=true"
"RefreshTokenValidity": "72:00:00" },
}, "PaymentProcessing": {
"Infrastructure": { "CallbackAddressBase": "https://api.travel-guide.cuqmbr.xyz",
"PaymentProcessing": { "ResultAddressBase": "https://travel-guide.cuqmbr.xyz",
"CallbackAddressBase": "https://api.travel-guide.cuqmbr.xyz", "LiqPay": {
"ResultAddressBase": "https://travel-guide.cuqmbr.xyz", "PublicKey": "sandbox_xxxxxxxxxxxx",
"LiqPay": { "PrivateKey": "sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
"PublicKey": "sandbox_xxxxxxxxxxxx", }
"PrivateKey": "sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" },
} "Email": {
} "Smtp": {
"Host": "mail.travel-guide.cuqmbr.xyz",
"Port": "465",
"UseTls": true,
"Username": "no-reply",
"Password": "super-secret-password",
"SenderAddress": "no-reply@travel-guide.cuqmbr.xyz",
"SenderName": "Travel Guide"
} }
} }
} }

View File

@ -1,35 +1,42 @@
{ {
"Application": { "Logging": {
"Logging": { "Type": "SimpleConsole",
"Type": "SimpleConsole", "LogLevel": "Information",
"LogLevel": "Information", "TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffK",
"TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffK", "UseUtcTimestamp": true
"UseUtcTimestamp": true },
}, "Localization": {
"Datastore": { "DefaultCultureName": "en-US",
"Type": "postgresql", "CacheDuration": "00:30:00"
"ConnectionString": "Host=127.0.0.1:5432;Database=travel_guide;Username=postgres;Password=0000;Include Error Detail=true" },
}, "JsonWebToken": {
"Localization": { "Issuer": "https://api.travel-guide.cuqmbr.xyz",
"DefaultCultureName": "en-US", "Audience": "https://travel-guide.cuqmbr.xyz",
"CacheDuration": "00:30:00" "IssuerSigningKey": "a2c98dec80787a4e85ffb5bcbc24f7e4cc014d8a4fe43e9520480a50759164bc",
}, "AccessTokenValidity": "24:00:00",
"JsonWebToken": { "RefreshTokenValidity": "72:00:00"
"Issuer": "https://api.travel-guide.cuqmbr.xyz", },
"Audience": "https://travel-guide.cuqmbr.xyz", "Datastore": {
"IssuerSigningKey": "a2c98dec80787a4e85ffb5bcbc24f7e4cc014d8a4fe43e9520480a50759164bc", "Type": "postgresql",
"AccessTokenValidity": "24:00:00", "ConnectionString": "Host=127.0.0.1:5432;Database=travel_guide;Username=postgres;Password=0000;Include Error Detail=true"
"RefreshTokenValidity": "72:00:00" },
}, "PaymentProcessing": {
"Infrastructure": { "CallbackAddressBase": "https://api.travel-guide.cuqmbr.xyz",
"PaymentProcessing": { "ResultAddressBase": "https://travel-guide.cuqmbr.xyz",
"CallbackAddressBase": "https://api.travel-guide.cuqmbr.xyz", "LiqPay": {
"ResultAddressBase": "https://travel-guide.cuqmbr.xyz", "PublicKey": "sandbox_xxxxxxxxxxxx",
"LiqPay": { "PrivateKey": "sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
"PublicKey": "sandbox_xxxxxxxxxxxx", }
"PrivateKey": "sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" },
} "Email": {
} "Smtp": {
"Host": "mail.travel-guide.cuqmbr.xyz",
"Port": "465",
"UseTls": true,
"Username": "no-reply",
"Password": "super-secret-password",
"SenderAddress": "no-reply@travel-guide.cuqmbr.xyz",
"SenderName": "Travel Guide"
} }
} }
} }

View File

@ -106,6 +106,11 @@
"Microsoft.Extensions.Options": "8.0.0" "Microsoft.Extensions.Options": "8.0.0"
} }
}, },
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.5.1",
"contentHash": "zy8TMeTP+1FH2NrLaNZtdRbBdq7u5MI+NFZQOBSM69u5RFkciinwzV2eveY6Kjf5MzgsYvvl6kTStsj3JrXqkg=="
},
"FluentValidation": { "FluentValidation": {
"type": "Transitive", "type": "Transitive",
"resolved": "11.11.0", "resolved": "11.11.0",
@ -125,6 +130,15 @@
"resolved": "2.14.1", "resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
}, },
"MailKit": {
"type": "Transitive",
"resolved": "4.12.1",
"contentHash": "rIqJm92qtHvk1hDchsJ95Hy7n46A7imE24ol++ikXBsjf3Bi1qDBu4H91FfY6LrYXJaxRlc2gIIpC8AOJrCbqg==",
"dependencies": {
"MimeKit": "4.12.0",
"System.Formats.Asn1": "8.0.1"
}
},
"MediatR": { "MediatR": {
"type": "Transitive", "type": "Transitive",
"resolved": "12.4.1", "resolved": "12.4.1",
@ -848,6 +862,15 @@
"System.Security.Principal.Windows": "4.5.0" "System.Security.Principal.Windows": "4.5.0"
} }
}, },
"MimeKit": {
"type": "Transitive",
"resolved": "4.12.0",
"contentHash": "PFUHfs6BZxKYM/QPJksAwXphbJf0SEfdSfsoQ6p6yvFRaJPofFJMBiotWhFRrdSUzfp6C6K49EjBIqIwZ2TJqA==",
"dependencies": {
"BouncyCastle.Cryptography": "2.5.1",
"System.Security.Cryptography.Pkcs": "8.0.1"
}
},
"Mono.TextTemplating": { "Mono.TextTemplating": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0", "resolved": "3.0.0",
@ -982,6 +1005,11 @@
"System.Composition.Runtime": "7.0.0" "System.Composition.Runtime": "7.0.0"
} }
}, },
"System.Formats.Asn1": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A=="
},
"System.IdentityModel.Tokens.Jwt": { "System.IdentityModel.Tokens.Jwt": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.0.1", "resolved": "8.0.1",
@ -1109,6 +1137,7 @@
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
"Application": "[1.0.0, )", "Application": "[1.0.0, )",
"MailKit": "[4.12.1, )",
"Microsoft.Extensions.Http": "[9.0.4, )", "Microsoft.Extensions.Http": "[9.0.4, )",
"Newtonsoft.Json": "[13.0.3, )" "Newtonsoft.Json": "[13.0.3, )"
} }

View File

@ -2,9 +2,11 @@ namespace cuqmbr.TravelGuide.Infrastructure;
public sealed class ConfigurationOptions public sealed class ConfigurationOptions
{ {
public static string SectionName { get; } = "Application:Infrastructure"; public static string SectionName { get; } = "";
public PaymentProcessingConfigurationOptions PaymentProcessing { get; set; } public PaymentProcessingConfigurationOptions PaymentProcessing { get; set; } = new();
public EmailConfigurationOptions Email { get; set; } = new();
} }
public sealed class PaymentProcessingConfigurationOptions public sealed class PaymentProcessingConfigurationOptions
@ -13,7 +15,7 @@ public sealed class PaymentProcessingConfigurationOptions
public string ResultAddressBase { get; set; } public string ResultAddressBase { get; set; }
public LiqPayConfigurationOptions LiqPay { get; set; } public LiqPayConfigurationOptions LiqPay { get; set; } = new();
} }
public sealed class LiqPayConfigurationOptions public sealed class LiqPayConfigurationOptions
@ -22,3 +24,25 @@ public sealed class LiqPayConfigurationOptions
public string PrivateKey { get; set; } public string PrivateKey { get; set; }
} }
public sealed class EmailConfigurationOptions
{
public SmtpConfigurationOptions Smtp { get; set; } = new();
}
public sealed class SmtpConfigurationOptions
{
public string Host { get; set; }
public ushort Port { get; set; }
public bool UseTls { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public string SenderAddress { get; set; }
public string SenderName { get; set; }
}

View File

@ -11,6 +11,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MailKit" Version="4.12.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,50 @@
using cuqmbr.TravelGuide.Application.Common.Services;
using MailKit.Net.Smtp;
using Microsoft.Extensions.Options;
using MimeKit;
namespace cuqmbr.TravelGuide.Infrastructure.Services;
public sealed class MailKitEmailSenderService : EmailSenderService
{
private readonly SmtpConfigurationOptions _configuration;
public MailKitEmailSenderService(
IOptions<ConfigurationOptions> configuration)
{
_configuration = configuration.Value.Email.Smtp;
}
public async Task SendAsync(string[] addresses, string subject,
string body, CancellationToken cancellationToken)
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(
_configuration.SenderName, _configuration.SenderAddress));
foreach (var address in addresses)
{
message.To.Add(new MailboxAddress("", address));
}
message.Subject = subject;
message.Body = new TextPart("plain")
{
Text = body
};
using var client = new SmtpClient();
await client.ConnectAsync(_configuration.Host,
_configuration.Port, _configuration.UseTls,
cancellationToken);
await client.AuthenticateAsync(_configuration.Username,
_configuration.Password, cancellationToken);
await client.SendAsync(message, cancellationToken);
await client.DisconnectAsync(true, cancellationToken);
}
}

View File

@ -2,6 +2,16 @@
"version": 1, "version": 1,
"dependencies": { "dependencies": {
"net9.0": { "net9.0": {
"MailKit": {
"type": "Direct",
"requested": "[4.12.1, )",
"resolved": "4.12.1",
"contentHash": "rIqJm92qtHvk1hDchsJ95Hy7n46A7imE24ol++ikXBsjf3Bi1qDBu4H91FfY6LrYXJaxRlc2gIIpC8AOJrCbqg==",
"dependencies": {
"MimeKit": "4.12.0",
"System.Formats.Asn1": "8.0.1"
}
},
"Microsoft.Extensions.Http": { "Microsoft.Extensions.Http": {
"type": "Direct", "type": "Direct",
"requested": "[9.0.4, )", "requested": "[9.0.4, )",
@ -40,6 +50,11 @@
"Microsoft.Extensions.Options": "8.0.0" "Microsoft.Extensions.Options": "8.0.0"
} }
}, },
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.5.1",
"contentHash": "zy8TMeTP+1FH2NrLaNZtdRbBdq7u5MI+NFZQOBSM69u5RFkciinwzV2eveY6Kjf5MzgsYvvl6kTStsj3JrXqkg=="
},
"FluentValidation": { "FluentValidation": {
"type": "Transitive", "type": "Transitive",
"resolved": "11.11.0", "resolved": "11.11.0",
@ -289,11 +304,25 @@
"Microsoft.IdentityModel.Logging": "8.11.0" "Microsoft.IdentityModel.Logging": "8.11.0"
} }
}, },
"MimeKit": {
"type": "Transitive",
"resolved": "4.12.0",
"contentHash": "PFUHfs6BZxKYM/QPJksAwXphbJf0SEfdSfsoQ6p6yvFRaJPofFJMBiotWhFRrdSUzfp6C6K49EjBIqIwZ2TJqA==",
"dependencies": {
"BouncyCastle.Cryptography": "2.5.1",
"System.Security.Cryptography.Pkcs": "8.0.1"
}
},
"QuikGraph": { "QuikGraph": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.5.0", "resolved": "2.5.0",
"contentHash": "sG+mrPpXwxlXknRK5VqWUGiOmDACa9X+3ftlkQIMgOZUqxVOQSe0+HIU9PTjwqazy0pqSf8MPDXYFGl0GYWcKw==" "contentHash": "sG+mrPpXwxlXknRK5VqWUGiOmDACa9X+3ftlkQIMgOZUqxVOQSe0+HIU9PTjwqazy0pqSf8MPDXYFGl0GYWcKw=="
}, },
"System.Formats.Asn1": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A=="
},
"System.IdentityModel.Tokens.Jwt": { "System.IdentityModel.Tokens.Jwt": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.0.1", "resolved": "8.0.1",
@ -308,6 +337,11 @@
"resolved": "1.6.2", "resolved": "1.6.2",
"contentHash": "piIcdelf4dGotuIjFlyu7JLIZkYTmYM0ZTLGpCcxs9iCJFflhJht0nchkIV+GS5wfA3OtC3QNjIcUqyOdBdOsA==" "contentHash": "piIcdelf4dGotuIjFlyu7JLIZkYTmYM0ZTLGpCcxs9iCJFflhJht0nchkIV+GS5wfA3OtC3QNjIcUqyOdBdOsA=="
}, },
"System.Security.Cryptography.Pkcs": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA=="
},
"application": { "application": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {

View File

@ -2,7 +2,7 @@ namespace cuqmbr.TravelGuide.Persistence;
public sealed class ConfigurationOptions public sealed class ConfigurationOptions
{ {
public static string SectionName { get; } = "Application:Datastore"; public static string SectionName { get; } = "Datastore";
public string Type { get; set; } = "inmemory"; public string Type { get; set; } = "inmemory";

View File

@ -38,5 +38,29 @@ public class CompanyConfiguration : BaseConfiguration<Company>
.HasColumnName("contact_phone_number") .HasColumnName("contact_phone_number")
.HasColumnType("varchar(64)") .HasColumnType("varchar(64)")
.IsRequired(true); .IsRequired(true);
builder
.Property(c => c.AccountId)
.HasColumnName("account_id")
.HasColumnType("bigint")
.IsRequired(true);
builder
.HasOne(c => c.Account)
.WithOne(a => a.Company)
.HasForeignKey<Company>(c => c.AccountId)
.HasConstraintName(
"fk_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(c => c.AccountId).Metadata.GetColumnName()}")
.OnDelete(DeleteBehavior.ClientNoAction);
builder
.HasIndex(c => c.AccountId)
.HasDatabaseName(
"ix_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(c => c.AccountId).Metadata.GetColumnName()}");
} }
} }

View File

@ -69,7 +69,7 @@ public class EmployeeConfiguration : BaseConfiguration<Employee>
"fk_" + "fk_" +
$"{builder.Metadata.GetTableName()}_" + $"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(e => e.CompanyId).Metadata.GetColumnName()}") $"{builder.Property(e => e.CompanyId).Metadata.GetColumnName()}")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.ClientNoAction);
builder builder
.HasIndex(e => e.CompanyId) .HasIndex(e => e.CompanyId)
@ -77,5 +77,29 @@ public class EmployeeConfiguration : BaseConfiguration<Employee>
"ix_" + "ix_" +
$"{builder.Metadata.GetTableName()}_" + $"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(e => e.CompanyId).Metadata.GetColumnName()}"); $"{builder.Property(e => e.CompanyId).Metadata.GetColumnName()}");
builder
.Property(e => e.AccountId)
.HasColumnName("account_id")
.HasColumnType("bigint")
.IsRequired(true);
builder
.HasOne(e => e.Account)
.WithOne(a => a.Employee)
.HasForeignKey<Employee>(e => e.AccountId)
.HasConstraintName(
"fk_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(e => e.AccountId).Metadata.GetColumnName()}")
.OnDelete(DeleteBehavior.Cascade);
builder
.HasIndex(e => e.AccountId)
.HasDatabaseName(
"ix_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(e => e.AccountId).Metadata.GetColumnName()}");
} }
} }

View File

@ -49,39 +49,69 @@ public class TicketGroupConfiguration : BaseConfiguration<TicketGroup>
builder builder
.Property(a => a.PassangerFirstName) .Property(tg => tg.PassangerFirstName)
.HasColumnName("passanger_first_name") .HasColumnName("passanger_first_name")
.HasColumnType("varchar(32)") .HasColumnType("varchar(32)")
.IsRequired(true); .IsRequired(true);
builder builder
.Property(a => a.PassangerLastName) .Property(tg => tg.PassangerLastName)
.HasColumnName("passanger_last_name") .HasColumnName("passanger_last_name")
.HasColumnType("varchar(32)") .HasColumnType("varchar(32)")
.IsRequired(true); .IsRequired(true);
builder builder
.Property(a => a.PassangerPatronymic) .Property(tg => tg.PassangerPatronymic)
.HasColumnName("passanger_patronymic") .HasColumnName("passanger_patronymic")
.HasColumnType("varchar(32)") .HasColumnType("varchar(32)")
.IsRequired(true); .IsRequired(true);
builder builder
.Property(a => a.PassangerBirthDate) .Property(tg => tg.PassangerBirthDate)
.HasColumnName("passanger_birth_date") .HasColumnName("passanger_birth_date")
.HasColumnType("date") .HasColumnType("date")
.IsRequired(true); .IsRequired(true);
builder builder
.Property(a => a.PurchaseTime) .Property(tg => tg.PassangerEmail)
.HasColumnName("passanger_email")
.HasColumnType("varchar(256)")
.IsRequired(false);
builder
.Property(tg => tg.PurchaseTime)
.HasColumnName("purchase_time") .HasColumnName("purchase_time")
.HasColumnType("timestamptz") .HasColumnType("timestamptz")
.IsRequired(true); .IsRequired(true);
builder builder
.Property(a => a.TravelTime) .Property(tg => tg.TravelTime)
.HasColumnName("travel_time") .HasColumnName("travel_time")
.HasColumnType("interval") .HasColumnType("interval")
.IsRequired(true); .IsRequired(true);
builder
.Property(tg => tg.AccountId)
.HasColumnName("account_id")
.HasColumnType("bigint")
.IsRequired(false);
builder
.HasOne(tg => tg.Account)
.WithMany(a => a.TicketGroups)
.HasForeignKey(tg => tg.AccountId)
.HasConstraintName(
"fk_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(c => c.AccountId).Metadata.GetColumnName()}")
.OnDelete(DeleteBehavior.SetNull);
builder
.HasIndex(c => c.AccountId)
.HasDatabaseName(
"ix_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(c => c.AccountId).Metadata.GetColumnName()}");
} }
} }

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