diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
new file mode 100644
index 0000000..70cedb5
--- /dev/null
+++ b/.github/workflows/dotnet.yml
@@ -0,0 +1,33 @@
+name: .NET
+
+on:
+ push:
+ branches: [ master, develop ]
+ pull_request:
+ branches: [ master, develop ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+ - name: Build
+ run: dotnet build
+
+ tests:
+ needs: build
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Test WeatherApp
+ run: dotnet test ./WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/WeatherApp.Tests.csproj --verbosity normal
+
+ - name: Test DatabaseApp
+ run: dotnet test ./WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/DatabaseApp.Tests.csproj --verbosity normal
diff --git a/.gitignore b/.gitignore
index 9491a2f..e728e7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -360,4 +360,7 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
-FodyWeavers.xsd
\ No newline at end of file
+FodyWeavers.xsd
+
+# idea
+.idea/
\ No newline at end of file
diff --git a/.idea/.idea.weather-bot-api/.idea/.gitignore b/.idea/.idea.weather-bot-api/.idea/.gitignore
deleted file mode 100644
index 53231e7..0000000
--- a/.idea/.idea.weather-bot-api/.idea/.gitignore
+++ /dev/null
@@ -1,15 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Rider ignored files
-/contentModel.xml
-/projectSettingsUpdater.xml
-/.idea.weather-bot-api.iml
-/modules.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
-# GitHub Copilot persisted chat sessions
-/copilot/chatSessions
diff --git a/.idea/.idea.weather-bot-api/.idea/encodings.xml b/.idea/.idea.weather-bot-api/.idea/encodings.xml
deleted file mode 100644
index df87cf9..0000000
--- a/.idea/.idea.weather-bot-api/.idea/encodings.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.weather-bot-api/.idea/indexLayout.xml b/.idea/.idea.weather-bot-api/.idea/indexLayout.xml
deleted file mode 100644
index 7b08163..0000000
--- a/.idea/.idea.weather-bot-api/.idea/indexLayout.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.weather-bot-api/.idea/vcs.xml b/.idea/.idea.weather-bot-api/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/.idea.weather-bot-api/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Common/Behaviors/RequestLoggingBehavior.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Common/Behaviors/RequestLoggingBehavior.cs
new file mode 100644
index 0000000..d1ad95b
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Common/Behaviors/RequestLoggingBehavior.cs
@@ -0,0 +1,36 @@
+using FluentResults;
+using MediatR;
+using Microsoft.Extensions.Logging;
+
+namespace DatabaseApp.Application.Common.Behaviors;
+
+// ReSharper disable once UnusedType.Global
+public class RequestLoggingBehavior(ILogger logger)
+ : IPipelineBehavior
+ where TRequest : notnull
+ where TResponse : ResultBase
+{
+ public async Task Handle(TRequest request, RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ var requestName = typeof(TRequest).Name;
+
+ logger.LogInformation("Handling {RequestName}: {@Request}", requestName, request);
+
+ var response = await next();
+
+ if (response.IsSuccess)
+ {
+ logger.LogInformation("{Request} handled successfully", requestName);
+ }
+ else
+ {
+ foreach (var error in response.Errors)
+ {
+ logger.LogError("Error: {Error}", error.Message);
+ }
+ }
+
+ return response;
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Common/Behaviors/ValidationBehavior.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Common/Behaviors/ValidationBehavior.cs
new file mode 100644
index 0000000..d3bee2d
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Common/Behaviors/ValidationBehavior.cs
@@ -0,0 +1,23 @@
+using FluentValidation;
+using MediatR;
+
+namespace DatabaseApp.Application.Common.Behaviors;
+
+public class ValidationBehavior(IEnumerable> validators)
+ : IPipelineBehavior where TRequest : IRequest
+{
+ public async Task Handle(TRequest request, RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ var context = new ValidationContext(request);
+ var failures = validators
+ .Select(v => v.Validate(context))
+ .SelectMany(result => result.Errors)
+ .Where(failure => failure != null)
+ .ToList();
+
+ if (failures.Count != 0) throw new ValidationException(failures);
+
+ return await next();
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Common/Mapping/RegisterMapper.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Common/Mapping/RegisterMapper.cs
new file mode 100644
index 0000000..29455dc
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Common/Mapping/RegisterMapper.cs
@@ -0,0 +1,22 @@
+using DatabaseApp.Application.Users;
+using DatabaseApp.Application.UserWeatherSubscriptions;
+using DatabaseApp.Domain.Models;
+using Mapster;
+
+namespace DatabaseApp.Application.Common.Mapping;
+
+public class RegisterMapper : IRegister
+{
+ public void Register(TypeAdapterConfig config)
+ {
+ config.NewConfig()
+ .Map(dest => dest.Location, src => src.Location.Value)
+ .Map(dest => dest.ResendInterval, src => src.ResendInterval)
+ .Map(dest => dest.UserTelegramId, src => src.User.TelegramId);
+ config.NewConfig()
+ .Map(dest => dest.TelegramId, src => src.TelegramId)
+ .Map(dest => dest.MobileNumber, src => src.Metadata.MobileNumber)
+ .Map(dest => dest.Username, src => src.Metadata.Username)
+ .Map(dest => dest.RegisteredAt, src => src.RegisteredAt);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/DatabaseApp.Application.csproj b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/DatabaseApp.Application.csproj
new file mode 100644
index 0000000..4775b7e
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/DatabaseApp.Application.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/DependencyInjection.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/DependencyInjection.cs
new file mode 100644
index 0000000..e0693b7
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/DependencyInjection.cs
@@ -0,0 +1,32 @@
+using System.Reflection;
+using DatabaseApp.Application.Common.Behaviors;
+using DatabaseApp.Application.Common.Mapping;
+using DatabaseApp.Application.Users.Queries.GetAllUsers;
+using FluentValidation;
+using Mapster;
+using MapsterMapper;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DatabaseApp.Application;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddApplication(this IServiceCollection services)
+ {
+ var config = new TypeAdapterConfig();
+ new RegisterMapper().Register(config);
+
+ services.AddSingleton(config);
+ services.AddScoped();
+ services.AddMediatR(cfg =>
+ {
+ cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
+ cfg.AddOpenBehavior(typeof(RequestLoggingBehavior<,>));
+ cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
+ });
+ services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/CreateUserWeatherSubscription/CreateUserWeatherSubscriptionCommand.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/CreateUserWeatherSubscription/CreateUserWeatherSubscriptionCommand.cs
new file mode 100644
index 0000000..66f5b11
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/CreateUserWeatherSubscription/CreateUserWeatherSubscriptionCommand.cs
@@ -0,0 +1,11 @@
+using FluentResults;
+using MediatR;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Commands.CreateUserWeatherSubscription;
+
+public class CreateUserWeatherSubscriptionCommand : IRequest
+{
+ public long TelegramUserId { get; init; }
+ public required string Location { get; init; }
+ public TimeSpan ResendInterval { get; init; }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/CreateUserWeatherSubscription/CreateUserWeatherSubscriptionCommandHandler.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/CreateUserWeatherSubscription/CreateUserWeatherSubscriptionCommandHandler.cs
new file mode 100644
index 0000000..a0511f0
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/CreateUserWeatherSubscription/CreateUserWeatherSubscriptionCommandHandler.cs
@@ -0,0 +1,39 @@
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Domain.Repositories;
+using FluentResults;
+using MediatR;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Commands.CreateUserWeatherSubscription;
+
+// ReSharper disable once UnusedType.Global
+public class CreateUserWeatherSubscriptionCommandHandler(IUnitOfWork unitOfWork)
+ : IRequestHandler
+{
+ public async Task Handle(CreateUserWeatherSubscriptionCommand request, CancellationToken cancellationToken)
+ {
+ var location = Location.Create(request.Location);
+
+ if (location.IsFailed) return location.ToResult();
+
+ var existingSubscription = await unitOfWork.UserWeatherSubscriptionRepository
+ .GetByUserTelegramIdAndLocationAsync(request.TelegramUserId, location.Value, cancellationToken);
+
+ if (existingSubscription != null) return Result.Fail(new Error("Weather subscription already exists"));
+
+ var user = await unitOfWork.UserRepository.GetByTelegramIdAsync(request.TelegramUserId, cancellationToken);
+
+ if (user == null) return Result.Fail(new Error("User not found"));
+
+ var weatherSubscription = new UserWeatherSubscription
+ {
+ UserId = user.Id,
+ Location = location.Value,
+ ResendInterval = request.ResendInterval
+ };
+
+ await unitOfWork.UserWeatherSubscriptionRepository.AddAsync(weatherSubscription, cancellationToken);
+ await unitOfWork.SaveDbChangesAsync(cancellationToken);
+
+ return Result.Ok();
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/CreateUserWeatherSubscription/CreateUserWeatherSubscriptionCommandValidator.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/CreateUserWeatherSubscription/CreateUserWeatherSubscriptionCommandValidator.cs
new file mode 100644
index 0000000..6250ee8
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/CreateUserWeatherSubscription/CreateUserWeatherSubscriptionCommandValidator.cs
@@ -0,0 +1,14 @@
+using FluentValidation;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Commands.CreateUserWeatherSubscription;
+
+// ReSharper disable once UnusedType.Global
+public class CreateUserWeatherSubscriptionCommandValidator : AbstractValidator
+{
+ public CreateUserWeatherSubscriptionCommandValidator()
+ {
+ RuleFor(x => x.TelegramUserId).GreaterThan(0);
+ RuleFor(x => x.Location).NotNull().NotEmpty();
+ RuleFor(x => x.ResendInterval).GreaterThan(TimeSpan.Zero);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/DeleteUserWeatherSubscription/DeleteUserWeatherSubscriptionCommand.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/DeleteUserWeatherSubscription/DeleteUserWeatherSubscriptionCommand.cs
new file mode 100644
index 0000000..bc6c8fb
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/DeleteUserWeatherSubscription/DeleteUserWeatherSubscriptionCommand.cs
@@ -0,0 +1,10 @@
+using FluentResults;
+using MediatR;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Commands.DeleteUserWeatherSubscription;
+
+public class DeleteUserWeatherSubscriptionCommand : IRequest
+{
+ public long UserTelegramId { get; init; }
+ public required string Location { get; init; }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/DeleteUserWeatherSubscription/DeleteUserWeatherSubscriptionCommandHandler.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/DeleteUserWeatherSubscription/DeleteUserWeatherSubscriptionCommandHandler.cs
new file mode 100644
index 0000000..464ab56
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/DeleteUserWeatherSubscription/DeleteUserWeatherSubscriptionCommandHandler.cs
@@ -0,0 +1,30 @@
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Domain.Repositories;
+using FluentResults;
+using MediatR;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Commands.DeleteUserWeatherSubscription;
+
+// ReSharper disable once UnusedType.Global
+public class DeleteUserWeatherSubscriptionCommandHandler(IWeatherSubscriptionRepository repository)
+ : IRequestHandler
+{
+ // ReSharper disable once UnusedMember.Global
+ public async Task Handle(DeleteUserWeatherSubscriptionCommand request, CancellationToken cancellationToken)
+ {
+ var location = Location.Create(request.Location);
+
+ if (location.IsFailed) return location.ToResult();
+
+ var subscription =
+ await repository.GetByUserTelegramIdAndLocationAsync(request.UserTelegramId, location.Value,
+ cancellationToken);
+
+ if (subscription == null) return Result.Fail(new Error("Subscription not found"));
+
+ repository.Delete(subscription);
+ await repository.SaveDbChangesAsync(cancellationToken);
+
+ return Result.Ok();
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/DeleteUserWeatherSubscription/DeleteUserWeatherSubscriptionCommandValidator.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/DeleteUserWeatherSubscription/DeleteUserWeatherSubscriptionCommandValidator.cs
new file mode 100644
index 0000000..10073aa
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/DeleteUserWeatherSubscription/DeleteUserWeatherSubscriptionCommandValidator.cs
@@ -0,0 +1,13 @@
+using FluentValidation;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Commands.DeleteUserWeatherSubscription;
+
+// ReSharper disable once UnusedType.Global
+public class DeleteUserWeatherSubscriptionCommandValidator : AbstractValidator
+{
+ public DeleteUserWeatherSubscriptionCommandValidator()
+ {
+ RuleFor(x => x.UserTelegramId).GreaterThan(0);
+ RuleFor(x => x.Location).NotNull().NotEmpty();
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/UpdateUserWeatherSubscription/UpdateUserWeatherSubscriptionCommand.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/UpdateUserWeatherSubscription/UpdateUserWeatherSubscriptionCommand.cs
new file mode 100644
index 0000000..56a7892
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/UpdateUserWeatherSubscription/UpdateUserWeatherSubscriptionCommand.cs
@@ -0,0 +1,11 @@
+using FluentResults;
+using MediatR;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Commands.UpdateUserWeatherSubscription;
+
+public class UpdateUserWeatherSubscriptionCommand : IRequest
+{
+ public long UserTelegramId { get; init; }
+ public required string Location { get; init; }
+ public TimeSpan ResendInterval { get; init; }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/UpdateUserWeatherSubscription/UpdateUserWeatherSubscriptionCommandHandler.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/UpdateUserWeatherSubscription/UpdateUserWeatherSubscriptionCommandHandler.cs
new file mode 100644
index 0000000..5e01457
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/UpdateUserWeatherSubscription/UpdateUserWeatherSubscriptionCommandHandler.cs
@@ -0,0 +1,36 @@
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Domain.Repositories;
+using FluentResults;
+using MediatR;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Commands.UpdateUserWeatherSubscription;
+
+// ReSharper disable once UnusedType.Global
+public class UpdateUserWeatherSubscriptionCommandHandler(IUnitOfWork unitOfWork)
+ : IRequestHandler
+{
+ public async Task Handle(UpdateUserWeatherSubscriptionCommand request, CancellationToken cancellationToken)
+ {
+ var location = Location.Create(request.Location);
+
+ if (location.IsFailed) return location.ToResult();
+
+ var user = await unitOfWork.UserRepository.GetByTelegramIdAsync(request.UserTelegramId, cancellationToken);
+
+ if (user == null) return Result.Fail(new Error("User not found"));
+
+ var subscription =
+ await unitOfWork.UserWeatherSubscriptionRepository.GetByUserTelegramIdAndLocationAsync(
+ request.UserTelegramId, location.Value, cancellationToken);
+
+ if (subscription == null) return Result.Fail(new Error("Subscription not found"));
+
+ subscription.Location = location.Value;
+ subscription.ResendInterval = request.ResendInterval;
+
+ unitOfWork.UserWeatherSubscriptionRepository.Update(subscription);
+ await unitOfWork.SaveDbChangesAsync(cancellationToken);
+
+ return Result.Ok();
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/UpdateUserWeatherSubscription/UpdateUserWeatherSubscriptionCommandValidator.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/UpdateUserWeatherSubscription/UpdateUserWeatherSubscriptionCommandValidator.cs
new file mode 100644
index 0000000..669cf11
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Commands/UpdateUserWeatherSubscription/UpdateUserWeatherSubscriptionCommandValidator.cs
@@ -0,0 +1,13 @@
+using FluentValidation;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Commands.UpdateUserWeatherSubscription;
+
+public class UpdateUserWeatherSubscriptionCommandValidator : AbstractValidator
+{
+ public UpdateUserWeatherSubscriptionCommandValidator()
+ {
+ RuleFor(x => x.UserTelegramId).GreaterThan(0);
+ RuleFor(x => x.Location).NotNull().NotEmpty();
+ RuleFor(x => x.ResendInterval).GreaterThan(TimeSpan.Zero);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Queries/GetWeatherSubscriptions/GetUserWeatherSubscriptionQueryValidator.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Queries/GetWeatherSubscriptions/GetUserWeatherSubscriptionQueryValidator.cs
new file mode 100644
index 0000000..a83b17e
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Queries/GetWeatherSubscriptions/GetUserWeatherSubscriptionQueryValidator.cs
@@ -0,0 +1,8 @@
+using FluentValidation;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Queries.GetWeatherSubscriptions;
+
+public class GetUserWeatherSubscriptionsQueryValidator : AbstractValidator
+{
+ public GetUserWeatherSubscriptionsQueryValidator() => RuleFor(x => x.UserTelegramId).GreaterThan(0);
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Queries/GetWeatherSubscriptions/GetUserWeatherSubscriptionsQuery.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Queries/GetWeatherSubscriptions/GetUserWeatherSubscriptionsQuery.cs
new file mode 100644
index 0000000..edf77a1
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Queries/GetWeatherSubscriptions/GetUserWeatherSubscriptionsQuery.cs
@@ -0,0 +1,8 @@
+using MediatR;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Queries.GetWeatherSubscriptions;
+
+public class GetUserWeatherSubscriptionsQuery : IRequest>
+{
+ public long UserTelegramId { get; init; }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Queries/GetWeatherSubscriptions/GetUserWeatherSubscriptionsQueryHandler.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Queries/GetWeatherSubscriptions/GetUserWeatherSubscriptionsQueryHandler.cs
new file mode 100644
index 0000000..0e03915
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/Queries/GetWeatherSubscriptions/GetUserWeatherSubscriptionsQueryHandler.cs
@@ -0,0 +1,16 @@
+using DatabaseApp.Domain.Repositories;
+using MapsterMapper;
+using MediatR;
+
+namespace DatabaseApp.Application.UserWeatherSubscriptions.Queries.GetWeatherSubscriptions;
+
+// ReSharper disable once UnusedType.Global
+public class GetUserWeatherSubscriptionsQueryHandler(IUnitOfWork unitOfWork, IMapper mapper)
+ : IRequestHandler>
+{
+ public async Task> Handle(GetUserWeatherSubscriptionsQuery request,
+ CancellationToken cancellationToken) =>
+ mapper.From(
+ await unitOfWork.UserWeatherSubscriptionRepository.GetAllByUserTelegramId(request.UserTelegramId, cancellationToken))
+ .AdaptToType>();
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/UserWeatherSubscriptionDto.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/UserWeatherSubscriptionDto.cs
new file mode 100644
index 0000000..38f69c2
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/UserWeatherSubscriptions/UserWeatherSubscriptionDto.cs
@@ -0,0 +1,10 @@
+namespace DatabaseApp.Application.UserWeatherSubscriptions;
+
+// ReSharper disable once ClassNeverInstantiated.Global
+public class UserWeatherSubscriptionDto
+{
+ // ReSharper disable once UnusedAutoPropertyAccessor.Global
+ public required string Location { get; set; }
+ public int UserTelegramId { get; set; }
+ public TimeSpan ResendInterval { get; set; }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Commands/CreateUser/CreateUserCommand.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Commands/CreateUser/CreateUserCommand.cs
new file mode 100644
index 0000000..4bfc0ef
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Commands/CreateUser/CreateUserCommand.cs
@@ -0,0 +1,12 @@
+using FluentResults;
+using MediatR;
+
+namespace DatabaseApp.Application.Users.Commands.CreateUser;
+
+public class CreateUserCommand : IRequest>
+{
+ public required long TelegramId { get; init; }
+ public required string Username { get; init; }
+ public required string MobileNumber { get; init; }
+ public required DateTime RegisteredAt { get; init; }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Commands/CreateUser/CreateUserCommandHandler.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Commands/CreateUser/CreateUserCommandHandler.cs
new file mode 100644
index 0000000..962c629
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Commands/CreateUser/CreateUserCommandHandler.cs
@@ -0,0 +1,36 @@
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Domain.Repositories;
+using FluentResults;
+using MediatR;
+
+namespace DatabaseApp.Application.Users.Commands.CreateUser;
+
+// ReSharper disable once UnusedType.Global
+public class CreateUserCommandHandler(IUserRepository repository) : IRequestHandler>
+{
+ public async Task> Handle(CreateUserCommand request, CancellationToken cancellationToken)
+ {
+ var existingUser = await repository.GetByTelegramIdAsync(request.TelegramId, cancellationToken);
+
+ if (existingUser != null)
+ {
+ return Result.Fail("User already exists");
+ }
+
+ var userMetadata = UserMetadata.Create(request.Username, request.MobileNumber);
+
+ if (userMetadata.IsFailed) return userMetadata.ToResult();
+
+ var user = new User
+ {
+ TelegramId = request.TelegramId,
+ Metadata = userMetadata.Value,
+ RegisteredAt = request.RegisteredAt
+ };
+
+ await repository.AddAsync(user, cancellationToken);
+ await repository.SaveDbChangesAsync(cancellationToken);
+
+ return user.TelegramId;
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Commands/CreateUser/CreateUserCommandValidator.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Commands/CreateUser/CreateUserCommandValidator.cs
new file mode 100644
index 0000000..0dddbe2
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Commands/CreateUser/CreateUserCommandValidator.cs
@@ -0,0 +1,16 @@
+using DatabaseApp.Domain.Models;
+using FluentValidation;
+
+namespace DatabaseApp.Application.Users.Commands.CreateUser;
+
+// ReSharper disable once UnusedType.Global
+public class CreateUserCommandValidator : AbstractValidator
+{
+ public CreateUserCommandValidator()
+ {
+ RuleFor(x => x.TelegramId).GreaterThan(0);
+ RuleFor(x => x.Username).NotEmpty().MaximumLength(UserMetadata.MaxUsernameLength);
+ RuleFor(x => x.MobileNumber).NotEmpty();
+ RuleFor(x => x.RegisteredAt).NotEmpty();
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetAllUsers/GetAllUsersQuery.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetAllUsers/GetAllUsersQuery.cs
new file mode 100644
index 0000000..d67d535
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetAllUsers/GetAllUsersQuery.cs
@@ -0,0 +1,6 @@
+using DatabaseApp.Application.Users.Queries.GetUser;
+using MediatR;
+
+namespace DatabaseApp.Application.Users.Queries.GetAllUsers;
+
+public class GetAllUsersQuery : IRequest>;
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetAllUsers/GetAllUsersQueryHandler.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetAllUsers/GetAllUsersQueryHandler.cs
new file mode 100644
index 0000000..91adbf7
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetAllUsers/GetAllUsersQueryHandler.cs
@@ -0,0 +1,12 @@
+using DatabaseApp.Domain.Repositories;
+using MapsterMapper;
+using MediatR;
+
+namespace DatabaseApp.Application.Users.Queries.GetAllUsers;
+
+public class GetAllUsersQueryHandler(IUserRepository repository, IMapper mapper)
+ : IRequestHandler>
+{
+ public async Task> Handle(GetAllUsersQuery request, CancellationToken cancellationToken) =>
+ mapper.Map>(await repository.GetAllAsync(cancellationToken));
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetUser/GetUserQuery.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetUser/GetUserQuery.cs
new file mode 100644
index 0000000..710c70e
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetUser/GetUserQuery.cs
@@ -0,0 +1,9 @@
+using FluentResults;
+using MediatR;
+
+namespace DatabaseApp.Application.Users.Queries.GetUser;
+
+public class GetUserQuery : IRequest>
+{
+ public required int UserTelegramId { get; init; }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetUser/GetUserQueryHandler.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetUser/GetUserQueryHandler.cs
new file mode 100644
index 0000000..82a44ac
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetUser/GetUserQueryHandler.cs
@@ -0,0 +1,19 @@
+using DatabaseApp.Domain.Repositories;
+using FluentResults;
+using MapsterMapper;
+using MediatR;
+
+namespace DatabaseApp.Application.Users.Queries.GetUser;
+
+public class GetUserQueryHandler(IUserRepository repository, IMapper mapper)
+ : IRequestHandler>
+{
+ public async Task> Handle(GetUserQuery request, CancellationToken cancellationToken)
+ {
+ var user = await repository.GetByTelegramIdAsync(request.UserTelegramId, cancellationToken);
+
+ if (user == null) return Result.Fail(new Error("User not found"));
+
+ return mapper.Map(user);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetUser/GetUserQueryValidator.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetUser/GetUserQueryValidator.cs
new file mode 100644
index 0000000..bf97bb4
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/Queries/GetUser/GetUserQueryValidator.cs
@@ -0,0 +1,9 @@
+using FluentValidation;
+
+namespace DatabaseApp.Application.Users.Queries.GetUser;
+
+// ReSharper disable once UnusedType.Global
+public class GetUserQueryValidator : AbstractValidator
+{
+ public GetUserQueryValidator() => RuleFor(x => x.UserTelegramId).GreaterThan(0);
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/UserDto.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/UserDto.cs
new file mode 100644
index 0000000..3ec8c9c
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/Users/UserDto.cs
@@ -0,0 +1,10 @@
+namespace DatabaseApp.Application.Users;
+
+// ReSharper disable once ClassNeverInstantiated.Global
+public class UserDto
+{
+ public required long TelegramId { get; set; }
+ public required string Username { get; set; }
+ public required string MobileNumber { get; set; }
+ public DateTime RegisteredAt { get; set; }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/DatabaseApp.Domain.csproj b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/DatabaseApp.Domain.csproj
new file mode 100644
index 0000000..d85fb6b
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/DatabaseApp.Domain.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/Interfaces.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/Interfaces.cs
new file mode 100644
index 0000000..25d4d53
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/Interfaces.cs
@@ -0,0 +1,6 @@
+namespace DatabaseApp.Domain.Models;
+
+public interface IEntity
+{
+ public int Id { get; }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/Location.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/Location.cs
new file mode 100644
index 0000000..513f4b7
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/Location.cs
@@ -0,0 +1,15 @@
+using FluentResults;
+
+namespace DatabaseApp.Domain.Models;
+
+public record Location
+{
+ public string Value { get; private set; }
+
+ private Location(string value) => Value = value;
+
+ public static Result Create(string value) =>
+ string.IsNullOrWhiteSpace(value)
+ ? Result.Fail("Location cannot be null or whitespace.")
+ : Result.Ok(new Location(value));
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/User.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/User.cs
new file mode 100644
index 0000000..c968f5e
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/User.cs
@@ -0,0 +1,9 @@
+namespace DatabaseApp.Domain.Models;
+
+public class User : IEntity
+{
+ public int Id { get; init; }
+ public long TelegramId { get; init; }
+ public required UserMetadata Metadata { get; init; }
+ public DateTime RegisteredAt { get; init; }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/UserMetadata.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/UserMetadata.cs
new file mode 100644
index 0000000..27e6374
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/UserMetadata.cs
@@ -0,0 +1,36 @@
+using FluentResults;
+
+namespace DatabaseApp.Domain.Models;
+
+public class UserMetadata
+{
+ public const int MaxUsernameLength = 100;
+
+ public required string Username { get; init; }
+ public required string MobileNumber { get; init; }
+
+ public static Result Create(string username, string number)
+ {
+ var validationResult = Result.Merge(
+ ValidateUsername(username),
+ ValidateNumber(number));
+
+ if (validationResult.IsFailed) return validationResult;
+
+ return Result.Ok(new UserMetadata
+ {
+ Username = username,
+ MobileNumber = number
+ });
+ }
+
+ private static Result ValidateNumber(string number) =>
+ Result.FailIf(string.IsNullOrEmpty(number), "Number is required.");
+
+ private static Result ValidateUsername(string username) =>
+ Result.Merge(
+ Result.FailIf(string.IsNullOrEmpty(username), "Username is required."),
+ Result.FailIf(!string.IsNullOrEmpty(username) && username.Length > MaxUsernameLength,
+ "Username should contain max 100 characters.")
+ );
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/UserWeatherSubscription.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/UserWeatherSubscription.cs
new file mode 100644
index 0000000..7744bb5
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Models/UserWeatherSubscription.cs
@@ -0,0 +1,10 @@
+namespace DatabaseApp.Domain.Models;
+
+public class UserWeatherSubscription : IEntity
+{
+ public int Id { get; init; }
+ public int UserId { get; init; }
+ public TimeSpan ResendInterval { get; set; }
+ public required Location Location { get; set; }
+ public User User { get; init; } = null!;
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Repositories/Interfaces.cs b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Repositories/Interfaces.cs
new file mode 100644
index 0000000..fa31c08
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/Repositories/Interfaces.cs
@@ -0,0 +1,35 @@
+using DatabaseApp.Domain.Models;
+
+namespace DatabaseApp.Domain.Repositories;
+
+public interface IUnitOfWork : IDisposable
+{
+ IUserRepository UserRepository { get; }
+ IWeatherSubscriptionRepository UserWeatherSubscriptionRepository { get; }
+
+ Task SaveDbChangesAsync(CancellationToken cancellationToken);
+}
+
+public interface IRepository
+{
+ Task SaveDbChangesAsync(CancellationToken cancellationToken);
+}
+
+public interface IUserRepository : IRepository
+{
+ Task GetByTelegramIdAsync(long telegramId, CancellationToken cancellationToken);
+ Task AddAsync(User user, CancellationToken cancellationToken);
+ Task> GetAllAsync(CancellationToken cancellationToken);
+}
+
+public interface IWeatherSubscriptionRepository : IRepository
+{
+ Task> GetAllByUserTelegramId(long userTelegramId, CancellationToken cancellationToken);
+
+ Task GetByUserTelegramIdAndLocationAsync(long userTelegramId, Location location,
+ CancellationToken cancellationToken);
+
+ Task AddAsync(UserWeatherSubscription weatherSubscription, CancellationToken cancellationToken);
+ void Update(UserWeatherSubscription weatherSubscription);
+ void Delete(UserWeatherSubscription weatherSubscription);
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/DatabaseApp.IntegrationEvents.csproj b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/DatabaseApp.IntegrationEvents.csproj
new file mode 100644
index 0000000..303e1aa
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/DatabaseApp.IntegrationEvents.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/EventBusExtensions.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/EventBusExtensions.cs
new file mode 100644
index 0000000..02da6db
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/EventBusExtensions.cs
@@ -0,0 +1,28 @@
+using DatabaseApp.IntegrationEvents.IntegrationEventHandlers;
+using DatabaseApp.IntegrationEvents.IntegrationEventHandlers.UserIntegrationEventHandlers;
+using DatabaseApp.IntegrationEvents.IntegrationEventHandlers.UserSubscriptionEventHandlers;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using TelegramBotApp.Messaging;
+using TelegramBotApp.Messaging.IntegrationContext.UserIntegrationEvents;
+using TelegramBotApp.Messaging.IntegrationContext.UserSubscriptionEvents;
+
+namespace DatabaseApp.IntegrationEvents;
+
+public static class EventBusExtensions
+{
+ // ReSharper disable once UnusedMethodReturnValue.Global
+ public static IApplicationBuilder SubscribeToEvents(this IApplicationBuilder app)
+ {
+ var eventBus = app.ApplicationServices.GetRequiredService();
+
+ eventBus.Subscribe();
+ eventBus.Subscribe();
+ eventBus.Subscribe();
+ eventBus.Subscribe();
+ eventBus.Subscribe();
+ eventBus.Subscribe();
+
+ return app;
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserIntegrationEventHandlers/CreatedUserIntegrationEventHandler.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserIntegrationEventHandlers/CreatedUserIntegrationEventHandler.cs
new file mode 100644
index 0000000..5365b52
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserIntegrationEventHandlers/CreatedUserIntegrationEventHandler.cs
@@ -0,0 +1,38 @@
+using DatabaseApp.Application.Users.Commands.CreateUser;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+using TelegramBotApp.Caching.Caching;
+using TelegramBotApp.Messaging.IntegrationContext;
+using TelegramBotApp.Messaging.IntegrationContext.UserIntegrationEvents;
+using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses;
+
+namespace DatabaseApp.IntegrationEvents.IntegrationEventHandlers.UserIntegrationEventHandlers;
+
+public class CreatedUserIntegrationEventHandler(IServiceScopeFactory factory, ICacheService cacheService)
+ : IIntegrationEventHandler
+{
+ public async Task Handle(CreatedUserIntegrationEvent @event)
+ {
+ using var scope = factory.CreateScope();
+ var mediator = scope.ServiceProvider.GetRequiredService();
+ var result = await mediator.Send(new CreateUserCommand
+ {
+ TelegramId = @event.UserTelegramId, // TODO
+ Username = @event.Username,
+ MobileNumber = @event.MobileNumber,
+ RegisteredAt = @event.RegisteredAt
+ });
+
+ if (!result.IsSuccess) return UniversalResponse.Empty;
+
+ var allUsers = await cacheService.GetAsync>("allUsers");
+ await cacheService.RemoveAsync("allUsers");
+
+ allUsers ??= [];
+ allUsers.Add(@event.UserTelegramId);
+
+ await cacheService.SetAsync("allUsers", allUsers);
+
+ return UniversalResponse.Empty;
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/CacheRequestUserSubscriptionsIntegrationEventHandler.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/CacheRequestUserSubscriptionsIntegrationEventHandler.cs
new file mode 100644
index 0000000..77fb79b
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/CacheRequestUserSubscriptionsIntegrationEventHandler.cs
@@ -0,0 +1,54 @@
+using DatabaseApp.Application.Users.Queries.GetAllUsers;
+using DatabaseApp.Application.UserWeatherSubscriptions.Queries.GetWeatherSubscriptions;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+using TelegramBotApp.Caching.Caching;
+using TelegramBotApp.Messaging.Common;
+using TelegramBotApp.Messaging.IntegrationContext;
+using TelegramBotApp.Messaging.IntegrationContext.UserIntegrationEvents;
+using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses;
+
+namespace DatabaseApp.IntegrationEvents.IntegrationEventHandlers.UserSubscriptionEventHandlers;
+
+public class CacheRequestUserSubscriptionsIntegrationEventHandler(
+ IServiceScopeFactory factory,
+ ICacheService cacheService)
+ : IIntegrationEventHandler
+{
+ public async Task Handle(CacheRequestUserSubscriptionsIntegrationEvent @event)
+ {
+ using var scope = factory.CreateScope();
+ var mediator = scope.ServiceProvider.GetRequiredService();
+ List subscriptionInfos = [];
+ var userTelegramIds = await cacheService.GetAsync>("allUsers");
+
+ if (userTelegramIds is null)
+ {
+ var userDtos = await mediator.Send(new GetAllUsersQuery());
+ userTelegramIds = userDtos.Select(dto => dto.TelegramId).ToList();
+ await cacheService.SetAsync("allUsers", userTelegramIds);
+ }
+
+ foreach (var telegramId in userTelegramIds)
+ {
+ var userSubscription = await mediator.Send(new GetUserWeatherSubscriptionsQuery
+ {
+ UserTelegramId = telegramId
+ });
+
+ if (userSubscription.Count == 0) continue;
+
+ subscriptionInfos.AddRange(userSubscription.Select(subscriptionDto => new UserSubscriptionInfo
+ {
+ TelegramId = subscriptionDto.UserTelegramId,
+ ResendInterval = subscriptionDto.ResendInterval,
+ Location = subscriptionDto.Location
+ }));
+ }
+
+ await cacheService.RemoveAsync("allSubscriptions");
+ await cacheService.SetAsync("allSubscriptions", subscriptionInfos);
+
+ return UniversalResponse.Empty;
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/CreatedUserSubscriptionIntegrationEventHandler.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/CreatedUserSubscriptionIntegrationEventHandler.cs
new file mode 100644
index 0000000..a018977
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/CreatedUserSubscriptionIntegrationEventHandler.cs
@@ -0,0 +1,44 @@
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.CreateUserWeatherSubscription;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+using TelegramBotApp.Caching.Caching;
+using TelegramBotApp.Messaging.Common;
+using TelegramBotApp.Messaging.IntegrationContext;
+using TelegramBotApp.Messaging.IntegrationContext.UserSubscriptionEvents;
+using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses;
+
+namespace DatabaseApp.IntegrationEvents.IntegrationEventHandlers.UserSubscriptionEventHandlers;
+
+public class CreatedUserSubscriptionIntegrationEventHandler(IServiceScopeFactory factory, ICacheService cacheService)
+ : IIntegrationEventHandler
+{
+ public async Task Handle(CreatedUserSubscriptionIntegrationEvent @event)
+ {
+ using var scope = factory.CreateScope();
+ var mediator = scope.ServiceProvider.GetRequiredService();
+
+ var result = await mediator.Send(new CreateUserWeatherSubscriptionCommand
+ {
+ Location = @event.City,
+ ResendInterval = @event.ResendInterval,
+ TelegramUserId = @event.TelegramId
+ });
+
+ if (result.IsFailed) return new UniversalResponse(result.Errors.First().Message);
+
+ var allSubscriptions = await cacheService.GetAsync>("allSubscriptions");
+ await cacheService.RemoveAsync("allSubscriptions");
+
+ allSubscriptions ??= [];
+
+ allSubscriptions.Add(new()
+ {
+ ResendInterval = @event.ResendInterval,
+ Location = @event.City,
+ TelegramId = @event.TelegramId
+ });
+ await cacheService.SetAsync("allSubscriptions", allSubscriptions);
+
+ return new UniversalResponse("Subscription created successfully");
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/DeletedUserSubscriptionIntegrationEventHandler.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/DeletedUserSubscriptionIntegrationEventHandler.cs
new file mode 100644
index 0000000..d4ade5d
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/DeletedUserSubscriptionIntegrationEventHandler.cs
@@ -0,0 +1,40 @@
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.DeleteUserWeatherSubscription;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+using TelegramBotApp.Caching.Caching;
+using TelegramBotApp.Messaging.Common;
+using TelegramBotApp.Messaging.IntegrationContext;
+using TelegramBotApp.Messaging.IntegrationContext.UserSubscriptionEvents;
+using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses;
+
+namespace DatabaseApp.IntegrationEvents.IntegrationEventHandlers.UserSubscriptionEventHandlers;
+
+public class DeletedUserSubscriptionIntegrationEventHandler(IServiceScopeFactory factory, ICacheService cacheService)
+ : IIntegrationEventHandler
+{
+ public async Task Handle(DeletedUserSubscriptionIntegrationEvent @event)
+ {
+ using var scope = factory.CreateScope();
+ var mediator = scope.ServiceProvider.GetRequiredService();
+
+ var result = await mediator.Send(new DeleteUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = @event.TelegramUserId,
+ Location = @event.Location
+ });
+
+ if (result.IsFailed) return new UniversalResponse(result.Errors.First().Message);
+
+ var allSubscriptions = await cacheService.GetAsync>("allSubscriptions");
+ await cacheService.RemoveAsync("allSubscriptions");
+
+ allSubscriptions ??= [];
+
+ allSubscriptions.RemoveAll(x =>
+ x.TelegramId == @event.TelegramUserId && x.Location == @event.Location);
+
+ await cacheService.SetAsync("allSubscriptions", allSubscriptions);
+
+ return new UniversalResponse("Subscription deleted successfully");
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/UpdatedUserSubscriptionIntegrationEventHandler.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/UpdatedUserSubscriptionIntegrationEventHandler.cs
new file mode 100644
index 0000000..cb039c4
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/UpdatedUserSubscriptionIntegrationEventHandler.cs
@@ -0,0 +1,47 @@
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.UpdateUserWeatherSubscription;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+using TelegramBotApp.Caching.Caching;
+using TelegramBotApp.Messaging.Common;
+using TelegramBotApp.Messaging.IntegrationContext;
+using TelegramBotApp.Messaging.IntegrationContext.UserSubscriptionEvents;
+using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses;
+
+namespace DatabaseApp.IntegrationEvents.IntegrationEventHandlers.UserSubscriptionEventHandlers;
+
+public class UpdatedUserSubscriptionIntegrationEventHandler(IServiceScopeFactory factory, ICacheService cacheService)
+ : IIntegrationEventHandler
+{
+ public async Task Handle(UpdatedUserSubscriptionIntegrationEvent @event)
+ {
+ using var scope = factory.CreateScope();
+ var mediator = scope.ServiceProvider.GetRequiredService();
+
+ var result = await mediator.Send(new UpdateUserWeatherSubscriptionCommand
+ {
+ Location = @event.Location,
+ ResendInterval = @event.ResendInterval,
+ UserTelegramId = @event.TelegramUserId
+ });
+
+ if (result.IsFailed) return new UniversalResponse(result.Errors.First().Message);
+
+ var allSubscriptions = await cacheService.GetAsync>("allSubscriptions");
+ await cacheService.RemoveAsync("allSubscriptions");
+
+ allSubscriptions ??= [];
+
+ allSubscriptions.RemoveAll(x =>
+ x.TelegramId == @event.TelegramUserId && x.Location == @event.Location);
+
+ allSubscriptions.Add(new()
+ {
+ ResendInterval = @event.ResendInterval,
+ Location = @event.Location,
+ TelegramId = @event.TelegramUserId
+ });
+ await cacheService.SetAsync("allSubscriptions", allSubscriptions);
+
+ return new UniversalResponse("Subscription updated successfully");
+ }
+}
\ No newline at end of file
diff --git "a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/\320\241acheRequestUsersIntegrationEventHandler.cs" "b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/\320\241acheRequestUsersIntegrationEventHandler.cs"
new file mode 100644
index 0000000..585fdb2
--- /dev/null
+++ "b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.IntegrationEvents/IntegrationEventHandlers/UserSubscriptionEventHandlers/\320\241acheRequestUsersIntegrationEventHandler.cs"
@@ -0,0 +1,27 @@
+using DatabaseApp.Application.Users.Queries.GetAllUsers;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+using TelegramBotApp.Caching.Caching;
+using TelegramBotApp.Messaging.IntegrationContext;
+using TelegramBotApp.Messaging.IntegrationContext.UserIntegrationEvents;
+using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses;
+
+namespace DatabaseApp.IntegrationEvents.IntegrationEventHandlers.UserSubscriptionEventHandlers;
+
+public class CacheRequestUsersIntegrationEventHandler(IServiceScopeFactory factory, ICacheService cacheService)
+ : IIntegrationEventHandler
+{
+ public async Task Handle(CacheRequestUsersIntegrationEvent @event)
+ {
+ using var scope = factory.CreateScope();
+ var mediator = scope.ServiceProvider.GetRequiredService();
+
+ var users = await mediator.Send(new GetAllUsersQuery());
+ var telegramIds = users.Select(u => u.TelegramId).ToList();
+
+ await cacheService.RemoveAsync("allUsers");
+ await cacheService.SetAsync("allUsers", telegramIds);
+
+ return UniversalResponse.Empty;
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DatabaseApp.Persistence.csproj b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DatabaseApp.Persistence.csproj
new file mode 100644
index 0000000..fcf128d
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DatabaseApp.Persistence.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DatabaseContext/ApplicationDbContext.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DatabaseContext/ApplicationDbContext.cs
new file mode 100644
index 0000000..c0a9585
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DatabaseContext/ApplicationDbContext.cs
@@ -0,0 +1,28 @@
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Persistence.EntityTypeConfiguration;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+
+namespace DatabaseApp.Persistence.DatabaseContext;
+
+public sealed class ApplicationDbContext(DbContextOptions options)
+ : DbContext(options), IDatabaseContext
+{
+ public required DbSet UserWeatherSubscriptions { get; init; }
+ public required DbSet Users { get; init; }
+ public DatabaseFacade Db => Database;
+
+ public DbSet SetEntity() where TEntity : class, IEntity => Set();
+
+ public Task SaveDbChangesAsync(CancellationToken cancellationToken) => SaveChangesAsync(cancellationToken);
+
+ public void DisposeResources() => Dispose();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.ApplyConfiguration(new UserConfiguration());
+ modelBuilder.ApplyConfiguration(new UserWeatherSubscriptionConfiguration());
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DatabaseContext/Interfaces.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DatabaseContext/Interfaces.cs
new file mode 100644
index 0000000..a69967f
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DatabaseContext/Interfaces.cs
@@ -0,0 +1,16 @@
+using DatabaseApp.Domain.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+
+namespace DatabaseApp.Persistence.DatabaseContext;
+
+public interface IDatabaseContext
+{
+ DbSet UserWeatherSubscriptions { get; }
+ DbSet Users { get; }
+ DatabaseFacade Db { get; }
+
+ public Task SaveDbChangesAsync(CancellationToken cancellationToken);
+ public DbSet SetEntity() where TEntity : class, IEntity;
+ public void DisposeResources();
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DependencyInjection.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DependencyInjection.cs
new file mode 100644
index 0000000..b63b1a3
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/DependencyInjection.cs
@@ -0,0 +1,28 @@
+using DatabaseApp.Domain.Repositories;
+using DatabaseApp.Persistence.DatabaseContext;
+using DatabaseApp.Persistence.Repositories;
+using DatabaseApp.Persistence.UnitOfWorkContext;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DatabaseApp.Persistence;
+
+public static class DependencyInjection
+{
+ // ReSharper disable once UnusedMethodReturnValue.Global
+ public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration)
+ {
+ var connectionString = configuration.GetConnectionString("DbConnection");
+
+ services.AddDbContext(options =>
+ options.UseNpgsql(connectionString, builder =>
+ builder.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName)));
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/EntityTypeConfiguration/UserConfiguration.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/EntityTypeConfiguration/UserConfiguration.cs
new file mode 100644
index 0000000..10233de
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/EntityTypeConfiguration/UserConfiguration.cs
@@ -0,0 +1,27 @@
+using DatabaseApp.Domain.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DatabaseApp.Persistence.EntityTypeConfiguration;
+
+public class UserConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("USERS");
+
+ builder.Property(user => user.Id).HasColumnName("ID");
+ builder.Property(user => user.TelegramId).HasColumnName("TELEGRAM_ID");
+ builder.Property(user => user.RegisteredAt).HasColumnName("TIMESTAMP");
+
+ builder.OwnsOne(user => user.Metadata, metadata =>
+ {
+ metadata.Property(m => m.Username).HasColumnName("USERNAME").HasMaxLength(UserMetadata.MaxUsernameLength);
+ metadata.Property(m => m.MobileNumber).HasColumnName("MOBILE_NUMBER");
+ });
+
+ builder.HasKey(user => user.Id);
+ builder.Property(user => user.Id).ValueGeneratedOnAdd()
+ .HasIdentityOptions(startValue: 1, incrementBy: 1);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/EntityTypeConfiguration/UserWeatherSubscriptionConfiguration.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/EntityTypeConfiguration/UserWeatherSubscriptionConfiguration.cs
new file mode 100644
index 0000000..0db8043
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/EntityTypeConfiguration/UserWeatherSubscriptionConfiguration.cs
@@ -0,0 +1,31 @@
+using DatabaseApp.Domain.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DatabaseApp.Persistence.EntityTypeConfiguration;
+
+public class UserWeatherSubscriptionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("WEATHER_SUBSCRIPTIONS");
+
+ builder.Property(uws => uws.Id).HasColumnName("ID");
+
+ builder.HasKey(uws => uws.Id);
+ builder.Property(uws => uws.Id).ValueGeneratedOnAdd()
+ .HasIdentityOptions(startValue: 1, incrementBy: 1);
+
+ builder.Property(uws => uws.UserId).HasColumnName("USER_ID");
+ builder.Property(uws => uws.ResendInterval).HasColumnName("RESEND_INTERVAL");
+
+ builder.OwnsOne(uws => uws.Location,
+ location => location.Property(l => l.Value).HasColumnName("LOCATION"));
+
+ builder
+ .HasOne(uws => uws.User)
+ .WithMany()
+ .HasForeignKey(uws => uws.UserId)
+ .OnDelete(DeleteBehavior.Cascade).HasConstraintName("FK_USER_ID");
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Migrations/20240411110006_Initialization.Designer.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Migrations/20240411110006_Initialization.Designer.cs
new file mode 100644
index 0000000..63e730c
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Migrations/20240411110006_Initialization.Designer.cs
@@ -0,0 +1,141 @@
+//
+using System;
+using DatabaseApp.Persistence.DatabaseContext;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DatabaseApp.Persistence.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20240411110006_Initialization")]
+ partial class Initialization
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("DatabaseApp.Domain.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("ID");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+ NpgsqlPropertyBuilderExtensions.HasIdentityOptions(b.Property("Id"), 1L, null, null, null, null, null);
+
+ b.Property("RegisteredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("TIMESTAMP");
+
+ b.Property("TelegramId")
+ .HasColumnType("bigint")
+ .HasColumnName("TELEGRAM_ID");
+
+ b.HasKey("Id");
+
+ b.ToTable("USERS", (string)null);
+ });
+
+ modelBuilder.Entity("DatabaseApp.Domain.Models.UserWeatherSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("ID");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+ NpgsqlPropertyBuilderExtensions.HasIdentityOptions(b.Property("Id"), 1L, null, null, null, null, null);
+
+ b.Property("ResendInterval")
+ .HasColumnType("interval")
+ .HasColumnName("RESEND_INTERVAL");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("USER_ID");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("WEATHER_SUBSCRIPTIONS", (string)null);
+ });
+
+ modelBuilder.Entity("DatabaseApp.Domain.Models.User", b =>
+ {
+ b.OwnsOne("DatabaseApp.Domain.Models.UserMetadata", "Metadata", b1 =>
+ {
+ b1.Property("UserId")
+ .HasColumnType("integer");
+
+ b1.Property("MobileNumber")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("MOBILE_NUMBER");
+
+ b1.Property("Username")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("USERNAME");
+
+ b1.HasKey("UserId");
+
+ b1.ToTable("USERS");
+
+ b1.WithOwner()
+ .HasForeignKey("UserId");
+ });
+
+ b.Navigation("Metadata")
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("DatabaseApp.Domain.Models.UserWeatherSubscription", b =>
+ {
+ b.HasOne("DatabaseApp.Domain.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_USER_ID");
+
+ b.OwnsOne("DatabaseApp.Domain.Models.Location", "Location", b1 =>
+ {
+ b1.Property("UserWeatherSubscriptionId")
+ .HasColumnType("integer");
+
+ b1.Property("Value")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("LOCATION");
+
+ b1.HasKey("UserWeatherSubscriptionId");
+
+ b1.ToTable("WEATHER_SUBSCRIPTIONS");
+
+ b1.WithOwner()
+ .HasForeignKey("UserWeatherSubscriptionId");
+ });
+
+ b.Navigation("Location")
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Migrations/20240411110006_Initialization.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Migrations/20240411110006_Initialization.cs
new file mode 100644
index 0000000..da4df4e
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Migrations/20240411110006_Initialization.cs
@@ -0,0 +1,70 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DatabaseApp.Persistence.Migrations
+{
+ ///
+ public partial class Initialization : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "USERS",
+ columns: table => new
+ {
+ ID = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:IdentitySequenceOptions", "'1', '1', '', '', 'False', '1'")
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ TELEGRAM_ID = table.Column(type: "bigint", nullable: false),
+ USERNAME = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ MOBILE_NUMBER = table.Column(type: "text", nullable: false),
+ TIMESTAMP = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_USERS", x => x.ID);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "WEATHER_SUBSCRIPTIONS",
+ columns: table => new
+ {
+ ID = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:IdentitySequenceOptions", "'1', '1', '', '', 'False', '1'")
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ USER_ID = table.Column(type: "integer", nullable: false),
+ RESEND_INTERVAL = table.Column(type: "interval", nullable: false),
+ LOCATION = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_WEATHER_SUBSCRIPTIONS", x => x.ID);
+ table.ForeignKey(
+ name: "FK_USER_ID",
+ column: x => x.USER_ID,
+ principalTable: "USERS",
+ principalColumn: "ID",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_WEATHER_SUBSCRIPTIONS_USER_ID",
+ table: "WEATHER_SUBSCRIPTIONS",
+ column: "USER_ID");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "WEATHER_SUBSCRIPTIONS");
+
+ migrationBuilder.DropTable(
+ name: "USERS");
+ }
+ }
+}
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs
new file mode 100644
index 0000000..60b442a
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -0,0 +1,138 @@
+//
+using System;
+using DatabaseApp.Persistence.DatabaseContext;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DatabaseApp.Persistence.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ partial class ApplicationDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("DatabaseApp.Domain.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("ID");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+ NpgsqlPropertyBuilderExtensions.HasIdentityOptions(b.Property("Id"), 1L, null, null, null, null, null);
+
+ b.Property("RegisteredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("TIMESTAMP");
+
+ b.Property("TelegramId")
+ .HasColumnType("bigint")
+ .HasColumnName("TELEGRAM_ID");
+
+ b.HasKey("Id");
+
+ b.ToTable("USERS", (string)null);
+ });
+
+ modelBuilder.Entity("DatabaseApp.Domain.Models.UserWeatherSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("ID");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+ NpgsqlPropertyBuilderExtensions.HasIdentityOptions(b.Property("Id"), 1L, null, null, null, null, null);
+
+ b.Property("ResendInterval")
+ .HasColumnType("interval")
+ .HasColumnName("RESEND_INTERVAL");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("USER_ID");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("WEATHER_SUBSCRIPTIONS", (string)null);
+ });
+
+ modelBuilder.Entity("DatabaseApp.Domain.Models.User", b =>
+ {
+ b.OwnsOne("DatabaseApp.Domain.Models.UserMetadata", "Metadata", b1 =>
+ {
+ b1.Property("UserId")
+ .HasColumnType("integer");
+
+ b1.Property("MobileNumber")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("MOBILE_NUMBER");
+
+ b1.Property("Username")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("USERNAME");
+
+ b1.HasKey("UserId");
+
+ b1.ToTable("USERS");
+
+ b1.WithOwner()
+ .HasForeignKey("UserId");
+ });
+
+ b.Navigation("Metadata")
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("DatabaseApp.Domain.Models.UserWeatherSubscription", b =>
+ {
+ b.HasOne("DatabaseApp.Domain.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_USER_ID");
+
+ b.OwnsOne("DatabaseApp.Domain.Models.Location", "Location", b1 =>
+ {
+ b1.Property("UserWeatherSubscriptionId")
+ .HasColumnType("integer");
+
+ b1.Property("Value")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("LOCATION");
+
+ b1.HasKey("UserWeatherSubscriptionId");
+
+ b1.ToTable("WEATHER_SUBSCRIPTIONS");
+
+ b1.WithOwner()
+ .HasForeignKey("UserWeatherSubscriptionId");
+ });
+
+ b.Navigation("Location")
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Repositories/Repository.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Repositories/Repository.cs
new file mode 100644
index 0000000..2041b9a
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Repositories/Repository.cs
@@ -0,0 +1,26 @@
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Persistence.DatabaseContext;
+using Microsoft.EntityFrameworkCore;
+
+namespace DatabaseApp.Persistence.Repositories;
+
+public abstract class RepositoryBase(IDatabaseContext context)
+ where TEntity : class, IEntity
+{
+ protected readonly IDatabaseContext _context = context;
+
+ public async Task SaveDbChangesAsync(CancellationToken cancellationToken) =>
+ await _context.SaveDbChangesAsync(cancellationToken);
+
+ public async Task GetByIdAsync(int id, CancellationToken cancellationToken) =>
+ await _context.SetEntity().SingleOrDefaultAsync(e => e.Id == id, cancellationToken);
+
+ public async Task AddAsync(TEntity entity, CancellationToken cancellationToken) =>
+ await _context.SetEntity().AddAsync(entity, cancellationToken);
+
+ public void Update(TEntity entity) =>
+ _context.SetEntity().Update(entity);
+
+ public void Delete(TEntity entity) =>
+ _context.SetEntity().Remove(entity);
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Repositories/UserRepository.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Repositories/UserRepository.cs
new file mode 100644
index 0000000..82dd648
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Repositories/UserRepository.cs
@@ -0,0 +1,17 @@
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Domain.Repositories;
+using DatabaseApp.Persistence.DatabaseContext;
+using Microsoft.EntityFrameworkCore;
+
+namespace DatabaseApp.Persistence.Repositories;
+
+public class UserRepository(IDatabaseContext context)
+ : RepositoryBase(context), IUserRepository
+{
+ public Task GetByTelegramIdAsync(long telegramId, CancellationToken cancellationToken) =>
+ _context.Users
+ .FirstOrDefaultAsync(u => u.TelegramId == telegramId, cancellationToken);
+
+ public async Task> GetAllAsync(CancellationToken cancellationToken) =>
+ await _context.Users.ToListAsync(cancellationToken);
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Repositories/WeatherSubscriptionRepository.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Repositories/WeatherSubscriptionRepository.cs
new file mode 100644
index 0000000..8db8496
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/Repositories/WeatherSubscriptionRepository.cs
@@ -0,0 +1,24 @@
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Domain.Repositories;
+using DatabaseApp.Persistence.DatabaseContext;
+using Microsoft.EntityFrameworkCore;
+
+namespace DatabaseApp.Persistence.Repositories;
+
+public class WeatherSubscriptionRepository(IDatabaseContext context)
+ : RepositoryBase(context), IWeatherSubscriptionRepository
+{
+ public Task> GetAllByUserTelegramId(long userTelegramId,
+ CancellationToken cancellationToken) =>
+ _context.UserWeatherSubscriptions
+ .Include(s => s.User)
+ .Where(s => s.User.TelegramId == userTelegramId)
+ .ToListAsync(cancellationToken);
+
+ public Task GetByUserTelegramIdAndLocationAsync(long userTelegramId, Location location,
+ CancellationToken cancellationToken) =>
+ _context.UserWeatherSubscriptions
+ .Include(s => s.User)
+ .FirstOrDefaultAsync(s =>
+ s.User.TelegramId == userTelegramId && s.Location.Value == location.Value, cancellationToken);
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/UnitOfWorkContext/UnitOfWork.cs b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/UnitOfWorkContext/UnitOfWork.cs
new file mode 100644
index 0000000..46a7ff1
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/UnitOfWorkContext/UnitOfWork.cs
@@ -0,0 +1,37 @@
+using DatabaseApp.Domain.Repositories;
+using DatabaseApp.Persistence.DatabaseContext;
+
+namespace DatabaseApp.Persistence.UnitOfWorkContext;
+
+public sealed class UnitOfWork(
+ IDatabaseContext context,
+ IUserRepository userRepository,
+ IWeatherSubscriptionRepository userWeatherSubscriptionRepository) : IUnitOfWork
+{
+ private bool _disposed;
+
+ public IUserRepository UserRepository => userRepository;
+ public IWeatherSubscriptionRepository UserWeatherSubscriptionRepository => userWeatherSubscriptionRepository;
+
+ ~UnitOfWork() => Dispose(false);
+
+ public Task SaveDbChangesAsync(CancellationToken cancellationToken) =>
+ context.SaveDbChangesAsync(cancellationToken);
+
+ private void Dispose(bool disposing)
+ {
+ if (_disposed) return;
+ if (disposing)
+ {
+ context.DisposeResources();
+ }
+
+ _disposed = true;
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Controllers/UserController.cs b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Controllers/UserController.cs
new file mode 100644
index 0000000..693cc4b
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Controllers/UserController.cs
@@ -0,0 +1,51 @@
+using DatabaseApp.Application.Users.Commands.CreateUser;
+using DatabaseApp.Application.Users.Queries.GetAllUsers;
+using DatabaseApp.Application.Users.Queries.GetUser;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DatabaseApp.WebApi.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+[Produces("application/json")]
+public class UserController(ISender mediator) : ControllerBase
+{
+ ///
+ /// Gets all users.
+ ///
+ /// Returns 200 OK and a list of all users.
+ /// Success
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task GetAllUsers() =>
+ Ok(await mediator.Send(new GetAllUsersQuery()));
+
+ ///
+ /// Gets a user by ID.
+ ///
+ /// The ID of the user.
+ /// Returns 200 OK and the user with the specified ID.
+ /// Success
+ [HttpGet("{userTelegramId:required:int}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task GetUser(int userTelegramId) =>
+ Ok(await mediator.Send(new GetUserQuery
+ {
+ UserTelegramId = userTelegramId
+ }));
+
+ ///
+ /// Creates a new user.
+ ///
+ /// The command containing the user data.
+ /// Returns 201 Created if the user is successfully created.
+ /// Success
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ public async Task CreateUser([FromBody] CreateUserCommand command)
+ {
+ var id = await mediator.Send(command);
+ return CreatedAtAction(nameof(GetUser), new { userTelegramId = id.Value }, null);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Controllers/WeatherSubscriptionController.cs b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Controllers/WeatherSubscriptionController.cs
new file mode 100644
index 0000000..3598f86
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Controllers/WeatherSubscriptionController.cs
@@ -0,0 +1,73 @@
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.CreateUserWeatherSubscription;
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.DeleteUserWeatherSubscription;
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.UpdateUserWeatherSubscription;
+using DatabaseApp.Application.UserWeatherSubscriptions.Queries.GetWeatherSubscriptions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DatabaseApp.WebApi.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+[Produces("application/json")]
+public class WeatherSubscriptionController(ISender mediator) : ControllerBase
+{
+ ///
+ /// Creates a new weather subscription for a user.
+ ///
+ /// The command containing the subscription data.
+ /// Returns 201 OK if the subscription is successfully created.
+ /// Success
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ public async Task CreateUserWeatherSubscription(
+ [FromBody] CreateUserWeatherSubscriptionCommand command)
+ {
+ await mediator.Send(command);
+ return CreatedAtAction(nameof(GetWeatherSubscriptionsByUserId), new { userId = command.TelegramUserId }, null);
+ }
+
+ ///
+ /// Gets weather subscriptions by user ID.
+ ///
+ /// The ID of the user.
+ /// Returns 200 OK and the list of subscriptions for the user.
+ /// Success
+ [HttpGet("{userTelegramId:required:int}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task GetWeatherSubscriptionsByUserId(int userTelegramId) =>
+ Ok(await mediator.Send(new GetUserWeatherSubscriptionsQuery
+ {
+ UserTelegramId = userTelegramId
+ }));
+
+ ///
+ /// Updates a user's weather subscription.
+ ///
+ /// The command containing the updated subscription data.
+ /// Returns 204 No Content if the subscription is successfully updated.
+ /// Success
+ [HttpPut]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task UpdateUserWeatherSubscription(
+ [FromBody] UpdateUserWeatherSubscriptionCommand command)
+ {
+ await mediator.Send(command);
+ return NoContent();
+ }
+
+ ///
+ /// Deletes a user's weather subscription.
+ ///
+ /// The command containing the ID of the subscription to delete.
+ /// Returns 204 No Content if the subscription is successfully deleted.
+ /// Success
+ [HttpDelete]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task DeleteUserWeatherSubscription(
+ [FromBody] DeleteUserWeatherSubscriptionCommand command)
+ {
+ await mediator.Send(command);
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/DatabaseApp.WebApi.csproj b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/DatabaseApp.WebApi.csproj
new file mode 100644
index 0000000..95ca744
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/DatabaseApp.WebApi.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Middleware/GlobalExceptionHandler.cs b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Middleware/GlobalExceptionHandler.cs
new file mode 100644
index 0000000..297489c
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Middleware/GlobalExceptionHandler.cs
@@ -0,0 +1,29 @@
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DatabaseApp.WebApi.Middleware;
+
+public class GlobalExceptionHandler(ILogger logger) : IExceptionHandler
+{
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ logger.LogError(
+ exception, "Exception occurred: {Message}", exception.Message);
+
+ var problemDetails = new ProblemDetails
+ {
+ Status = StatusCodes.Status500InternalServerError,
+ Title = "Server error"
+ };
+
+ httpContext.Response.StatusCode = problemDetails.Status.Value;
+
+ await httpContext.Response
+ .WriteAsJsonAsync(problemDetails, cancellationToken);
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Program.cs b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Program.cs
new file mode 100644
index 0000000..19821a3
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Program.cs
@@ -0,0 +1,74 @@
+using System.Reflection;
+using DatabaseApp.Application;
+using DatabaseApp.IntegrationEvents;
+using DatabaseApp.Persistence;
+using DatabaseApp.Persistence.DatabaseContext;
+using DatabaseApp.WebApi.Middleware;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.OpenApi.Any;
+using Serilog;
+using TelegramBotApp.Caching;
+using TelegramBotApp.Messaging;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Host.UseSerilog((context, configuration) => configuration
+ .ReadFrom.Configuration(context.Configuration));
+
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
+ var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
+ c.IncludeXmlComments(xmlPath);
+ c.SupportNonNullableReferenceTypes();
+ c.MapType(() => new()
+ {
+ Type = "string",
+ Example = new OpenApiString("00:00:00")
+ });
+});
+builder.Services.AddExceptionHandler();
+builder.Services.AddProblemDetails();
+builder.Services.AddControllers();
+builder.Services
+ .AddApplication()
+ .AddPersistence(builder.Configuration)
+ .AddMessaging(builder.Configuration)
+ .AddCaching(builder.Configuration);
+
+var app = builder.Build();
+
+try
+{
+ using var scope = app.Services.CreateScope();
+ var databaseContext = scope.ServiceProvider.GetRequiredService();
+ databaseContext.Db.Migrate();
+}
+catch (Exception e)
+{
+ Log.Fatal(e, "An error occurred while migrating the database.");
+}
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseDeveloperExceptionPage();
+ app.UseSwagger();
+ app.UseSwaggerUI(c =>
+ {
+ c.SwaggerEndpoint("/swagger/v1/swagger.json", "DatabaseApp.WebApi v1");
+ c.RoutePrefix = string.Empty;
+ });
+}
+
+app.SubscribeToEvents();
+app.UseHttpsRedirection();
+app.UseExceptionHandler();
+app.UseSerilogRequestLogging();
+app.UseRouting();
+app.MapControllers();
+
+app.Run();
+
+// ReSharper disable once ClassNeverInstantiated.Global
+public partial class Program;
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Properties/Dockerfile b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Properties/Dockerfile
new file mode 100644
index 0000000..68029ea
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Properties/Dockerfile
@@ -0,0 +1,32 @@
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+WORKDIR /app
+
+COPY *.sln .
+COPY WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/*.csproj ./WeatherBotApi.DatabaseApp/Core/DatabaseApp.Application/
+COPY WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/*.csproj ./WeatherBotApi.DatabaseApp/Core/DatabaseApp.Domain/
+COPY WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/*.csproj ./WeatherBotApi.DatabaseApp/Infrastructure/DatabaseApp.Persistence/
+COPY WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/*.csproj ./WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/
+COPY WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/*.csproj ./WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/
+COPY WeatherBotApi.WeatherApp/Core/WeatherApp.Application/*.csproj ./WeatherBotApi.WeatherApp/Core/WeatherApp.Application/
+COPY WeatherBotApi.WeatherApp/Core/WeatherApp.Domain/*.csproj ./WeatherBotApi.WeatherApp/Core/WeatherApp.Domain/
+COPY WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.Converters/*.csproj WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.Converters/
+COPY WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/*.csproj ./WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/
+COPY WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/*.csproj ./WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/
+COPY WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/*.csproj ./WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/
+COPY WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Domain/*.csproj ./WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Domain/
+COPY WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/*.csproj ./WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/
+COPY WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/*.csproj ./WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/
+COPY WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/*.csproj ./WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/
+
+RUN dotnet restore
+
+COPY WeatherBotApi.DatabaseApp/ ./WeatherBotApi.DatabaseApp/
+
+WORKDIR /app/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/
+RUN dotnet publish -c Release -o out
+
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
+WORKDIR /app
+COPY --from=build /app/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/out ./
+
+ENTRYPOINT ["dotnet", "DatabaseApp.WebApi.dll"]
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Properties/launchSettings.json b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Properties/launchSettings.json
new file mode 100644
index 0000000..9c91895
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:34556",
+ "sslPort": 44342
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5065",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7117;http://localhost:5065",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/appsettings.Development.json b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/appsettings.Development.json
new file mode 100644
index 0000000..f0b1fe1
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/appsettings.Development.json
@@ -0,0 +1,32 @@
+{
+ "ConnectionStrings": {
+ "DbConnection": "Host=localhost:5755; Database=weather-database; Username=user; Password=pass",
+ "Redis": "localhost:6379"
+ },
+ "Serilog": {
+ "Using": [
+ "Serilog.Sinks.Console"
+ ],
+ "MinimumLevel": "Debug",
+ "WriteTo": [
+ {
+ "Name": "Console"
+ }
+ ],
+ "Enrich": [
+ "FromLogContext",
+ "WithMachineName",
+ "WithThreadId"
+ ]
+ },
+ "RabbitMqSettings": {
+ "HostName": "localhost",
+ "Port": 15672,
+ "Username": "guest",
+ "Password": "guest",
+ "EventQueueName": "database-event-queue",
+ "ResponseQueueName": "database-response-queue",
+ "EventExchangeName": "event-exchange",
+ "ResponseExchangeName": "response-exchange"
+ }
+}
diff --git a/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/appsettings.json b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/appsettings.json
new file mode 100644
index 0000000..acf97a7
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/appsettings.json
@@ -0,0 +1,32 @@
+{
+ "ConnectionStrings": {
+ "DbConnection": "Host=localhost:5755; Database=weather-database; Username=user; Password=pass",
+ "Redis": "localhost:6379"
+ },
+ "Serilog": {
+ "Using": [
+ "Serilog.Sinks.Console"
+ ],
+ "MinimumLevel": "Debug",
+ "WriteTo": [
+ {
+ "Name": "Console"
+ }
+ ],
+ "Enrich": [
+ "FromLogContext",
+ "WithMachineName",
+ "WithThreadId"
+ ]
+ },
+ "RabbitMqSettings": {
+ "HostName": "localhost",
+ "Port": 15672,
+ "Username": "guest",
+ "Password": "guest",
+ "EventQueueName": "database-event-queue",
+ "ResponseQueueName": "database-response-queue",
+ "EventExchangeName": "event-exchange",
+ "ResponseExchangeName": "response-exchange"
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/BasicTestContext/IntegrationTestBase.cs b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/BasicTestContext/IntegrationTestBase.cs
new file mode 100644
index 0000000..9e28544
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/BasicTestContext/IntegrationTestBase.cs
@@ -0,0 +1,47 @@
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Domain.Repositories;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace DatabaseApp.Tests.BasicTestContext;
+
+public abstract class IntegrationTestBase : IClassFixture, IAsyncLifetime
+{
+ private readonly IntegrationWebAppFactory _factory;
+ protected readonly ISender _sender;
+ protected readonly IUnitOfWork _unitOfWork;
+
+ protected IntegrationTestBase(IntegrationWebAppFactory factory)
+ {
+ _factory = factory;
+ var scope = factory.Services.CreateScope();
+
+ _sender = scope.ServiceProvider.GetRequiredService();
+ _unitOfWork = scope.ServiceProvider.GetRequiredService();
+ }
+
+ public Task InitializeAsync() => Task.CompletedTask;
+
+ public async Task DisposeAsync() => await _factory.ResetDatabaseAsync();
+
+ protected async Task AddUsersToDatabase(params User[] users)
+ {
+ foreach (var user in users)
+ {
+ await _unitOfWork.UserRepository.AddAsync(user, CancellationToken.None);
+ }
+
+ await _unitOfWork.SaveDbChangesAsync(CancellationToken.None);
+ }
+
+ protected async Task AddWeatherSubscriptionsToDatabase(params UserWeatherSubscription[] weatherSubscriptions)
+ {
+ foreach (var weatherSubscription in weatherSubscriptions)
+ {
+ await _unitOfWork.UserWeatherSubscriptionRepository.AddAsync(weatherSubscription, CancellationToken.None);
+ }
+
+ await _unitOfWork.SaveDbChangesAsync(CancellationToken.None);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/BasicTestContext/IntegrationWebAppFactory.cs b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/BasicTestContext/IntegrationWebAppFactory.cs
new file mode 100644
index 0000000..34ed0d4
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/BasicTestContext/IntegrationWebAppFactory.cs
@@ -0,0 +1,89 @@
+using System.Data.Common;
+using DatabaseApp.Persistence.DatabaseContext;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Respawn;
+using TelegramBotApp.Messaging;
+using TelegramBotApp.Messaging.Settings;
+using Testcontainers.PostgreSql;
+using Testcontainers.RabbitMq;
+using Xunit;
+
+namespace DatabaseApp.Tests.BasicTestContext;
+
+// ReSharper disable once ClassNeverInstantiated.Global
+public class IntegrationWebAppFactory : WebApplicationFactory, IAsyncLifetime
+{
+ private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
+ .WithImage("postgres:latest")
+ .WithDatabase("weather-database")
+ .WithUsername("user")
+ .WithPassword("pass").Build();
+
+ private readonly RabbitMqContainer _rabbitMqContainer = new RabbitMqBuilder()
+ .WithImage("rabbitmq:latest")
+ .WithUsername("guest")
+ .WithPassword("guest")
+ .Build();
+
+ private Respawner _respawner = null!;
+ private DbConnection _connection = null!;
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureTestServices(services =>
+ {
+ var descriptor = services.FirstOrDefault(d =>
+ d.ServiceType == typeof(DbContextOptions));
+
+ if (descriptor != null)
+ {
+ services.Remove(descriptor);
+ }
+
+ services.AddDbContext(options =>
+ options.UseNpgsql(_dbContainer.GetConnectionString()));
+
+ descriptor = services.FirstOrDefault(d =>
+ d.ServiceType == typeof(IMessageSettings));
+
+ if (descriptor != null)
+ {
+ services.Remove(descriptor);
+ }
+
+ services.AddSingleton(new RabbitMqSettings
+ {
+ EventExchangeName = "event-exchange",
+ ResponseExchangeName = "response-exchange",
+ EventQueueName = "event-queue",
+ ResponseQueueName = "response-queue",
+ ConnectionString = _rabbitMqContainer.GetConnectionString()
+ });
+ });
+ }
+
+ public async Task ResetDatabaseAsync() => await _respawner.ResetAsync(_connection);
+
+ public async Task InitializeAsync()
+ {
+ await _dbContainer.StartAsync();
+ await _rabbitMqContainer.StartAsync();
+ _connection = Services.CreateScope().ServiceProvider.GetRequiredService()
+ .Db.GetDbConnection();
+ await _connection.OpenAsync();
+ _respawner = await Respawner.CreateAsync(_connection, new()
+ {
+ DbAdapter = DbAdapter.Postgres,
+ SchemasToInclude = ["public"]
+ });
+ }
+
+ public new async Task DisposeAsync() =>
+ await Task.WhenAll(
+ _dbContainer.DisposeAsync().AsTask(),
+ _rabbitMqContainer.DisposeAsync().AsTask());
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/DatabaseApp.Tests.csproj b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/DatabaseApp.Tests.csproj
new file mode 100644
index 0000000..1887dfa
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/DatabaseApp.Tests.csproj
@@ -0,0 +1,39 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/MiddlewareTests/GlobalExceptionHandlerTests.cs b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/MiddlewareTests/GlobalExceptionHandlerTests.cs
new file mode 100644
index 0000000..5ee4633
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/MiddlewareTests/GlobalExceptionHandlerTests.cs
@@ -0,0 +1,28 @@
+using DatabaseApp.WebApi.Middleware;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace DatabaseApp.Tests.MiddlewareTests;
+
+public class GlobalExceptionHandlerTests
+{
+ [Fact]
+ public async Task TryHandleAsync_ShouldReturnTrue_WhenExceptionOccurs()
+ {
+ // Arrange
+ var mockLogger = Substitute.For>();
+ var handler = new GlobalExceptionHandler(mockLogger);
+ var context = new DefaultHttpContext();
+ var exception = new Exception("Test exception");
+
+ // Act
+ var result = await handler.TryHandleAsync(context, exception, CancellationToken.None);
+
+ // Assert
+ result.Should().Be(true);
+ context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/UserTests/UserControllerTests.cs b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/UserTests/UserControllerTests.cs
new file mode 100644
index 0000000..97d8db9
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/UserTests/UserControllerTests.cs
@@ -0,0 +1,78 @@
+using DatabaseApp.Application.Users.Commands.CreateUser;
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Tests.BasicTestContext;
+using DatabaseApp.WebApi.Controllers;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Xunit;
+
+namespace DatabaseApp.Tests.UserTests;
+
+public class UserControllerTests(IntegrationWebAppFactory factory) : IntegrationTestBase(factory)
+{
+ [Fact]
+ public async Task GetAllUsers_WithValidData_ReturnsOkObjectResult()
+ {
+ // Arrange
+ var controller = new UserController(_sender);
+ await AddUsersToDatabase(new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ });
+
+ // Act
+ var result = await controller.GetAllUsers();
+ var okResult = result as OkObjectResult;
+
+ // Assert
+ okResult.Should().NotBeNull();
+ okResult?.StatusCode.Should().Be(StatusCodes.Status200OK);
+ }
+
+ [Fact]
+ public async Task GetUser_WithValidData_ReturnsOkObjectResult()
+ {
+ // Arrange
+ var controller = new UserController(_sender);
+ const int userTelegramId = 1234567890;
+ await AddUsersToDatabase(new User
+ {
+ TelegramId = userTelegramId,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ });
+
+ // Act
+ var result = await controller.GetUser(userTelegramId);
+ var okResult = result as OkObjectResult;
+
+ // Assert
+ okResult.Should().NotBeNull();
+ okResult?.StatusCode.Should().Be(StatusCodes.Status200OK);
+ }
+
+ [Fact]
+ public async Task CreateUser_WithValidData_ReturnsCreatedAtActionResult()
+ {
+ // Arrange
+ var controller = new UserController(_sender);
+ var command = new CreateUserCommand
+ {
+ TelegramId = 1234567890,
+ Username = "JohnDoe",
+ MobileNumber = "+1234567890",
+ RegisteredAt = DateTime.UtcNow
+ };
+
+ // Act
+ var result = await controller.CreateUser(command);
+ var okResult = result as CreatedAtActionResult;
+
+ // Assert
+ okResult.Should().NotBeNull();
+ okResult?.StatusCode.Should().Be(StatusCodes.Status201Created);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/UserTests/UserTests.cs b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/UserTests/UserTests.cs
new file mode 100644
index 0000000..78850d0
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/UserTests/UserTests.cs
@@ -0,0 +1,231 @@
+using DatabaseApp.Application.Users.Commands.CreateUser;
+using DatabaseApp.Application.Users.Queries.GetAllUsers;
+using DatabaseApp.Application.Users.Queries.GetUser;
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Tests.BasicTestContext;
+using FluentAssertions;
+using FluentValidation;
+using Xunit;
+
+namespace DatabaseApp.Tests.UserTests;
+
+public class UserTests(IntegrationWebAppFactory factory) : IntegrationTestBase(factory)
+{
+ [Fact]
+ public async Task CreateUser_WithValidData_ShouldAddUserToDatabaseAndReturnsUserTelegramId()
+ {
+ // Arrange
+ var command = new CreateUserCommand
+ {
+ Username = "JohnDoe",
+ MobileNumber = "+1234567890",
+ TelegramId = 1234567890,
+ RegisteredAt = DateTime.UtcNow
+ };
+
+ // Act
+ var result = await _sender.Send(command);
+ var user = await _unitOfWork.UserRepository.GetByTelegramIdAsync(command.TelegramId, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().Be(true);
+ result.Value.Should().Be(command.TelegramId);
+ user.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task CreateUser_WhenUserAlreadyExists_ShouldNotAddUserToDatabase()
+ {
+ // Arrange
+ const int telegramId = 1234567890;
+ const string username = "JohnDoe";
+ const string mobileNumber = "+1234567890";
+
+ var user = new User
+ {
+ TelegramId = telegramId,
+ Metadata = UserMetadata.Create(username, mobileNumber).Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var command = new CreateUserCommand
+ {
+ Username = username,
+ MobileNumber = mobileNumber,
+ TelegramId = telegramId,
+ RegisteredAt = DateTime.UtcNow
+ };
+ await AddUsersToDatabase(user);
+
+ // Act
+ var result = await _sender.Send(command);
+
+ // Assert
+ result.IsFailed.Should().Be(true);
+ result.Errors.Should().ContainSingle().Which.Message.Should().Be("User already exists");
+ }
+
+ [Fact]
+ public async Task CreateUser_WhenUsernameIsInvalid_ShouldNotAddUserToDatabase()
+ {
+ // Arrange
+ var command = new CreateUserCommand
+ {
+ Username = new('a', UserMetadata.MaxUsernameLength + 1),
+ MobileNumber = "+1234567890",
+ TelegramId = 1234567890,
+ RegisteredAt = DateTime.UtcNow
+ };
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task CreateUser_WhenTelegramIdIsInvalid_ShouldNotAddUserToDatabase()
+ {
+ // Arrange
+ var command = new CreateUserCommand
+ {
+ Username = "JohnDoe",
+ MobileNumber = "+1234567890",
+ TelegramId = 0,
+ RegisteredAt = DateTime.UtcNow
+ };
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task CreateUser_WhenMobileNumberIsInvalid_ShouldNotAddUserToDatabase()
+ {
+ // Arrange
+ var command = new CreateUserCommand
+ {
+ Username = "JohnDoe",
+ MobileNumber = string.Empty,
+ TelegramId = 1234567890,
+ RegisteredAt = DateTime.UtcNow
+ };
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task CreateUser_WhenRegisteredAtIsInvalid_ShouldNotAddUserToDatabase()
+ {
+ // Arrange
+ var command = new CreateUserCommand
+ {
+ Username = "JohnDoe",
+ MobileNumber = "+1234567890",
+ TelegramId = 1234567890,
+ RegisteredAt = DateTime.MinValue
+ };
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetUser_WhenUserExists_ShouldReturnUserFromDatabase()
+ {
+ // Arrange
+ const int telegramId = 1234567890;
+ const string username = "JohnDoe";
+ const string mobileNumber = "+1234567890";
+
+ var existingUser = new User
+ {
+ TelegramId = telegramId,
+ Metadata = UserMetadata.Create(username, mobileNumber).Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var query = new GetUserQuery
+ {
+ UserTelegramId = telegramId
+ };
+ await AddUsersToDatabase(existingUser);
+
+ // Act
+ var user = await _sender.Send(query);
+
+ // Assert
+ user.IsSuccess.Should().Be(true);
+ user.Value.Should().NotBeNull();
+ user.Value.TelegramId.Should().Be(telegramId);
+ user.Value.Username.Should().Be(username);
+ user.Value.MobileNumber.Should().Be(mobileNumber);
+ }
+
+ [Fact]
+ public async Task GetUser_WhenUserDoesNotExist_ShouldReturnNull()
+ {
+ // Arrange
+ var query = new GetUserQuery
+ {
+ UserTelegramId = 1234567891
+ };
+
+ // Act
+ var result = await _sender.Send(query);
+
+ // Assert
+ result.IsFailed.Should().Be(true);
+ result.Errors.Should().ContainSingle().Which.Message.Should().Be("User not found");
+ }
+
+ [Fact]
+ public async Task GetAllUsers_WhenUsersExist_ShouldReturnAllUsersFromDatabase()
+ {
+ // Arrange
+ const int telegramId1 = 1234567890;
+ const string username1 = "JohnDoe";
+ const string mobileNumber1 = "+1234567890";
+
+ const int telegramId2 = 1234567891;
+ const string username2 = "JaneDoe";
+ const string mobileNumber2 = "+1234567891";
+
+ var user1 = new User
+ {
+ TelegramId = telegramId1,
+ Metadata = UserMetadata.Create(username1, mobileNumber1).Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var user2 = new User
+ {
+ TelegramId = telegramId2,
+ Metadata = UserMetadata.Create(username2, mobileNumber2).Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ await AddUsersToDatabase(user1, user2);
+ var query = new GetAllUsersQuery();
+
+ // Act
+ var users = await _sender.Send(query);
+
+ // Assert
+ users.Should().NotBeNullOrEmpty();
+ users.Should().HaveCount(2);
+ users.Should().ContainSingle(u =>
+ u.TelegramId == telegramId1 && u.Username == username1 &&
+ u.MobileNumber == mobileNumber1);
+ users.Should().ContainSingle(u =>
+ u.TelegramId == telegramId2 && u.Username == username2 &&
+ u.MobileNumber == mobileNumber2);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/WeatherSubscriptionTests/WeatherSubscriptionControllerTests.cs b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/WeatherSubscriptionTests/WeatherSubscriptionControllerTests.cs
new file mode 100644
index 0000000..dbd8e75
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/WeatherSubscriptionTests/WeatherSubscriptionControllerTests.cs
@@ -0,0 +1,111 @@
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.CreateUserWeatherSubscription;
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.DeleteUserWeatherSubscription;
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.UpdateUserWeatherSubscription;
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Tests.BasicTestContext;
+using DatabaseApp.WebApi.Controllers;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Xunit;
+
+namespace DatabaseApp.Tests.WeatherSubscriptionTests;
+
+public class WeatherSubscriptionControllerTests(IntegrationWebAppFactory factory) : IntegrationTestBase(factory)
+{
+ [Fact]
+ public async Task CreateWeatherSubscription_WithValidData_ReturnsOkObjectResult()
+ {
+ // Arrange
+ var controller = new WeatherSubscriptionController(_sender);
+ var command = new CreateUserWeatherSubscriptionCommand
+ {
+ TelegramUserId = 1234567890,
+ Location = "London",
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+
+ // Act
+ var result = await controller.CreateUserWeatherSubscription(command);
+ var okResult = result as CreatedAtActionResult;
+
+ // Assert
+ okResult.Should().NotBeNull();
+ okResult?.StatusCode.Should().Be(StatusCodes.Status201Created);
+ }
+
+ [Fact]
+ public async Task GetWeatherSubscriptionsByUserId_WithValidData_ReturnsOkObjectResult()
+ {
+ // Arrange
+ var controller = new WeatherSubscriptionController(_sender);
+ const int userTelegramId = 1234567890;
+ await AddUsersToDatabase(new User
+ {
+ TelegramId = userTelegramId,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ });
+
+ // Act
+ var result = await controller.GetWeatherSubscriptionsByUserId(userTelegramId);
+ var okResult = result as OkObjectResult;
+
+ // Assert
+ okResult.Should().NotBeNull();
+ okResult?.StatusCode.Should().Be(StatusCodes.Status200OK);
+ }
+
+ [Fact]
+ public async Task UpdateWeatherSubscription_WithValidData_ReturnsNoContentResult()
+ {
+ // Arrange
+ var controller = new WeatherSubscriptionController(_sender);
+ var command = new UpdateUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 1234567890,
+ Location = "London",
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ await AddUsersToDatabase(new User
+ {
+ TelegramId = command.UserTelegramId,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ });
+
+ // Act
+ var result = await controller.UpdateUserWeatherSubscription(command);
+ var noContentResult = result as NoContentResult;
+
+ // Assert
+ noContentResult.Should().NotBeNull();
+ noContentResult?.StatusCode.Should().Be(StatusCodes.Status204NoContent);
+ }
+
+ [Fact]
+ public async Task DeleteWeatherSubscription_WithValidData_ReturnsNoContentResult()
+ {
+ // Arrange
+ var controller = new WeatherSubscriptionController(_sender);
+ var command = new DeleteUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 1234567890,
+ Location = "London"
+ };
+ await AddUsersToDatabase(new User
+ {
+ TelegramId = command.UserTelegramId,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ });
+
+ // Act
+ var result = await controller.DeleteUserWeatherSubscription(command);
+ var noContentResult = result as NoContentResult;
+
+ // Assert
+ noContentResult.Should().NotBeNull();
+ noContentResult?.StatusCode.Should().Be(StatusCodes.Status204NoContent);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/WeatherSubscriptionTests/WeatherSubscriptionTests.cs b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/WeatherSubscriptionTests/WeatherSubscriptionTests.cs
new file mode 100644
index 0000000..db45b12
--- /dev/null
+++ b/WeatherBotApi.DatabaseApp/Tests/DatabaseApp.Tests/WeatherSubscriptionTests/WeatherSubscriptionTests.cs
@@ -0,0 +1,509 @@
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.CreateUserWeatherSubscription;
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.DeleteUserWeatherSubscription;
+using DatabaseApp.Application.UserWeatherSubscriptions.Commands.UpdateUserWeatherSubscription;
+using DatabaseApp.Application.UserWeatherSubscriptions.Queries.GetWeatherSubscriptions;
+using DatabaseApp.Domain.Models;
+using DatabaseApp.Tests.BasicTestContext;
+using FluentAssertions;
+using FluentValidation;
+using Xunit;
+
+namespace DatabaseApp.Tests.WeatherSubscriptionTests;
+
+public class WeatherSubscriptionTests(IntegrationWebAppFactory factory) : IntegrationTestBase(factory)
+{
+ [Fact]
+ public async Task CreateWeatherSubscription_WithValidData_ShouldAddWeatherSubscriptionToDatabase()
+ {
+ // Arrange
+ var command = new CreateUserWeatherSubscriptionCommand
+ {
+ TelegramUserId = 1234567890,
+ Location = "London",
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ await AddUsersToDatabase(user);
+
+ // Act
+ var result = await _sender.Send(command);
+ var weatherSubscription =
+ await _unitOfWork.UserWeatherSubscriptionRepository.GetByUserTelegramIdAndLocationAsync(
+ command.TelegramUserId, Location.Create(command.Location).Value, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().Be(true);
+ weatherSubscription.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task CreateWeatherSubscription_WhenUserDoesNotExist_ShouldNotAddWeatherSubscriptionToDatabase()
+ {
+ // Arrange
+ var command = new CreateUserWeatherSubscriptionCommand
+ {
+ TelegramUserId = 1234567890,
+ Location = "London",
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+
+ // Act
+ var result = await _sender.Send(command);
+ var weatherSubscription =
+ await _unitOfWork.UserWeatherSubscriptionRepository.GetByUserTelegramIdAndLocationAsync(
+ command.TelegramUserId, Location.Create(command.Location).Value, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().Be(false);
+ result.Errors.Should().ContainSingle().Which.Message.Should().Be("User not found");
+ weatherSubscription.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task CreateWeatherSubscription_WithInvalidLocation_ShouldNotAddWeatherSubscriptionToDatabase()
+ {
+ // Arrange
+ var command = new CreateUserWeatherSubscriptionCommand
+ {
+ TelegramUserId = 1234567890,
+ Location = string.Empty,
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ await AddUsersToDatabase(user);
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task CreateWeatherSubscription_WithInvalidResendInterval_ShouldNotAddWeatherSubscriptionToDatabase()
+ {
+ // Arrange
+ var command = new CreateUserWeatherSubscriptionCommand
+ {
+ TelegramUserId = 1234567890,
+ Location = "London",
+ ResendInterval = TimeSpan.Zero
+ };
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ await AddUsersToDatabase(user);
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task
+ CreateWeatherSubscription_WhenWeatherSubscriptionAlreadyExists_ShouldNotAddWeatherSubscriptionToDatabase()
+ {
+ // Arrange
+ const string location = "London";
+
+ var command = new CreateUserWeatherSubscriptionCommand
+ {
+ TelegramUserId = 1234567890,
+ Location = location,
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var weatherSubscription = new UserWeatherSubscription
+ {
+ User = user,
+ Location = Location.Create(location).Value,
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ await AddUsersToDatabase(user);
+ await AddWeatherSubscriptionsToDatabase(weatherSubscription);
+
+ // Act
+ var result = await _sender.Send(command);
+
+ // Assert
+ result.IsFailed.Should().Be(true);
+ result.Errors.Should().ContainSingle().Which.Message.Should().Be("Weather subscription already exists");
+ }
+
+ [Fact]
+ public async Task CreateWeatherSubscription_WhenTelegramIdIsInvalid_ShouldNotAddWeatherSubscriptionToDatabase()
+ {
+ // Arrange
+ var command = new CreateUserWeatherSubscriptionCommand
+ {
+ TelegramUserId = 0,
+ Location = "London",
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task UpdateWeatherSubscription_WithValidData_ShouldUpdateWeatherSubscriptionInDatabase()
+ {
+ // Arrange
+ const string location = "London";
+
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var weatherSubscription = new UserWeatherSubscription
+ {
+ User = user,
+ Location = Location.Create(location).Value,
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ await AddUsersToDatabase(user);
+ await AddWeatherSubscriptionsToDatabase(weatherSubscription);
+
+ var command = new UpdateUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 1234567890,
+ Location = location,
+ ResendInterval = TimeSpan.FromHours(2)
+ };
+
+ // Act
+ var result = await _sender.Send(command);
+ var updatedWeatherSubscription =
+ await _unitOfWork.UserWeatherSubscriptionRepository.GetByUserTelegramIdAndLocationAsync(
+ command.UserTelegramId, Location.Create(command.Location).Value, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().Be(true);
+ updatedWeatherSubscription.Should().NotBeNull();
+ updatedWeatherSubscription!.ResendInterval.Should().Be(TimeSpan.FromHours(2));
+ }
+
+ [Fact]
+ public async Task UpdateWeatherSubscription_WhenUserDoesNotExist_ShouldNotUpdateWeatherSubscriptionInDatabase()
+ {
+ // Arrange
+ const string location = "London";
+
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var weatherSubscription = new UserWeatherSubscription
+ {
+ User = user,
+ Location = Location.Create(location).Value,
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ await AddUsersToDatabase(user);
+ await AddWeatherSubscriptionsToDatabase(weatherSubscription);
+
+ var command = new UpdateUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 1234567891,
+ Location = location,
+ ResendInterval = TimeSpan.FromHours(2)
+ };
+
+ // Act
+ var result = await _sender.Send(command);
+ var updatedWeatherSubscription =
+ await _unitOfWork.UserWeatherSubscriptionRepository.GetByUserTelegramIdAndLocationAsync(
+ command.UserTelegramId, Location.Create(command.Location).Value, CancellationToken.None);
+
+ // Assert
+ result.IsFailed.Should().Be(true);
+ result.Errors.Should().ContainSingle().Which.Message.Should().Be("User not found");
+ updatedWeatherSubscription.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task
+ UpdateWeatherSubscription_WhenWeatherSubscriptionDoesNotExist_ShouldNotUpdateWeatherSubscriptionInDatabase()
+ {
+ // Arrange
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var weatherSubscription = new UserWeatherSubscription
+ {
+ User = user,
+ Location = Location.Create("London").Value,
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ await AddUsersToDatabase(user);
+ await AddWeatherSubscriptionsToDatabase(weatherSubscription);
+
+ var command = new UpdateUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 1234567890,
+ Location = "Paris",
+ ResendInterval = TimeSpan.FromHours(2)
+ };
+
+ // Act
+ var result = await _sender.Send(command);
+ var updatedWeatherSubscription =
+ await _unitOfWork.UserWeatherSubscriptionRepository.GetByUserTelegramIdAndLocationAsync(
+ command.UserTelegramId, Location.Create(command.Location).Value, CancellationToken.None);
+
+ // Assert
+ result.IsFailed.Should().Be(true);
+ result.Errors.Should().ContainSingle().Which.Message.Should().Be("Subscription not found");
+ updatedWeatherSubscription.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task UpdateWeatherSubscription_WithInvalidLocation_ShouldNotUpdateWeatherSubscriptionInDatabase()
+ {
+ // Arrange
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var weatherSubscription = new UserWeatherSubscription
+ {
+ User = user,
+ Location = Location.Create("London").Value,
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ await AddUsersToDatabase(user);
+ await AddWeatherSubscriptionsToDatabase(weatherSubscription);
+
+ var command = new UpdateUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 1234567890,
+ Location = string.Empty,
+ ResendInterval = TimeSpan.FromHours(2)
+ };
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task UpdateWeatherSubscription_WithInvalidResendInterval_ShouldNotUpdateWeatherSubscriptionInDatabase()
+ {
+ // Arrange
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var weatherSubscription = new UserWeatherSubscription
+ {
+ User = user,
+ Location = Location.Create("London").Value,
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ await AddUsersToDatabase(user);
+ await AddWeatherSubscriptionsToDatabase(weatherSubscription);
+
+ var command = new UpdateUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 1234567890,
+ Location = "London",
+ ResendInterval = TimeSpan.Zero
+ };
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task UpdateWeatherSubscription_WhenTelegramIdIsInvalid_ShouldNotUpdateWeatherSubscriptionInDatabase()
+ {
+ // Arrange
+ var command = new UpdateUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 0,
+ Location = "London",
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task DeleteWeatherSubscription_WithValidData_ShouldDeleteWeatherSubscriptionFromDatabase()
+ {
+ // Arrange
+ const string location = "London";
+
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var command = new DeleteUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 1234567890,
+ Location = location
+ };
+ var weatherSubscription = new UserWeatherSubscription
+ {
+ User = user,
+ Location = Location.Create(location).Value,
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ await AddUsersToDatabase(user);
+ await AddWeatherSubscriptionsToDatabase(weatherSubscription);
+
+ // Act
+ var result = await _sender.Send(command);
+ var deletedWeatherSubscription =
+ await _unitOfWork.UserWeatherSubscriptionRepository.GetByUserTelegramIdAndLocationAsync(
+ command.UserTelegramId, Location.Create(command.Location).Value, CancellationToken.None);
+
+ // Assert
+ result.IsSuccess.Should().Be(true);
+ deletedWeatherSubscription.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task
+ DeleteWeatherSubscription_WhenSubscriptionDoesNotExist_ShouldNotDeleteWeatherSubscriptionFromDatabase()
+ {
+ // Arrange
+ const string location = "London";
+
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var command = new DeleteUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 1234567890,
+ Location = location
+ };
+ await AddUsersToDatabase(user);
+
+ // Act
+ var result = await _sender.Send(command);
+
+ // Assert
+ result.IsFailed.Should().Be(true);
+ result.Errors.Should().ContainSingle().Which.Message.Should().Be("Subscription not found");
+ }
+
+ [Fact]
+ public async Task DeleteWeatherSubscription_WithInvalidLocation_ShouldNotDeleteWeatherSubscriptionFromDatabase()
+ {
+ // Arrange
+ var command = new DeleteUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 1234567890,
+ Location = string.Empty
+ };
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task DeleteWeatherSubscription_WhenTelegramIdIsInvalid_ShouldNotDeleteWeatherSubscriptionFromDatabase()
+ {
+ // Arrange
+ var command = new DeleteUserWeatherSubscriptionCommand
+ {
+ UserTelegramId = 0,
+ Location = "London"
+ };
+
+ // Act
+ Func act = async () => await _sender.Send(command);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetWeatherSubscriptions_WithValidData_ShouldReturnWeatherSubscriptionsFromDatabase()
+ {
+ // Arrange
+ var user = new User
+ {
+ TelegramId = 1234567890,
+ Metadata = UserMetadata.Create("JohnDoe", "+1234567890").Value,
+ RegisteredAt = DateTime.UtcNow
+ };
+ var weatherSubscription1 = new UserWeatherSubscription
+ {
+ User = user,
+ Location = Location.Create("London").Value,
+ ResendInterval = TimeSpan.FromHours(1)
+ };
+ var weatherSubscription2 = new UserWeatherSubscription
+ {
+ User = user,
+ Location = Location.Create("Paris").Value,
+ ResendInterval = TimeSpan.FromHours(2)
+ };
+ var query = new GetUserWeatherSubscriptionsQuery
+ {
+ UserTelegramId = 1234567890
+ };
+ await AddUsersToDatabase(user);
+ await AddWeatherSubscriptionsToDatabase(weatherSubscription1, weatherSubscription2);
+
+ // Act
+ var weatherSubscriptions = await _sender.Send(query);
+
+ // Assert
+ weatherSubscriptions.Should().NotBeEmpty();
+ weatherSubscriptions.Should().HaveCount(2);
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/DependencyInjection.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/DependencyInjection.cs
new file mode 100644
index 0000000..7dedcf8
--- /dev/null
+++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/DependencyInjection.cs
@@ -0,0 +1,30 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using TelegramBotApp.Application.Factories;
+using TelegramBotApp.Application.Services;
+using TelegramBotApp.Application.TelegramBotContext;
+using TelegramBotApp.Domain.Models;
+
+namespace TelegramBotApp.Application;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton(s =>
+ {
+ var botInitializer = s.GetRequiredService();
+ return botInitializer.CreateBot(configuration.GetSection("TelegramSettings:BotToken").Value ??
+ throw new InvalidOperationException("Bot token is not set."),
+ botInitializer.CreateReceiverOptions());
+ });
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Factories/Common/Metadata.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Factories/Common/Metadata.cs
new file mode 100644
index 0000000..3ead27e
--- /dev/null
+++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Factories/Common/Metadata.cs
@@ -0,0 +1,12 @@
+namespace TelegramBotApp.Application.Factories.Common;
+
+// ReSharper disable once ClassNeverInstantiated.Global
+internal class TelegramCommandMetadata
+{
+ // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Local
+ // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global
+ public string Command { get; set; } = string.Empty;
+
+ // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global
+ public string Description { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Factories/TelegramCommandFactory.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Factories/TelegramCommandFactory.cs
new file mode 100644
index 0000000..1ce48b3
--- /dev/null
+++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Factories/TelegramCommandFactory.cs
@@ -0,0 +1,95 @@
+using System.Collections.Concurrent;
+using System.CommandLine;
+using System.Composition;
+using FluentResults;
+using TelegramBotApp.Application.Factories.Common;
+using TelegramBotApp.Application.Services;
+using TelegramBotApp.Application.TelegramCommands;
+using TelegramBotApp.Caching.Caching;
+using TelegramBotApp.Domain.Models;
+using TelegramBotApp.Messaging;
+
+namespace TelegramBotApp.Application.Factories;
+
+public class TelegramCommandFactory
+{
+ private class ImportInfo
+ {
+ [ImportMany]
+ // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Local
+ public ExportFactory[] Factories { get; set; } = [];
+ }
+
+ private static readonly ImportInfo s_info = new();
+ private readonly RootCommand _rootCommand = new("Root command");
+ private readonly Option _globalOption;
+ private readonly ConcurrentDictionary