Merge pull request #13 from prtsie/12-client

12 client
This commit is contained in:
prtsie
2024-09-16 15:16:28 +03:00
committed by GitHub
138 changed files with 6822 additions and 604 deletions

View File

@@ -0,0 +1,13 @@
using System.Text.RegularExpressions;
namespace ApplicationLayer
{
public static class Constants
{
public readonly static Regex EnglishWordRegex = new("^[a-zA-Z]*$");
public readonly static Regex EnglishPhraseRegex = new(@"^[a-zA-Z№0-9?><;,{}[\]\-_+=!@#$%\^&*|']*$");
public readonly static Regex PhoneNumRegex = new(@"^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$");
}
}

View File

@@ -1,6 +1,6 @@
using Domains; using Domains;
namespace ApplicationLayer.Services.GeneralExceptions; namespace ApplicationLayer.GeneralExceptions;
/// Exception to throw when entity not found /// Exception to throw when entity not found
/// <param name="id">Identifier of entity</param> /// <param name="id">Identifier of entity</param>

View File

@@ -1,6 +1,4 @@
using ApplicationLayer.GeneralExceptions; namespace ApplicationLayer.GeneralExceptions;
namespace ApplicationLayer.Services.GeneralExceptions;
/// Exception to throw when entity not found /// Exception to throw when entity not found
public class EntityNotFoundException(string message) : ApiException(message); public class EntityNotFoundException(string message) : ApiException(message);

View File

@@ -0,0 +1,11 @@
using ApplicationLayer.Services.AuthServices.Common;
using Domains.Users;
namespace ApplicationLayer.InfrastructureServicesInterfaces;
/// Generates jwt-tokens
public interface ITokenGenerator
{
/// returns jwt-token for specific user
AuthToken CreateToken(User user);
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
using Domains;
namespace ApplicationLayer.Services.Applicants.Models;
public class AddressModel
{
/// Country part of address
[Required]
[MaxLength(ConfigurationConstraints.CountryNameLength)]
public string Country { get; set; } = null!;
/// City part of address
[Required]
[MaxLength(ConfigurationConstraints.CityNameLength)]
public string City { get; set; } = null!;
/// Street part of address
[Required]
[MaxLength(ConfigurationConstraints.StreetNameLength)]
public string Street { get; set; } = null!;
/// Building part of address
[Required]
[MaxLength(ConfigurationConstraints.BuildingNumberLength)]
public string Building { get; set; } = null!;
}

View File

@@ -1,49 +1,64 @@
using Domains.ApplicantDomain; using System.ComponentModel.DataAnnotations;
using Domains.ApplicantDomain;
namespace ApplicationLayer.Services.Applicants.Models; namespace ApplicationLayer.Services.Applicants.Models;
/// Model of <see cref="Applicant"/> /// Model of <see cref="Applicant" />
public class ApplicantModel public class ApplicantModel
{ {
/// <inheritdoc cref="Applicant.Name"/> /// <inheritdoc cref="Applicant.Name" />
public Name Name { get; set; } = null!; [Required]
public NameModel Name { get; set; } = null!;
/// <inheritdoc cref="Applicant.Passport"/> /// <inheritdoc cref="Applicant.Passport" />
public Passport Passport { get; set; } = null!; [Required]
public PassportModel Passport { get; set; } = null!;
/// <inheritdoc cref="Applicant.BirthDate"/> /// <inheritdoc cref="Applicant.BirthDate" />
[Required]
public DateTime BirthDate { get; set; } public DateTime BirthDate { get; set; }
/// <inheritdoc cref="Applicant.CountryOfBirth"/> /// <inheritdoc cref="Applicant.CountryOfBirth" />
[Required]
public string CountryOfBirth { get; set; } = null!; public string CountryOfBirth { get; set; } = null!;
/// <inheritdoc cref="Applicant.CityOfBirth"/> /// <inheritdoc cref="Applicant.CityOfBirth" />
[Required]
public string CityOfBirth { get; set; } = null!; public string CityOfBirth { get; set; } = null!;
/// <inheritdoc cref="Applicant.Citizenship"/> /// <inheritdoc cref="Applicant.Citizenship" />
[Required]
public string Citizenship { get; set; } = null!; public string Citizenship { get; set; } = null!;
/// <inheritdoc cref="Applicant.CitizenshipByBirth"/> /// <inheritdoc cref="Applicant.CitizenshipByBirth" />
[Required]
public string CitizenshipByBirth { get; set; } = null!; public string CitizenshipByBirth { get; set; } = null!;
/// <inheritdoc cref="Applicant.Gender"/> /// <inheritdoc cref="Applicant.Gender" />
[Required]
public Gender Gender { get; set; } public Gender Gender { get; set; }
/// <inheritdoc cref="Applicant.MaritalStatus"/> /// <inheritdoc cref="Applicant.MaritalStatus" />
[Required]
public MaritalStatus MaritalStatus { get; set; } public MaritalStatus MaritalStatus { get; set; }
/// <inheritdoc cref="Applicant.FatherName"/> /// <inheritdoc cref="Applicant.FatherName" />
public Name FatherName { get; set; } = null!; [Required]
public NameModel FatherName { get; set; } = null!;
/// <inheritdoc cref="Applicant.MotherName"/> /// <inheritdoc cref="Applicant.MotherName" />
public Name MotherName { get; set; } = null!; [Required]
public NameModel MotherName { get; set; } = null!;
/// <inheritdoc cref="Applicant.JobTitle"/> /// <inheritdoc cref="Applicant.JobTitle" />
[Required]
public string JobTitle { get; set; } = null!; public string JobTitle { get; set; } = null!;
/// <inheritdoc cref="Applicant.PlaceOfWork"/> /// <inheritdoc cref="Applicant.PlaceOfWork" />
public PlaceOfWork PlaceOfWork { get; set; } = null!; [Required]
public PlaceOfWorkModel PlaceOfWork { get; set; } = null!;
/// <inheritdoc cref="Applicant.IsNonResident"/> /// <inheritdoc cref="Applicant.IsNonResident" />
[Required]
public bool IsNonResident { get; set; } public bool IsNonResident { get; set; }
} }

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using Domains;
namespace ApplicationLayer.Services.Applicants.Models;
/// Model of name for presentation layer
public class NameModel
{
[Required]
[MaxLength(ConfigurationConstraints.NameLength)]
public string FirstName { get; set; } = null!;
[Required]
[MaxLength(ConfigurationConstraints.NameLength)]
public string Surname { get; set; } = null!;
[MaxLength(ConfigurationConstraints.NameLength)]
public string? Patronymic { get; set; }
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using Domains;
namespace ApplicationLayer.Services.Applicants.Models;
/// Model of passport fpr presentation layer
public class PassportModel
{
/// Number of passport
[Required]
[MaxLength(ConfigurationConstraints.PassportNumberLength)]
public string Number { get; set; } = null!;
/// Issuing authority of passport
[Required]
[MaxLength(ConfigurationConstraints.IssuerNameLength)]
public string Issuer { get; set; } = null!;
/// Date of issue
[Required]
public DateTime IssueDate { get; set; }
/// Date when the passport expires
[Required]
public DateTime ExpirationDate { get; set; }
}

View File

@@ -1,15 +1,22 @@
using Domains.ApplicantDomain; using System.ComponentModel.DataAnnotations;
using Domains;
namespace ApplicationLayer.Services.Applicants.Models; namespace ApplicationLayer.Services.Applicants.Models;
public class PlaceOfWorkModel public class PlaceOfWorkModel
{ {
/// Name of hirer /// Name of hirer
[Required]
[MaxLength(ConfigurationConstraints.PlaceOfWorkNameLength)]
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
/// Address of hirer /// Address of hirer
public Address Address { get; set; } = null!; [Required]
public AddressModel Address { get; set; } = null!;
/// Phone number of hirer /// Phone number of hirer
[Required]
[MaxLength(ConfigurationConstraints.PhoneNumberLength)]
[MinLength(ConfigurationConstraints.PhoneNumberMinLength)]
public string PhoneNum { get; set; } = null!; public string PhoneNum { get; set; } = null!;
} }

View File

@@ -0,0 +1,32 @@
using Domains;
using FluentValidation;
namespace ApplicationLayer.Services.Applicants.Models.Validation;
public class NameModelValidator : AbstractValidator<NameModel>
{
public NameModelValidator()
{
RuleFor(m => m.FirstName)
.NotEmpty()
.WithMessage("First Name can not be empty")
.Matches(Constants.EnglishWordRegex)
.WithMessage("First name must be in english characters")
.MaximumLength(ConfigurationConstraints.NameLength)
.WithMessage($"First Name length must be less than {ConfigurationConstraints.NameLength}");
RuleFor(m => m.Surname)
.NotEmpty()
.WithMessage("Surname can not be empty")
.Matches(Constants.EnglishWordRegex)
.WithMessage("Surname must be in english characters")
.MaximumLength(ConfigurationConstraints.NameLength)
.WithMessage($"Surname length must be less than {ConfigurationConstraints.NameLength}");
RuleFor(m => m.Patronymic)
.Matches(Constants.EnglishWordRegex)
.WithMessage("Patronymic must be in english characters")
.MaximumLength(ConfigurationConstraints.NameLength)
.WithMessage($"Patronymic length must be less than {ConfigurationConstraints.NameLength}");
}
}

View File

@@ -0,0 +1,39 @@
using ApplicationLayer.InfrastructureServicesInterfaces;
using Domains;
using FluentValidation;
namespace ApplicationLayer.Services.Applicants.Models.Validation;
public class PassportModelValidator : AbstractValidator<PassportModel>
{
public PassportModelValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(r => r.Issuer)
.NotEmpty()
.WithMessage("Passport issuer can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Passport issuer field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.IssuerNameLength)
.WithMessage($"Passport issuer length must be less than {ConfigurationConstraints.IssuerNameLength}");
RuleFor(r => r.Number)
.NotEmpty()
.WithMessage("Passport number can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Passport number field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.PassportNumberLength)
.WithMessage($"Passport number length must be less than {ConfigurationConstraints.PassportNumberLength}");
RuleFor(r => r.ExpirationDate)
.NotEmpty()
.WithMessage("Passport expiration date can not be empty")
.GreaterThan(dateTimeProvider.Now())
.WithMessage("Can not approve visa for applicants with expired passport");
RuleFor(r => r.IssueDate)
.NotEmpty()
.WithMessage("Passport issue date can not be empty")
.LessThanOrEqualTo(dateTimeProvider.Now())
.WithMessage("Passport issue date must be in past");
}
}

View File

@@ -0,0 +1,66 @@
using Domains;
using FluentValidation;
namespace ApplicationLayer.Services.Applicants.Models.Validation;
public class PlaceOfWorkModelValidator : AbstractValidator<PlaceOfWorkModel>
{
public PlaceOfWorkModelValidator()
{
RuleFor(p => p.Name)
.NotEmpty()
.WithMessage("Place of work name can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Place of work name field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.PlaceOfWorkNameLength)
.WithMessage($"Place of work name length must be less than {ConfigurationConstraints.PlaceOfWorkNameLength}");
RuleFor(p => p.PhoneNum)
.NotEmpty()
.WithMessage("Place of work phone number can not be empty")
.Matches(Constants.PhoneNumRegex)
.WithMessage("Place of work phone number field must be valid")
.MaximumLength(ConfigurationConstraints.PhoneNumberLength)
.WithMessage(
$"Phone number length must be in range from {ConfigurationConstraints.PhoneNumberMinLength} to {ConfigurationConstraints.PhoneNumberLength}")
.MinimumLength(ConfigurationConstraints.PhoneNumberMinLength)
.WithMessage(
$"Phone number length must be in range from {ConfigurationConstraints.PhoneNumberMinLength} to {ConfigurationConstraints.PhoneNumberLength}");
RuleFor(p => p.Address)
.NotEmpty()
.WithMessage("Place of work address can not be empty");
RuleFor(p => p.Address.Country)
.NotEmpty()
.WithMessage("Country name of place of work can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Place of work Country field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.CountryNameLength)
.WithMessage($"Country name of place of work length must be less than {ConfigurationConstraints.CountryNameLength}");
RuleFor(p => p.Address.City)
.NotEmpty()
.WithMessage("City name of place of work can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Place of work City field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.CityNameLength)
.WithMessage($"City name of place of work length must be less than {ConfigurationConstraints.CityNameLength}");
RuleFor(p => p.Address.Street)
.NotEmpty()
.WithMessage("Street name of place of work can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Place of work Street field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.StreetNameLength)
.WithMessage($"Street name of place of work length must be less than {ConfigurationConstraints.StreetNameLength}");
RuleFor(p => p.Address.Building)
.NotEmpty()
.WithMessage("Building of place of work can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Place of work building field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.CountryNameLength)
.WithMessage($"Building of place of work length must be less than {ConfigurationConstraints.BuildingNumberLength}");
}
}

View File

@@ -1,3 +1,15 @@
namespace ApplicationLayer.Services.AuthServices.Common; using System.ComponentModel.DataAnnotations;
using Domains;
public record AuthData(string Email, string Password); namespace ApplicationLayer.Services.AuthServices.Common;
public class AuthData
{
[Required]
[MaxLength(ConfigurationConstraints.EmailLength)]
public string Email { get; set; } = null!;
[Required]
[MaxLength(ConfigurationConstraints.PasswordLength)]
public string Password { get; set; } = null!;
}

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace ApplicationLayer.Services.AuthServices.Common
{
public class AuthToken
{
[Required] public string Token { get; set; } = null!;
}
}

View File

@@ -1,22 +1,25 @@
using ApplicationLayer.Services.AuthServices.LoginService.Exceptions; using ApplicationLayer.InfrastructureServicesInterfaces;
using ApplicationLayer.Services.AuthServices.Common;
using ApplicationLayer.Services.AuthServices.LoginService.Exceptions;
using ApplicationLayer.Services.AuthServices.NeededServices; using ApplicationLayer.Services.AuthServices.NeededServices;
using ApplicationLayer.Services.AuthServices.Requests;
using Domains.Users; using Domains.Users;
namespace ApplicationLayer.Services.AuthServices.LoginService; namespace ApplicationLayer.Services.AuthServices.LoginService;
public class DevelopmentLoginService(IUsersRepository users, ITokenGenerator tokenGenerator) : ILoginService public class DevelopmentLoginService(IUsersRepository users, ITokenGenerator tokenGenerator) : ILoginService
{ {
async Task<string> ILoginService.LoginAsync(string email, string password, CancellationToken cancellationToken) async Task<AuthToken> ILoginService.LoginAsync(LoginRequest request, CancellationToken cancellationToken)
{ {
if (email == "admin@mail.ru" && password == "admin") if (request.AuthData is { Email: "admin@mail.ru", Password: "admin" })
{ {
var admin = new User { Role = Role.Admin }; var admin = new User { Role = Role.Admin };
return tokenGenerator.CreateToken(admin); return tokenGenerator.CreateToken(admin);
} }
var user = await users.FindByEmailAsync(email, cancellationToken); var user = await users.FindByEmailAsync(request.AuthData.Email, cancellationToken);
if (user is null || user.Password != password) if (user is null || user.Password != request.AuthData.Password)
{ {
throw new IncorrectLoginDataException(); throw new IncorrectLoginDataException();
} }

View File

@@ -1,9 +1,12 @@
namespace ApplicationLayer.Services.AuthServices.LoginService; using ApplicationLayer.Services.AuthServices.Common;
using ApplicationLayer.Services.AuthServices.Requests;
namespace ApplicationLayer.Services.AuthServices.LoginService;
/// Handles login requests /// Handles login requests
public interface ILoginService public interface ILoginService
{ {
/// Handle login request /// Handle login request
/// <returns>JWT-token</returns> /// <returns>JWT-token</returns>
Task<string> LoginAsync(string email, string password, CancellationToken cancellationToken); Task<AuthToken> LoginAsync(LoginRequest request, CancellationToken cancellationToken);
} }

View File

@@ -1,15 +1,18 @@
using ApplicationLayer.Services.AuthServices.LoginService.Exceptions; using ApplicationLayer.InfrastructureServicesInterfaces;
using ApplicationLayer.Services.AuthServices.Common;
using ApplicationLayer.Services.AuthServices.LoginService.Exceptions;
using ApplicationLayer.Services.AuthServices.NeededServices; using ApplicationLayer.Services.AuthServices.NeededServices;
using ApplicationLayer.Services.AuthServices.Requests;
namespace ApplicationLayer.Services.AuthServices.LoginService; namespace ApplicationLayer.Services.AuthServices.LoginService;
/// <inheritdoc cref="ILoginService"/> /// <inheritdoc cref="ILoginService" />
public class LoginService(IUsersRepository users, ITokenGenerator tokenGenerator) : ILoginService public class LoginService(IUsersRepository users, ITokenGenerator tokenGenerator) : ILoginService
{ {
async Task<string> ILoginService.LoginAsync(string email, string password, CancellationToken cancellationToken) async Task<AuthToken> ILoginService.LoginAsync(LoginRequest request, CancellationToken cancellationToken)
{ {
var user = await users.FindByEmailAsync(email, cancellationToken); var user = await users.FindByEmailAsync(request.AuthData.Email, cancellationToken);
if (user is null || user.Password != password) if (user is null || user.Password != request.AuthData.Password)
{ {
throw new IncorrectLoginDataException(); throw new IncorrectLoginDataException();
} }

View File

@@ -1,10 +0,0 @@
using Domains.Users;
namespace ApplicationLayer.Services.AuthServices.NeededServices;
/// Generates jwt-tokens
public interface ITokenGenerator
{
/// returns jwt-token for specific user
string CreateToken(User user);
}

View File

@@ -17,7 +17,7 @@ public class RegisterService(
{ {
async Task IRegisterService.RegisterApplicant(RegisterApplicantRequest request, CancellationToken cancellationToken) async Task IRegisterService.RegisterApplicant(RegisterApplicantRequest request, CancellationToken cancellationToken)
{ {
var user = mapper.Map<User>(request.AuthData); var user = mapper.Map<User>(request.RegisterRequest.AuthData);
user.Role = Role.Applicant; user.Role = Role.Applicant;
var applicant = mapper.Map<Applicant>(request); var applicant = mapper.Map<Applicant>(request);

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
using ApplicationLayer.Services.AuthServices.Common;
namespace ApplicationLayer.Services.AuthServices.Requests;
public class LoginRequest
{
[Required] public AuthData AuthData { get; set; } = null!;
}

View File

@@ -1,22 +1,49 @@
using ApplicationLayer.Services.Applicants.Models; using System.ComponentModel.DataAnnotations;
using ApplicationLayer.Services.AuthServices.Common; using ApplicationLayer.Services.Applicants.Models;
using Domains;
using Domains.ApplicantDomain; using Domains.ApplicantDomain;
namespace ApplicationLayer.Services.AuthServices.Requests; namespace ApplicationLayer.Services.AuthServices.Requests;
public record RegisterApplicantRequest( public record RegisterApplicantRequest
AuthData AuthData, {
Name ApplicantName, [Required] public RegisterRequest RegisterRequest { get; set; } = null!;
Passport Passport,
DateTime BirthDate, [Required] public NameModel ApplicantName { get; set; } = null!;
string CityOfBirth,
string CountryOfBirth, [Required] public PassportModel Passport { get; set; } = null!;
string Citizenship,
string CitizenshipByBirth, [Required] public DateTime BirthDate { get; set; }
Gender Gender,
MaritalStatus MaritalStatus, [Required]
Name FatherName, [MaxLength(ConfigurationConstraints.CityNameLength)]
Name MotherName, public string CityOfBirth { get; set; } = null!;
string JobTitle,
PlaceOfWorkModel PlaceOfWork, [Required]
bool IsNonResident) : RegisterRequest(AuthData); [MaxLength(ConfigurationConstraints.CountryNameLength)]
public string CountryOfBirth { get; set; } = null!;
[Required]
[MaxLength(ConfigurationConstraints.CitizenshipLength)]
public string Citizenship { get; set; } = null!;
[Required]
[MaxLength(ConfigurationConstraints.CitizenshipLength)]
public string CitizenshipByBirth { get; set; } = null!;
[Required] public Gender Gender { get; set; }
[Required] public MaritalStatus MaritalStatus { get; set; }
[Required] public NameModel FatherName { get; set; } = null!;
[Required] public NameModel MotherName { get; set; } = null!;
[Required]
[MaxLength(ConfigurationConstraints.JobTitleLength)]
public string JobTitle { get; set; } = null!;
[Required] public PlaceOfWorkModel PlaceOfWork { get; set; } = null!;
[Required] public bool IsNonResident { get; set; }
}

View File

@@ -1,5 +1,9 @@
using ApplicationLayer.Services.AuthServices.Common; using System.ComponentModel.DataAnnotations;
using ApplicationLayer.Services.AuthServices.Common;
namespace ApplicationLayer.Services.AuthServices.Requests; namespace ApplicationLayer.Services.AuthServices.Requests;
public record RegisterRequest(AuthData AuthData); public class RegisterRequest
{
[Required] public AuthData AuthData { get; set; } = null!;
}

View File

@@ -1,5 +1,4 @@
using ApplicationLayer.Services.AuthServices.Common; using ApplicationLayer.Services.AuthServices.Common;
using ApplicationLayer.Services.AuthServices.NeededServices;
using Domains; using Domains;
using FluentValidation; using FluentValidation;
@@ -7,7 +6,7 @@ namespace ApplicationLayer.Services.AuthServices.Requests.Validation;
public class AuthDataValidator : AbstractValidator<AuthData> public class AuthDataValidator : AbstractValidator<AuthData>
{ {
public AuthDataValidator(IUsersRepository users) public AuthDataValidator()
{ {
RuleFor(d => d.Email) RuleFor(d => d.Email)
.NotEmpty() .NotEmpty()
@@ -15,16 +14,13 @@ public class AuthDataValidator : AbstractValidator<AuthData>
.EmailAddress() .EmailAddress()
.WithMessage("Email must be valid") .WithMessage("Email must be valid")
.MaximumLength(ConfigurationConstraints.EmailLength) .MaximumLength(ConfigurationConstraints.EmailLength)
.WithMessage($"Email length must be less than {ConfigurationConstraints.EmailLength}") .WithMessage($"Email length must be less than {ConfigurationConstraints.EmailLength}");
.MustAsync(async (email, ct) =>
{
return await users.FindByEmailAsync(email, ct) is null;
})
.WithMessage("Email already exists");
RuleFor(d => d.Password) RuleFor(d => d.Password)
.NotEmpty() .NotEmpty()
.WithMessage("Password can not be empty") .WithMessage("Password can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Password can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.PasswordLength) .MaximumLength(ConfigurationConstraints.PasswordLength)
.WithMessage($"Password length must be less than {ConfigurationConstraints.PasswordLength}"); .WithMessage($"Password length must be less than {ConfigurationConstraints.PasswordLength}");
} }

View File

@@ -1,27 +0,0 @@
using Domains;
using Domains.ApplicantDomain;
using FluentValidation;
namespace ApplicationLayer.Services.AuthServices.Requests.Validation;
public class NameValidator : AbstractValidator<Name>
{
public NameValidator()
{
RuleFor(m => m.FirstName)
.NotEmpty()
.WithMessage("First Name can not be empty")
.MaximumLength(ConfigurationConstraints.NameLength)
.WithMessage($"First Name length must be less than {ConfigurationConstraints.NameLength}");
RuleFor(m => m.Surname)
.NotEmpty()
.WithMessage("Surname can not be empty")
.MaximumLength(ConfigurationConstraints.NameLength)
.WithMessage($"Surname length must be less than {ConfigurationConstraints.NameLength}");
RuleFor(m => m.Patronymic)
.MaximumLength(ConfigurationConstraints.NameLength)
.WithMessage($"Patronymic length must be less than {ConfigurationConstraints.NameLength}");
}
}

View File

@@ -1,36 +0,0 @@
using ApplicationLayer.InfrastructureServicesInterfaces;
using Domains;
using Domains.ApplicantDomain;
using FluentValidation;
namespace ApplicationLayer.Services.AuthServices.Requests.Validation;
public class PassportValidator : AbstractValidator<Passport>
{
public PassportValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(r => r.Issuer)
.NotEmpty()
.WithMessage("Passport issuer can not be empty")
.MaximumLength(ConfigurationConstraints.IssuerNameLength)
.WithMessage($"Passport issuer length must be less than {ConfigurationConstraints.IssuerNameLength}");
RuleFor(r => r.Number)
.NotEmpty()
.WithMessage("Passport number can not be empty")
.MaximumLength(ConfigurationConstraints.PassportNumberLength)
.WithMessage($"Passport number length must be less than {ConfigurationConstraints.PassportNumberLength}");
RuleFor(r => r.ExpirationDate)
.NotEmpty()
.WithMessage("Passport expiration date can not be empty")
.GreaterThan(dateTimeProvider.Now())
.WithMessage("Can not approve visa for applicants with expired passport");
RuleFor(r => r.IssueDate)
.NotEmpty()
.WithMessage("Passport issue date can not be empty")
.LessThanOrEqualTo(dateTimeProvider.Now())
.WithMessage("Passport issue date must be in past");
}
}

View File

@@ -1,49 +0,0 @@
using ApplicationLayer.Services.Applicants.Models;
using Domains;
using FluentValidation;
namespace ApplicationLayer.Services.AuthServices.Requests.Validation;
public class PlaceOfWorkModelValidator : AbstractValidator<PlaceOfWorkModel>
{
public PlaceOfWorkModelValidator()
{
RuleFor(p => p.Name)
.NotEmpty()
.WithMessage("Place of work name can not be empty")
.MaximumLength(ConfigurationConstraints.PlaceOfWorkNameLength)
.WithMessage($"Place of work name length must be less than {ConfigurationConstraints.PlaceOfWorkNameLength}");
RuleFor(p => p.PhoneNum)
.NotEmpty()
.WithMessage("Place of work phone number can not be empty")
.MaximumLength(ConfigurationConstraints.PhoneNumberLength)
.WithMessage($"Phone number length must be in range from {ConfigurationConstraints.PhoneNumberMinLength} to {ConfigurationConstraints.PhoneNumberLength}")
.MinimumLength(ConfigurationConstraints.PhoneNumberMinLength)
.WithMessage($"Phone number length must be in range from {ConfigurationConstraints.PhoneNumberMinLength} to {ConfigurationConstraints.PhoneNumberLength}");
RuleFor(p => p.Address.Country)
.NotEmpty()
.WithMessage("Country name of place of work can not be empty")
.MaximumLength(ConfigurationConstraints.CountryNameLength)
.WithMessage($"Country name of place of work length must be less than {ConfigurationConstraints.CountryNameLength}");
RuleFor(p => p.Address.City)
.NotEmpty()
.WithMessage("City name of place of work can not be empty")
.MaximumLength(ConfigurationConstraints.CityNameLength)
.WithMessage($"City name of place of work length must be less than {ConfigurationConstraints.CityNameLength}");
RuleFor(p => p.Address.Street)
.NotEmpty()
.WithMessage("Street name of place of work can not be empty")
.MaximumLength(ConfigurationConstraints.StreetNameLength)
.WithMessage($"Street name of place of work length must be less than {ConfigurationConstraints.StreetNameLength}");
RuleFor(p => p.Address.Building)
.NotEmpty()
.WithMessage("Building of place of work can not be empty")
.MaximumLength(ConfigurationConstraints.CountryNameLength)
.WithMessage($"Building of place of work length must be less than {ConfigurationConstraints.BuildingNumberLength}");
}
}

View File

@@ -1,8 +1,6 @@
using ApplicationLayer.InfrastructureServicesInterfaces; using ApplicationLayer.InfrastructureServicesInterfaces;
using ApplicationLayer.Services.Applicants.Models; using ApplicationLayer.Services.Applicants.Models;
using ApplicationLayer.Services.AuthServices.Common;
using Domains; using Domains;
using Domains.ApplicantDomain;
using FluentValidation; using FluentValidation;
namespace ApplicationLayer.Services.AuthServices.Requests.Validation; namespace ApplicationLayer.Services.AuthServices.Requests.Validation;
@@ -11,24 +9,29 @@ public class RegisterApplicantRequestValidator : AbstractValidator<RegisterAppli
{ {
public RegisterApplicantRequestValidator( public RegisterApplicantRequestValidator(
IDateTimeProvider dateTimeProvider, IDateTimeProvider dateTimeProvider,
IValidator<Name> nameValidator, IValidator<NameModel> nameValidator,
IValidator<AuthData> authDataValidator, IValidator<RegisterRequest> registerRequestValidator,
IValidator<Passport> passportValidator, IValidator<PassportModel> passportValidator,
IValidator<PlaceOfWorkModel> placeOfWorkModelValidator) IValidator<PlaceOfWorkModel> placeOfWorkModelValidator)
{ {
RuleFor(r => r.AuthData) RuleFor(r => r.RegisterRequest)
.SetValidator(authDataValidator); .NotEmpty()
.SetValidator(registerRequestValidator);
RuleFor(r => r.ApplicantName) RuleFor(r => r.ApplicantName)
.NotEmpty()
.SetValidator(nameValidator); .SetValidator(nameValidator);
RuleFor(r => r.FatherName) RuleFor(r => r.FatherName)
.NotEmpty()
.SetValidator(nameValidator); .SetValidator(nameValidator);
RuleFor(r => r.MotherName) RuleFor(r => r.MotherName)
.NotEmpty()
.SetValidator(nameValidator); .SetValidator(nameValidator);
RuleFor(r => r.Passport) RuleFor(r => r.Passport)
.NotEmpty()
.SetValidator(passportValidator); .SetValidator(passportValidator);
RuleFor(r => r.BirthDate) RuleFor(r => r.BirthDate)
@@ -40,38 +43,51 @@ public class RegisterApplicantRequestValidator : AbstractValidator<RegisterAppli
RuleFor(r => r.CountryOfBirth) RuleFor(r => r.CountryOfBirth)
.NotEmpty() .NotEmpty()
.WithMessage("Country of birth can not be empty") .WithMessage("Country of birth can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Country of birth field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.CountryNameLength) .MaximumLength(ConfigurationConstraints.CountryNameLength)
.WithMessage($"Country of birth name length must be less than {ConfigurationConstraints.CountryNameLength}"); .WithMessage($"Country of birth name length must be less than {ConfigurationConstraints.CountryNameLength}");
RuleFor(r => r.CityOfBirth) RuleFor(r => r.CityOfBirth)
.NotEmpty() .NotEmpty()
.WithMessage("City of birth can not be empty") .WithMessage("City of birth can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("City of birth field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.CityNameLength) .MaximumLength(ConfigurationConstraints.CityNameLength)
.WithMessage($"City of birth name length must be less than {ConfigurationConstraints.CityNameLength}"); .WithMessage($"City of birth name length must be less than {ConfigurationConstraints.CityNameLength}");
RuleFor(r => r.Citizenship) RuleFor(r => r.Citizenship)
.NotEmpty() .NotEmpty()
.WithMessage("Citizenship can not be empty") .WithMessage("Citizenship can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Citizenship field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.CitizenshipLength) .MaximumLength(ConfigurationConstraints.CitizenshipLength)
.WithMessage($"Citizenship length must be less than {ConfigurationConstraints.CitizenshipLength}"); .WithMessage($"Citizenship length must be less than {ConfigurationConstraints.CitizenshipLength}");
RuleFor(r => r.CitizenshipByBirth) RuleFor(r => r.CitizenshipByBirth)
.NotEmpty() .NotEmpty()
.WithMessage("Citizenship by birth can not be empty") .WithMessage("Citizenship by birth can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Citizenship by birth field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.CitizenshipLength) .MaximumLength(ConfigurationConstraints.CitizenshipLength)
.WithMessage($"Citizenship by birth length must be less than {ConfigurationConstraints.CitizenshipLength}"); .WithMessage($"Citizenship by birth length must be less than {ConfigurationConstraints.CitizenshipLength}");
RuleFor(r => r.Gender).IsInEnum(); RuleFor(r => r.Gender)
.IsInEnum();
RuleFor(r => r.MaritalStatus).IsInEnum(); RuleFor(r => r.MaritalStatus)
.IsInEnum();
RuleFor(r => r.JobTitle) RuleFor(r => r.JobTitle)
.NotEmpty() .NotEmpty()
.WithMessage("Title of job can not be empty") .WithMessage("Title of job can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Title of job field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.JobTitleLength) .MaximumLength(ConfigurationConstraints.JobTitleLength)
.WithMessage($"Title of job length must be less than {ConfigurationConstraints.JobTitleLength}"); .WithMessage($"Title of job length must be less than {ConfigurationConstraints.JobTitleLength}");
RuleFor(r => r.PlaceOfWork) RuleFor(r => r.PlaceOfWork)
.NotEmpty()
.SetValidator(placeOfWorkModelValidator); .SetValidator(placeOfWorkModelValidator);
} }
} }

View File

@@ -0,0 +1,17 @@
using ApplicationLayer.Services.AuthServices.Common;
using ApplicationLayer.Services.AuthServices.NeededServices;
using FluentValidation;
namespace ApplicationLayer.Services.AuthServices.Requests.Validation;
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
{
public RegisterRequestValidator(IUsersRepository users, IValidator<AuthData> authDataValidator)
{
RuleFor(r => r.AuthData)
.NotEmpty()
.SetValidator(authDataValidator)
.MustAsync(async (authData, ct) => await users.FindByEmailAsync(authData.Email, ct) is null)
.WithMessage("Email already exists");
}
}

View File

@@ -0,0 +1,6 @@
using ApplicationLayer.GeneralExceptions;
using Domains.Users;
namespace ApplicationLayer.Services.Users.Exceptions;
public class WrongRoleException(Guid userId) : EntityNotFoundByIdException<User>(userId);

View File

@@ -1,5 +1,6 @@
using ApplicationLayer.Services.Users.Requests; using ApplicationLayer.Services.Applicants.Models;
using Domains.Users; using ApplicationLayer.Services.Users.Models;
using ApplicationLayer.Services.Users.Requests;
namespace ApplicationLayer.Services.Users; namespace ApplicationLayer.Services.Users;
@@ -8,15 +9,19 @@ public interface IUsersService
{ {
/// Returns all user accounts with role of approving authority /// Returns all user accounts with role of approving authority
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
Task<List<User>> GetAuthoritiesAccountsAsync(CancellationToken cancellationToken); Task<List<UserModel>> GetAuthoritiesAccountsAsync(CancellationToken cancellationToken);
/// Changes authentication data for an account /// Changes authentication data for an authority account
/// <param name="request"> Request object with identifier of user and new authentication data</param> /// <param name="request"> Request object with identifier of user and new authentication data</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
Task ChangeAccountAuthDataAsync(ChangeUserAuthDataRequest request, CancellationToken cancellationToken); Task ChangeAuthorityAuthDataAsync(ChangeUserAuthDataRequest request, CancellationToken cancellationToken);
/// Removes user account /// Removes account of authority
/// <param name="userId">Identifier of account</param> /// <param name="userId">Identifier of account</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
Task RemoveUserAccount(Guid userId, CancellationToken cancellationToken); Task RemoveAuthorityAccount(Guid userId, CancellationToken cancellationToken);
/// Get applicant that made request
/// <param name="cancellationToken">cancellation token</param>
Task<ApplicantModel> GetAuthenticatedApplicant(CancellationToken cancellationToken);
} }

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
using Domains;
namespace ApplicationLayer.Services.Users.Models
{
/// Auth data with nullable password for making change auth data requests
public class ChangeAuthData
{
[Required]
[MaxLength(ConfigurationConstraints.EmailLength)]
public string Email { get; set; } = null!;
[MaxLength(ConfigurationConstraints.PasswordLength)]
public string? Password { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
using Domains;
namespace ApplicationLayer.Services.Users.Models
{
public class UserModel
{
/// Unique Identifier of user
[Required]
public Guid Id { get; private set; } = Guid.NewGuid();
[Required]
[MaxLength(ConfigurationConstraints.EmailLength)]
public string Email { get; set; } = null!;
}
}

View File

@@ -1,5 +1,11 @@
using ApplicationLayer.Services.AuthServices.Common; using System.ComponentModel.DataAnnotations;
using ApplicationLayer.Services.Users.Models;
namespace ApplicationLayer.Services.Users.Requests; namespace ApplicationLayer.Services.Users.Requests;
public record ChangeUserAuthDataRequest(Guid UserId, AuthData NewAuthData); public class ChangeUserAuthDataRequest(Guid userId, ChangeAuthData newAuthData)
{
[Required] public Guid UserId { get; set; } = userId;
[Required] public ChangeAuthData NewAuthData { get; set; } = newAuthData;
}

View File

@@ -0,0 +1,21 @@
using Domains;
using FluentValidation;
namespace ApplicationLayer.Services.Users.Requests.Validation
{
public class ChangeUserAuthDataRequestValidator : AbstractValidator<ChangeUserAuthDataRequest>
{
public ChangeUserAuthDataRequestValidator()
{
RuleFor(r => r.NewAuthData)
.NotEmpty();
RuleFor(r => r.NewAuthData.Email)
.NotEmpty()
.EmailAddress()
.WithMessage("Email should be valid")
.MaximumLength(ConfigurationConstraints.EmailLength)
.WithMessage($"Email address length must be less than {ConfigurationConstraints.EmailLength}");
}
}
}

View File

@@ -1,33 +1,85 @@
using ApplicationLayer.InfrastructureServicesInterfaces; using ApplicationLayer.InfrastructureServicesInterfaces;
using ApplicationLayer.Services.Applicants.Models;
using ApplicationLayer.Services.Applicants.NeededServices;
using ApplicationLayer.Services.AuthServices.NeededServices; using ApplicationLayer.Services.AuthServices.NeededServices;
using ApplicationLayer.Services.Users.Exceptions;
using ApplicationLayer.Services.Users.Models;
using ApplicationLayer.Services.Users.Requests; using ApplicationLayer.Services.Users.Requests;
using AutoMapper;
using Domains.Users; using Domains.Users;
namespace ApplicationLayer.Services.Users; namespace ApplicationLayer.Services.Users;
public class UsersService(IUsersRepository users, IUnitOfWork unitOfWork) : IUsersService public class UsersService(
IMapper mapper,
IUserIdProvider userIdProvider,
IUsersRepository users,
IApplicantsRepository applicants,
IUnitOfWork unitOfWork) : IUsersService
{ {
async Task<List<User>> IUsersService.GetAuthoritiesAccountsAsync(CancellationToken cancellationToken) async Task<List<UserModel>> IUsersService.GetAuthoritiesAccountsAsync(CancellationToken cancellationToken)
{ {
return await users.GetAllOfRoleAsync(Role.ApprovingAuthority, cancellationToken); var userList = await users.GetAllOfRoleAsync(Role.ApprovingAuthority, cancellationToken);
return mapper.Map<List<UserModel>>(userList);
} }
async Task IUsersService.ChangeAccountAuthDataAsync(ChangeUserAuthDataRequest request, CancellationToken cancellationToken) async Task IUsersService.ChangeAuthorityAuthDataAsync(ChangeUserAuthDataRequest request, CancellationToken cancellationToken)
{ {
var user = await users.GetByIdAsync(request.UserId, cancellationToken); var user = await users.GetByIdAsync(request.UserId, cancellationToken);
user.Email = request.NewAuthData.Email; ValidateRole(user, Role.ApprovingAuthority);
user.Password = request.NewAuthData.Password;
await ChangeAccountAuthDataAsync(user, request.NewAuthData, cancellationToken);
}
async Task IUsersService.RemoveAuthorityAccount(Guid userId, CancellationToken cancellationToken)
{
var user = await users.GetByIdAsync(userId, cancellationToken);
ValidateRole(user, Role.ApprovingAuthority);
await RemoveUserAccount(user, cancellationToken);
}
async Task<ApplicantModel> IUsersService.GetAuthenticatedApplicant(CancellationToken cancellationToken)
{
var applicant = await applicants.FindByUserIdAsync(userIdProvider.GetUserId(), cancellationToken);
return mapper.Map<ApplicantModel>(applicant);
}
/// Updates user account auth data
/// <param name="user">User to remove</param>
/// <param name="authData">New auth data</param>
/// <param name="cancellationToken">Cancellation token</param>
private async Task ChangeAccountAuthDataAsync(User user, ChangeAuthData authData, CancellationToken cancellationToken)
{
user.Email = authData.Email;
user.Password = authData.Password ?? user.Password;
await users.UpdateAsync(user, cancellationToken); await users.UpdateAsync(user, cancellationToken);
await unitOfWork.SaveAsync(cancellationToken); await unitOfWork.SaveAsync(cancellationToken);
} }
async Task IUsersService.RemoveUserAccount(Guid userId, CancellationToken cancellationToken) /// Removes user account from DB
/// <param name="user">User to remove</param>
/// <param name="cancellationToken">Cancellation token</param>
private async Task RemoveUserAccount(User user, CancellationToken cancellationToken)
{ {
var user = await users.GetByIdAsync(userId, cancellationToken);
users.Remove(user); users.Remove(user);
await unitOfWork.SaveAsync(cancellationToken); await unitOfWork.SaveAsync(cancellationToken);
} }
/// Checks if role of user equals expected
/// <param name="user">User to check</param>
/// <param name="expectedRole">Expected role</param>
/// <exception cref="WrongRoleException">Role is not expected</exception>
private static void ValidateRole(User user, Role expectedRole)
{
if (user.Role != expectedRole)
{
throw new WrongRoleException(user.Id);
}
}
} }

View File

@@ -6,10 +6,16 @@ namespace ApplicationLayer.Services.VisaApplications.Handlers;
public interface IVisaApplicationRequestsHandler public interface IVisaApplicationRequestsHandler
{ {
/// Returns all applications for approving authorities /// Returns all applications for approving authorities
Task<List<VisaApplicationModelForAuthority>> GetPendingAsync(CancellationToken cancellationToken); Task<List<VisaApplicationPreview>> GetPendingAsync(CancellationToken cancellationToken);
/// Returns all applications of one applicant /// Returns all applications of one applicant
Task<List<VisaApplicationModelForApplicant>> GetForApplicantAsync(CancellationToken cancellationToken); Task<List<VisaApplicationPreview>> GetForApplicantAsync(CancellationToken cancellationToken);
/// Returns one application with specific id
Task<VisaApplicationModel> GetApplicationForApplicantAsync(Guid id, CancellationToken cancellationToken);
/// Returns one application with specific id
Task<VisaApplicationModel> GetApplicationForAuthorityAsync(Guid id, CancellationToken cancellationToken);
/// Creates application for applicant with specific user identifier /// Creates application for applicant with specific user identifier
Task HandleCreateRequestAsync(VisaApplicationCreateRequest request, CancellationToken cancellationToken); Task HandleCreateRequestAsync(VisaApplicationCreateRequest request, CancellationToken cancellationToken);

View File

@@ -19,36 +19,41 @@ public class VisaApplicationRequestsHandler(
IDateTimeProvider dateTimeProvider, IDateTimeProvider dateTimeProvider,
IUserIdProvider userIdProvider) : IVisaApplicationRequestsHandler IUserIdProvider userIdProvider) : IVisaApplicationRequestsHandler
{ {
async Task<List<VisaApplicationModelForAuthority>> IVisaApplicationRequestsHandler.GetPendingAsync(CancellationToken cancellationToken) async Task<List<VisaApplicationPreview>> IVisaApplicationRequestsHandler.GetPendingAsync(CancellationToken cancellationToken)
{ {
var applicationsList = await applications.GetPendingApplicationsAsync(cancellationToken); var applicationsList = await applications.GetPendingApplicationsAsync(cancellationToken);
var applicationModels = applicationsList var applicationModels = mapper.Map<List<VisaApplicationPreview>>(applicationsList);
.Select(a => MapVisaApplicationToModelForAuthorities(a, cancellationToken).Result)
.ToList();
return applicationModels; return applicationModels;
} }
private async Task<VisaApplicationModelForAuthority> MapVisaApplicationToModelForAuthorities(VisaApplication visaApplication, async Task<List<VisaApplicationPreview>> IVisaApplicationRequestsHandler.GetForApplicantAsync(CancellationToken cancellationToken)
CancellationToken cancellationToken)
{
var applicant = await applicants.GetByIdAsync(visaApplication.ApplicantId, cancellationToken);
var applicantModel = mapper.Map<ApplicantModel>(applicant);
var model = mapper.Map<VisaApplicationModelForAuthority>(visaApplication);
model.Applicant = applicantModel;
return model;
}
public async Task<List<VisaApplicationModelForApplicant>> GetForApplicantAsync(CancellationToken cancellationToken)
{ {
var applicantId = await applicants.GetApplicantIdByUserId(userIdProvider.GetUserId(), cancellationToken); var applicantId = await applicants.GetApplicantIdByUserId(userIdProvider.GetUserId(), cancellationToken);
var visaApplications = await applications.GetOfApplicantAsync(applicantId, cancellationToken); var visaApplications = await applications.GetOfApplicantAsync(applicantId, cancellationToken);
return mapper.Map<List<VisaApplicationModelForApplicant>>(visaApplications); return mapper.Map<List<VisaApplicationPreview>>(visaApplications);
} }
public async Task HandleCreateRequestAsync(VisaApplicationCreateRequest request, CancellationToken cancellationToken) async Task<VisaApplicationModel> IVisaApplicationRequestsHandler.GetApplicationForApplicantAsync(Guid id, CancellationToken cancellationToken)
{
var applicant = await applicants.FindByUserIdAsync(userIdProvider.GetUserId(), cancellationToken);
var application = await applications.GetByApplicantAndApplicationIdAsync(applicant.Id, id, cancellationToken);
var mapped = mapper.Map<VisaApplicationModel>(application);
mapped.Applicant = mapper.Map<ApplicantModel>(applicant);
return mapped;
}
async Task<VisaApplicationModel> IVisaApplicationRequestsHandler.GetApplicationForAuthorityAsync(Guid id, CancellationToken cancellationToken)
{
var pending = await applications.GetPendingApplicationsAsync(cancellationToken);
var application = pending.SingleOrDefault(a => a.Id == id) ?? throw new ApplicationAlreadyProcessedException();
var mapped = mapper.Map<VisaApplicationModel>(application);
var applicant = await applicants.GetByIdAsync(application.ApplicantId, cancellationToken);
mapped.Applicant = mapper.Map<ApplicantModel>(applicant);
return mapped;
}
async Task IVisaApplicationRequestsHandler.HandleCreateRequestAsync(VisaApplicationCreateRequest request, CancellationToken cancellationToken)
{ {
var applicant = await applicants.FindByUserIdAsync(userIdProvider.GetUserId(), cancellationToken); var applicant = await applicants.FindByUserIdAsync(userIdProvider.GetUserId(), cancellationToken);
@@ -65,6 +70,10 @@ public class VisaApplicationRequestsHandler(
{ {
var applicantId = await applicants.GetApplicantIdByUserId(userIdProvider.GetUserId(), cancellationToken); var applicantId = await applicants.GetApplicantIdByUserId(userIdProvider.GetUserId(), cancellationToken);
var application = await applications.GetByApplicantAndApplicationIdAsync(applicantId, applicationId, cancellationToken); var application = await applications.GetByApplicantAndApplicationIdAsync(applicantId, applicationId, cancellationToken);
if (application.Status is ApplicationStatus.Approved or ApplicationStatus.Rejected)
{
throw new ApplicationAlreadyProcessedException();
}
application.Status = ApplicationStatus.Closed; application.Status = ApplicationStatus.Closed;
await applications.UpdateAsync(application, cancellationToken); await applications.UpdateAsync(application, cancellationToken);
@@ -83,7 +92,7 @@ public class VisaApplicationRequestsHandler(
throw new ApplicationAlreadyProcessedException(); throw new ApplicationAlreadyProcessedException();
} }
ApplicationStatus statusToSet = status switch var statusToSet = status switch
{ {
AuthorityRequestStatuses.Approved => ApplicationStatus.Approved, AuthorityRequestStatuses.Approved => ApplicationStatus.Approved,
AuthorityRequestStatuses.Rejected => ApplicationStatus.Rejected, AuthorityRequestStatuses.Rejected => ApplicationStatus.Rejected,

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Domains;
namespace ApplicationLayer.Services.VisaApplications.Models;
/// Model of past visa for presentation layer
public class PastVisaModel
{
// Date of issue
[Required]
public DateTime IssueDate { get; set; }
/// Name of visa
[MaxLength(ConfigurationConstraints.VisaNameLength)]
[Required]
public string Name { get; set; } = null!;
/// Date when visa expires
[Required]
public DateTime ExpirationDate { get; set; }
}

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Domains;
namespace ApplicationLayer.Services.VisaApplications.Models;
/// Model of past visit for presentation layer
public class PastVisitModel
{
/// First day of past visit
[Required]
public DateTime StartDate { get; set; }
/// Last day of past visit
[Required]
public DateTime EndDate { get; set; }
/// Destination country of past visit
[MaxLength(ConfigurationConstraints.CountryNameLength)]
[Required]
public string DestinationCountry { get; set; } = null!;
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using Domains;
namespace ApplicationLayer.Services.VisaApplications.Models;
/// Model of permission to destination country for presentation layer
public class PermissionToDestCountryModel
{
/// Date when permission to destination country expires
[Required]
public DateTime ExpirationDate { get; set; }
/// Issuing authority
[MaxLength(ConfigurationConstraints.IssuerNameLength)]
[Required]
public string Issuer { get; set; } = null!;
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using Domains;
namespace ApplicationLayer.Services.VisaApplications.Models;
/// Model of re-entry permit for presentation layer
public class ReentryPermitModel
{
/// Number of re-entry permit
[MaxLength(ConfigurationConstraints.ReentryPermitNumberLength)]
[Required]
public string Number { get; set; } = null!;
/// Date when re-entry permit expires
[Required]
public DateTime ExpirationDate { get; set; }
}

View File

@@ -0,0 +1,31 @@
using ApplicationLayer.InfrastructureServicesInterfaces;
using Domains;
using FluentValidation;
namespace ApplicationLayer.Services.VisaApplications.Models.Validation;
public class PastVisaModelValidator : AbstractValidator<PastVisaModel>
{
public PastVisaModelValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(v => v.ExpirationDate)
.NotEmpty()
.WithMessage("Expiration date of past visa can not be empty")
.GreaterThan(v => v.IssueDate)
.WithMessage("Past visa expiration date can not be earlier than issue date");
RuleFor(v => v.IssueDate)
.NotEmpty()
.WithMessage("Issue date of past visa can not be empty")
.LessThan(dateTimeProvider.Now())
.WithMessage("Issue date of past visa must be in past");
RuleFor(v => v.Name)
.NotEmpty()
.WithMessage("Name of past visa can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Name of past visa can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.VisaNameLength)
.WithMessage($"Past visa name length must be less than {ConfigurationConstraints.VisaNameLength}");
}
}

View File

@@ -0,0 +1,31 @@
using ApplicationLayer.InfrastructureServicesInterfaces;
using Domains;
using FluentValidation;
namespace ApplicationLayer.Services.VisaApplications.Models.Validation;
public class PastVisitModelValidator : AbstractValidator<PastVisitModel>
{
public PastVisitModelValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(v => v.StartDate)
.NotEmpty()
.WithMessage("Start date of past visit can not be empty")
.LessThan(v => v.EndDate)
.WithMessage("Start date of past visit must be earlier than end date")
.LessThan(dateTimeProvider.Now())
.WithMessage("Start date of past visit must be in past");
RuleFor(v => v.EndDate)
.NotEmpty()
.WithMessage("End date of past visit can not be empty");
RuleFor(v => v.DestinationCountry)
.NotEmpty()
.WithMessage("Destination Country of past visit can not be null")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Destination Country of past visit can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.CountryNameLength)
.WithMessage($"Destination Country of past visit length must be less than {ConfigurationConstraints.CountryNameLength}");
}
}

View File

@@ -0,0 +1,25 @@
using ApplicationLayer.InfrastructureServicesInterfaces;
using Domains;
using FluentValidation;
namespace ApplicationLayer.Services.VisaApplications.Models.Validation;
public class PermissionToDestCountryModelValidator : AbstractValidator<PermissionToDestCountryModel?>
{
public PermissionToDestCountryModelValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(p => p!.ExpirationDate)
.NotEmpty()
.WithMessage("Expiration date of permission to destination Country can not be empty")
.GreaterThan(dateTimeProvider.Now())
.WithMessage("Permission to destination Country must not be expired");
RuleFor(p => p!.Issuer)
.NotEmpty()
.WithMessage("Issuer of permission for destination Country can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Issuer of permission for destination Country can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.IssuerNameLength)
.WithMessage($"Issuer of permission to destination Country length must be less than {ConfigurationConstraints.IssuerNameLength}");
}
}

View File

@@ -0,0 +1,25 @@
using ApplicationLayer.InfrastructureServicesInterfaces;
using Domains;
using FluentValidation;
namespace ApplicationLayer.Services.VisaApplications.Models.Validation;
public class ReentryPermitModelValidator : AbstractValidator<ReentryPermitModel?>
{
public ReentryPermitModelValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(p => p!.Number)
.NotEmpty()
.WithMessage("Re-entry permit number can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Re-entry permit number can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.ReentryPermitNumberLength)
.WithMessage($"Re-entry permit number length must be less than {ConfigurationConstraints.ReentryPermitNumberLength}");
RuleFor(p => p!.ExpirationDate)
.NotEmpty()
.WithMessage("Re-entry permit expiration date can not be empty")
.GreaterThan(dateTimeProvider.Now())
.WithMessage("Re-entry permit must not be expired");
}
}

View File

@@ -0,0 +1,58 @@
using System.ComponentModel.DataAnnotations;
using ApplicationLayer.Services.Applicants.Models;
using Domains.VisaApplicationDomain;
namespace ApplicationLayer.Services.VisaApplications.Models;
/// Model of <see cref="VisaApplication" /> with applicant property
public class VisaApplicationModel
{
/// <inheritdoc cref="VisaApplication.Id" />
[Required]
public Guid Id { get; set; }
/// Applicant of application
[Required]
public ApplicantModel Applicant { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.Status" />
[Required]
public ApplicationStatus Status { get; set; }
/// <inheritdoc cref="VisaApplication.ReentryPermit" />
public ReentryPermitModel? ReentryPermit { get; set; }
/// <inheritdoc cref="VisaApplication.DestinationCountry" />
[Required]
public string DestinationCountry { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.PastVisas" />
[Required]
public List<PastVisaModel> PastVisas { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.PermissionToDestCountry" />
public PermissionToDestCountryModel? PermissionToDestCountry { get; set; }
[Required]
public List<PastVisitModel> PastVisits { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.VisaCategory" />
[Required]
public VisaCategory VisaCategory { get; set; }
/// <inheritdoc cref="VisaApplication.ForGroup" />
[Required]
public bool ForGroup { get; set; }
/// <inheritdoc cref="VisaApplication.RequestedNumberOfEntries" />
[Required]
public RequestedNumberOfEntries RequestedNumberOfEntries { get; set; }
/// <inheritdoc cref="VisaApplication.RequestDate" />
[Required]
public DateTime RequestDate { get; set; }
/// <inheritdoc cref="VisaApplication.ValidDaysRequested" />
[Required]
public int ValidDaysRequested { get; set; }
}

View File

@@ -1,43 +0,0 @@
using Domains.VisaApplicationDomain;
namespace ApplicationLayer.Services.VisaApplications.Models;
/// Model of <see cref="VisaApplication"/>
public class VisaApplicationModelForApplicant
{
/// <inheritdoc cref="VisaApplication.Id"/>
public Guid Id { get; set; }
/// <inheritdoc cref="VisaApplication.Status"/>
public ApplicationStatus Status { get; set; }
/// <inheritdoc cref="VisaApplication.ReentryPermit"/>
public ReentryPermit? ReentryPermit { get; set; }
/// <inheritdoc cref="VisaApplication.DestinationCountry"/>
public string DestinationCountry { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.PastVisas"/>
public List<PastVisa> PastVisas { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.PermissionToDestCountry"/>
public PermissionToDestCountry? PermissionToDestCountry { get; set; }
/// <inheritdoc cref="VisaApplication.PastVisits"/>
public List<PastVisit> PastVisits { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.VisaCategory"/>
public VisaCategory VisaCategory { get; set; }
/// <inheritdoc cref="VisaApplication.ForGroup"/>
public bool ForGroup { get; set; }
/// <inheritdoc cref="VisaApplication.RequestedNumberOfEntries"/>
public RequestedNumberOfEntries RequestedNumberOfEntries { get; set; }
/// <inheritdoc cref="VisaApplication.RequestDate"/>
public DateTime RequestDate { get; set; }
/// <inheritdoc cref="VisaApplication.ValidDaysRequested"/>
public int ValidDaysRequested { get; set; }
}

View File

@@ -1,46 +0,0 @@
using ApplicationLayer.Services.Applicants.Models;
using Domains.VisaApplicationDomain;
namespace ApplicationLayer.Services.VisaApplications.Models;
/// Model of <see cref="VisaApplication"/> with applicant property
public class VisaApplicationModelForAuthority
{
/// <inheritdoc cref="VisaApplication.Id"/>
public Guid Id { get; set; }
/// Applicant of application
public ApplicantModel Applicant { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.Status"/>
public ApplicationStatus Status { get; set; }
/// <inheritdoc cref="VisaApplication.ReentryPermit"/>
public ReentryPermit? ReentryPermit { get; set; }
/// <inheritdoc cref="VisaApplication.DestinationCountry"/>
public string DestinationCountry { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.PastVisas"/>
public List<PastVisa> PastVisas { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.PermissionToDestCountry"/>
public PermissionToDestCountry? PermissionToDestCountry { get; set; }
public List<PastVisit> PastVisits { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.VisaCategory"/>
public VisaCategory VisaCategory { get; set; }
/// <inheritdoc cref="VisaApplication.ForGroup"/>
public bool ForGroup { get; set; }
/// <inheritdoc cref="VisaApplication.RequestedNumberOfEntries"/>
public RequestedNumberOfEntries RequestedNumberOfEntries { get; set; }
/// <inheritdoc cref="VisaApplication.RequestDate"/>
public DateTime RequestDate { get; set; }
/// <inheritdoc cref="VisaApplication.ValidDaysRequested"/>
public int ValidDaysRequested { get; set; }
}

View File

@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using Domains.VisaApplicationDomain;
namespace ApplicationLayer.Services.VisaApplications.Models;
/// Model of <see cref="VisaApplication" />
public class VisaApplicationPreview
{
/// <inheritdoc cref="VisaApplication.Id" />
[Required]
public Guid Id { get; set; }
/// <inheritdoc cref="VisaApplication.Status" />
[Required]
public ApplicationStatus Status { get; set; }
/// <inheritdoc cref="VisaApplication.DestinationCountry" />
[Required]
public string DestinationCountry { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.VisaCategory" />
[Required]
public VisaCategory VisaCategory { get; set; }
/// <inheritdoc cref="VisaApplication.RequestDate" />
[Required]
public DateTime RequestDate { get; set; }
/// <inheritdoc cref="VisaApplication.ValidDaysRequested" />
[Required]
public int ValidDaysRequested { get; set; }
}

View File

@@ -1,27 +0,0 @@
using ApplicationLayer.InfrastructureServicesInterfaces;
using Domains.VisaApplicationDomain;
using FluentValidation;
namespace ApplicationLayer.Services.VisaApplications.Requests.Validation;
public class PastVisaValidator : AbstractValidator<PastVisa>
{
public PastVisaValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(v => v.ExpirationDate)
.NotEmpty()
.WithMessage("Expiration date of past visa can not be empty")
.GreaterThan(v => v.IssueDate)
.WithMessage("Past visa expiration date can not be earlier than issue date");
RuleFor(v => v.IssueDate)
.NotEmpty()
.WithMessage("Issue date of past visa can not be empty")
.LessThan(dateTimeProvider.Now())
.WithMessage("Issue date of past visa must be in past");
RuleFor(v => v.Name)
.NotEmpty()
.WithMessage("Name of past visa can not be empty");
}
}

View File

@@ -1,30 +0,0 @@
using ApplicationLayer.InfrastructureServicesInterfaces;
using Domains;
using Domains.VisaApplicationDomain;
using FluentValidation;
namespace ApplicationLayer.Services.VisaApplications.Requests.Validation;
public class PastVisitValidator : AbstractValidator<PastVisit>
{
public PastVisitValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(v => v.StartDate)
.NotEmpty()
.WithMessage("Start date of past visit can not be empty")
.LessThan(v => v.EndDate)
.WithMessage("Start date of past visit must be earlier than end date")
.LessThan(dateTimeProvider.Now())
.WithMessage("Start date of past visit must be in past");
RuleFor(v => v.EndDate)
.NotEmpty()
.WithMessage("End date of past visit can not be empty");
RuleFor(v => v.DestinationCountry)
.NotEmpty()
.WithMessage("Destination Country of past visit can not be null")
.MaximumLength(ConfigurationConstraints.CountryNameLength)
.WithMessage($"Destination Country of past visit length must be less than {ConfigurationConstraints.CountryNameLength}");
}
}

View File

@@ -1,24 +0,0 @@
using ApplicationLayer.InfrastructureServicesInterfaces;
using Domains;
using Domains.VisaApplicationDomain;
using FluentValidation;
namespace ApplicationLayer.Services.VisaApplications.Requests.Validation;
public class PermissionToDestCountryValidator : AbstractValidator<PermissionToDestCountry?>
{
public PermissionToDestCountryValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(p => p!.ExpirationDate)
.NotEmpty()
.WithMessage("Expiration date of permission to destination Country can not be empty")
.GreaterThan(dateTimeProvider.Now())
.WithMessage("Permission to destination Country must not be expired");
RuleFor(p => p!.Issuer)
.NotEmpty()
.WithMessage("Issuer of permission for destination Country can not be empty")
.MaximumLength(ConfigurationConstraints.IssuerNameLength)
.WithMessage($"Issuer of permission to destination Country length must be less than {ConfigurationConstraints.IssuerNameLength}");
}
}

View File

@@ -1,24 +0,0 @@
using ApplicationLayer.InfrastructureServicesInterfaces;
using Domains;
using Domains.VisaApplicationDomain;
using FluentValidation;
namespace ApplicationLayer.Services.VisaApplications.Requests.Validation;
public class ReentryPermitValidator : AbstractValidator<ReentryPermit?>
{
public ReentryPermitValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(p => p!.Number)
.NotEmpty()
.WithMessage("Re-entry permit number can not be empty")
.MaximumLength(ConfigurationConstraints.ReentryPermitNumberLength)
.WithMessage($"Re-entry permit number length must be less than {ConfigurationConstraints.ReentryPermitNumberLength}");
RuleFor(p => p!.ExpirationDate)
.NotEmpty()
.WithMessage("Re-entry permit expiration date can not be empty")
.GreaterThan(dateTimeProvider.Now())
.WithMessage("Re-entry permit must not be expired");
}
}

View File

@@ -1,5 +1,6 @@
using ApplicationLayer.InfrastructureServicesInterfaces; using ApplicationLayer.InfrastructureServicesInterfaces;
using ApplicationLayer.Services.Applicants.NeededServices; using ApplicationLayer.Services.Applicants.NeededServices;
using ApplicationLayer.Services.VisaApplications.Models;
using Domains; using Domains;
using Domains.VisaApplicationDomain; using Domains.VisaApplicationDomain;
using FluentValidation; using FluentValidation;
@@ -9,17 +10,23 @@ namespace ApplicationLayer.Services.VisaApplications.Requests.Validation;
public class VisaApplicationCreateRequestValidator : AbstractValidator<VisaApplicationCreateRequest> public class VisaApplicationCreateRequestValidator : AbstractValidator<VisaApplicationCreateRequest>
{ {
public VisaApplicationCreateRequestValidator( public VisaApplicationCreateRequestValidator(
IValidator<ReentryPermit?> reentryPermitValidator, IValidator<ReentryPermitModel?> reentryPermitModelValidator,
IValidator<PastVisa> pastVisaValidator, IValidator<PastVisaModel> pastVisaModelValidator,
IValidator<PermissionToDestCountry?> permissionToDestCountryValidator, IValidator<PermissionToDestCountryModel?> permissionToDestCountryModelValidator,
IValidator<PastVisit> pastVisitValidator, IValidator<PastVisitModel> pastVisitModelValidator,
IApplicantsRepository applicants, IApplicantsRepository applicants,
IUserIdProvider userIdProvider) IUserIdProvider userIdProvider)
{ {
RuleFor(r => r.PermissionToDestCountry)
.NotEmpty()
.WithMessage("For transit you must provide permission to destination country")
.SetValidator(permissionToDestCountryModelValidator)
.When(r => r.VisaCategory is VisaCategory.Transit);
RuleFor(r => r.ReentryPermit) RuleFor(r => r.ReentryPermit)
.NotEmpty() .NotEmpty()
.WithMessage("Non-residents must provide re-entry permission") .WithMessage("Non-residents must provide re-entry permission")
.SetValidator(reentryPermitValidator) .SetValidator(reentryPermitModelValidator)
.WhenAsync(async (_, ct) => .WhenAsync(async (_, ct) =>
await applicants.IsApplicantNonResidentByUserId(userIdProvider.GetUserId(), ct)); await applicants.IsApplicantNonResidentByUserId(userIdProvider.GetUserId(), ct));
@@ -40,14 +47,9 @@ public class VisaApplicationCreateRequestValidator : AbstractValidator<VisaAppli
.WithMessage($"Valid days requested must be less than or equal to {ConfigurationConstraints.MaxValidDays}"); .WithMessage($"Valid days requested must be less than or equal to {ConfigurationConstraints.MaxValidDays}");
RuleForEach(r => r.PastVisas) RuleForEach(r => r.PastVisas)
.SetValidator(pastVisaValidator); .SetValidator(pastVisaModelValidator);
When(r => r.VisaCategory == VisaCategory.Transit,
() =>
RuleFor(r => r.PermissionToDestCountry)
.SetValidator(permissionToDestCountryValidator));
RuleForEach(r => r.PastVisits) RuleForEach(r => r.PastVisits)
.SetValidator(pastVisitValidator); .SetValidator(pastVisitModelValidator);
} }
} }

View File

@@ -1,16 +1,37 @@
using Domains.VisaApplicationDomain; using System.ComponentModel.DataAnnotations;
using ApplicationLayer.Services.VisaApplications.Models;
using Domains;
using Domains.VisaApplicationDomain;
namespace ApplicationLayer.Services.VisaApplications.Requests; namespace ApplicationLayer.Services.VisaApplications.Requests;
/// Model of visa request from user /// Model of visa request from user
public record VisaApplicationCreateRequest( public class VisaApplicationCreateRequest
ReentryPermit? ReentryPermit, {
string DestinationCountry, public ReentryPermitModel? ReentryPermit { get; set; }
VisaCategory VisaCategory,
bool IsForGroup, [Required]
RequestedNumberOfEntries RequestedNumberOfEntries, [MaxLength(ConfigurationConstraints.CountryNameLength)]
int ValidDaysRequested, public string DestinationCountry { get; set; } = null!;
PastVisa[] PastVisas,
PermissionToDestCountry? PermissionToDestCountry, [Required]
PastVisit[] PastVisits public VisaCategory VisaCategory { get; set; }
);
[Required]
public bool IsForGroup { get; set; }
[Required]
public RequestedNumberOfEntries RequestedNumberOfEntries { get; set; }
[Required]
[Range(0, ConfigurationConstraints.MaxValidDays)]
public int ValidDaysRequested { get; set; }
[Required]
public PastVisaModel[] PastVisas { get; set; } = null!;
public PermissionToDestCountryModel? PermissionToDestCountry { get; set; }
[Required]
public PastVisitModel[] PastVisits { get; set; } = null!;
}

View File

@@ -0,0 +1,16 @@
@using BlazorWebAssemblyVisaApiClient.ErrorHandling
<GlobalErrorHandler >
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</GlobalErrorHandler>

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="13.0.1" />
<PackageReference Include="Blazor.Bootstrap" Version="3.0.0" />
<PackageReference Include="FluentValidation" Version="11.9.2" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.DataAnnotations.Validation" Version="3.2.0-rc1.20223.4" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.1" PrivateAssets="all"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VisaApiClient\VisaApiClient.csproj" />
</ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="wwwroot\sample-data\weather.json" />
<_ContentIncludedByDefault Remove="wwwroot\css\bootstrap\bootstrap.min.css" />
<_ContentIncludedByDefault Remove="wwwroot\css\bootstrap\bootstrap.min.css.map" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,4 @@
namespace BlazorWebAssemblyVisaApiClient.Common.Exceptions
{
public class BlazorClientException(string message) : Exception(message);
}

View File

@@ -0,0 +1,4 @@
namespace BlazorWebAssemblyVisaApiClient.Common.Exceptions
{
public class NotLoggedInException() : BlazorClientException("User is not logged in.");
}

View File

@@ -0,0 +1,81 @@
@using System.Net
@using BlazorWebAssemblyVisaApiClient.ErrorHandling
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider
@using VisaApiClient
@code {
public static AuthData? AuthData;
[CascadingParameter] private GlobalErrorHandler ErrorHandler { get; set; } = null!;
[CascadingParameter] private Status? Status { get; set; }
[Inject] private Client Client { get; set; } = null!;
[Inject] private NavigationManager Nav { get; set; } = null!;
[Inject] private IUserDataProvider UserDataProvider { get; set; } = null!;
///Authorize with email and password
/// <returns>Message to user</returns>
public async Task TryAuthorize(AuthData authData)
{
Status?.SetMessage("Wait...");
try
{
var token = await Client.LoginAsync(authData.Email, authData.Password);
Client.AuthToken = token;
AuthData = authData;
UserDataProvider.UpdateCurrentRole();
Status?.SetSuccess("Logged in successfully.");
}
catch (ApiException<ProblemDetails> e)
{
if (e.Result.Status == (int)HttpStatusCode.Forbidden)
{
Status?.SetError(e.Result.Detail!);
}
else
{
Status?.SetError("Error occured");
ErrorHandler.Handle(e);
}
}
catch (Exception e)
{
Status?.SetError("Error occured");
ErrorHandler.Handle(e);
}
}
public void Logout()
{
Client.AuthToken = null;
AuthData = null;
try
{
UserDataProvider.UpdateCurrentRole();
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
///Re-auth if token expired or something
public async Task ReAuthenticate()
{
if (AuthData is not null)
{
await TryAuthorize(AuthData);
}
else
{
Client.AuthToken = null;
AuthData = null;
Nav.NavigateTo("/");
}
}
}

View File

@@ -0,0 +1,9 @@
@using BlazorWebAssemblyVisaApiClient.ErrorHandling
@using VisaApiClient
@code
{
[CascadingParameter] protected GlobalErrorHandler ErrorHandler { get; set; } = null!;
[Inject] protected Client Client { get; set; } = null!;
}

View File

@@ -0,0 +1,39 @@
@using VisaApiClient
<div>
<div >
<label >
Country:<br/>
<InputText class="rounded" @bind-Value="Address.Country"/>
</label><br/>
<ValidationMessage For="() => Address.Country"></ValidationMessage><br/>
</div>
<div >
<label >
City:<br/>
<InputText class="rounded" @bind-Value="Address.City"/>
</label><br/>
<ValidationMessage For="() => Address.City"></ValidationMessage><br/>
</div>
<div >
<label >
Street:<br/>
<InputText class="rounded" @bind-Value="Address.Street"/>
</label><br/>
<ValidationMessage For="() => Address.Street"></ValidationMessage><br/>
</div>
<div >
<label >
Building:<br/>
<InputText class="rounded" @bind-Value="Address.Building"/>
</label><br/>
<ValidationMessage For="() => Address.Building"></ValidationMessage>
</div>
</div>
@code {
[Parameter, EditorRequired] public AddressModel Address { get; set; } = null!;
}

View File

@@ -0,0 +1,23 @@
@using VisaApiClient
<div>
<div >
<label >
Email:<br/>
<InputText class="rounded" @bind-Value="AuthData.Email"/>
</label><br/>
<ValidationMessage For="() => AuthData.Email"></ValidationMessage><br/>
</div>
<div >
<label >
Password:<br/>
<InputText class="rounded" @bind-Value="AuthData.Password"/>
</label><br/>
<ValidationMessage For="() => AuthData.Password"></ValidationMessage>
</div>
</div>
@code {
[Parameter, EditorRequired] public AuthData AuthData { get; set; } = null!;
}

View File

@@ -0,0 +1,51 @@
@using System.Linq.Expressions
@using System.Reflection
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Helpers
@typeparam TItem where TItem : class
@typeparam TMember where TMember : struct, Enum
<InputSelect TValue="TMember" @bind-Value="selected">
@foreach (var value in enumValues)
{
<option value="@value.Key">@value.Value</option>
}
</InputSelect><br/>
@code {
[Parameter, EditorRequired] public TItem Model { get; set; } = default!;
[Parameter, EditorRequired] public Expression<Func<TItem, TMember>> EnumProperty { get; set; } = null!;
[Parameter] public Action? OnChanged { get; set; }
private Dictionary<TMember, string> enumValues = new();
private PropertyInfo modelMemberInfo = null!;
private TMember selected;
protected override void OnInitialized()
{
var modelMemberName = ((MemberExpression)EnumProperty.Body).Member.Name;
modelMemberInfo = typeof(TItem).GetProperty(modelMemberName)!;
foreach (var value in Enum.GetValues<TMember>())
{
enumValues.Add(value, value.GetDisplayName());
}
}
protected override void OnAfterRender(bool firstRender)
{
var current = (TMember)modelMemberInfo.GetValue(Model)!;
if (!current.Equals(selected))
{
OnValueChanged();
}
}
private void OnValueChanged()
{
modelMemberInfo.SetValue(Model, selected);
OnChanged?.Invoke();
}
}

View File

@@ -0,0 +1,31 @@
@using VisaApiClient
<div>
<div >
<label>
First name@(Constants.RequiredFieldMarkup):<br/>
<InputText DisplayName="First name" class="rounded" @bind-Value="Name.FirstName"/>
</label><br/>
<ValidationMessage For="() => Name.FirstName"></ValidationMessage>
</div><br/>
<div >
<label>
Surname@(Constants.RequiredFieldMarkup):<br/>
<InputText class="rounded" @bind-Value="Name.Surname"/>
</label><br/>
<ValidationMessage For="() => Name.Surname"></ValidationMessage>
</div><br/>
<div >
<label>
Patronymic:<br/>
<InputText class="rounded" @bind-Value="Name.Patronymic"/>
</label><br/>
<ValidationMessage For="() => Name.Patronymic"></ValidationMessage>
</div>
</div>
@code {
[Parameter, EditorRequired] public NameModel Name { get; set; } = null!;
}

View File

@@ -0,0 +1,52 @@
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.DateTimeProvider
@using VisaApiClient
<div>
<div >
<label>
Passport number:<br/>
<InputText DisplayName="Passport number" class="rounded" @bind-Value="Passport.Number"/>
</label><br/>
<ValidationMessage For="() => Passport.Number"></ValidationMessage>
</div><br/>
<div >
<label>
Issuer:<br/>
<InputText class="rounded" @bind-Value="Passport.Issuer"/>
</label><br/>
<ValidationMessage For="() => Passport.Issuer"></ValidationMessage>
</div><br/>
<div >
<label>
Issue date:<br/>
<InputDate DisplayName="Issue date" class="rounded" @bind-Value="Passport.IssueDate" max="@formattedDate"/>
</label><br/>
<ValidationMessage For="() => Passport.IssueDate"></ValidationMessage>
</div><br/>
<div >
<label>
Expiration date:<br/>
<InputDate DisplayName="Expiration date" class="rounded" @bind-Value="Passport.ExpirationDate" min="@formattedDate"/>
</label><br/>
<ValidationMessage For="() => Passport.ExpirationDate"></ValidationMessage>
</div>
</div>
@code {
private string formattedDate = null!;
[Parameter, EditorRequired] public PassportModel Passport { get; set; } = null!;
[Inject] IDateTimeProvider DateTimeProvider { get; set; } = null!;
protected override void OnInitialized()
{
Passport.IssueDate = DateTime.Now;
Passport.ExpirationDate = DateTime.Now;
formattedDate = DateTimeProvider.FormattedNow();
}
}

View File

@@ -0,0 +1,22 @@
@using BlazorWebAssemblyVisaApiClient.Validation.Applicants.Models
<div>
<div >
<label >
Name:<br/>
<InputText class="rounded" @bind-Value="PlaceOfWork.Name"/>
</label><br/>
<ValidationMessage For="() => PlaceOfWork.Name"></ValidationMessage><br/>
</div>
<div >
<label >
Phone number:<br/>
<InputText DisplayName="Phone number" class="rounded" @bind-Value="PlaceOfWork.PhoneNum"/>
</label><br/>
<ValidationMessage For="() => PlaceOfWork.PhoneNum"></ValidationMessage>
</div>
</div>
@code {
[Parameter, EditorRequired] public PlaceOfWorkModel PlaceOfWork { get; set; } = null!;
}

View File

@@ -0,0 +1,33 @@
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.DateTimeProvider
@using VisaApiClient
<div>
<label>
Issuer:<br/>
<InputText DisplayName="Issuer of permission to destination Country" class="rounded"
@bind-Value="PermissionToDestCountry.Issuer"/>
</label><br/>
<ValidationMessage For="() => PermissionToDestCountry.Issuer"></ValidationMessage><br/>
<label>
Expiration date:<br/>
<InputDate DisplayName="Expiration date of permission to destination Country" class="rounded"
@bind-Value="PermissionToDestCountry.ExpirationDate"
min="@formattedDate"/>
</label><br/>
<ValidationMessage For="() => PermissionToDestCountry.ExpirationDate"></ValidationMessage>
</div>
@code {
private string formattedDate = null!;
[Parameter, EditorRequired] public PermissionToDestCountryModel PermissionToDestCountry { get; set; } = null!;
[Inject] IDateTimeProvider DateTimeProvider { get; set; } = null!;
protected override void OnInitialized()
{
formattedDate = DateTimeProvider.FormattedNow();
}
}

View File

@@ -0,0 +1,34 @@
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.DateTimeProvider
@using VisaApiClient
<div>
<label>
Number:<br/>
<InputText DisplayName="Number of re-entry permit" class="rounded"
@bind-Value="ReentryPermit.Number"/>
</label><br/>
<ValidationMessage For="() => ReentryPermit.Number"></ValidationMessage><br/>
<label>
Expiration date:<br/>
<InputDate DisplayName="Expiration date of re-entry permit" class="rounded"
@bind-Value="ReentryPermit.ExpirationDate"
min="@formattedDate"/>
</label><br/>
<ValidationMessage For="() => ReentryPermit.ExpirationDate"></ValidationMessage>
</div>
@code {
private string formattedDate = null!;
[Parameter, EditorRequired] public ReentryPermitModel ReentryPermit { get; set; } = null!;
[Inject] IDateTimeProvider DateTimeProvider { get; set; } = null!;
protected override void OnInitialized()
{
formattedDate = DateTimeProvider.FormattedNow();
ReentryPermit.ExpirationDate = DateTimeProvider.Now();
}
}

View File

@@ -0,0 +1,34 @@
<p class="@statusClass">@((MarkupString)StatusText)</p>
<CascadingValue Value="this">
@ChildContent
</CascadingValue>
@code {
private string statusClass = string.Empty;
[Parameter]
public RenderFragment? ChildContent { get; set; }
public string StatusText { get; private set; } = string.Empty;
public void SetMessage(string message)
{
statusClass = string.Empty;
StatusText = message;
StateHasChanged();
}
public void SetError(string message)
{
statusClass = "validation-message";
StatusText = message;
StateHasChanged();
}
public void SetSuccess(string message)
{
statusClass = "text-success";
StatusText = message;
StateHasChanged();
}
}

View File

@@ -0,0 +1,20 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Components;
namespace BlazorWebAssemblyVisaApiClient
{
public static class Constants
{
public readonly static Regex EnglishWordRegex = new("^[a-zA-Z]*$");
public readonly static Regex EnglishPhraseRegex = new(@"^[a-zA-Z№0-9?><;,{}[\]\-_+=!@#$%\^&*|']*$");
public readonly static Regex PhoneNumRegex = new(@"^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$");
public readonly static MarkupString RequiredFieldMarkup = (MarkupString)"<span style=\"color: red;\">*</span>";
public const string ApplicantRole = "Applicant";
public const string ApprovingAuthorityRole = "ApprovingAuthority";
public const string AdminRole = "Admin";
}
}

View File

@@ -0,0 +1,53 @@
@using System.Net
@using BlazorWebAssemblyVisaApiClient.Common.Exceptions
@using VisaApiClient
<CascadingValue Value="this">
<Modal @ref="modal">
<BodyTemplate>
@errorDetails
</BodyTemplate>
<FooterTemplate>
<Button Color="ButtonColor.Secondary" @onclick="modal.HideAsync">Okaaaay</Button>
</FooterTemplate>
</Modal>
@ChildContent
</CascadingValue>
@code
{
private Modal modal = null!;
private string errorDetails = null!;
[Parameter] public RenderFragment? ChildContent { get; set; }
[Inject] private NavigationManager Nav { get; set; } = null!;
public void Handle(Exception ex)
{
switch (ex)
{
case ApiException<ProblemDetails>
{
StatusCode: (int)HttpStatusCode.Unauthorized or (int)HttpStatusCode.Forbidden
} or NotLoggedInException:
Nav.NavigateTo("/");
modal.Title = "Authorization failed";
errorDetails = "You are not authorized or your authorization is expired";
modal.ShowAsync();
break;
case ApiException<ProblemDetails> problemDetails:
modal.Title = problemDetails.Result.Title!;
errorDetails = problemDetails.Result.Detail!;
modal.ShowAsync();
break;
default:
modal.Title = "Something went wrong";
errorDetails = "Please, text an email with your problem definition on nasrudin@mail.ru";
modal.ShowAsync();
break;
}
}
}

View File

@@ -0,0 +1,19 @@
using AutoMapper;
using BlazorWebAssemblyVisaApiClient.Validation.Applicants.Models;
using VisaApiClient;
using PlaceOfWorkModel = BlazorWebAssemblyVisaApiClient.Validation.Applicants.Models.PlaceOfWorkModel;
namespace BlazorWebAssemblyVisaApiClient.Infrastructure.AutoMapper.Profiles
{
public class RegisterApplicantRequestProfile : Profile
{
public RegisterApplicantRequestProfile()
{
CreateMap<RegisterApplicantRequestModel, RegisterApplicantRequest>(MemberList.Destination);
CreateMap<RegisterRequestModel, RegisterRequest>(MemberList.Destination);
CreateMap<PlaceOfWorkModel, VisaApiClient.PlaceOfWorkModel>(MemberList.Destination);
}
}
}

View File

@@ -0,0 +1,14 @@
using AutoMapper;
using BlazorWebAssemblyVisaApiClient.Validation.VisaApplications.Models;
using VisaApiClient;
namespace BlazorWebAssemblyVisaApiClient.Infrastructure.AutoMapper.Profiles
{
public class VisaApplicationCreateRequestProfile : Profile
{
public VisaApplicationCreateRequestProfile()
{
CreateMap<VisaApplicationCreateRequestModel, VisaApplicationCreateRequest>(MemberList.Destination);
}
}
}

View File

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace BlazorWebAssemblyVisaApiClient.Infrastructure.Helpers
{
public static class EnumExtensions
{
public static string GetDisplayName(this Enum value)
{
var enumMembers = value.GetType().GetMembers();
var member = enumMembers.First(info => info.Name == value.ToString());
var displayAttribute = (DisplayAttribute?)member
.GetCustomAttributes(typeof(DisplayAttribute), false)
.FirstOrDefault();
var displayName = displayAttribute?.Name ?? value.ToString();
return displayName;
}
}
}

View File

@@ -0,0 +1,22 @@
using System.Text;
using FluentValidation.Results;
namespace BlazorWebAssemblyVisaApiClient.Infrastructure.Helpers
{
public static class ValidationResultExtensions
{
public static string ToErrorsString(this ValidationResult validationResult)
=> ErrorsToString(validationResult.Errors.Select(e => e.ErrorMessage));
private static string ErrorsToString(IEnumerable<string> errors)
{
var stringBuilder = new StringBuilder();
foreach (var error in errors)
{
stringBuilder.Append($"{error}<br/>");
}
return stringBuilder.ToString();
}
}
}

View File

@@ -0,0 +1,9 @@
namespace BlazorWebAssemblyVisaApiClient.Infrastructure.Services.DateTimeProvider
{
public class DateTimeProvider : IDateTimeProvider
{
public DateTime Now() => DateTime.Now;
public string FormattedNow() => Now().ToString("yyyy-MM-dd");
}
}

View File

@@ -0,0 +1,9 @@
namespace BlazorWebAssemblyVisaApiClient.Infrastructure.Services.DateTimeProvider
{
public interface IDateTimeProvider
{
DateTime Now();
string FormattedNow();
}
}

View File

@@ -0,0 +1,6 @@
using BlazorWebAssemblyVisaApiClient.Common.Exceptions;
namespace BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider.Exceptions
{
public class UnknownRoleException() : BlazorClientException("Unknown user role");
}

View File

@@ -0,0 +1,15 @@
using VisaApiClient;
namespace BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider
{
public interface IUserDataProvider
{
public string? CurrentRole { get; }
public Action? OnRoleChanged { get; set; }
public Task<ApplicantModel> GetApplicant();
public void UpdateCurrentRole();
}
}

View File

@@ -0,0 +1,53 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider.Exceptions;
using VisaApiClient;
namespace BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider
{
public class UserDataProvider(Client client) : IUserDataProvider
{
private readonly static JwtSecurityTokenHandler tokenHandler = new();
public string? CurrentRole { get; private set; }
public Action? OnRoleChanged { get; set; }
public async Task<ApplicantModel> GetApplicant()
{
return await client.GetApplicantAsync();
}
public void UpdateCurrentRole()
{
var role = CurrentRole;
if (client.AuthToken is null)
{
if (CurrentRole is not null)
{
role = null;
}
}
else
{
var token = tokenHandler.ReadJwtToken(client.AuthToken.Token);
role = token.Claims.FirstOrDefault(claim => claim.Type == ClaimTypes.Role)?.Value;
switch (role)
{
case Constants.ApplicantRole: break;
case Constants.ApprovingAuthorityRole: break;
case Constants.AdminRole: break;
default: throw new UnknownRoleException();
}
}
if (CurrentRole != role)
{
CurrentRole = role;
OnRoleChanged?.Invoke();
}
}
}
}

View File

@@ -0,0 +1,41 @@
@using BlazorWebAssemblyVisaApiClient.Components.Auth
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu/>
</div>
<main class="fullscreen">
<div class="top-row px-4">
<AuthComponent @ref="authComponent"/>
@if (UserDataProvider.CurrentRole is not null)
{
<p>
Logged as @UserDataProvider.CurrentRole (@AuthComponent.AuthData?.Email)
<button class="btn-secondary" @onclick="authComponent.Logout">Log out</button>
</p>
}
else
{
<NavLink href="/">Log in</NavLink>
}
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
@code
{
private AuthComponent authComponent = null!;
[Inject] private IUserDataProvider UserDataProvider { get; set; } = null!;
protected override void OnInitialized()
{
UserDataProvider.OnRoleChanged += StateHasChanged;
}
}

View File

@@ -0,0 +1,77 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}

View File

@@ -0,0 +1,79 @@
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">Schengen Visa</a>
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Login
</NavLink>
</div>
</nav>
@if (UserDataProvider.CurrentRole is Constants.ApplicantRole or Constants.ApprovingAuthorityRole)
{
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="applications" Match="NavLinkMatch.All">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Applications
</NavLink>
</div>
</nav>
}
@if (UserDataProvider.CurrentRole is Constants.ApplicantRole)
{
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="applications/new" Match="NavLinkMatch.All">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> New application
</NavLink>
</div>
</nav>
}
@if (UserDataProvider.CurrentRole is Constants.AdminRole)
{
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="authorities" Match="NavLinkMatch.All">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Authorities
</NavLink>
</div>
</nav>
}
@if (UserDataProvider.CurrentRole is Constants.AdminRole)
{
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="authorities/add" Match="NavLinkMatch.All">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Add authority
</NavLink>
</div>
</nav>
}
</div>
@code {
private bool collapseNavMenu = true;
private string? currentRole = null!;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
[Inject] private IUserDataProvider UserDataProvider { get; set; } = null!;
protected override void OnInitialized()
{
UserDataProvider.OnRoleChanged += StateHasChanged;
}
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}

View File

@@ -0,0 +1,83 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
.nav-scrollable {
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,60 @@
@page "/authorities/add"
@using AutoMapper
@using BlazorWebAssemblyVisaApiClient.Validation.Applicants.Models
@using VisaApiClient
@using BlazorWebAssemblyVisaApiClient.Components
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Helpers
@using FluentValidation
@inherits BlazorWebAssemblyVisaApiClient.Components.Base.VisaClientComponentBase
<EditForm Model="requestModel" class="with-centered-content">
<ObjectGraphDataAnnotationsValidator/>
<div >
<label>
Email:<br/>
<InputText class="rounded" @bind-Value="requestModel.AuthData.Email"/>
<ValidationMessage For="() => requestModel.AuthData.Email"/>
</label><br/>
<p/>
<label>
Password:<br/>
<InputText class="rounded" @bind-Value="requestModel.AuthData.Password"/>
<ValidationMessage For="() => requestModel.AuthData.Password"/>
</label><br/>
<p/>
<button class="btn-primary rounded" @onclick="Add">Add</button><br/>
<Status @ref="status"/>
</div>
</EditForm>
@code
{
private RegisterRequestModel requestModel = new();
private Status status = new();
[Inject] private IValidator<RegisterRequestModel> RegisterRequestModelValidator { get; set; } = null!;
[Inject] private IMapper Mapper { get; set; } = null!;
private async Task Add()
{
var validationResult = await RegisterRequestModelValidator.ValidateAsync(requestModel);
if (!validationResult.IsValid)
{
status.SetError(validationResult.ToErrorsString());
return;
}
try
{
status.SetMessage("Wait...");
await Client.RegisterAuthorityAsync(Mapper.Map<RegisterRequest>(requestModel));
status.SetSuccess("Success");
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
}

View File

@@ -0,0 +1,259 @@
@page "/applications/{ApplicationId}"
@using System.Net
@using BlazorWebAssemblyVisaApiClient.Common.Exceptions
@using BlazorWebAssemblyVisaApiClient.Components
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Helpers
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider
@using VisaApiClient
@inherits BlazorWebAssemblyVisaApiClient.Components.Base.VisaClientComponentBase
<PageTitle>Application</PageTitle>
<table class="table table-bordered table-hover table-sm">
<tbody>
<tr>
<td >
Applicant's fullname:<br/>
<em>@NameToString(application.Applicant.Name)</em>
</td>
<td colspan="2">
Date of birth:<br/>
<em>@application.Applicant.BirthDate.ToString("d")</em>
</td>
</tr>
<tr>
<td colspan="3">
Country and city of birth:<br/>
<em>@application.Applicant.Passport.Number</em>
</td>
</tr>
<tr>
<td colspan="2">
Citizenship:<br/>
<em>@application.Applicant.Citizenship</em>
</td>
<td >
Citizenship by birth:<br/>
<em>@application.Applicant.CitizenshipByBirth</em>
</td>
</tr>
<tr>
<td >
Gender:<br/>
<em>@application.Applicant.Gender.GetDisplayName()</em>
</td>
<td >
Marital status:<br/>
<em>@application.Applicant.MaritalStatus.GetDisplayName()</em>
</td>
</tr>
<tr>
<td >
Father's fullname:<br/>
<em>@NameToString(application.Applicant.FatherName)</em>
</td>
<td colspan="2">
Mother's fullname:<br/>
<em>@NameToString(application.Applicant.MotherName)</em>
</td>
</tr>
<tr>
<td >
Passport number:<br/>
<em>@application.Applicant.Passport.Number</em>
</td>
<td >
Issue date:<br/>
<em>@application.Applicant.Passport.IssueDate.ToString("d")</em>
</td>
<td >
Expiration date:<br/>
<em>@application.Applicant.Passport.ExpirationDate.ToString("d")</em>
</td>
</tr>
<tr>
<td colspan="3">
Passport issuer:<br/>
<em>@application.Applicant.Passport.Issuer</em>
</td>
</tr>
<tr>
<td colspan="3">
Re-entry permission (for non-residents):<br/>
@if (application.Applicant.IsNonResident)
{
<em>@(application.ReentryPermit is null ? "None" : $"{application.ReentryPermit.Number}, expires at {application.ReentryPermit.ExpirationDate:d}")</em>
}
else
{
<em>Not non-resident</em>
}
</td>
</tr>
<tr>
<td colspan="3">
Job title:<br/>
<em>@application.Applicant.JobTitle</em>
</td>
</tr>
<tr>
<td colspan="3">
Place of work, address, hirer's phone number:<br/>
<em>
@((MarkupString)$"{application.Applicant.PlaceOfWork.Name}<br>Address: {AddressToString(application.Applicant.PlaceOfWork.Address)}<br>Phone num: {application.Applicant.PlaceOfWork.PhoneNum}")
</em>
</td>
</tr>
<tr>
<td >
Destination Country:<br/>
<em>@application.DestinationCountry</em>
</td>
<td >
Visa category:<br/>
<em>@application.VisaCategory</em>
</td>
<td >
Visa:<br/>
<em>@(application.ForGroup ? "For group" : "Individual")</em>
</td>
</tr>
<tr>
<td >
Requested number of entries:<br/>
<em>@application.RequestedNumberOfEntries.GetDisplayName()</em>
</td>
<td colspan="2">
Valid for:<br/>
<em>@($"{application.ValidDaysRequested} days")</em>
</td>
</tr>
<tr>
<td colspan="3">
Past visas:<br/>
@if (application.PastVisas.Any())
{
foreach (var visa in application.PastVisas)
{
<em>@($"{visa.Name} issued at {visa.IssueDate:d} and was valid until {visa.ExpirationDate:d}")</em>
<br/>
}
}
else
{
<em>None</em>
}
</td>
</tr>
<tr>
<td colspan="3">
Permission to destination Country, if transit:<br/>
@if (application.VisaCategory is VisaCategory.Transit)
{
<em>@(application.PermissionToDestCountry is null ? "None" : $"Expires at {application.PermissionToDestCountry.ExpirationDate}, issued by: {application.PermissionToDestCountry.Issuer}")</em>
}
else
{
<em>Non-transit</em>
}
</td>
</tr>
<tr>
<td colspan="3">
Past visits:<br/>
@if (application.PastVisas.Any())
{
foreach (var visit in application.PastVisits)
{
<em>@($"Visit to {visit.DestinationCountry}, entered at {visit.StartDate:d} and lasts until {visit.EndDate:d}")</em>
<br/>
}
}
else
{
<em>None</em>
}
</td>
</tr>
</tbody>
</table>
@if (currentRole == Constants.ApprovingAuthorityRole)
{
<button class="btn-outline-primary" @onclick="Approve">Approve</button>
<button class="btn-outline-danger" @onclick="Reject">Reject</button>
<Status @ref="status"/>
}
@code {
private VisaApplicationModel application = new();
private string currentRole = null!;
private Status status = null!;
[Parameter] public string ApplicationId { get; set; } = null!;
[Inject] private IUserDataProvider UserDataProvider { get; set; } = null!;
[Inject] private NavigationManager Nav { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
try
{
var applicationId = Guid.Parse(ApplicationId);
currentRole = UserDataProvider.CurrentRole ?? throw new NotLoggedInException();
application = currentRole switch
{
Constants.ApplicantRole => await Client.GetApplicationForApplicantAsync(applicationId),
Constants.ApprovingAuthorityRole => await Client.GetApplicationForAuthorityAsync(applicationId),
_ => throw new NotLoggedInException()
};
}
catch (Exception e)
{
if (e is ApiException<ProblemDetails> { Result.Status: (int)HttpStatusCode.Conflict })
{
Nav.NavigateTo("/applications");
}
ErrorHandler.Handle(e);
}
}
private static string NameToString(NameModel name)
=> $"{name.FirstName} {name.Surname} {name.Patronymic}".TrimEnd();
private static string AddressToString(AddressModel address)
=> $"{address.Country}, {address.City}, {address.Street} {address.Building}";
private async void Approve()
{
try
{
status.SetMessage("Wait...");
await Client.SetStatusFromAuthorityAsync(application.Id, AuthorityRequestStatuses.Approved);
Nav.NavigateTo("/applications");
}
catch (Exception e)
{
status.SetError("Error occured.");
ErrorHandler.Handle(e);
}
}
private async void Reject()
{
try
{
status.SetMessage("Wait...");
await Client.SetStatusFromAuthorityAsync(application.Id, AuthorityRequestStatuses.Rejected);
Nav.NavigateTo("/applications");
}
catch (Exception e)
{
status.SetError("Error occured.");
ErrorHandler.Handle(e);
}
}
}

View File

@@ -0,0 +1,105 @@
@page "/applications"
@using BlazorWebAssemblyVisaApiClient.Common.Exceptions
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Helpers
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider
@using VisaApiClient
@inherits BlazorWebAssemblyVisaApiClient.Components.Base.VisaClientComponentBase
<PageTitle>Applications</PageTitle>
<table class="table table-bordered table-hover">
<thead>
<tr>
<th>Destination Country</th>
<th>Visa Category</th>
<th>Request date</th>
<th>Days requested</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var application in applications)
{
var rowClass = application.Status switch
{
ApplicationStatus.Pending => "",
ApplicationStatus.Approved => "table-success",
ApplicationStatus.Rejected => "table-danger",
ApplicationStatus.Closed => "table-danger",
_ => throw new ArgumentOutOfRangeException()
};
<tr class="@rowClass">
<td>@application.DestinationCountry</td>
<td>@application.VisaCategory.GetDisplayName()</td>
<td>@application.RequestDate.ToString("d")</td>
<td>@application.ValidDaysRequested</td>
<td>@application.Status.GetDisplayName()</td>
<td>
<NavLink href="@($"/applications/{application.Id}")">
<button class="btn-outline-primary">See</button>
</NavLink>
@if (currentRole == Constants.ApplicantRole && application.Status is ApplicationStatus.Pending)
{
<span> | </span>
<input type="button" class="border-danger" @onclick="() => CloseApplication(application)" value="Close"/>
}
</td>
</tr>
}
</tbody>
</table >
@code {
private string currentRole = null!;
private List<VisaApplicationPreview> applications = [];
[Inject] private IUserDataProvider UserDataProvider { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
try
{
currentRole = UserDataProvider.CurrentRole ?? throw new NotLoggedInException();
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
await Fetch();
}
private async Task Fetch()
{
try
{
applications = currentRole switch
{
Constants.ApplicantRole => (await Client.GetApplicationsForApplicantAsync()).OrderByDescending(a => a.RequestDate).ToList(),
Constants.ApprovingAuthorityRole => (await Client.GetPendingAsync()).OrderByDescending(a => a.RequestDate).ToList(),
_ => throw new NotLoggedInException()
};
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
private async Task CloseApplication(VisaApplicationPreview application)
{
try
{
await Client.CloseApplicationAsync(application.Id);
application.Status = ApplicationStatus.Closed;
StateHasChanged();
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
}

View File

@@ -0,0 +1,32 @@
@page "/"
@using BlazorWebAssemblyVisaApiClient.Components.Auth
@using VisaApiClient
@using BlazorWebAssemblyVisaApiClient.Components.FormComponents.Applicants
@using BlazorWebAssemblyVisaApiClient.Components
@inherits BlazorWebAssemblyVisaApiClient.Components.Base.VisaClientComponentBase
<PageTitle>Authentication</PageTitle>
<div class="with-centered-content">
<EditForm class="form" Model="loginData" OnValidSubmit="TryLogin">
<DataAnnotationsValidator/>
<AuthDataInput AuthData="loginData"/><br/>
<input class="btn-outline-primary rounded" type="submit" value="Login"/>
or
<NavLink href="register">Register</NavLink >
<Status><AuthComponent @ref="auth"/></Status>
</EditForm>
</div>
@code
{
private AuthData loginData = new();
private AuthComponent auth = null!;
private async Task TryLogin()
{
await auth.TryAuthorize(loginData);
}
}

View File

@@ -0,0 +1,58 @@
@page "/authorities"
@using VisaApiClient
@inherits BlazorWebAssemblyVisaApiClient.Components.Base.VisaClientComponentBase
<table class="table table-bordered table-hover">
<thead>
<tr>
<th>Email</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var authority in authorities)
{
var path = $"authorities/{authority.Id}/{authority.Email}";
<tr>
<td>@authority.Email</td>
<td>
<NavLink href="@path">
<button class="btn-outline-primary">Change</button>
</NavLink>
|
<button class="btn-outline-danger" @onclick="() => Delete(authority)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
@code {
private List<UserModel> authorities = [];
protected override async Task OnInitializedAsync()
{
try
{
authorities = (await Client.GetAuthorityAccountsAsync()).ToList();
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
private async Task Delete(UserModel authority)
{
try
{
await Client.RemoveAuthorityAccountAsync(authority.Id);
authorities.Remove(authority);
StateHasChanged();
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
}

View File

@@ -0,0 +1,83 @@
@page "/authorities/{authorityId}/{oldEmail}"
@inherits BlazorWebAssemblyVisaApiClient.Components.Base.VisaClientComponentBase
@using BlazorWebAssemblyVisaApiClient.Common.Exceptions
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider
@using VisaApiClient
@using BlazorWebAssemblyVisaApiClient.Components
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Helpers
@using FluentValidation
<EditForm Model="model" class="with-centered-content">
<div >
<label>
New email:<br/>
<InputText class="rounded" @bind-Value="model.Email"/>
</label><br/><p/>
<label>
New password (leave blank if shouldn't be changed):<br/>
<InputText class="rounded" @bind-Value="model.Password"/>
</label><br/><p/>
<button class="btn-primary rounded" @onclick="Save">Save</button><br/>
<Status @ref="status"/>
</div>
</EditForm>
@code
{
private Status status = null!;
private ChangeAuthData model = new();
[Parameter] public string AuthorityId { get; set; } = null!;
[Parameter] public string OldEmail { get; set; } = null!;
[Inject] private IUserDataProvider UserDataProvider { get; set; } = null!;
[Inject] private IValidator<ChangeUserAuthDataRequest> ChangeUserAuthDataRequestValidator { get; set; } = null!;
protected override void OnInitialized()
{
try
{
if (UserDataProvider.CurrentRole is not Constants.AdminRole)
{
throw new NotLoggedInException();
}
model.Email = OldEmail;
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
private async Task Save()
{
var request = new ChangeUserAuthDataRequest
{
UserId = Guid.Parse(AuthorityId),
NewAuthData = model
};
var validationResult = await ChangeUserAuthDataRequestValidator.ValidateAsync(request);
if (!validationResult.IsValid)
{
status.SetError(validationResult.ToErrorsString());
return;
}
try
{
status.SetMessage("Wait...");
await Client.ChangeAuthorityAuthDataAsync(request);
status.SetSuccess("Success");
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
}

View File

@@ -0,0 +1,362 @@
@page "/applications/new"
@using System.Net
@using AutoMapper
@using BlazorWebAssemblyVisaApiClient.Validation.VisaApplications.Models
@using BlazorWebAssemblyVisaApiClient.Components.FormComponents.Applicants
@using VisaApiClient
@using BlazorWebAssemblyVisaApiClient.Components
@using BlazorWebAssemblyVisaApiClient.Components.FormComponents.VisaApplications
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Helpers
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.DateTimeProvider
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider
@using BlazorWebAssemblyVisaApiClient.Validation
@using FluentValidation
@using Newtonsoft.Json.Linq
@inherits BlazorWebAssemblyVisaApiClient.Components.Base.VisaClientComponentBase
<PageTitle>New Application</PageTitle>
<div class="horizontal-centered-content">
<h3>New application</h3>
<EditForm class="form" Model="requestModel" OnValidSubmit="TryCreate">
<ObjectGraphDataAnnotationsValidator/>
<div class="form-block">
<h5>Visa@(Constants.RequiredFieldMarkup)</h5>
<label>
Destination Country:<br/>
<InputText DisplayName="Destination Country" class="rounded" @bind-Value="requestModel.DestinationCountry"/>
</label><br/>
<ValidationMessage For="() => requestModel.DestinationCountry"></ValidationMessage><br/>
<label>
Category:
<EnumInputList Model="requestModel"
EnumProperty="r => r.VisaCategory"
OnChanged="StateHasChanged"/>
</label><br/>
<ValidationMessage For="() => requestModel.VisaCategory"></ValidationMessage><br/>
<label>
Number of entries: <EnumInputList Model="requestModel" EnumProperty="r => r.RequestedNumberOfEntries"/>
</label><br/>
<ValidationMessage For="() => requestModel.RequestedNumberOfEntries"></ValidationMessage><br/>
<label>
For group: <InputCheckbox @bind-Value="requestModel.IsForGroup"/>
</label><br/>
<ValidationMessage For="() => requestModel.IsForGroup"></ValidationMessage><br/>
<label>
Valid for days:<br/>
<InputNumber DisplayName="Valid days" class="rounded" @bind-Value="requestModel.ValidDaysRequested"/>
</label>
<ValidationMessage For="() => requestModel.ValidDaysRequested"></ValidationMessage><br/>
</div>
<div class="form-block">
<h5>Past visas</h5>
@if (requestModel.PastVisas.Count > 0)
{
<table class="table table-bordered table-hover">
<thead>
<tr>
<th>Name</th><th>Issue date</th><th>Expiration date</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var visa in requestModel.PastVisas)
{
<tr>
<td>@visa.Name</td>
<td>@visa.IssueDate.ToString("d.MM.yyyy")</td>
<td>@visa.ExpirationDate.ToString("d.MM.yyyy")</td>
<td>
<input type="button" class="border-danger" @onclick="() => RemovePastVisa(visa)" value="X"/>
</td>
</tr>
}
</tbody>
</table>
}
<label>
Name:<br/>
<InputText DisplayName="Past visa name" class="rounded" @bind-Value="editableVisa.Name"/>
</label><br/>
<ValidationMessage For="() => editableVisa.Name"></ValidationMessage><br/>
<label>
Issue date:<br/>
<InputDate DisplayName="Past visa issue date"
class="rounded"
@bind-Value="editableVisa.IssueDate"
max="@formattedNow"/>
</label><br/>
<ValidationMessage For="() => editableVisa.IssueDate"></ValidationMessage><br/>
<label>
Expiration date:<br/>
<InputDate DisplayName="Past visa expiration date"
class="rounded"
@bind-Value="editableVisa.ExpirationDate"/>
</label><br/>
<ValidationMessage For="() => editableVisa.ExpirationDate"></ValidationMessage><br/>
<input type="button" class="btn-outline-primary rounded"
disabled="@(requestModel.PastVisas.Count == ConfigurationConstraints.MaxPastVisas)"
@onclick="AddPastVisa" value="Add"/>
<Status @ref="pastVisaStatus"/>
</div>
<div class="form-block">
<h5>Past visits</h5>
@if (requestModel.PastVisits.Count > 0)
{
<table class="table table-bordered table-hover">
<thead>
<tr>
<th>Destination Country</th><th>Start date</th><th>End date</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var visit in requestModel.PastVisits)
{
<tr>
<td>@visit.DestinationCountry</td>
<td>@visit.StartDate.ToString("d.MM.yyyy")</td>
<td>@visit.EndDate.ToString("d.MM.yyyy")</td>
<td>
<input type="button" class="border-danger" @onclick="() => RemovePastVisit(visit)" value="X"/>
</td>
</tr>
}
</tbody>
</table>
}
<label>
Destination Country:<br/>
<InputText DisplayName="Past visit destination Country" class="rounded" @bind-Value="editableVisit.DestinationCountry"/>
</label><br/>
<ValidationMessage For="() => editableVisit.DestinationCountry"></ValidationMessage><br/>
<label>
Start date:<br/>
<InputDate DisplayName="Past visit start date"
class="rounded"
@bind-Value="editableVisit.StartDate"
max="@formattedNow"/>
</label><br/>
<ValidationMessage For="() => editableVisit.StartDate"></ValidationMessage><br/>
<label>
End date:<br/>
<InputDate DisplayName="Past visit end date"
class="rounded"
@bind-Value="editableVisit.EndDate"
max="@formattedNow"/>
</label><br/>
<ValidationMessage For="() => editableVisit.EndDate"></ValidationMessage><br/>
<input type="button" class="btn-outline-primary rounded"
disabled="@(requestModel.PastVisits.Count == ConfigurationConstraints.MaxPastVisits)"
@onclick="AddPastVisit" value="Add"/>
<Status @ref="pastVisitStatus"/>
</div>
@if (requestModel.VisaCategory is VisaCategory.Transit)
{
requestModel.PermissionToDestCountry ??= NewPermissionToDestCountry();
<div class="form-block">
<h5>Permission to destination Country@(Constants.RequiredFieldMarkup)</h5>
<PermissionToDestCountryInput PermissionToDestCountry="requestModel.PermissionToDestCountry"/>
</div>
}
else
{
requestModel.PermissionToDestCountry = null;
}
@if (isNonResident)
{
requestModel.ReentryPermit ??= NewReentryPermit();
<div class="form-block">
<h5>Re-entry permission@(Constants.RequiredFieldMarkup)</h5>
<ReentryPermitInput ReentryPermit="requestModel.ReentryPermit"/>
</div>
}
<br/><input type="submit" class="btn-outline-primary rounded" value="Register"/>
<ValidationSummary/>
<Status @ref="status"/>
</EditForm>
</div>
@code {
//todo past visas and visits
private VisaApplicationCreateRequestModel requestModel = new();
private Status status = null!;
private Status pastVisaStatus = null!;
private Status pastVisitStatus = null!;
private bool isNonResident;
private string formattedNow = null!;
private PastVisaModel editableVisa = null!;
private PastVisitModel editableVisit = null!;
[Inject] IDateTimeProvider DateTimeProvider { get; set; } = null!;
[Inject] IUserDataProvider UserDataProvider { get; set; } = null!;
[Inject] IValidator<VisaApplicationCreateRequestModel> VisaApplicationCreateRequestValidator { get; set; } = null!;
[Inject] IValidator<PastVisaModel> PastVisaModelValidator { get; set; } = null!;
[Inject] IValidator<PastVisitModel> PastVisitModelValidator { get; set; } = null!;
[Inject] IMapper Mapper { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
editableVisa = NewPastVisa();
editableVisit = NewPastVisit();
requestModel.PermissionToDestCountry = NewPermissionToDestCountry();
formattedNow = DateTimeProvider.FormattedNow();
try
{
isNonResident = (await UserDataProvider.GetApplicant()).IsNonResident;
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
private async Task TryCreate()
{
var validationResult = await VisaApplicationCreateRequestValidator.ValidateAsync(requestModel);
if (!validationResult.IsValid)
{
var errorsString = validationResult.ToErrorsString();
status.SetError(errorsString);
}
status.SetMessage("Wait...");
var request = Mapper.Map<VisaApplicationCreateRequest>(requestModel);
try
{
await Client.CreateApplicationAsync(request);
status.SetSuccess("Application created successfully.");
}
catch (ApiException<ProblemDetails> e)
{
if (e.StatusCode == (int)HttpStatusCode.BadRequest
&& e.Result.AdditionalProperties.TryGetValue("errors", out var errors))
{
try
{
var errorsList = ((JArray)errors).ToObject<List<string>>();
status.SetError(string.Join("<br/>", errorsList!));
}
catch (Exception inner)
{
ErrorHandler.Handle(inner);
status.SetError("Error occured");
}
}
else
{
throw;
}
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
private PastVisaModel NewPastVisa()
{
return new()
{
ExpirationDate = DateTimeProvider.Now(),
IssueDate = DateTimeProvider.Now()
};
}
private ReentryPermitModel NewReentryPermit()
{
return new()
{
ExpirationDate = DateTimeProvider.Now()
};
}
private PermissionToDestCountryModel NewPermissionToDestCountry()
{
return new()
{
ExpirationDate = DateTimeProvider.Now()
};
}
private PastVisitModel NewPastVisit()
{
return new()
{
StartDate = DateTimeProvider.Now(),
EndDate = DateTimeProvider.Now()
};
}
private void AddPastVisa()
{
if (requestModel.PastVisas.Count >= ConfigurationConstraints.MaxPastVisas)
{
pastVisaStatus.SetError($"{ConfigurationConstraints.MaxPastVisas} past visas is maximum");
return;
}
var validationResult = PastVisaModelValidator.Validate(editableVisa);
if (!validationResult.IsValid)
{
pastVisaStatus.SetError(validationResult.ToErrorsString());
return;
}
requestModel.PastVisas.Add(editableVisa);
editableVisa = NewPastVisa();
pastVisaStatus.SetSuccess("Added successfully");
}
private void RemovePastVisa(PastVisaModel visa)
{
requestModel.PastVisas.Remove(visa);
}
private void AddPastVisit()
{
if (requestModel.PastVisits.Count >= ConfigurationConstraints.MaxPastVisits)
{
pastVisitStatus.SetError($"{ConfigurationConstraints.MaxPastVisits} past visits is maximum");
return;
}
var validationResult = PastVisitModelValidator.Validate(editableVisit);
if (!validationResult.IsValid)
{
pastVisitStatus.SetError(validationResult.ToErrorsString());
return;
}
requestModel.PastVisits.Add(editableVisit);
editableVisit = NewPastVisit();
pastVisitStatus.SetSuccess("Added successfully");
}
private void RemovePastVisit(PastVisitModel visit)
{
requestModel.PastVisits.Remove(visit);
}
}

View File

@@ -0,0 +1,191 @@
@page "/register"
@using System.Net
@using AutoMapper
@using VisaApiClient
@using BlazorWebAssemblyVisaApiClient.Components.FormComponents.Applicants
@using global::FluentValidation
@using Newtonsoft.Json
@using Newtonsoft.Json.Linq
@using BlazorWebAssemblyVisaApiClient.Components
@using BlazorWebAssemblyVisaApiClient.Infrastructure.Helpers
@using BlazorWebAssemblyVisaApiClient.Validation
@using BlazorWebAssemblyVisaApiClient.Validation.Applicants.Models
@inherits BlazorWebAssemblyVisaApiClient.Components.Base.VisaClientComponentBase
<PageTitle>Registration</PageTitle>
<div class="horizontal-centered-content">
<h3>Registration data</h3>
<EditForm class="form" Model="requestModel" OnValidSubmit="TryRegisterApplicant">
<ObjectGraphDataAnnotationsValidator/>
<div class="form-block">
<h5>Authentication data@(Constants.RequiredFieldMarkup)</h5>
<AuthDataInput AuthData="requestModel.RegisterRequest.AuthData"/>
</div>
<div class="form-block">
<h5>Your Fullname</h5>
<NameInput Name="requestModel.ApplicantName"/>
</div>
<div class="form-block">
<h5>Fullname of your mother</h5>
<NameInput Name="requestModel.MotherName"/>
</div>
<div class="form-block">
<h5>Fullname of your father</h5>
<NameInput Name="requestModel.FatherName"/>
</div>
<div class="form-block">
<h5>Your passport@(Constants.RequiredFieldMarkup)</h5>
<PassportInput Passport="requestModel.Passport"/>
</div>
<div class="form-block">
<h5>Birth data@(Constants.RequiredFieldMarkup)</h5>
<div >
<label>
Country of birth:<br/>
<InputText DisplayName="Country of birth" class="rounded" @bind-Value="requestModel.CountryOfBirth"/>
</label><br/>
<ValidationMessage For="() => requestModel.CountryOfBirth"></ValidationMessage><br/>
<label>
City of birth:<br/>
<InputText DisplayName="City of birth" class="rounded" @bind-Value="requestModel.CityOfBirth"/>
</label><br/>
<ValidationMessage For="() => requestModel.CityOfBirth"></ValidationMessage><br/>
<label>
Birth date:<br/>
<InputDate DisplayName="Birth date" class="rounded" @bind-Value="requestModel.BirthDate" max="@formattedMaxBirthdayDate"/>
</label><br/>
<ValidationMessage For="() => requestModel.BirthDate"></ValidationMessage>
</div>
</div>
<div class="form-block">
<h5>Citizenship@(Constants.RequiredFieldMarkup)</h5>
<div >
<label>
Citizenship:<br/>
<InputText class="rounded" @bind-Value="requestModel.Citizenship"/>
</label><br/>
<ValidationMessage For="() => requestModel.Citizenship"></ValidationMessage><br/>
<label>
Citizenship by birth:<br/>
<InputText DisplayName="Citizenship by birth" class="rounded" @bind-Value="requestModel.CitizenshipByBirth"/>
</label><br/>
<ValidationMessage For="() => requestModel.CitizenshipByBirth"></ValidationMessage>
</div>
</div>
<div class="form-block">
<h5>Address of your place of work@(Constants.RequiredFieldMarkup)</h5>
<div >
<AddressInput Address="requestModel.PlaceOfWork.Address"/>
</div>
</div>
<div class="form-block">
<h5>Place of work data@(Constants.RequiredFieldMarkup)</h5>
<div >
<PlaceOfWorkInput PlaceOfWork="requestModel.PlaceOfWork"/><br/>
<label>
Job title:<br/>
<InputText DisplayName="Job title" class="rounded" @bind-Value="requestModel.JobTitle"/>
</label><br/>
<ValidationMessage For="() => requestModel.JobTitle"></ValidationMessage>
</div>
</div>
<div class="form-block">
<h5>Other</h5>
<div >
<label>
Gender: <EnumInputList Model="requestModel" EnumProperty="r => r.Gender"/>
</label>
</div><br/>
<div >
<label>
Marital status: <EnumInputList Model="requestModel" EnumProperty="r => r.MaritalStatus"/>
</label>
</div><br/>
<div >
<label>
Non-resident: <InputCheckbox @bind-Value="requestModel.IsNonResident"/>
</label>
</div>
</div><br/>
<input type="submit" class="btn-outline-primary" value="Register"/>
<Status @ref="status"/>
</EditForm>
</div>
@code
{
private RegisterApplicantRequestModel requestModel = new();
private Status status = null!;
private string formattedMaxBirthdayDate = null!;
[Inject] IValidator<RegisterApplicantRequestModel> RegisterApplicantRequestValidator { get; set; } = null!;
[Inject] IMapper Mapper { get; set; } = null!;
protected override void OnInitialized()
{
requestModel.BirthDate = DateTime.Now.AddYears(-ConfigurationConstraints.ApplicantMinAge);
formattedMaxBirthdayDate = requestModel.BirthDate.ToString("yyyy-MM-dd");
}
private async void TryRegisterApplicant()
{
var validationResult = await RegisterApplicantRequestValidator.ValidateAsync(requestModel);
if (!validationResult.IsValid)
{
var errorsString = validationResult.ToErrorsString();
status.SetError(errorsString);
return;
}
status.SetMessage("Wait...");
var request = Mapper.Map<RegisterApplicantRequest>(requestModel);
try
{
await Client.RegisterAsync(request);
status.SetSuccess("Register successful. Now log in.");
}
catch (ApiException<ProblemDetails> e)
{
if (e.StatusCode == (int)HttpStatusCode.BadRequest
&& e.Result.AdditionalProperties.TryGetValue("errors", out var errors))
{
var errorsList = ((JArray)errors).ToObject<List<string>>();
if (errorsList is null)
{
ErrorHandler.Handle(new JsonException("Can't convert validation errors to list"));
return;
}
status.SetError(string.Join("<br/>", errorsList));
}
else
{
throw;
}
}
catch (Exception e)
{
status.SetError("Error occured");
ErrorHandler.Handle(e);
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Reflection;
using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.DateTimeProvider;
using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.UserDataProvider;
using FluentValidation;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using VisaApiClient;
namespace BlazorWebAssemblyVisaApiClient;
public class Program
{
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
//todo move to launch settings
const string baseAddress = "https://localhost:44370";
//todo make pretty
builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(baseAddress) });
builder.Services.AddBlazorBootstrap();
builder.Services.AddScoped<Client>(sp => new Client(baseAddress, sp.GetRequiredService<HttpClient>()));
builder.Services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
builder.Services.AddScoped<IUserDataProvider, UserDataProvider>();
builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly());
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
await builder.Build().RunAsync();
}
}

View File

@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:25927",
"sslPort": 44345
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5038",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7200;http://localhost:5038",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using VisaApiClient;
namespace BlazorWebAssemblyVisaApiClient.Validation.Applicants.Models
{
/// Model of place of work with attributes required for validation to work
public class PlaceOfWorkModel
{
[Required]
[StringLength(ConfigurationConstraints.PlaceOfWorkNameLength, MinimumLength = 1)]
public string Name { get; set; } = default!;
[Required]
[ValidateComplexType]
public AddressModel Address { get; set; } = new AddressModel();
[Required]
[StringLength(ConfigurationConstraints.PhoneNumberLength, MinimumLength = ConfigurationConstraints.PhoneNumberMinLength)]
public string PhoneNum { get; set; } = default!;
}
}

View File

@@ -0,0 +1,68 @@
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using VisaApiClient;
namespace BlazorWebAssemblyVisaApiClient.Validation.Applicants.Models
{
/// Model of request with attributes required for validation to work
public class RegisterApplicantRequestModel
{
[Required]
[ValidateComplexType]
public RegisterRequestModel RegisterRequest { get; set; } = new();
[Required]
[ValidateComplexType]
public NameModel ApplicantName { get; set; } = new();
[Required]
[ValidateComplexType]
public PassportModel Passport { get; set; } = new();
[Required(AllowEmptyStrings = true)]
public DateTimeOffset BirthDate { get; set; }
[Required]
[StringLength(70, MinimumLength = 1)]
public string CityOfBirth { get; set; } = default!;
[Required]
[StringLength(70, MinimumLength = 1)]
public string CountryOfBirth { get; set; } = default!;
[Required]
[StringLength(30, MinimumLength = 1)]
public string Citizenship { get; set; } = default!;
[Required]
[StringLength(30, MinimumLength = 1)]
public string CitizenshipByBirth { get; set; } = default!;
[Required(AllowEmptyStrings = true)]
[JsonConverter(typeof(StringEnumConverter))]
public Gender Gender { get; set; }
[Required(AllowEmptyStrings = true)]
[JsonConverter(typeof(StringEnumConverter))]
public MaritalStatus MaritalStatus { get; set; }
[Required]
[ValidateComplexType]
public NameModel FatherName { get; set; } = new();
[Required]
[ValidateComplexType]
public NameModel MotherName { get; set; } = new();
[Required]
[StringLength(50, MinimumLength = 1)]
public string JobTitle { get; set; } = default!;
[Required]
[ValidateComplexType]
public PlaceOfWorkModel PlaceOfWork { get; set; } = new();
public bool IsNonResident { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
using VisaApiClient;
namespace BlazorWebAssemblyVisaApiClient.Validation.Applicants.Models
{
/// Model of request with attributes required for validation to work
public class RegisterRequestModel
{
[Required]
[ValidateComplexType]
public AuthData AuthData { get; set; } = new AuthData();
}
}

View File

@@ -0,0 +1,32 @@
using FluentValidation;
using VisaApiClient;
namespace BlazorWebAssemblyVisaApiClient.Validation.Applicants.Validators;
public class NameModelValidator : AbstractValidator<NameModel>
{
public NameModelValidator()
{
RuleFor(m => m.FirstName)
.NotEmpty()
.WithMessage("First Name can not be empty")
.Matches(Constants.EnglishWordRegex)
.WithMessage("First name must be in english characters")
.MaximumLength(ConfigurationConstraints.NameLength)
.WithMessage($"First Name length must be less than {ConfigurationConstraints.NameLength}");
RuleFor(m => m.Surname)
.NotEmpty()
.WithMessage("Surname can not be empty")
.Matches(Constants.EnglishWordRegex)
.WithMessage("Surname must be in english characters")
.MaximumLength(ConfigurationConstraints.NameLength)
.WithMessage($"Surname length must be less than {ConfigurationConstraints.NameLength}");
RuleFor(m => m.Patronymic)
.Matches(Constants.EnglishWordRegex)
.WithMessage("Patronymic must be in english characters")
.MaximumLength(ConfigurationConstraints.NameLength)
.WithMessage($"Patronymic length must be less than {ConfigurationConstraints.NameLength}");
}
}

View File

@@ -0,0 +1,39 @@
using BlazorWebAssemblyVisaApiClient.Infrastructure.Services.DateTimeProvider;
using FluentValidation;
using VisaApiClient;
namespace BlazorWebAssemblyVisaApiClient.Validation.Applicants.Validators;
public class PassportModelValidator : AbstractValidator<PassportModel>
{
public PassportModelValidator(IDateTimeProvider dateTimeProvider)
{
RuleFor(r => r.Issuer)
.NotEmpty()
.WithMessage("Passport issuer can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Passport issuer field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.IssuerNameLength)
.WithMessage($"Passport issuer length must be less than {ConfigurationConstraints.IssuerNameLength}");
RuleFor(r => r.Number)
.NotEmpty()
.WithMessage("Passport number can not be empty")
.Matches(Constants.EnglishPhraseRegex)
.WithMessage("Passport number field can contain only english letters, digits and special symbols")
.MaximumLength(ConfigurationConstraints.PassportNumberLength)
.WithMessage($"Passport number length must be less than {ConfigurationConstraints.PassportNumberLength}");
RuleFor(r => r.ExpirationDate)
.NotEmpty()
.WithMessage("Passport expiration date can not be empty")
.GreaterThan(dateTimeProvider.Now())
.WithMessage("Can not approve visa for applicants with expired passport");
RuleFor(r => r.IssueDate)
.NotEmpty()
.WithMessage("Passport issue date can not be empty")
.LessThanOrEqualTo(dateTimeProvider.Now())
.WithMessage("Passport issue date must be in past");
}
}

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