diff --git a/UserService/.gitignore b/UserService/.gitignore new file mode 100644 index 0000000..0425988 --- /dev/null +++ b/UserService/.gitignore @@ -0,0 +1,4 @@ +obj/* +bin/* + +appsettings.Development.json \ No newline at end of file diff --git a/UserService/.vscode/launch.json b/UserService/.vscode/launch.json new file mode 100644 index 0000000..bfbcad8 --- /dev/null +++ b/UserService/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md. + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/bin/Debug/net8.0/UserService.dll", + "args": [], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/UserService/.vscode/tasks.json b/UserService/.vscode/tasks.json new file mode 100644 index 0000000..1325e85 --- /dev/null +++ b/UserService/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/UserService.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/UserService.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/UserService.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/UserService/Attributes/Validation/ValidAge.cs b/UserService/Attributes/Validation/ValidAge.cs new file mode 100644 index 0000000..b414835 --- /dev/null +++ b/UserService/Attributes/Validation/ValidAge.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Attributes.Validation; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] +sealed public partial class ValidAge : ValidationAttribute +{ + public override bool IsValid(object? value) + { + try + { + if (value != null) + { + DateTime date = (DateTime)value; + + // FIXME: Check if this is the actual age we need + int age = DateTime.Now.Year - date.Year; + if (age < 18) + { + return false; + } + } + return true; + } + catch + { + return false; + } + } + + public override string FormatErrorMessage(string name) + { + return "You must be at least 18 years old"; + } +} \ No newline at end of file diff --git a/UserService/Attributes/Validation/ValidAvatar.cs b/UserService/Attributes/Validation/ValidAvatar.cs new file mode 100644 index 0000000..2c350a8 --- /dev/null +++ b/UserService/Attributes/Validation/ValidAvatar.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +namespace UserService.Attributes.Validation; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] +sealed public partial class ValidAvatar : ValidationAttribute +{ + // FIXME: test regex + private const string Pattern = @"^.{1,150}$"; + + public override bool IsValid(object? value) + { + try + { + // FIXME: Implementation + return true; + } + catch + { + return false; + } + } + + public override string FormatErrorMessage(string name) + { + return "Must be a valid avatar URL in the S3 storage"; + } + + [GeneratedRegex(Pattern)] + private static partial Regex MyRegex(); +} \ No newline at end of file diff --git a/UserService/Attributes/Validation/ValidGuid.cs b/UserService/Attributes/Validation/ValidGuid.cs new file mode 100644 index 0000000..a997acb --- /dev/null +++ b/UserService/Attributes/Validation/ValidGuid.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Attributes.Validation; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] +sealed public class ValidGuid : ValidationAttribute +{ + public override bool IsValid(object? value) + { + try + { + if (value != null) + { + var guid = new Guid(value.ToString()!); + } + return true; + } + catch + { + return false; + } + } + + public override string FormatErrorMessage(string name) + { + return "Not a valid GUID"; + } + +} \ No newline at end of file diff --git a/UserService/Attributes/Validation/ValidName.cs b/UserService/Attributes/Validation/ValidName.cs new file mode 100644 index 0000000..edad507 --- /dev/null +++ b/UserService/Attributes/Validation/ValidName.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +namespace UserService.Attributes.Validation; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] +sealed public partial class ValidName : ValidationAttribute +{ + // FIXME: test regex + private const string Pattern = @"^.{1,150}$"; + + public override bool IsValid(object? value) + { + try + { + if (value != null) + { + string name = value.ToString() ?? ""; + + if (!MyRegex().IsMatch(name)) + { + return false; + } + } + return true; + } + catch + { + return false; + } + } + + public override string FormatErrorMessage(string name) + { + return "Name must be between 1 and 150 characters"; + } + + [GeneratedRegex(Pattern)] + private static partial Regex MyRegex(); +} \ No newline at end of file diff --git a/UserService/Attributes/Validation/ValidPassword.cs b/UserService/Attributes/Validation/ValidPassword.cs new file mode 100644 index 0000000..8cff208 --- /dev/null +++ b/UserService/Attributes/Validation/ValidPassword.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +namespace UserService.Attributes.Validation; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] +sealed public partial class ValidPassword : ValidationAttribute +{ + private const string Pattern = @"^.{8,256}$"; + private string? _errorMessage; + + public override bool IsValid(object? value) + { + try + { + if (value != null) + { + string password = value.ToString() ?? ""; + + if (!MyRegex().IsMatch(password)) + { + _errorMessage = "Password must be between 8 and 256 characters"; + return false; + } + } + return true; + } + catch (Exception e) + { + // FIXME: use logger instead + Console.WriteLine(e); + _errorMessage = "Invalid password. Contact administrator if you think this is a mistake."; + return false; + throw; + } + } + + public override string FormatErrorMessage(string name) + { + return _errorMessage ?? "Invalid password"; + } + + [GeneratedRegex(Pattern)] + private static partial Regex MyRegex(); +} \ No newline at end of file diff --git a/UserService/Attributes/Validation/ValidUsername.cs b/UserService/Attributes/Validation/ValidUsername.cs new file mode 100644 index 0000000..da2aac8 --- /dev/null +++ b/UserService/Attributes/Validation/ValidUsername.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +namespace UserService.Attributes.Validation; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] +sealed public partial class ValidUsername : ValidationAttribute +{ + private const string Pattern = @"^[a-zA-Z0-9_]{3,18}$"; + + public override bool IsValid(object? value) + { + try + { + if (value != null) + { + string username = value.ToString() ?? ""; + + if (!MyRegex().IsMatch(username)) + { + return false; + } + } + return true; + } + catch + { + return false; + } + } + + public override string FormatErrorMessage(string name) + { + return "Username must be between 3 and 18 characters and contain only letters, numbers, and underscores"; + } + + [GeneratedRegex(Pattern)] + private static partial Regex MyRegex(); +} \ No newline at end of file diff --git a/UserService/Database/ApplicationContext.cs b/UserService/Database/ApplicationContext.cs new file mode 100644 index 0000000..d2a3ddc --- /dev/null +++ b/UserService/Database/ApplicationContext.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using UserService.Database.Models; + +namespace UserService.Database; + +public class ApplicationContext : DbContext +{ + public DbSet Users { get; set; } = null!; + public DbSet Metas { get; set; } = null!; + public DbSet Roles { get; set; } = null!; + public DbSet PersonalDatas { get; set; } = null!; + public DbSet VisitedTours { get; set; } = null!; + public DbSet RegistrationCodes { get; set; } = null!; + + public ApplicationContext(DbContextOptions options) : base(options) + { + Database.EnsureCreated(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasData( + new Role {Id = 1, Name = "user", IsProtected = true}, + new Role {Id = 2, Name = "admin", IsProtected = true} + ); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + } + +} \ No newline at end of file diff --git a/UserService/Database/Models/Meta.cs b/UserService/Database/Models/Meta.cs new file mode 100644 index 0000000..d99d7d8 --- /dev/null +++ b/UserService/Database/Models/Meta.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using UserService.Attributes.Validation; + +namespace UserService.Database.Models; + +public class Meta +{ + [Key] + public long Id { get; set; } + + [Required] + public long UserId { get; set; } + public virtual User User { get; set; } = null!; + + [Required] + public string Name { get; set; } = null!; + [Required] + public string Surname { get; set; } = null!; + public string? Patronymic { get; set; } + + [Required] + [ValidAge] + public DateTime Birthday { get; set; } + + [ValidAvatar] + public string? Avatar { get; set; } +} diff --git a/UserService/Database/Models/PersonalData.cs b/UserService/Database/Models/PersonalData.cs new file mode 100644 index 0000000..a9c6760 --- /dev/null +++ b/UserService/Database/Models/PersonalData.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Database.Models; + +public class PersonalData +{ + [Key] + public long Id { get; set; } + + [Required] + public long UserId { get; set; } + public virtual User User { get; set; } = null!; + + public string? Passport { get; set; } + public string? Snils { get; set; } +} diff --git a/UserService/Database/Models/RegistrationCode.cs b/UserService/Database/Models/RegistrationCode.cs new file mode 100644 index 0000000..752bc50 --- /dev/null +++ b/UserService/Database/Models/RegistrationCode.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace UserService.Database.Models; + +[Index(nameof(UserId), IsUnique = true)] +public class RegistrationCode +{ + [Key] + public long Id { get; set; } + + // FIXME: Match the six-character registration code template + [Required] + [Column(TypeName = "VARCHAR(6)")] + public string Code { get; set; } = Guid.NewGuid().ToString(); + + public DateTime ExpirationDate { get; set; } = DateTime.Now.AddMinutes(10); + + [Required] + public User User { get; set; } = null!; + public long UserId { get; set; } +} \ No newline at end of file diff --git a/UserService/Database/Models/ResetCode.cs b/UserService/Database/Models/ResetCode.cs new file mode 100644 index 0000000..73f5914 --- /dev/null +++ b/UserService/Database/Models/ResetCode.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; +using UserService.Attributes.Validation; + +namespace UserService.Database.Models; + +/// +/// Код для сброса пароля. +/// +[Index(nameof(UserId), IsUnique = true)] +public class ResetCode +{ + [Key] + public long Id { get; set; } + + [Required] + [ValidGuid] + [Column(TypeName = "VARCHAR(36)")] + public string Code { get; set; } = Guid.NewGuid().ToString(); + + public DateTime ExpirationDate { get; set; } = DateTime.Now.AddMinutes(10); + + [Required] + public long UserId { get; set; } + public virtual User User { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Database/Models/Role.cs b/UserService/Database/Models/Role.cs new file mode 100644 index 0000000..9adb859 --- /dev/null +++ b/UserService/Database/Models/Role.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Database.Models; + +public class Role +{ + [Key] + public long Id { get; set; } + + [Required] + public string Name { get; set; } = null!; + + public bool IsProtected { get; set; } +} diff --git a/UserService/Database/Models/User.cs b/UserService/Database/Models/User.cs new file mode 100644 index 0000000..87aeb28 --- /dev/null +++ b/UserService/Database/Models/User.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using UserService.Attributes.Validation; + +namespace UserService.Database.Models; + +public class User +{ + [Key] + public long Id { get; set; } + [Required] + [Column(TypeName = "VARCHAR(18)")] + [ValidUsername] + public string Username { get; set; } = null!; + + [Required] + [EmailAddress] + public string Email { get; set; } = null!; + + [Phone] + public string? Phone { get; set; } + + [Required] + public string Password { get; set; } = null!; + + [Required] + public virtual Role Role { get; set; } = null!; + public long RoleId { get; set; } + + public virtual Meta? Meta { get; set; } = null!; + public long? MetaId { get; set; } + + [Required] + public virtual PersonalData? PersonalData { get; set; } = null!; + public long? PersonalDataId { get; set; } + + [Required] + public bool IsActivated { get; set; } = false; + + [Required] + public bool IsTerminated { get; set; } = false; + public string? TerminationReason { get; set; } + public DateTime? AllowRecreationAt { get; set; } + + [Required] + public DateTime RegistrationDate { get; set; } = DateTime.UtcNow; + + [Required] + [ValidGuid] + [Column(TypeName = "VARCHAR(36)")] + public string Salt { get; set; } = Guid.NewGuid().ToString(); +} diff --git a/UserService/Database/Models/VisitedTour.cs b/UserService/Database/Models/VisitedTour.cs new file mode 100644 index 0000000..f43cbbb --- /dev/null +++ b/UserService/Database/Models/VisitedTour.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Database.Models; + +public class VisitedTour +{ + [Key] + public long Id { get; set; } + + [Required] + public long UserId { get; set; } + public virtual User User { get; set; } = null!; + + [Required] + public long TourId { get; set; } +} diff --git a/UserService/Dockerfile b/UserService/Dockerfile new file mode 100644 index 0000000..fa14246 --- /dev/null +++ b/UserService/Dockerfile @@ -0,0 +1,24 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 5095 + +ENV ASPNETCORE_URLS=http://+:5095 + +USER app +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG configuration=Release +WORKDIR /src +COPY ["UserService.csproj", "./"] +RUN dotnet restore "UserService.csproj" +COPY . . +WORKDIR "/src/." +RUN dotnet build "UserService.csproj" -c $configuration -o /app/build + +FROM build AS publish +ARG configuration=Release +RUN dotnet publish "UserService.csproj" -c $configuration -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "UserService.dll"] \ No newline at end of file diff --git a/UserService/Exceptions/Account/CodeHasNotExpiredException.cs b/UserService/Exceptions/Account/CodeHasNotExpiredException.cs new file mode 100644 index 0000000..aef447b --- /dev/null +++ b/UserService/Exceptions/Account/CodeHasNotExpiredException.cs @@ -0,0 +1,9 @@ +namespace UserService.Exceptions.Account; + +[Serializable] +public class CodeHasNotExpiredException : Exception +{ + public CodeHasNotExpiredException() { } + public CodeHasNotExpiredException(string message) : base(message) { } + public CodeHasNotExpiredException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file diff --git a/UserService/Exceptions/Account/InsufficientDataException.cs b/UserService/Exceptions/Account/InsufficientDataException.cs new file mode 100644 index 0000000..c961af6 --- /dev/null +++ b/UserService/Exceptions/Account/InsufficientDataException.cs @@ -0,0 +1,9 @@ +namespace UserService.Exceptions.Account; + +[Serializable] +public class InsufficientDataException : Exception +{ + public InsufficientDataException() { } + public InsufficientDataException(string message) : base(message) { } + public InsufficientDataException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file diff --git a/UserService/Exceptions/Account/InvalidCodeError.cs b/UserService/Exceptions/Account/InvalidCodeError.cs new file mode 100644 index 0000000..84cd33a --- /dev/null +++ b/UserService/Exceptions/Account/InvalidCodeError.cs @@ -0,0 +1,9 @@ +namespace UserService.Exceptions.Account; + +[Serializable] +public class InvalidCodeException : Exception +{ + public InvalidCodeException() { } + public InvalidCodeException(string message) : base(message) { } + public InvalidCodeException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file diff --git a/UserService/Exceptions/Account/UserExistsError.cs b/UserService/Exceptions/Account/UserExistsError.cs new file mode 100644 index 0000000..439803b --- /dev/null +++ b/UserService/Exceptions/Account/UserExistsError.cs @@ -0,0 +1,9 @@ +namespace UserService.Exceptions.Account; + +[Serializable] +public class UserExistsException : Exception +{ + public UserExistsException() { } + public UserExistsException(string message) : base(message) { } + public UserExistsException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file diff --git a/UserService/Exceptions/Account/UserNotFoundException.cs b/UserService/Exceptions/Account/UserNotFoundException.cs new file mode 100644 index 0000000..9dd5853 --- /dev/null +++ b/UserService/Exceptions/Account/UserNotFoundException.cs @@ -0,0 +1,9 @@ +namespace UserService.Exceptions.Account; + +[Serializable] +public class UserNotFoundException : Exception +{ + public UserNotFoundException() { } + public UserNotFoundException(string message) : base(message) { } + public UserNotFoundException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file diff --git a/UserService/Kafka/KafkaRequestService.cs b/UserService/Kafka/KafkaRequestService.cs new file mode 100644 index 0000000..35b0d0a --- /dev/null +++ b/UserService/Kafka/KafkaRequestService.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Confluent.Kafka; +using TourService.Kafka; +using TourService.KafkaException; +using TourService.KafkaException.ConsumerException; +using TourService.Kafka.Utils; +using TourService.KafkaException.ConfigurationException; +using Newtonsoft.Json; + +namespace TourService.Kafka +{ + public class KafkaRequestService + { + private readonly IProducer _producer; + private readonly ILogger _logger; + private readonly KafkaTopicManager _kafkaTopicManager; + private readonly HashSet _pendingMessagesBus; + private readonly HashSet _recievedMessagesBus; + private readonly HashSet> _consumerPool; + public KafkaRequestService( + IProducer producer, + ILogger logger, + KafkaTopicManager kafkaTopicManager, + List responseTopics, + List requestsTopics) + { + _producer = producer; + _logger = logger; + _kafkaTopicManager = kafkaTopicManager; + _recievedMessagesBus = ConfigureRecievedMessages(responseTopics); + _pendingMessagesBus = ConfigurePendingMessages(requestsTopics); + _consumerPool = ConfigureConsumers(responseTopics.Count()); + + } + public void BeginRecieving(List responseTopics) + { + int topicCount = 0; + foreach(var consumer in _consumerPool) + { + + Thread thread = new Thread(async x=>{ + await Consume(consumer,responseTopics[topicCount]); + }); + thread.Start(); + topicCount++; + } + } + + private HashSet> ConfigureConsumers(int amount) + { + try + { + if(amount<=0) + { + throw new ConfigureConsumersException(" Amount of consumers must be above 0!"); + } + HashSet> consumers = new HashSet>(); + for (int i = 0; i < amount; i++) + { + consumers.Add( + new ConsumerBuilder( + new ConsumerConfig() + { + BootstrapServers = Environment.GetEnvironmentVariable("KAFKA_BROKERS"), + GroupId = "gatewayConsumer"+Guid.NewGuid().ToString(), + EnableAutoCommit = true, + AutoCommitIntervalMs = 10, + EnableAutoOffsetStore = true, + AutoOffsetReset = AutoOffsetReset.Earliest + + } + ).Build() + ); + } + return consumers; + } + catch (Exception ex) + { + if (ex is MyKafkaException) + { + _logger.LogError(ex, "Error configuring consumers"); + throw new ProducerException("Error configuring consumers",ex); + } + throw; + } + + } + private HashSet ConfigurePendingMessages(List ResponseTopics) + { + if(ResponseTopics.Count == 0) + { + throw new ConfigureMessageBusException("At least one requests topic must e provided!"); + } + var PendingMessages = new HashSet(); + foreach(var requestTopic in ResponseTopics) + { + PendingMessages.Add(new PendingMessagesBus(){ TopicName=requestTopic, MessageKeys = new HashSet()}); + } + return PendingMessages; + } + private HashSet ConfigureRecievedMessages(List ResponseTopics) + { + if(ResponseTopics.Count == 0) + { + throw new ConfigureMessageBusException("At least one response topic must e provided!"); + } + HashSet Responses = new HashSet(); + foreach(var RequestTopic in ResponseTopics) + { + Responses.Add(new RecievedMessagesBus() { TopicName = RequestTopic, Messages = new HashSet>()}); + } + return Responses; + } + public T GetMessage(string MessageKey, string topicName) + { + if(IsMessageRecieved(MessageKey)) + { + var message = _recievedMessagesBus.FirstOrDefault(x=>x.TopicName == topicName)!.Messages.FirstOrDefault(x=>x.Key==MessageKey); + _recievedMessagesBus.FirstOrDefault(x=>x.TopicName == topicName)!.Messages.Remove(message); + return JsonConvert.DeserializeObject(message.Value); + } + throw new ConsumerException("Message not recieved"); + } + private bool IsTopicAvailable(string topicName) + { + try + { + bool IsTopicExists = _kafkaTopicManager.CheckTopicExists(topicName); + if (IsTopicExists) + { + return IsTopicExists; + } + _logger.LogError("Unable to subscribe to topic"); + throw new ConsumerTopicUnavailableException("Topic unavailable"); + + } + catch (Exception e) + { + if (e is MyKafkaException) + { + _logger.LogError(e,"Error checking topic"); + throw new ConsumerException("Error checking topic",e); + } + _logger.LogError(e,"Unhandled error"); + throw; + } + } + + public bool IsMessageRecieved(string MessageKey) + { + try + { + return _recievedMessagesBus.Any(x=>x.Messages.Any(x=>x.Key==MessageKey)); + } + catch (Exception e) + { + throw new ConsumerException($"Recieved message bus error",e); + } + } + public async Task Produce(string topicName, Message message, string responseTopic) + { + try + { + bool IsTopicExists = IsTopicAvailable(topicName); + if (IsTopicExists && IsTopicPendingMessageBusExist( responseTopic)) + { + var deliveryResult = await _producer.ProduceAsync(topicName, message); + if (deliveryResult.Status == PersistenceStatus.Persisted) + { + _logger.LogInformation("Message delivery status: Persisted {Result}", deliveryResult.Value); + + _pendingMessagesBus.FirstOrDefault(x=>x.TopicName == responseTopic).MessageKeys.Add(new MethodKeyPair(){ + MessageKey = message.Key, + MessageMethod = Encoding.UTF8.GetString(message.Headers.FirstOrDefault(x => x.Key.Equals("method")).GetValueBytes()) + }); + return true; + + + } + + _logger.LogError("Message delivery status: Not persisted {Result}", deliveryResult.Value); + throw new MessageProduceException("Message delivery status: Not persisted" + deliveryResult.Value); + + } + + bool IsTopicCreated = _kafkaTopicManager.CreateTopic(topicName, Convert.ToInt32(Environment.GetEnvironmentVariable("PARTITIONS_STANDART")), Convert.ToInt16(Environment.GetEnvironmentVariable("REPLICATION_FACTOR_STANDART"))); + if (IsTopicCreated && IsTopicPendingMessageBusExist( responseTopic)) + { + var deliveryResult = await _producer.ProduceAsync(topicName, message); + if (deliveryResult.Status == PersistenceStatus.Persisted) + { + _logger.LogInformation("Message delivery status: Persisted {Result}", deliveryResult.Value); + _pendingMessagesBus.FirstOrDefault(x=>x.TopicName == responseTopic).MessageKeys.Add(new MethodKeyPair(){ + MessageKey = message.Key, + MessageMethod = Encoding.UTF8.GetString(message.Headers.FirstOrDefault(x => x.Key.Equals("method")).GetValueBytes()) + }); + return true; + } + + _logger.LogError("Message delivery status: Not persisted {Result}", deliveryResult.Value); + throw new MessageProduceException("Message delivery status: Not persisted"); + + } + _logger.LogError("Topic unavailable"); + throw new MessageProduceException("Topic unavailable"); + } + catch (Exception e) + { + if (e is MyKafkaException) + { + _logger.LogError(e, "Error producing message"); + throw new ProducerException("Error producing message",e); + } + throw; + } + } + private bool IsTopicPendingMessageBusExist(string responseTopic) + { + return _pendingMessagesBus.Any(x => x.TopicName == responseTopic); + } + private async Task Consume(IConsumer localConsumer,string topicName) + { + localConsumer.Subscribe(topicName); + while (true) + { + ConsumeResult result = localConsumer.Consume(); + + if (result != null) + { + try + { + if( _pendingMessagesBus.FirstOrDefault(x=>x.TopicName==topicName).MessageKeys.Any(x=>x.MessageKey==result.Message.Key)) + { + if(result.Message.Headers.Any(x => x.Key.Equals("errors"))) + { + var errors = Encoding.UTF8.GetString(result.Message.Headers.FirstOrDefault(x => x.Key.Equals("errors")).GetValueBytes()); + _logger.LogError(errors); + + throw new ConsumerException(errors); + } + + MethodKeyPair pendingMessage = _pendingMessagesBus.FirstOrDefault(x=>x.TopicName==topicName).MessageKeys.FirstOrDefault(x=>x.MessageKey==result.Message.Key); + if(_pendingMessagesBus.FirstOrDefault(x=>x.TopicName==topicName).MessageKeys.Any(x=>x.MessageMethod== Encoding.UTF8.GetString(result.Message.Headers.FirstOrDefault(x => x.Key.Equals("method")).GetValueBytes()))) + { + + localConsumer.Commit(result); + _recievedMessagesBus.FirstOrDefault(x=>x.TopicName== topicName).Messages.Add(result.Message); + _pendingMessagesBus.FirstOrDefault(x=>x.TopicName==topicName).MessageKeys.Remove(pendingMessage); + } + _logger.LogError("Wrong message method"); + throw new ConsumerException("Wrong message method"); + } + } + catch (Exception e) + { + if (e is MyKafkaException) + { + _logger.LogError(e,"Consumer error"); + throw new ConsumerException("Consumer error ",e); + } + _logger.LogError(e,"Unhandled error"); + localConsumer.Commit(result); + throw; + } + + } + } + } + } +} \ No newline at end of file diff --git a/UserService/Kafka/KafkaService.cs b/UserService/Kafka/KafkaService.cs new file mode 100644 index 0000000..6d11c9a --- /dev/null +++ b/UserService/Kafka/KafkaService.cs @@ -0,0 +1,121 @@ +using System.ComponentModel; +using System.Text; +using Confluent.Kafka; +using TourService.KafkaException; +using TourService.KafkaException.ConsumerException; +using Newtonsoft.Json; +namespace TourService.Kafka; + +public abstract class KafkaService(ILogger logger, IProducer producer, KafkaTopicManager kafkaTopicManager) +{ + protected readonly IProducer _producer = producer; + protected readonly ILogger _logger = logger; + protected readonly KafkaTopicManager _kafkaTopicManager = kafkaTopicManager; + protected IConsumer?_consumer; + + protected void ConfigureConsumer(string topicName) + { + try + { + var config = new ConsumerConfig + { + GroupId = "test-consumer-group", + BootstrapServers = Environment.GetEnvironmentVariable("BOOTSTRAP_SERVERS"), + AutoOffsetReset = AutoOffsetReset.Earliest + }; + _consumer = new ConsumerBuilder(config).Build(); + if(IsTopicAvailable(topicName)) + { + _consumer.Subscribe(topicName); + } + throw new ConsumerTopicUnavailableException("Topic unavailable"); + } + catch (Exception e) + { + if (e is MyKafkaException) + { + _logger.LogError(e,"Error configuring consumer"); + throw new ConsumerException("Error configuring consumer",e); + } + _logger.LogError(e,"Unhandled error"); + throw; + } + } + private bool IsTopicAvailable(string topicName) + { + try + { + bool IsTopicExists = _kafkaTopicManager.CheckTopicExists(topicName); + if (IsTopicExists) + { + return IsTopicExists; + } + _logger.LogError("Unable to subscribe to topic"); + throw new ConsumerTopicUnavailableException("Topic unavailable"); + + } + catch (Exception e) + { + if (e is MyKafkaException) + { + _logger.LogError(e,"Error checking topic"); + throw new ConsumerException("Error checking topic",e); + } + _logger.LogError(e,"Unhandled error"); + throw; + } + } + public abstract Task Consume(); + public async Task Produce( string topicName,Message message) + { + try + { + bool IsTopicExists = IsTopicAvailable(topicName); + if (IsTopicExists) + { + var deliveryResult = await _producer.ProduceAsync(topicName, message); + if (deliveryResult.Status == PersistenceStatus.Persisted) + { + + _logger.LogInformation("Message delivery status: Persisted {Result}", deliveryResult.Value); + return true; + } + + _logger.LogError("Message delivery status: Not persisted {Result}", deliveryResult.Value); + throw new MessageProduceException("Message delivery status: Not persisted" + deliveryResult.Value); + + } + + bool IsTopicCreated = _kafkaTopicManager.CreateTopic(topicName, Convert.ToInt32(Environment.GetEnvironmentVariable("PARTITIONS_STANDART")), Convert.ToInt16(Environment.GetEnvironmentVariable("REPLICATION_FACTOR_STANDART"))); + if (IsTopicCreated) + { + var deliveryResult = await _producer.ProduceAsync(topicName, message); + if (deliveryResult.Status == PersistenceStatus.Persisted) + { + _logger.LogInformation("Message delivery status: Persisted {Result}", deliveryResult.Value); + return true; + } + + _logger.LogError("Message delivery status: Not persisted {Result}", deliveryResult.Value); + throw new MessageProduceException("Message delivery status: Not persisted"); + + } + _logger.LogError("Topic unavailable"); + throw new MessageProduceException("Topic unavailable"); + } + catch (Exception e) + { + if (e is MyKafkaException) + { + _logger.LogError(e, "Error producing message"); + throw new ProducerException("Error producing message",e); + } + throw; + } + + + + } + + +} \ No newline at end of file diff --git a/UserService/Kafka/KafkaTopicManager.cs b/UserService/Kafka/KafkaTopicManager.cs new file mode 100644 index 0000000..9825681 --- /dev/null +++ b/UserService/Kafka/KafkaTopicManager.cs @@ -0,0 +1,74 @@ +using Confluent.Kafka; +using Confluent.Kafka.Admin; +using TourService.KafkaException; + +namespace TourService.Kafka; + +public class KafkaTopicManager(IAdminClient adminClient) +{ + private readonly IAdminClient _adminClient = adminClient; + + /// + /// Checks if a Kafka topic with the specified name exists. + /// + /// The name of the topic to check. + /// True if the topic exists, false otherwise. + /// Thrown if the topic check fails. + public bool CheckTopicExists(string topicName) + { + try + { + var topicExists = _adminClient.GetMetadata(topicName, TimeSpan.FromSeconds(10)); + if (topicExists.Topics.Count == 0) + { + return false; + } + return true; + } + catch (Exception e) + { + + Console.WriteLine($"An error occurred: {e.Message}"); + throw new CheckTopicException("Failed to check topic"); + } + } + + /// + /// Creates a new Kafka topic with the specified name, number of partitions, and replication factor. + /// + /// The name of the topic to create. + /// The number of partitions for the topic. + /// The replication factor for the topic. + /// True if the topic was successfully created, false otherwise. + /// Thrown if the topic creation fails. + public bool CreateTopic(string topicName, int numPartitions, short replicationFactor) + { + try + { + + var result = _adminClient.CreateTopicsAsync(new TopicSpecification[] + { + new() { + Name = topicName, + NumPartitions = numPartitions, + ReplicationFactor = replicationFactor, + Configs = new Dictionary + { + { "min.insync.replicas", "2" } + }} + }); + if (result.IsCompleted) + { + return true; + } + throw new CreateTopicException("Failed to create topic"); + } + catch (Exception e) + { + Console.WriteLine(e); + throw new CreateTopicException("Failed to create topic"); + } + } + + +} \ No newline at end of file diff --git a/UserService/Kafka/Utils/MethodKeyPair.cs b/UserService/Kafka/Utils/MethodKeyPair.cs new file mode 100644 index 0000000..f022f61 --- /dev/null +++ b/UserService/Kafka/Utils/MethodKeyPair.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace TourService.Kafka.Utils +{ + public class MethodKeyPair + { + public string MessageKey { get; set; } = ""; + public string MessageMethod {get;set;} = ""; + } +} \ No newline at end of file diff --git a/UserService/Kafka/Utils/PendingMessagesBus.cs b/UserService/Kafka/Utils/PendingMessagesBus.cs new file mode 100644 index 0000000..6602342 --- /dev/null +++ b/UserService/Kafka/Utils/PendingMessagesBus.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TourService.Kafka.Utils; + +namespace TourService.Kafka +{ + public class PendingMessagesBus + { + public string TopicName {get;set;} = ""; + public HashSet MessageKeys {get;set;} = new HashSet(); + } +} \ No newline at end of file diff --git a/UserService/Kafka/Utils/RecievedMessagesBus.cs b/UserService/Kafka/Utils/RecievedMessagesBus.cs new file mode 100644 index 0000000..8db6b95 --- /dev/null +++ b/UserService/Kafka/Utils/RecievedMessagesBus.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Confluent.Kafka; + +namespace TourService.Kafka +{ + public class RecievedMessagesBus + { + public string TopicName { get; set; } = ""; + public HashSet> Messages { get; set;} = new HashSet>(); + } +} \ No newline at end of file diff --git a/UserService/KafkaException/ConfigurationException/ConfigureConsumersException.cs b/UserService/KafkaException/ConfigurationException/ConfigureConsumersException.cs new file mode 100644 index 0000000..911b2fd --- /dev/null +++ b/UserService/KafkaException/ConfigurationException/ConfigureConsumersException.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TourService.KafkaException; + +namespace TourService.KafkaException.ConfigurationException +{ + public class ConfigureConsumersException : MyKafkaException + { + public ConfigureConsumersException() {} + public ConfigureConsumersException(string message) : base(message) {} + public ConfigureConsumersException(string message, System.Exception inner) : base(message, inner) {} + } +} \ No newline at end of file diff --git a/UserService/KafkaException/ConfigurationException/ConfigureMessageBusException.cs b/UserService/KafkaException/ConfigurationException/ConfigureMessageBusException.cs new file mode 100644 index 0000000..d2db883 --- /dev/null +++ b/UserService/KafkaException/ConfigurationException/ConfigureMessageBusException.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using TourService.KafkaException; + +namespace TourService.KafkaException.ConfigurationException +{ + public class ConfigureMessageBusException : MyKafkaException + { + public ConfigureMessageBusException() {} + public ConfigureMessageBusException(string message) : base(message) {} + public ConfigureMessageBusException(string message, System.Exception inner) : base(message, inner) {} + } +} \ No newline at end of file diff --git a/UserService/KafkaException/ConsumerException/ConsumerException.cs b/UserService/KafkaException/ConsumerException/ConsumerException.cs new file mode 100644 index 0000000..06a63ee --- /dev/null +++ b/UserService/KafkaException/ConsumerException/ConsumerException.cs @@ -0,0 +1,18 @@ +namespace TourService.KafkaException.ConsumerException; + +public class ConsumerException : MyKafkaException +{ + public ConsumerException() + { + } + + public ConsumerException(string message) + : base(message) + { + } + + public ConsumerException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/UserService/KafkaException/ConsumerException/ConsumerRecievedMessageInvalidException.cs b/UserService/KafkaException/ConsumerException/ConsumerRecievedMessageInvalidException.cs new file mode 100644 index 0000000..416c5ff --- /dev/null +++ b/UserService/KafkaException/ConsumerException/ConsumerRecievedMessageInvalidException.cs @@ -0,0 +1,18 @@ +namespace TourService.KafkaException.ConsumerException; + +public class ConsumerRecievedMessageInvalidException : ConsumerException +{ + public ConsumerRecievedMessageInvalidException() + { + } + + public ConsumerRecievedMessageInvalidException(string message) + : base(message) + { + } + + public ConsumerRecievedMessageInvalidException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/UserService/KafkaException/ConsumerException/ConsumerTopicUnavailableException.cs b/UserService/KafkaException/ConsumerException/ConsumerTopicUnavailableException.cs new file mode 100644 index 0000000..46835d7 --- /dev/null +++ b/UserService/KafkaException/ConsumerException/ConsumerTopicUnavailableException.cs @@ -0,0 +1,18 @@ +namespace TourService.KafkaException.ConsumerException; + +public class ConsumerTopicUnavailableException : ConsumerException +{ + public ConsumerTopicUnavailableException() + { + } + + public ConsumerTopicUnavailableException(string message) + : base(message) + { + } + + public ConsumerTopicUnavailableException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/UserService/KafkaException/MyKafkaException.cs b/UserService/KafkaException/MyKafkaException.cs new file mode 100644 index 0000000..4ca7eed --- /dev/null +++ b/UserService/KafkaException/MyKafkaException.cs @@ -0,0 +1,21 @@ +namespace TourService.KafkaException; + +public class MyKafkaException : Exception +{ + public MyKafkaException() + { + + } + + public MyKafkaException(string message) + : base(message) + { + + } + + public MyKafkaException(string message, Exception innerException) + : base(message, innerException) + { + + } +} \ No newline at end of file diff --git a/UserService/KafkaException/ProducerExceptions/MessageProduceException.cs b/UserService/KafkaException/ProducerExceptions/MessageProduceException.cs new file mode 100644 index 0000000..e1b833f --- /dev/null +++ b/UserService/KafkaException/ProducerExceptions/MessageProduceException.cs @@ -0,0 +1,18 @@ +namespace TourService.KafkaException; + +public class MessageProduceException : ProducerException +{ + public MessageProduceException() + { + } + + public MessageProduceException(string message) + : base(message) + { + } + + public MessageProduceException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/UserService/KafkaException/ProducerExceptions/ProducerException.cs b/UserService/KafkaException/ProducerExceptions/ProducerException.cs new file mode 100644 index 0000000..8e4a6f8 --- /dev/null +++ b/UserService/KafkaException/ProducerExceptions/ProducerException.cs @@ -0,0 +1,21 @@ +namespace TourService.KafkaException; + +public class ProducerException : MyKafkaException +{ + public ProducerException() + { + + } + + public ProducerException(string message) + : base(message) + { + + } + + public ProducerException(string message, Exception innerException) + : base(message, innerException) + { + + } +} \ No newline at end of file diff --git a/UserService/KafkaException/TopicExceptions/CheckTopicException.cs b/UserService/KafkaException/TopicExceptions/CheckTopicException.cs new file mode 100644 index 0000000..63d6546 --- /dev/null +++ b/UserService/KafkaException/TopicExceptions/CheckTopicException.cs @@ -0,0 +1,18 @@ +namespace TourService.KafkaException; + +public class CheckTopicException : TopicException +{ + public CheckTopicException() + { + } + + public CheckTopicException(string message) + : base(message) + { + } + + public CheckTopicException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/UserService/KafkaException/TopicExceptions/CreateTopicException.cs b/UserService/KafkaException/TopicExceptions/CreateTopicException.cs new file mode 100644 index 0000000..22053ba --- /dev/null +++ b/UserService/KafkaException/TopicExceptions/CreateTopicException.cs @@ -0,0 +1,18 @@ +namespace TourService.KafkaException; + +public class CreateTopicException : TopicException +{ + public CreateTopicException() + { + } + + public CreateTopicException(string message) + : base(message) + { + } + + public CreateTopicException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/UserService/KafkaException/TopicExceptions/TopicException.cs b/UserService/KafkaException/TopicExceptions/TopicException.cs new file mode 100644 index 0000000..3c52c6a --- /dev/null +++ b/UserService/KafkaException/TopicExceptions/TopicException.cs @@ -0,0 +1,18 @@ +namespace TourService.KafkaException; + +public class TopicException : MyKafkaException +{ + public TopicException() + { + } + + public TopicException(string message) + : base(message) + { + } + + public TopicException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/UserService/KafkaServices/AccountKafkaService.cs b/UserService/KafkaServices/AccountKafkaService.cs new file mode 100644 index 0000000..09e1b98 --- /dev/null +++ b/UserService/KafkaServices/AccountKafkaService.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Confluent.Kafka; +using Newtonsoft.Json; +using TourService.Kafka; +using TourService.KafkaException; +using TourService.KafkaException.ConsumerException; +using UserService.Models; +using UserService.Models.Account.Requests; +using UserService.Services.Account; + +namespace UserService.KafkaServices +{ + public class AccountKafkaService : KafkaService + { + private readonly string _accountResponseTopic = Environment.GetEnvironmentVariable("ACCOUNT_RESPONSE_TOPIC") ?? "userServiceAccountsResponses"; + private readonly string _accountRequestTopic = Environment.GetEnvironmentVariable("ACCOUNT_REQUEST_TOPIC") ?? "userServiceAccountsRequests"; + private readonly IAccountService _accountService; + public AccountKafkaService( + ILogger logger, + IAccountService accountService, + IProducer producer, + KafkaTopicManager kafkaTopicManager) : base(logger, producer, kafkaTopicManager) + { + _accountService = accountService; + base.ConfigureConsumer(_accountRequestTopic); + } + + public override async Task Consume() + { + try + { + + while (true) + { + if(_consumer == null) + { + _logger.LogError("Consumer is null"); + throw new ConsumerException("Consumer is null"); + } + ConsumeResult consumeResult = _consumer.Consume(); + if (consumeResult != null) + { + var headerBytes = consumeResult.Message.Headers + .FirstOrDefault(x => x.Key.Equals("method")) ?? throw new NullReferenceException("headerBytes is null"); + + + var methodString = Encoding.UTF8.GetString(headerBytes.GetValueBytes()); + switch (methodString) + { + case "AccountAccessData": + try + { + if(await base.Produce(_accountResponseTopic,new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject( await _accountService.AccountAccessData(JsonConvert.DeserializeObject(consumeResult.Message.Value))), + Headers = [ + new Header("method",Encoding.UTF8.GetBytes("AccountAccessData")), + new Header("sender",Encoding.UTF8.GetBytes("userService")), + ] + })) + { + + _logger.LogDebug("Successfully sent message {Key}",consumeResult.Message.Key); + _consumer.Commit(consumeResult); + } + } + catch (Exception e) + { + if(e is MyKafkaException) + { + _logger.LogError(e,"Error sending message"); + throw; + } + _ = await base.Produce(_accountResponseTopic, new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(new MessageResponse(){ Message = e.Message}), + Headers = [ + new Header("method", Encoding.UTF8.GetBytes("AccountAccessData")), + new Header("sender", Encoding.UTF8.GetBytes("userService")), + new Header("error", Encoding.UTF8.GetBytes(e.Message)) + ] + }); + _consumer.Commit(consumeResult); + _logger.LogError(e, "Error sending message"); + } + + break; + case "BeginPasswordReset": + try + { + if(await base.Produce(_accountResponseTopic,new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject( await _accountService.BeginPasswordReset(JsonConvert.DeserializeObject(consumeResult.Message.Value))), + Headers = [ + new Header("method",Encoding.UTF8.GetBytes("BeginPasswordReset")), + new Header("sender",Encoding.UTF8.GetBytes("userService")), + ] + })) + { + _logger.LogDebug("Successfully sent message {Key}",consumeResult.Message.Key); + _consumer.Commit(consumeResult); + } + } + catch (Exception e) + { + if(e is MyKafkaException) + { + _logger.LogError(e,"Error sending message"); + throw; + } + _ = await base.Produce(_accountResponseTopic, new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(new MessageResponse(){ Message = e.Message}), + Headers = [ + new Header("method", Encoding.UTF8.GetBytes("BeginPasswordReset")), + new Header("sender", Encoding.UTF8.GetBytes("userService")), + new Header("error", Encoding.UTF8.GetBytes(e.Message)) + ] + }); + _consumer.Commit(consumeResult); + _logger.LogError(e, "Error sending message"); + } + break; + case "BeginRegistration": + try + { + if(await base.Produce(_accountResponseTopic,new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject( await _accountService.BeginRegistration(JsonConvert.DeserializeObject(consumeResult.Message.Value))), + Headers = [ + new Header("method",Encoding.UTF8.GetBytes("BeginRegistration")), + new Header("sender",Encoding.UTF8.GetBytes("userService")), + ] + })) + { + _logger.LogDebug("Successfully sent message {Key}",consumeResult.Message.Key); + _consumer.Commit(consumeResult); + } + } + catch (Exception e) + { + if(e is MyKafkaException) + { + _logger.LogError(e,"Error sending message"); + throw; + } + _ = await base.Produce(_accountResponseTopic, new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(new MessageResponse(){ Message = e.Message}), + Headers = [ + new Header("method", Encoding.UTF8.GetBytes("BeginRegistration")), + new Header("sender", Encoding.UTF8.GetBytes("userService")), + new Header("error", Encoding.UTF8.GetBytes(e.Message)) + ] + }); + _consumer.Commit(consumeResult); + _logger.LogError(e, "Error sending message"); + } + break; + case "ChangePassword": + try + { + if(await base.Produce(_accountResponseTopic,new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject( await _accountService.ChangePassword(JsonConvert.DeserializeObject(consumeResult.Message.Value))), + Headers = [ + new Header("method",Encoding.UTF8.GetBytes("ChangePassword")), + new Header("sender",Encoding.UTF8.GetBytes("userService")), + ] + })) + { + _logger.LogDebug("Successfully sent message {Key}",consumeResult.Message.Key); + _consumer.Commit(consumeResult); + } + } + catch (Exception e) + { + if(e is MyKafkaException) + { + _logger.LogError(e,"Error sending message"); + throw; + } + _ = await base.Produce(_accountResponseTopic, new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(new MessageResponse(){ Message = e.Message}), + Headers = [ + new Header("method", Encoding.UTF8.GetBytes("ChangePassword")), + new Header("sender", Encoding.UTF8.GetBytes("userService")), + new Header("error", Encoding.UTF8.GetBytes(e.Message)) + ] + }); + _consumer.Commit(consumeResult); + _logger.LogError(e, "Error sending message"); + } + break; + case "CompletePasswordReset": + try + { + if(await base.Produce(_accountResponseTopic,new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(await _accountService.CompletePasswordReset(JsonConvert.DeserializeObject(consumeResult.Message.Value))), + Headers = [ + new Header("method",Encoding.UTF8.GetBytes("CompletePasswordReset")), + new Header("sender",Encoding.UTF8.GetBytes("userService")), + ] + })) + { + _logger.LogDebug("Successfully sent message {Key}",consumeResult.Message.Key); + _consumer.Commit(consumeResult); + } + + } + catch (Exception e) + { + if(e is MyKafkaException) + { + _logger.LogError(e,"Error sending message"); + throw; + } + _ = await base.Produce(_accountResponseTopic, new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(new MessageResponse(){ Message = e.Message}), + Headers = [ + new Header("method", Encoding.UTF8.GetBytes("CompletePasswordReset")), + new Header("sender", Encoding.UTF8.GetBytes("userService")), + new Header("error", Encoding.UTF8.GetBytes(e.Message)) + ] + }); + _consumer.Commit(consumeResult); + _logger.LogError(e, "Error sending message"); + } + break; + case "CompleteRegistration": + try + { + if(await base.Produce(_accountResponseTopic,new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject( await _accountService.CompleteRegistration(JsonConvert.DeserializeObject(consumeResult.Message.Value))), + Headers = [ + new Header("method",Encoding.UTF8.GetBytes("CompleteRegistration")), + new Header("sender",Encoding.UTF8.GetBytes("userService")), + ] + })) + { + _logger.LogDebug("Successfully sent message {Key}",consumeResult.Message.Key); + _consumer.Commit(consumeResult); + + } + } + catch (Exception e) + { + if(e is MyKafkaException) + { + _logger.LogError(e,"Error sending message"); + throw; + } + _ = await base.Produce(_accountResponseTopic, new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(new MessageResponse(){ Message = e.Message}), + Headers = [ + new Header("method", Encoding.UTF8.GetBytes("CompleteRegistration")), + new Header("sender", Encoding.UTF8.GetBytes("userService")), + new Header("error", Encoding.UTF8.GetBytes(e.Message)) + ] + }); + _consumer.Commit(consumeResult); + _logger.LogError(e, "Error sending message"); + } + break; + case "ResendPasswordResetCode": + try + { + if(await base.Produce(_accountResponseTopic,new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(await _accountService.ResendPasswordResetCode(JsonConvert.DeserializeObject(consumeResult.Message.Value))), + Headers = [ + new Header("method",Encoding.UTF8.GetBytes("ResendPasswordResetCode")), + new Header("sender",Encoding.UTF8.GetBytes("userService")), + ] + })) + { + _logger.LogDebug("Successfully sent message {Key}",consumeResult.Message.Key); + _consumer.Commit(consumeResult); + } + } + catch (Exception e) + { + if(e is MyKafkaException) + { + _logger.LogError(e,"Error sending message"); + throw; + } + _ = await base.Produce(_accountResponseTopic, new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(new MessageResponse(){ Message = e.Message}), + Headers = [ + new Header("method", Encoding.UTF8.GetBytes("ResendPasswordResetCode")), + new Header("sender", Encoding.UTF8.GetBytes("userService")), + new Header("error", Encoding.UTF8.GetBytes(e.Message)) + ] + }); + _consumer.Commit(consumeResult); + _logger.LogError(e, "Error sending message"); + } + break; + case "ResendRegistrationCode": + try + { + if(await base.Produce(_accountResponseTopic,new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject( await _accountService.ResendRegistrationCode(JsonConvert.DeserializeObject(consumeResult.Message.Value))), + Headers = [ + new Header("method",Encoding.UTF8.GetBytes("ResendRegistrationCode")), + new Header("sender",Encoding.UTF8.GetBytes("userService")), + ] + })) + { + _logger.LogDebug("Successfully sent message {Key}",consumeResult.Message.Key); + _consumer.Commit(consumeResult); + } + } + catch (Exception e) + { + if(e is MyKafkaException) + { + _logger.LogError(e,"Error sending message"); + throw; + } + _ = await base.Produce(_accountResponseTopic, new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(new MessageResponse(){ Message = e.Message}), + Headers = [ + new Header("method", Encoding.UTF8.GetBytes("ResendRegistrationCode")), + new Header("sender", Encoding.UTF8.GetBytes("userService")), + new Header("error", Encoding.UTF8.GetBytes(e.Message)) + ] + }); + _consumer.Commit(consumeResult); + _logger.LogError(e, "Error sending message"); + } + break; + case "VerifyPasswordResetCode": + try + { + if(await base.Produce(_accountResponseTopic,new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject( await _accountService.VerifyPasswordResetCode(JsonConvert.DeserializeObject(consumeResult.Message.Value))), + Headers = [ + new Header("method",Encoding.UTF8.GetBytes("VerifyPasswordResetCode")), + new Header("sender",Encoding.UTF8.GetBytes("userService")), + ] + })) + { + _logger.LogDebug("Successfully sent message {Key}",consumeResult.Message.Key); + _consumer.Commit(consumeResult); + } + } + catch (Exception e) + { + if(e is MyKafkaException) + { + _logger.LogError(e,"Error sending message"); + throw; + } + _ = await base.Produce(_accountResponseTopic, new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(new MessageResponse(){ Message = e.Message}), + Headers = [ + new Header("method", Encoding.UTF8.GetBytes("VerifyPasswordResetCode")), + new Header("sender", Encoding.UTF8.GetBytes("userService")), + new Header("error", Encoding.UTF8.GetBytes(e.Message)) + ] + }); + _consumer.Commit(consumeResult); + _logger.LogError(e, "Error sending message"); + } + break; + case "VerifyRegistrationCode": + try + { + if(await base.Produce(_accountResponseTopic,new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject( await _accountService.VerifyRegistrationCode(JsonConvert.DeserializeObject(consumeResult.Message.Value))), + Headers = [ + new Header("method",Encoding.UTF8.GetBytes("VerifyRegistrationCode")), + new Header("sender",Encoding.UTF8.GetBytes("userService")), + ] + })) + { + _logger.LogDebug("Successfully sent message {Key}",consumeResult.Message.Key); + _consumer.Commit(consumeResult); + } + } + catch (Exception e) + { + if(e is MyKafkaException) + { + _logger.LogError(e,"Error sending message"); + throw; + } + _ = await base.Produce(_accountResponseTopic, new Message() + { + Key = consumeResult.Message.Key, + Value = JsonConvert.SerializeObject(new MessageResponse(){ Message = e.Message}), + Headers = [ + new Header("method", Encoding.UTF8.GetBytes("VerifyRegistrationCode")), + new Header("sender", Encoding.UTF8.GetBytes("userService")), + new Header("error", Encoding.UTF8.GetBytes(e.Message)) + ] + }); + _consumer.Commit(consumeResult); + _logger.LogError(e, "Error sending message"); + } + break; + default: + _consumer.Commit(consumeResult); + + throw new ConsumerRecievedMessageInvalidException("Invalid message received"); + } + + } + } + } + catch(Exception ex) + { + if(_consumer != null) + { + _consumer.Dispose(); + } + if (ex is MyKafkaException) + { + _logger.LogError(ex,"Consumer error"); + throw new ConsumerException("Consumer error ",ex); + } + else + { + _logger.LogError(ex,"Unhandled error"); + throw; + } + } + } + + } +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/AccountAccessDataRequest.cs b/UserService/Models/Account/Requests/AccountAccessDataRequest.cs new file mode 100644 index 0000000..946ffa9 --- /dev/null +++ b/UserService/Models/Account/Requests/AccountAccessDataRequest.cs @@ -0,0 +1,10 @@ +namespace UserService.Models.Account.Requests; + +/// +/// Запрос на получение данных для входа в аккаунт. +/// +public class AccountAccessDataRequest +{ + public string? Username { get; set; } + public string? Email { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/BeginPasswordResetRequest.cs b/UserService/Models/Account/Requests/BeginPasswordResetRequest.cs new file mode 100644 index 0000000..e88e547 --- /dev/null +++ b/UserService/Models/Account/Requests/BeginPasswordResetRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Requests; + +/// +/// Запрос начала операции сброса пароля. +/// +public class BeginPasswordResetRequest +{ + [Required] + [EmailAddress] + public string Email { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/BeginRegistrationRequest.cs b/UserService/Models/Account/Requests/BeginRegistrationRequest.cs new file mode 100644 index 0000000..1f80a25 --- /dev/null +++ b/UserService/Models/Account/Requests/BeginRegistrationRequest.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using UserService.Attributes.Validation; + +namespace UserService.Models.Account.Requests; + +/// +/// Запрос на регистрацию пользователя. +/// +public class BeginRegistrationRequest +{ + [Required] + [EmailAddress] + public string Email { get; set; } = null!; + + [Required] + [ValidUsername] + public string Username { get; set; } = null!; + + [Required] + [ValidPassword] + public string Password { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/ChangePasswordRequest.cs b/UserService/Models/Account/Requests/ChangePasswordRequest.cs new file mode 100644 index 0000000..703b799 --- /dev/null +++ b/UserService/Models/Account/Requests/ChangePasswordRequest.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using UserService.Attributes.Validation; + +namespace UserService.Models.Account.Requests; + +/// +/// Запрос на изменение пароля. +/// Если LogoutSessions true, для пользователя будет сгенерирована +/// новая salt, что приведет к выходу из всех сеансов. +/// +public class ChangePasswordRequest +{ + [Required] + public long UserId { get; set; } + [Required] + public string OldPassword { get; set; } = null!; + + [Required] + [ValidPassword] + public string NewPassword { get; set; } = null!; + + [Required] + public bool LogoutSessions { get; set; } = true; +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/CompletePasswordResetRequest.cs b/UserService/Models/Account/Requests/CompletePasswordResetRequest.cs new file mode 100644 index 0000000..d892880 --- /dev/null +++ b/UserService/Models/Account/Requests/CompletePasswordResetRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using UserService.Attributes.Validation; + +namespace UserService.Models.Account.Requests; + +/// +/// Запрос на завершение сброса пароля. +/// +public class CompletePasswordResetRequest +{ + [EmailAddress] + public string Email { get; set; } = null!; + + [Required] + [ValidGuid] + public string Code { get; set; } = null!; + + [Required] + [ValidPassword] + public string NewPassword = null!; +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/CompleteRegistrationRequest.cs b/UserService/Models/Account/Requests/CompleteRegistrationRequest.cs new file mode 100644 index 0000000..4564452 --- /dev/null +++ b/UserService/Models/Account/Requests/CompleteRegistrationRequest.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using UserService.Attributes.Validation; + +namespace UserService.Models.Account.Requests; + +/// +/// Запрос на завершение регистрации. +/// +public class CompleteRegistrationRequest +{ + [Required] + [EmailAddress] + public string Email { get; set; } = null!; + + [Required] + [Length(6,6)] + public string RegistrationCode { get; set; } = null!; + + [Required] + [ValidName] + public string Name { get; set; } = null!; + + [Required] + [ValidName] + public string Surname { get; set; } = null!; + + [ValidName] + public string? Patronymic { get; set; } + + [Required] + [ValidAge] + public DateTime Birthday { get; set; } + + public string? Avatar { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/GetUserRequest.cs b/UserService/Models/Account/Requests/GetUserRequest.cs new file mode 100644 index 0000000..14346a6 --- /dev/null +++ b/UserService/Models/Account/Requests/GetUserRequest.cs @@ -0,0 +1,5 @@ +namespace UserService.Models.Account.Requests; + +public class GetUserRequest +{ +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/ResendPasswordResetCodeRequest.cs b/UserService/Models/Account/Requests/ResendPasswordResetCodeRequest.cs new file mode 100644 index 0000000..b2329d2 --- /dev/null +++ b/UserService/Models/Account/Requests/ResendPasswordResetCodeRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Requests; + +/// +/// Запрос на повторное отправку кода для сброса пароля. +/// +public class ResendPasswordResetCodeRequest +{ + [Required] + [EmailAddress] + public string Email { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/ResendRegistrationCodeRequest.cs b/UserService/Models/Account/Requests/ResendRegistrationCodeRequest.cs new file mode 100644 index 0000000..de364d1 --- /dev/null +++ b/UserService/Models/Account/Requests/ResendRegistrationCodeRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Requests; + +/// +/// Запрос на повторное отправку кода регистрации. +/// +public class ResendRegistrationCodeRequest +{ + [Required] + [EmailAddress] + public string Email { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/VerifyPasswordResetCodeRequest.cs b/UserService/Models/Account/Requests/VerifyPasswordResetCodeRequest.cs new file mode 100644 index 0000000..3f604ec --- /dev/null +++ b/UserService/Models/Account/Requests/VerifyPasswordResetCodeRequest.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using UserService.Attributes.Validation; + +namespace UserService.Models.Account.Requests; + +/// +/// Запрос на проверку кода восстановления. +/// +public class VerifyPasswordResetCodeRequest +{ + [Required] + [EmailAddress] + public string Email { get; set; } = null!; + + [Required] + [ValidGuid] + public string Code { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Models/Account/Requests/VerifyRegistrationCodeRequest.cs b/UserService/Models/Account/Requests/VerifyRegistrationCodeRequest.cs new file mode 100644 index 0000000..8a44fb4 --- /dev/null +++ b/UserService/Models/Account/Requests/VerifyRegistrationCodeRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Requests; + +/// +/// Запрос на проверку кода регистрации. +/// +public class VerifyRegistrationCodeRequest +{ + [Required] + [EmailAddress] + public string Email { get; set; } = null!; + + [Required] + [Length(6, 6)] + public string Code { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/AccountAccessDataResponse.cs b/UserService/Models/Account/Responses/AccountAccessDataResponse.cs new file mode 100644 index 0000000..5e118bf --- /dev/null +++ b/UserService/Models/Account/Responses/AccountAccessDataResponse.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Responses; + +/// +/// Данные для доступа к аккаунту. +/// +public class AccountAccessDataResponse +{ + [Required] + public long UserId { get; set; } + + [Required] + public string Email { get; set; } = null!; + + [Required] + public string Username { get; set; } = null!; + + [Required] + public string Password { get; set; } = null!; + + [Required] + public string Salt { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/BeginPasswordResetResponse.cs b/UserService/Models/Account/Responses/BeginPasswordResetResponse.cs new file mode 100644 index 0000000..d0c0a7e --- /dev/null +++ b/UserService/Models/Account/Responses/BeginPasswordResetResponse.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Responses; + +/// +/// Результат запроса на сброс пароля. +/// +public class BeginPasswordResetResponse +{ + [Required] + public bool IsSuccess { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/BeginRegistrationResponse.cs b/UserService/Models/Account/Responses/BeginRegistrationResponse.cs new file mode 100644 index 0000000..536f24b --- /dev/null +++ b/UserService/Models/Account/Responses/BeginRegistrationResponse.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Responses; + +/// +/// Результат инициализации процесса регистрации. +/// +public class BeginRegistrationResponse +{ + [Required] + public bool IsSuccess { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/ChangePasswordResponse.cs b/UserService/Models/Account/Responses/ChangePasswordResponse.cs new file mode 100644 index 0000000..02b11b2 --- /dev/null +++ b/UserService/Models/Account/Responses/ChangePasswordResponse.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Responses; + +/// +/// Результат изменения пароля. +/// +public class ChangePasswordResponse +{ + [Required] + public bool IsSuccess { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/CompletePasswordResetResponse.cs b/UserService/Models/Account/Responses/CompletePasswordResetResponse.cs new file mode 100644 index 0000000..83d1ec4 --- /dev/null +++ b/UserService/Models/Account/Responses/CompletePasswordResetResponse.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Responses; + +/// +/// Результат запроса на сброс пароля. +/// +public class CompletePasswordResetResponse +{ + [Required] + public bool IsSuccess { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/CompleteRegistrationResponse.cs b/UserService/Models/Account/Responses/CompleteRegistrationResponse.cs new file mode 100644 index 0000000..8fc5cea --- /dev/null +++ b/UserService/Models/Account/Responses/CompleteRegistrationResponse.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Responses; + +/// +/// Результат завершения регистрации. +/// +public class CompleteRegistrationResponse +{ + [Required] + public bool IsSuccess { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/GetUserResponse.cs b/UserService/Models/Account/Responses/GetUserResponse.cs new file mode 100644 index 0000000..5c95cc7 --- /dev/null +++ b/UserService/Models/Account/Responses/GetUserResponse.cs @@ -0,0 +1,8 @@ +namespace UserService.Models.Account.Responses; + +public class GetUserResponse +{ + public long Id { get; set; } + public string Username { get; set; } = null!; + public string? Avatar { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/ResendPasswordResetCodeResponse.cs b/UserService/Models/Account/Responses/ResendPasswordResetCodeResponse.cs new file mode 100644 index 0000000..8a7b808 --- /dev/null +++ b/UserService/Models/Account/Responses/ResendPasswordResetCodeResponse.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Responses; + +/// +/// Результат запроса на повторное отправку кода для сброса пароля. +/// +public class ResendPasswordResetCodeResponse +{ + [Required] + public bool IsSuccess { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/ResendRegistrationCodeResponse.cs b/UserService/Models/Account/Responses/ResendRegistrationCodeResponse.cs new file mode 100644 index 0000000..64e46ec --- /dev/null +++ b/UserService/Models/Account/Responses/ResendRegistrationCodeResponse.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Responses; + +/// +/// Результат запроса на повторную отправку кода регистрации. +/// +public class ResendRegistrationCodeResponse +{ + [Required] + public bool IsSuccess { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/VerifyPasswordResetCodeResponse.cs b/UserService/Models/Account/Responses/VerifyPasswordResetCodeResponse.cs new file mode 100644 index 0000000..836d8a9 --- /dev/null +++ b/UserService/Models/Account/Responses/VerifyPasswordResetCodeResponse.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Responses; + +/// +/// Результат запроса на проверку актуальности кода восстановления. +/// +public class VerifyPasswordResetCodeResponse +{ + [Required] + public bool IsSuccess { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Account/Responses/VerifyRegistrationCodeResponse.cs b/UserService/Models/Account/Responses/VerifyRegistrationCodeResponse.cs new file mode 100644 index 0000000..195987e --- /dev/null +++ b/UserService/Models/Account/Responses/VerifyRegistrationCodeResponse.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Account.Responses; + +/// +/// Результат запроса на проверку актуальности кода регистрации. +/// +public class VerifyRegistrationCodeResponse +{ + [Required] + public bool IsSuccess { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/MessageResponse.cs b/UserService/Models/MessageResponse.cs new file mode 100644 index 0000000..1c52a3a --- /dev/null +++ b/UserService/Models/MessageResponse.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace UserService.Models +{ + public class MessageResponse + { + public string Message { get; set; } = null!; + } +} \ No newline at end of file diff --git a/UserService/Models/Profile/Requests/GetProfileRequest.cs b/UserService/Models/Profile/Requests/GetProfileRequest.cs new file mode 100644 index 0000000..a88187c --- /dev/null +++ b/UserService/Models/Profile/Requests/GetProfileRequest.cs @@ -0,0 +1,6 @@ +namespace UserService.Models.Profile.Requests; + +public class GetProfileRequest +{ + public long UserId { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Profile/Requests/UpdateProfileRequest.cs b/UserService/Models/Profile/Requests/UpdateProfileRequest.cs new file mode 100644 index 0000000..a13f0ab --- /dev/null +++ b/UserService/Models/Profile/Requests/UpdateProfileRequest.cs @@ -0,0 +1,17 @@ +using UserService.Attributes.Validation; + +namespace UserService.Models.Profile.Requests; + +public class UpdateProfileRequest +{ + [ValidName] + public string? Name { get; set; } + [ValidName] + public string? Surname { get; set; } + [ValidName] + public string? Patronymic { get; set; } + [ValidAge] + public DateTime? Birthday { get; set; } + [ValidAvatar] + public string? Avatar { get; set; } +} \ No newline at end of file diff --git a/UserService/Models/Profile/Requests/UploadAvatarRequest.cs b/UserService/Models/Profile/Requests/UploadAvatarRequest.cs new file mode 100644 index 0000000..687d938 --- /dev/null +++ b/UserService/Models/Profile/Requests/UploadAvatarRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace UserService.Models.Profile.Requests; + +public class UploadAvatarRequest +{ + [Required] + public Byte[] Avatar { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Models/Profile/Responses/GetProfileResponse.cs b/UserService/Models/Profile/Responses/GetProfileResponse.cs new file mode 100644 index 0000000..272962a --- /dev/null +++ b/UserService/Models/Profile/Responses/GetProfileResponse.cs @@ -0,0 +1,5 @@ +using UserService.Database.Models; + +namespace UserService.Models.Profile.Responses; + +public class GetProfileResponse : Meta; \ No newline at end of file diff --git a/UserService/Models/Profile/Responses/GetUsernameAndAvatarResponse.cs b/UserService/Models/Profile/Responses/GetUsernameAndAvatarResponse.cs new file mode 100644 index 0000000..86f8287 --- /dev/null +++ b/UserService/Models/Profile/Responses/GetUsernameAndAvatarResponse.cs @@ -0,0 +1,7 @@ +namespace UserService.Models.Profile.Responses; + +public class GetUsernameAndAvatarResponse +{ + public string Username { get; set; } = null!; + public string? Avatar { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Models/Profile/Responses/UpdateProfileResponse.cs b/UserService/Models/Profile/Responses/UpdateProfileResponse.cs new file mode 100644 index 0000000..a94d005 --- /dev/null +++ b/UserService/Models/Profile/Responses/UpdateProfileResponse.cs @@ -0,0 +1,5 @@ +using UserService.Database.Models; + +namespace UserService.Models.Profile.Responses; + +public class UpdateProfileResponse : Meta; \ No newline at end of file diff --git a/UserService/Models/Profile/Responses/UploadAvatarResponse.cs b/UserService/Models/Profile/Responses/UploadAvatarResponse.cs new file mode 100644 index 0000000..c18b6fa --- /dev/null +++ b/UserService/Models/Profile/Responses/UploadAvatarResponse.cs @@ -0,0 +1,6 @@ +namespace UserService.Models.Profile.Responses; + +public class UploadAvatarResponse +{ + public string Url { get; set; } = null!; +} \ No newline at end of file diff --git a/UserService/Program.cs b/UserService/Program.cs new file mode 100644 index 0000000..fa5ca40 --- /dev/null +++ b/UserService/Program.cs @@ -0,0 +1,29 @@ +using Serilog; +using Microsoft.EntityFrameworkCore; +using UserService.Database; +using UserService.Database.Models; +using UserService.Utils; +using UserService.Repositories; +using UserService.Services.Account; +using UserService.Services.Profile; + +var builder = WebApplication.CreateBuilder(args); + +Logging.configureLogging(); + +builder.Host.UseSerilog(); + +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")) +); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + + +var app = builder.Build(); + +app.UseHttpsRedirection(); + +app.Run(); \ No newline at end of file diff --git a/UserService/Properties/launchSettings.json b/UserService/Properties/launchSettings.json new file mode 100644 index 0000000..6cb6c6e --- /dev/null +++ b/UserService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:20249", + "sslPort": 44379 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5260", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7111;http://localhost:5260", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/UserService/Repositories/FindOptions.cs b/UserService/Repositories/FindOptions.cs new file mode 100644 index 0000000..0825f8f --- /dev/null +++ b/UserService/Repositories/FindOptions.cs @@ -0,0 +1,6 @@ +namespace UserService.Repositories; +public class FindOptions +{ + public bool IsIgnoreAutoIncludes { get; set; } + public bool IsAsNoTracking { get; set; } +} \ No newline at end of file diff --git a/UserService/Repositories/IRepository.cs b/UserService/Repositories/IRepository.cs new file mode 100644 index 0000000..4846867 --- /dev/null +++ b/UserService/Repositories/IRepository.cs @@ -0,0 +1,17 @@ +using System.Linq.Expressions; + +namespace UserService.Repositories; + +public interface IRepository where TEntity : class +{ + IQueryable GetAll(FindOptions? findOptions = null); + Task FindOneAsync(Expression> predicate, FindOptions? findOptions = null); + IQueryable Find(Expression> predicate, FindOptions? findOptions = null); + Task AddAsync(TEntity entity); + Task AddManyAsync(IEnumerable entities); + bool Update(TEntity entity); + bool Delete(TEntity entity); + bool DeleteMany(Expression> predicate); + Task AnyAsync(Expression> predicate); + Task CountAsync(Expression> predicate); +} \ No newline at end of file diff --git a/UserService/Repositories/ITransaction.cs b/UserService/Repositories/ITransaction.cs new file mode 100644 index 0000000..b4049b8 --- /dev/null +++ b/UserService/Repositories/ITransaction.cs @@ -0,0 +1,8 @@ +namespace UserService.Repositories; + +public interface ITransaction : IDisposable +{ + void Commit(); + bool SaveAndCommit(); + void Rollback(); +} \ No newline at end of file diff --git a/UserService/Repositories/IUnitOfWork.cs b/UserService/Repositories/IUnitOfWork.cs new file mode 100644 index 0000000..0bb1a7a --- /dev/null +++ b/UserService/Repositories/IUnitOfWork.cs @@ -0,0 +1,18 @@ +using UserService.Database.Models; +using UserService.Repositories; + +namespace UserService.Repositories; + +public interface IUnitOfWork : IDisposable +{ + ITransaction BeginTransaction(); + IRepository Users { get; } + IRepository Metas { get; } + IRepository PersonalDatas { get; } + IRepository RegistrationCodes { get; } + IRepository ResetCodes { get; } + IRepository VisitedTours { get; } + IRepository Roles { get; } + + public int Save(); +} diff --git a/UserService/Repositories/Repository.cs b/UserService/Repositories/Repository.cs new file mode 100644 index 0000000..b2bb9d4 --- /dev/null +++ b/UserService/Repositories/Repository.cs @@ -0,0 +1,80 @@ +using System.Linq.Expressions; +using UserService.Database; +using Microsoft.EntityFrameworkCore; + +namespace UserService.Repositories; + +public class Repository : IRepository where TEntity : class +{ + private readonly ApplicationContext _empDBContext; + + public Repository(ApplicationContext empDBContext) + { + _empDBContext = empDBContext; + } + + public async Task AddAsync(TEntity entity) + { + await _empDBContext.Set().AddAsync(entity); + return true; + } + public async Task AddManyAsync(IEnumerable entities) + { + await _empDBContext.Set().AddRangeAsync(entities); + return true; + } + public bool Delete(TEntity entity) + { + _empDBContext.Set().Remove(entity); + return true; + } + public bool DeleteMany(Expression> predicate) + { + var entities = Find(predicate); + _empDBContext.Set().RemoveRange(entities); + return true; + } + public async Task FindOneAsync(Expression> predicate, FindOptions? findOptions = null) + { + return await Get(findOptions).FirstOrDefaultAsync(predicate)! ?? throw new NullReferenceException("Entity not found!"); + } + public IQueryable Find(Expression> predicate, FindOptions? findOptions = null) + { + return Get(findOptions).Where(predicate); + } + public IQueryable GetAll(FindOptions? findOptions = null) + { + return Get(findOptions); + } + public bool Update(TEntity entity) + { + _empDBContext.Set().Update(entity); + return true; + } + public async Task AnyAsync(Expression> predicate) + { + return await _empDBContext.Set().AnyAsync(predicate); + } + public async Task CountAsync(Expression> predicate) + { + return await _empDBContext.Set().CountAsync(predicate); + } + private DbSet Get(FindOptions? findOptions = null) + { + findOptions ??= new FindOptions(); + var entity = _empDBContext.Set(); + if (findOptions.IsAsNoTracking && findOptions.IsIgnoreAutoIncludes) + { + entity.IgnoreAutoIncludes().AsNoTracking(); + } + else if (findOptions.IsIgnoreAutoIncludes) + { + entity.IgnoreAutoIncludes(); + } + else if (findOptions.IsAsNoTracking) + { + entity.AsNoTracking(); + } + return entity; + } +} \ No newline at end of file diff --git a/UserService/Repositories/Transaction.cs b/UserService/Repositories/Transaction.cs new file mode 100644 index 0000000..f20c035 --- /dev/null +++ b/UserService/Repositories/Transaction.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace UserService.Repositories; + +public class Transaction(DbContext context) : ITransaction +{ + private readonly DbContext _context = context; + private readonly IDbContextTransaction _transaction = context.Database.BeginTransaction(); + + public void Commit() + { + _transaction.Commit(); + } + + public bool SaveAndCommit() + { + var result = _context.SaveChanges() >= 0; + _transaction.Commit(); + return result; + } + + public void Rollback() + { + _transaction.Rollback(); + } + + public void Dispose() + { + _transaction.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/UserService/Repositories/UnitOfWork.cs b/UserService/Repositories/UnitOfWork.cs new file mode 100644 index 0000000..c6512f3 --- /dev/null +++ b/UserService/Repositories/UnitOfWork.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using UserService.Database; +using UserService.Database.Models; + +namespace UserService.Repositories; + +public class UnitOfWork : IUnitOfWork +{ + private readonly ILogger _logger; + private readonly ApplicationContext _context; + public IRepository Users { get; } + public IRepository Roles { get; } + public IRepository Metas { get; } + public IRepository VisitedTours { get; } + public IRepository RegistrationCodes { get; } + public IRepository ResetCodes { get; } + public IRepository PersonalDatas { get; } + + public UnitOfWork(ILogger logger, ApplicationContext context, IRepository users, IRepository roles, IRepository metas, + IRepository visitedTours, IRepository registrationCodes, IRepository resetCodes, + IRepository personalDatas) + { + _logger = logger; + _context = context; + Users = users; + Roles = roles; + Metas = metas; + VisitedTours = visitedTours; + RegistrationCodes = registrationCodes; + ResetCodes = resetCodes; + PersonalDatas = personalDatas; + } + + public int Save() + { + return _context.SaveChanges(); + } + + [Obsolete("UnitOfWork is not supposed to be manually disposed.")] + public void Dispose() + { + _logger.LogWarning("UnitOfWork is not supposed to be manually disposed. Causing this method may cause trouble!"); + _context.Dispose(); + GC.SuppressFinalize(this); + } + + public ITransaction BeginTransaction() + { + return new Transaction(_context); + } +} \ No newline at end of file diff --git a/UserService/Services/Account/AccountService.cs b/UserService/Services/Account/AccountService.cs new file mode 100644 index 0000000..2f0a416 --- /dev/null +++ b/UserService/Services/Account/AccountService.cs @@ -0,0 +1,532 @@ +using UserService.Database.Models; +using UserService.Exceptions.Account; +using UserService.Models.Account.Requests; +using UserService.Models.Account.Responses; +using UserService.Repositories; +using UserService.Utils; + +namespace UserService.Services.Account; + +public class AccountService(IUnitOfWork unitOfWork, ILogger logger) : IAccountService +{ + private readonly IUnitOfWork _uow = unitOfWork; + private readonly ILogger _logger = logger; + + public async Task AccountAccessData(AccountAccessDataRequest request) + { + if ((request.Email ?? request.Username) is null) + { + _logger.LogDebug("No user identification was provided for account search"); + throw new InsufficientDataException("Email or username must be provided"); + } + + User user; + try + { + user = await _uow.Users.FindOneAsync(u => u.Email == request.Email || u.Username == request.Username); + _logger.LogDebug("Found user {user.Id} with email {request.Email} or username {request.Username}", user.Id, request.Email, request.Username); + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with email {request.Email} or username {request.Username} found", request.Email, request.Username); + throw new UserNotFoundException($"No user with email {request.Email} or username {request.Username} found"); + } + + _logger.LogDebug("Returning account access data for user {user.Id}", user.Id); + return new AccountAccessDataResponse + { + UserId = user.Id, + Email = user.Email, + Password = user.Password, + Salt = user.Salt + }; + } + + public async Task BeginPasswordReset(BeginPasswordResetRequest request) + { + User user; + try + { + user = await _uow.Users.FindOneAsync(u => u.Email == request.Email); + _logger.LogDebug("Found user {user.Id} with email {request.Email}", user.Id, request.Email); + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with email {request.Email} found to start password reset procedure", request.Email); + throw new UserNotFoundException($"No user with email {request.Email} found"); + } + + ResetCode existingCode; + try + { + existingCode = await _uow.ResetCodes.FindOneAsync(rc => rc.UserId == user.Id); + _logger.LogDebug("Found existing reset code for user {user.Id}", user.Id); + + if (existingCode.ExpirationDate < DateTime.UtcNow) + { + _logger.LogDebug("Reset code for user {user.Id} expired, updating", user.Id); + + existingCode.ExpirationDate = DateTime.UtcNow.AddMinutes(10); + existingCode.Code = Guid.NewGuid().ToString(); + + using var transaction = _uow.BeginTransaction(); + try + { + _uow.ResetCodes.Update(existingCode); + transaction.SaveAndCommit(); + } + catch (Exception e) + { + _logger.LogError("Failed updating reset code for user {user.Id}. {e}", user.Id, e); + transaction.Rollback(); + throw; + } + } + else + { + _logger.LogDebug("Reset code for user {user.Id} not expired", user.Id); + throw new CodeHasNotExpiredException($"Reset code for user {user.Id} already exists and is not expired"); + } + } + catch (Exception e) when (e is not CodeHasNotExpiredException) + { + _logger.LogError("Failed retrieving/updating reset code for user {user.Id}", user.Id); + throw; + } + + // TODO: Send email + _logger.LogWarning("Mailing backend is not yet implemented, reset code {existingCode.Code} for user {user.Id} was not sent", existingCode.Code, user.Id); + + _logger.LogDebug("Reset code for user {user.Id} sent", user.Id); + + _logger.LogDebug("Replying with success"); + return new BeginPasswordResetResponse + { + IsSuccess = true + }; + } + + public async Task BeginRegistration(BeginRegistrationRequest request) + { + User user; + try + { + user = await _uow.Users.FindOneAsync(u => u.Email == request.Email || u.Username == request.Username); + _logger.LogDebug("Found user {user.Id} with email {request.Email} or username {request.Username}. Aborting registration", user.Id, request.Email, request.Username); + + if (user.Email == request.Email) + throw new UserExistsException($"User with email {request.Email} already exists"); + + if (user.Username == request.Username) + throw new UserExistsException($"User with username {request.Username} already exists"); + } + catch (NullReferenceException) + { + _logger.LogDebug("Email {request.Email} and username {request.Username} are not taken, proceeding with registration", request.Email, request.Username); + } + + // User creation + user = new User + { + Email = request.Email, + Username = request.Username, + Password = request.Password, + Salt = Guid.NewGuid().ToString() + }; + + try + { + await _uow.Users.AddAsync(user); + _logger.LogDebug("Inserted user {user.Id} with email {request.Email} and username {request.Username}", user.Id, request.Email, request.Username); + } + catch (Exception e) + { + _logger.LogError("Failed inserting user {user.Id} with email {request.Email} and username {request.Username}. {e}", user.Id, request.Email, request.Username, e); + throw; + } + + // Registration code creation + RegistrationCode regCode = new() + { + UserId = user.Id + }; + + using var transaction = _uow.BeginTransaction(); + try + { + await _uow.RegistrationCodes.AddAsync(regCode); + transaction.SaveAndCommit(); + _logger.LogDebug("Inserted registration code for user {user.Id}", user.Id); + } + catch (Exception e) + { + _logger.LogError("Failed inserting registration code for user {user.Id}. {e}", user.Id, e); + transaction.Rollback(); + throw; + } + + + // TODO: Send email + _logger.LogWarning("Mailing backend is not yet implemented, registration code for user {user.Id} was not sent", user.Id); + + _logger.LogDebug("Registration code for user {user.Id} sent", user.Id); + + _logger.LogDebug("Replying with success"); + return new BeginRegistrationResponse + { + IsSuccess = true + }; + } + + public async Task ChangePassword(ChangePasswordRequest request) + { + User user; + try + { + user = await _uow.Users.FindOneAsync(u => u.Id == request.UserId); + _logger.LogDebug("Found user {user.Id} with email {user.Email}", user.Id, user.Email); + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with id {userId} found to change password", request.UserId); + throw new UserNotFoundException($"No user with id {request.UserId} found"); + } + + // Verify old password + var inputOldPasswordHash = BcryptUtils.HashPassword(request.OldPassword); + if (!BcryptUtils.VerifyPassword(request.OldPassword, user.Password)) + { + _logger.LogDebug("Old password did not match for user {user.Id}", user.Id); + } + + // Update password + user.Password = BcryptUtils.HashPassword(request.NewPassword); + using var transaction = _uow.BeginTransaction(); + try + { + _uow.Users.Update(user); + transaction.SaveAndCommit(); + _logger.LogDebug("Updated password for user {user.Id}", user.Id); + } + catch (Exception e) + { + _logger.LogError("Failed updating password for user {user.Id}. {e}", user.Id, e); + transaction.Rollback(); + throw; + } + + // TODO: Ask AuthService and ApiGateway to recache information + _logger.LogWarning("Warning! UserService MUST notify AuthService and ApiGateway to recache information for user {user.Id}, but this feature is not yet implemented!", user.Id); + + // TODO: Send notification email + _logger.LogWarning("Mailing backend is not yet implemented, notification for user {user.Id} was not sent", user.Id); + + _logger.LogDebug("Replying with success"); + return new ChangePasswordResponse + { + IsSuccess = true + }; + } + + public async Task CompletePasswordReset(CompletePasswordResetRequest request) + { + User user; + ResetCode resetCode; + try + { + user = await _uow.Users.FindOneAsync(u => u.Email == request.Email); + resetCode = await _uow.ResetCodes.FindOneAsync(rc => rc.Code == request.Code); + _logger.LogDebug("Found user {user.Id} with email {request.Email} and respective resetCode {resetCode.Id}", user.Id, request.Email, resetCode.Id); + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with email {request.Email} or reset code {request.Code} found", request.Email, request.Code); + throw new InvalidCodeException($"Invalid email or code"); + } + + // Update password + user.Password = BcryptUtils.HashPassword(request.NewPassword); + user.Salt = Guid.NewGuid().ToString(); + + using var transaction = _uow.BeginTransaction(); + try + { + _uow.Users.Update(user); + _uow.ResetCodes.Delete(resetCode); + transaction.SaveAndCommit(); + + _logger.LogDebug("Updated password for user {user.Id}", user.Id); + } + catch (Exception e) + { + _logger.LogError("Failed updating password for user {user.Id}. {e}", user.Id, e); + transaction.Rollback(); + throw; + } + + + // TODO: Ask AuthService and ApiGateway to recache information + _logger.LogWarning("Warning! UserService MUST notify AuthService and ApiGateway to recache information for user {user.Id}, but this feature is not yet implemented!", user.Id); + + // TODO: Send notification email + _logger.LogWarning("Mailing backend is not yet implemented, notification for user {user.Id} was not sent", user.Id); + + _logger.LogDebug("Replying with success"); + return new CompletePasswordResetResponse + { + IsSuccess = true + }; + } + + public async Task CompleteRegistration(CompleteRegistrationRequest request) + { + User user; + RegistrationCode regCode; + + try + { + user = await _uow.Users.FindOneAsync(u => u.Email == request.Email); + + regCode = await _uow.RegistrationCodes.FindOneAsync(rc => rc.Code == request.RegistrationCode && rc.UserId == user.Id); + _logger.LogDebug("Found user {user.Id} with email {request.Email} and respective registrationCode {regCode.Id}", user.Id, request.Email, regCode.Id); + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with email {request.Email} or registration code {equest.RegistrationCode} found", request.Email, request.RegistrationCode); + throw new InvalidCodeException($"Invalid email or code"); + } + + using (var transaction = _uow.BeginTransaction()) + { + try + { + // Create meta + var meta = new Meta + { + UserId = user.Id, + Name = request.Name, + Surname = request.Surname, + Patronymic = request.Patronymic, + Birthday = request.Birthday, + Avatar = request.Avatar + }; + try + { + await _uow.Metas.AddAsync(meta); + _logger.LogDebug("Created meta for user {user.Id}", user.Id); + } + catch (Exception e) + { + _logger.LogError("Failed creating meta for user {user.Id}. {e}", user.Id, e); + throw; + } + + // Create personal data + var personalData = new PersonalData + { + UserId = user.Id + }; + try + { + await _uow.PersonalDatas.AddAsync(personalData); + _logger.LogDebug("Created personal data for user {user.Id}", user.Id); + } + catch (Exception e) + { + _logger.LogError("Failed creating personal data for user {user.Id}. {e}", user.Id, e); + throw; + } + + user.IsActivated = true; + try + { + _uow.Users.Update(user); + _uow.RegistrationCodes.Delete(regCode); + _logger.LogDebug("Updated user {user.Id}", user.Id); + } + catch (Exception e) + { + _logger.LogError("Failed activating user {user.Id}. {e}", user.Id, e); + throw; + } + + transaction.SaveAndCommit(); + } + catch (Exception e) + { + _logger.LogError("Failed completing registration for user {user.Id}. {e}", user.Id, e); + transaction.Rollback(); + throw; + } + } + + return new CompleteRegistrationResponse + { + IsSuccess = true + }; + } + + public async Task GetUser(long userId, GetUserRequest request) + { + User user; + Meta profile; + try + { + user = await _uow.Users.FindOneAsync(u => u.Id == userId); + profile = await _uow.Metas.FindOneAsync(p => p.UserId == userId); + _logger.LogDebug("Found user {user.Id} with profile {profile.Id}", user.Id, profile.Id); + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with id {userId} found", userId); + throw new UserNotFoundException($"No user with id {userId} found"); + } + return new GetUserResponse + { + Id = user.Id, + Username = user.Username, + Avatar = profile.Avatar, + }; + } + + public async Task ResendPasswordResetCode(ResendPasswordResetCodeRequest request) + { + User user; + ResetCode resetCode; + try + { + user = await _uow.Users.FindOneAsync(u => u.Email == request.Email); + resetCode = await _uow.ResetCodes.FindOneAsync(rc => rc.UserId == user.Id); + _logger.LogDebug("Found reset code {resetCode.Id} for user {user.Id}", resetCode.Id, user.Id); + + if (resetCode.ExpirationDate > DateTime.Now) + { + // Code is not yet expired + _logger.LogDebug("Reset code for user {user.Id} is not yet expired", user.Id); + throw new CodeHasNotExpiredException("Please, wait at least 10 minutes."); + } + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with email {request.Email} found", request.Email); + throw new UserNotFoundException($"No user with email {request.Email} found"); + } + + using var transaction = _uow.BeginTransaction(); + try + { + // Regenerate code + resetCode.Code = Guid.NewGuid().ToString(); + resetCode.ExpirationDate = DateTime.UtcNow.AddMinutes(10); + _uow.ResetCodes.Update(resetCode); + transaction.SaveAndCommit(); + _logger.LogDebug("Updated reset code {resetCode.Id} for user {user.Id}", resetCode.Id, user.Id); + + // TODO: Send email + _logger.LogWarning("Mail service is not implemented, skipping sending password reset email with code {resetCode.Code} to user {user.Id}", resetCode.Code, user.Id); + + return new ResendPasswordResetCodeResponse + { + IsSuccess = true + }; + } + catch (Exception e) + { + transaction.Rollback(); + _logger.LogError("Failed resending password reset code for user {user.Id}. {e}", user.Id, e); + + throw; + } + } + + public async Task ResendRegistrationCode(ResendRegistrationCodeRequest request) + { + User user; + RegistrationCode regCode; + try + { + user = await _uow.Users.FindOneAsync(u => u.Email == request.Email); + regCode = await _uow.RegistrationCodes.FindOneAsync(rc => rc.UserId == user.Id); + _logger.LogDebug("Found registration code {regCode.Id} for user {user.Id}", regCode.Id, user.Id); + + if (regCode.ExpirationDate > DateTime.Now.AddMinutes(9)) + { + // Code is not yet expired + _logger.LogDebug("Registration code for user {user.Id} is not yet expired", user.Id); + throw new CodeHasNotExpiredException("Please, wait at least 1 minute."); + } + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with email {request.Email} found", request.Email); + throw new UserNotFoundException($"No user with email {request.Email} found"); + } + + using var transaction = _uow.BeginTransaction(); + try + { + // Regenerate code + // FIXME: Match the six-character registration code template + regCode.Code = Guid.NewGuid().ToString(); + regCode.ExpirationDate = DateTime.UtcNow.AddMinutes(10); + _uow.RegistrationCodes.Update(regCode); + transaction.SaveAndCommit(); + _logger.LogDebug("Updated registration code {regCode.Id} for user {user.Id}", regCode.Id, user.Id); + + // TODO: Send email + _logger.LogWarning("Mail service is not implemented, skipping sending registration email with code {regCode.Code} to user {user.Id}", regCode.Code, user.Id); + + return new ResendRegistrationCodeResponse + { + IsSuccess = true + }; + } + catch (Exception e) + { + transaction.Rollback(); + _logger.LogError("Failed resending registration code for user {user.Id}. {e}", user.Id, e); + + throw; + } + } + + public async Task VerifyPasswordResetCode(VerifyPasswordResetCodeRequest request) + { + try + { + User user = await _uow.Users.FindOneAsync(u => u.Email == request.Email); + ResetCode resetCode = await _uow.ResetCodes.FindOneAsync(rc => rc.Code == request.Code); + _logger.LogDebug("Found reset code {resetCode.Id} for user {user.Id}", resetCode.Id, user.Id); + + return new VerifyPasswordResetCodeResponse + { + IsSuccess = true + }; + } + catch (NullReferenceException) + { + _logger.LogDebug("No reset code {request.Code} found for user {request.Email}", request.Code, request.Email); + throw new InvalidCodeException($"Invalid code"); + } + } + + public async Task VerifyRegistrationCode(VerifyRegistrationCodeRequest request) + { + try + { + User user = await _uow.Users.FindOneAsync(u => u.Email == request.Email); + RegistrationCode regCode = await _uow.RegistrationCodes.FindOneAsync(rc => rc.Code == request.Code); + _logger.LogDebug("Found reset code {regCode.Id} for user {user.Id}", regCode.Id, user.Id); + + return new VerifyRegistrationCodeResponse + { + IsSuccess = true + }; + } + catch (NullReferenceException) + { + _logger.LogDebug("No registration code {request.Code} found for user {request.Email}", request.Code, request.Email); + throw new InvalidCodeException($"Invalid code"); + } + } +} \ No newline at end of file diff --git a/UserService/Services/Account/IAccountService.cs b/UserService/Services/Account/IAccountService.cs new file mode 100644 index 0000000..316f80a --- /dev/null +++ b/UserService/Services/Account/IAccountService.cs @@ -0,0 +1,75 @@ +using UserService.Models.Account.Requests; +using UserService.Models.Account.Responses; + +namespace UserService.Services.Account; + +/// +/// IAccountService ответственен за работу с аккаунтом +/// пользователя. +/// +public interface IAccountService +{ + /// + /// Выполняет регистрацию нового пользователя и отправляет + /// код подтверждения на почту. + /// + Task BeginRegistration(BeginRegistrationRequest request); + + /// + /// Позволяет завершить регистрацию пользователя и активировать + /// его аккаунт. + /// + Task CompleteRegistration(CompleteRegistrationRequest request); + + /// + /// Позволяет проверить, является ли код подтверждения для + /// регистрации пользователя актуальным. + /// + Task VerifyRegistrationCode(VerifyRegistrationCodeRequest request); + + /// + /// Позволяет переотправить код подтверждения, если он был + /// утерян или его срок действия истек. + /// + Task ResendRegistrationCode(ResendRegistrationCodeRequest request); + + /// + /// Позволяет начать сброс пароля пользователя. Отправляет + /// код сброса пароля на почту пользователя. + /// + Task BeginPasswordReset(BeginPasswordResetRequest request); + + /// + /// Позволяет завершить сброс пароля пользователя. + /// + Task CompletePasswordReset(CompletePasswordResetRequest request); + + /// + /// Позволяет проверить, является ли код сброса пароля актуальным. + /// + Task VerifyPasswordResetCode(VerifyPasswordResetCodeRequest request); + + /// + /// Позволяет переотправить код сброса пароля, если он был утерян + /// или его срок действия истек. + /// + Task ResendPasswordResetCode(ResendPasswordResetCodeRequest request); + + /// + /// Позволяет изменить пароль пользователя. Отправляет сообщение + /// об изменении пароля на почту пользователя, а также + /// оповещает сервисы AuthService и ApiGateway об изменении. + /// + Task ChangePassword(ChangePasswordRequest request); + + /// + /// Позволяет получить данные для входа в аккаунт. Предназначен + /// только для использования в микросервисе AuthService. + /// + Task AccountAccessData(AccountAccessDataRequest request); + + /// + /// Позволяет получить данные о пользователе. + /// + Task GetUser(long userId, GetUserRequest request); +} \ No newline at end of file diff --git a/UserService/Services/Profile/IProfileService.cs b/UserService/Services/Profile/IProfileService.cs new file mode 100644 index 0000000..65d6f09 --- /dev/null +++ b/UserService/Services/Profile/IProfileService.cs @@ -0,0 +1,12 @@ +using UserService.Models.Profile.Requests; +using UserService.Models.Profile.Responses; + +namespace UserService.Services.Profile; + +public interface IProfileService +{ + Task GetMyProfile(long userId); + Task UpdateProfile(long userId, UpdateProfileRequest request); + Task UploadAvatar(long userId, UploadAvatarRequest request); + Task GetUsernameAndAvatar(long userId); +} \ No newline at end of file diff --git a/UserService/Services/Profile/ProfileService.cs b/UserService/Services/Profile/ProfileService.cs new file mode 100644 index 0000000..5e2973b --- /dev/null +++ b/UserService/Services/Profile/ProfileService.cs @@ -0,0 +1,115 @@ +using UserService.Database.Models; +using UserService.Exceptions.Account; +using UserService.Models.Profile.Requests; +using UserService.Models.Profile.Responses; +using UserService.Repositories; + +namespace UserService.Services.Profile; + +public class ProfileService(UnitOfWork unitOfWork, ILogger logger) : IProfileService +{ + private readonly UnitOfWork _uow = unitOfWork; + private readonly ILogger _logger = logger; + + public async Task GetMyProfile(long userId) + { + Meta profile; + try + { + profile = await _uow.Metas.FindOneAsync(p => p.UserId == userId); + _logger.LogDebug("Found profile for user {userId}", userId); + } + catch (Exception e) + { + _logger.LogDebug("Failed to acquire profile for user {userId}", userId); + + try + { + User user = await _uow.Users.FindOneAsync(u => u.Id == userId); + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with id {userId} found", userId); + throw new UserNotFoundException($"No profile for user {userId} found"); + } + throw; + } + return (GetProfileResponse)profile; + } + + public async Task GetUsernameAndAvatar(long userId) + { + User user; + Meta profile; + try + { + user = await _uow.Users.FindOneAsync(u => u.Id == userId); + profile = await _uow.Metas.FindOneAsync(p => p.UserId == userId); + _logger.LogDebug("Found profile for user {userId}", userId); + + return new GetUsernameAndAvatarResponse + { + Username = user.Username, + Avatar = profile.Avatar + }; + } + catch (Exception e) + { + _logger.LogDebug("Failed to acquire profile for user {userId}", userId); + try + { + user = await _uow.Users.FindOneAsync(u => u.Id == userId); + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with id {userId} found", userId); + throw new UserNotFoundException($"No profile for user {userId} found"); + } + throw; + } + } + + public async Task UpdateProfile(long userId, UpdateProfileRequest request) + { + Meta profile; + using var transaction = _uow.BeginTransaction(); + try + { + profile = await _uow.Metas.FindOneAsync(p => p.UserId == userId); + _logger.LogDebug("Found profile for user {userId}", userId); + + profile.Name = request.Name ?? profile.Name; + profile.Surname = request.Surname ?? profile.Surname; + profile.Patronymic = request.Patronymic ?? profile.Patronymic; + profile.Birthday = request.Birthday ?? profile.Birthday; + profile.Avatar = request.Avatar ?? profile.Avatar; + + transaction.SaveAndCommit(); + + _logger.LogDebug("Updated profile for user {userId}", userId); + } + catch (Exception e) + { + _logger.LogDebug("Failed to acquire profile for user {userId}", userId); + transaction.Rollback(); + + try + { + User user = await _uow.Users.FindOneAsync(u => u.Id == userId); + } + catch (NullReferenceException) + { + _logger.LogDebug("No user with id {userId} found", userId); + throw new UserNotFoundException($"No profile for user {userId} found"); + } + throw; + } + return (UpdateProfileResponse)profile; + } + + // TODO: Ereshkigal wants to implement this + public async Task UploadAvatar(long userId, UploadAvatarRequest request) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/UserService/UserService.csproj b/UserService/UserService.csproj new file mode 100644 index 0000000..394732a --- /dev/null +++ b/UserService/UserService.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UserService/UserService.http b/UserService/UserService.http new file mode 100644 index 0000000..8ecbd65 --- /dev/null +++ b/UserService/UserService.http @@ -0,0 +1,3 @@ +@UserService_HostAddress = http://localhost:5260 + +### diff --git a/UserService/UserService.sln b/UserService/UserService.sln new file mode 100644 index 0000000..3cb8a7b --- /dev/null +++ b/UserService/UserService.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UserService", "UserService.csproj", "{4C576833-E295-4851-B740-3C60A34F9E3F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4C576833-E295-4851-B740-3C60A34F9E3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C576833-E295-4851-B740-3C60A34F9E3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C576833-E295-4851-B740-3C60A34F9E3F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C576833-E295-4851-B740-3C60A34F9E3F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D9AAE3D2-BDEC-4911-BF33-DCD1EF82C721} + EndGlobalSection +EndGlobal diff --git a/UserService/Utils/BcryptUtils.cs b/UserService/Utils/BcryptUtils.cs new file mode 100644 index 0000000..837a17c --- /dev/null +++ b/UserService/Utils/BcryptUtils.cs @@ -0,0 +1,27 @@ +namespace UserService.Utils; + +public static class BcryptUtils +{ + public static string HashPassword(string password) + { + // Generate a salt + string salt = BCrypt.Net.BCrypt.GenerateSalt(); + + // Hash the password with the salt and a work factor of 10 + string hashedPassword = BCrypt.Net.BCrypt.HashPassword(password, salt); + + return hashedPassword; + } + + public static bool VerifyPassword(string password, string hashedPassword) + { + try { + // Check if the provided password matches the hashed password + return BCrypt.Net.BCrypt.Verify(password, hashedPassword); + } + catch (Exception) + { + return false; + } + } +} \ No newline at end of file diff --git a/UserService/Utils/Logging.cs b/UserService/Utils/Logging.cs new file mode 100644 index 0000000..784dcef --- /dev/null +++ b/UserService/Utils/Logging.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using Serilog; +using Serilog.Exceptions; +using Serilog.Sinks.OpenSearch; + +namespace UserService.Utils; + +public static class Logging +{ + static OpenSearchSinkOptions _configureOpenSearchSink(IConfiguration configuration,string environment){ + return new OpenSearchSinkOptions(new Uri(configuration["OpenSearchConfiguration:Uri"]!)) + { + AutoRegisterTemplate = true, + IndexFormat = $"{Assembly.GetExecutingAssembly().GetName().Name!.ToLower().Replace(".","-")}-{environment.ToLower()}-{DateTime.UtcNow:yyyy-MM-DD}", + NumberOfReplicas =1, + NumberOfShards = 1 + }; + } + + public static void configureLogging(){ + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json",optional:false,reloadOnChange:true).Build(); + Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .Enrich.WithExceptionDetails() + .WriteTo.Debug() + .WriteTo.Console() + .WriteTo.OpenSearch(_configureOpenSearchSink(configuration,environment)) + .Enrich.WithProperty("Environment",environment) + .ReadFrom.Configuration(configuration) + .CreateLogger(); + } +} \ No newline at end of file diff --git a/UserService/appsettings.json b/UserService/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/UserService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}