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> _results = new(); + private readonly IEventBus _bus; + private readonly ICacheService _cacheService; + private readonly ITelegramBotSettings _settings; + private readonly IResendMessageService _messageService; + + public TelegramCommandFactory(IEventBus bus, ICacheService cacheService, IResendMessageService messageService, + ITelegramBotSettings settings) + { + MefContainerConfiguration.SatisfyImports(s_info); + _bus = bus; + _settings = settings; + _cacheService = cacheService; + _messageService = messageService; + + _globalOption = new( + name: "--telegram-id", + description: "Telegram chat id"); + + _rootCommand.AddGlobalOption(_globalOption); + + foreach (var exportFactory in s_info.Factories) + { + _rootCommand.AddCommand(InitializeCommand(exportFactory.CreateExport().Value)); + } + } + + public async Task> StartCommand(string args, long telegramId) + { + await _rootCommand.InvokeAsync($"{args} --telegram-id {telegramId}"); + _results.TryRemove(telegramId, out var result); + return result ?? Result.Fail("Command not found or bad arguments"); + } + + private Command InitializeCommand(ITelegramCommand telegramCommand) + { + var command = new Command(telegramCommand.Command); + var arguments = telegramCommand.Arguments.ToList(); + + foreach (var argument in arguments) + { + // TODO: Uncomment when validators are implemented + // argument.AddValidator(TelegramCommandValidatorFactory.GetValidatorForCommand(telegramCommand.Command).GetValidator()); + command.AddArgument(argument); + } + + command.SetHandler(async context => + { + var telegramId = context.ParseResult.GetValueForOption(_globalOption); + var result = await telegramCommand.Execute( + context, + telegramId, + GetValueForArgument, + _bus, + _cacheService, + _messageService, + _settings.Token); + _results.TryAdd(telegramId, result.Value); + }); + + return command; + + Argument GetValueForArgument(string name) => + arguments.FirstOrDefault(a => a.Name == name) ?? + throw new ArgumentException($"Argument not found with name '{name}'", nameof(name)); + } + + public static IEnumerable GetCommands() => + s_info.Factories.Select(f => $"{f.Metadata.Command} {f.Metadata.Description}"); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Factories/TelegramCommandValidatorFactory.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Factories/TelegramCommandValidatorFactory.cs new file mode 100644 index 0000000..39b9ec1 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Factories/TelegramCommandValidatorFactory.cs @@ -0,0 +1,25 @@ +using System.Composition; +using TelegramBotApp.Application.Factories.Common; +using TelegramBotApp.Application.TelegramCommands; + +namespace TelegramBotApp.Application.Factories; + +public class TelegramCommandValidatorFactory +{ + private class ImportInfo + { + [ImportMany] + // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Local + public IEnumerable> Validators { get; set; } = + Enumerable.Empty>(); + } + + private static readonly ImportInfo s_info = new(); + + static TelegramCommandValidatorFactory() => + MefContainerConfiguration.SatisfyImports(s_info); + + public static ITelegramCommandValidator GetValidatorForCommand(string command) => + s_info.Validators.FirstOrDefault(x => x.Metadata.Command == command)?.Value ?? + new DefaultTelegramCommandValidator(); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/MefContainerConfiguration.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/MefContainerConfiguration.cs new file mode 100644 index 0000000..ee9019c --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/MefContainerConfiguration.cs @@ -0,0 +1,16 @@ +using System.Composition; +using System.Composition.Hosting; + +namespace TelegramBotApp.Application; + +public static class MefContainerConfiguration +{ + public static void SatisfyImports(object importInfo) + { + var assemblies = new[] { typeof(T).Assembly }; + var configuration = new ContainerConfiguration().WithAssemblies(assemblies); + + using var container = configuration.CreateContainer(); + container.SatisfyImports(importInfo); + } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Services/ResendMessageService.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Services/ResendMessageService.cs new file mode 100644 index 0000000..9e15756 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/Services/ResendMessageService.cs @@ -0,0 +1,50 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using TelegramBotApp.Domain.Models; +using TelegramBotApp.Messaging; +using TelegramBotApp.Messaging.IntegrationContext.WeatherForecastIntegrationEvents; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +namespace TelegramBotApp.Application.Services; + +[SuppressMessage("ReSharper", "AsyncVoidLambda")] +public class ResendMessageService(ITelegramBot bot, IEventBus bus) : IResendMessageService +{ + private readonly ConcurrentDictionary> _timers = new(); + + public void AddOrUpdateResendProcess(long telegramId, string location, TimeSpan interval) + { + var timer = new Timer(async _ => + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // TODO: add common settings + var response = await bus.Publish(new WeatherForecastRequestIntegrationEvent(location), + replyTo: nameof(UniversalResponse), cancellationToken: cts.Token); + await bot.SendMessageAsync(telegramId, response.Message); + }, null, interval, interval); + + _timers.AddOrUpdate(telegramId, _ => [new(timer, location)], + (_, timers) => + { + var resendTimer = timers.Find(x => x.Location == location); + + if (resendTimer != null) + { + resendTimer.Timer.Dispose(); + timers.Remove(resendTimer); + } + + timers.Add(new(timer, location)); + return timers; + }); + } + + public void RemoveResendProcess(long telegramId, string location) + { + if (!_timers.TryGetValue(telegramId, out var timers)) return; + var timer = timers.FirstOrDefault(x => x.Location == location); + timer?.Timer.Dispose(); + if (timer != null) timers.Remove(timer); + } +} + +internal record ResendTimer(Timer Timer, string Location); \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotApp.Application.csproj b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotApp.Application.csproj new file mode 100644 index 0000000..ddc6def --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotApp.Application.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotContext/TelegramBot.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotContext/TelegramBot.cs new file mode 100644 index 0000000..e1ec0dd --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotContext/TelegramBot.cs @@ -0,0 +1,115 @@ +using FluentResults; +using Telegram.Bot; +using Telegram.Bot.Exceptions; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; +using TelegramBotApp.Application.Factories; +using TelegramBotApp.Application.Services; +using TelegramBotApp.Caching.Caching; +using TelegramBotApp.Domain.Models; +using TelegramBotApp.Messaging; +using TelegramBotApp.Messaging.IntegrationContext.UserIntegrationEvents; + +namespace TelegramBotApp.Application.TelegramBotContext; + +public class TelegramBot(ITelegramBotClient telegramBot, ReceiverOptions receiverOptions) : ITelegramBot +{ + private const string HelpCommand = "/help"; + + private readonly ITelegramBotSettings _settings = TelegramBotSettings.CreateDefault(); + private TelegramCommandFactory _telegramCommandFactory = null!; // TODO: Refactor to use DI + private ICacheService _cacheService = null!; + + public void StartReceiving(IEventBus bus, ICacheService cacheService, IResendMessageService messageService, + CancellationToken cancellationToken) + { + _telegramCommandFactory = new(bus, cacheService, messageService, _settings); + _cacheService = cacheService; + + telegramBot.StartReceiving( + updateHandler: async (botClient, update, token) => + await HandleUpdateInnerAsync(botClient, update, bus, token), + pollingErrorHandler: HandlePollingErrorInner, + receiverOptions: receiverOptions, + cancellationToken: cancellationToken); + } + + public Task SendMessageAsync(long telegramId, string message) => + telegramBot.SendTextMessageAsync(telegramId, message); + + public Task GetMeAsync() => telegramBot.GetMeAsync(); + + private async Task HandleUpdateInnerAsync(ITelegramBotClient botClient, Update update, IEventBus bus, + CancellationToken cancellationToken) + { + if (update.Message is not { } message) return; + if (message.Text is not { } messageText) return; + + var chatId = message.Chat.Id; // chat id equals to user telegram id + using var cts = new CancellationTokenSource(_settings.Timeout); + + try + { + _ = Task.Run(async () => await UpdateUsersCacheAsync( + message, + bus, + cancellationToken), + cancellationToken); + var result = await _telegramCommandFactory.StartCommand(messageText, chatId); + + if (result.IsFailed) + { + await HandleError(botClient, chatId, result); + return; + } + + await botClient.SendTextMessageAsync(chatId: chatId, result.Value, + cancellationToken: cancellationToken); + } + catch (Exception) + { + await HandleError(botClient, chatId, result: Result.Fail("Internal error")); + } + + return; + + async Task HandleError(ITelegramBotClient bot, long chatIdInner, IResultBase result) + { + await bot.SendTextMessageAsync(chatId: chatIdInner, result.Errors.First().Message, + cancellationToken: cancellationToken); + var text = await _telegramCommandFactory.StartCommand(HelpCommand, chatIdInner); + await bot.SendTextMessageAsync(chatId: chatIdInner, text.Value, cancellationToken: cancellationToken); + } + } + + private static Task HandlePollingErrorInner(ITelegramBotClient botClientInner, Exception exception, + CancellationToken cancellationToken) + { + var errorMessage = exception switch + { + ApiRequestException apiRequestException + => $"Telegram API Error:\n[{apiRequestException.ErrorCode}]\n{apiRequestException.Message}", + _ => exception.ToString() + }; + + Console.WriteLine(errorMessage); + + return Task.CompletedTask; + } + + private async Task UpdateUsersCacheAsync(Message message, IEventBus bus, CancellationToken cancellationToken) + { + var userTelegramIds = await _cacheService.GetAsync>("allUsers", cancellationToken); + + if (userTelegramIds?.Contains(message.From!.Id) == false) + { + _ = await bus.Publish(new CreatedUserIntegrationEvent + { + MobileNumber = message.Contact?.PhoneNumber ?? "fake-number", + Username = message.From?.Username ?? string.Empty, + UserTelegramId = message.From?.Id ?? 0, + RegisteredAt = DateTime.UtcNow + }, cancellationToken: cancellationToken); + } + } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotContext/TelegramBotInitializer.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotContext/TelegramBotInitializer.cs new file mode 100644 index 0000000..f552c07 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotContext/TelegramBotInitializer.cs @@ -0,0 +1,24 @@ +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types.Enums; +using TelegramBotApp.Domain.Models; + +namespace TelegramBotApp.Application.TelegramBotContext; + +public class TelegramBotInitializer : ITelegramBotInitializer +{ +#pragma warning disable CA1822 + public ITelegramBot CreateBot(string token, ReceiverOptions receiverOptions) +#pragma warning restore CA1822 + { + return new TelegramBot(new TelegramBotClient(token), receiverOptions); + } + +#pragma warning disable CA1822 + public ReceiverOptions CreateReceiverOptions() => +#pragma warning restore CA1822 + new() + { + AllowedUpdates = [UpdateType.Message] + }; +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotContext/TelegramBotSettings.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotContext/TelegramBotSettings.cs new file mode 100644 index 0000000..cb7cb18 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramBotContext/TelegramBotSettings.cs @@ -0,0 +1,14 @@ +using TelegramBotApp.Domain.Models; + +namespace TelegramBotApp.Application.TelegramBotContext; + +public class TelegramBotSettings : ITelegramBotSettings +{ + private readonly CancellationTokenSource _cts; + public TimeSpan Timeout => TimeSpan.FromSeconds(10); + public CancellationToken Token => _cts.Token; + + private TelegramBotSettings() => _cts = new(Timeout); + + public static ITelegramBotSettings CreateDefault() => new TelegramBotSettings(); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramCommands/Interfaces.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramCommands/Interfaces.cs new file mode 100644 index 0000000..f1a3413 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramCommands/Interfaces.cs @@ -0,0 +1,32 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; +using FluentResults; +using TelegramBotApp.Application.Services; +using TelegramBotApp.Caching.Caching; +using TelegramBotApp.Domain.Models; +using TelegramBotApp.Messaging; + +namespace TelegramBotApp.Application.TelegramCommands; + +public interface ITelegramCommand +{ + string Command { get; } + string Description { get; } + IEnumerable> Arguments { get; } + + Task> Execute(InvocationContext context, + long telegramId, + Func> getArgument, + IEventBus bus, + ICacheService cacheService, + IResendMessageService messageService, + CancellationToken cancellationToken); +} + +public interface ITelegramCommandValidator +{ + string Command { get; } + + ParseArgument GetValidator(); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramCommands/TelegramCommandValidators.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramCommands/TelegramCommandValidators.cs new file mode 100644 index 0000000..dfa0799 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramCommands/TelegramCommandValidators.cs @@ -0,0 +1,32 @@ +using System.CommandLine.Parsing; +using System.Composition; +using FluentResults; + +namespace TelegramBotApp.Application.TelegramCommands; + +public class DefaultTelegramCommandValidator : ITelegramCommandValidator +{ + public string Command => string.Empty; + + public ParseArgument GetValidator() => _ => Result.Ok(); +} + +[Export(typeof(ITelegramCommandValidator))] +[ExportMetadata(nameof(Command), "/help")] +public class HelpTelegramCommandValidator : ITelegramCommandValidator +{ + public string Command => "/help"; + + public ParseArgument GetValidator() => + _ => Result.Ok(); +} + +[Export(typeof(ITelegramCommandValidator))] +[ExportMetadata(nameof(Command), "/weather")] +public class WeatherTelegramCommandValidator : ITelegramCommandValidator +{ + public string Command => "/weather"; + + public ParseArgument GetValidator() => + result => result.Tokens.Count == 0 ? Result.Fail("City name is required") : Result.Ok(); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramCommands/TelegramCommands.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramCommands/TelegramCommands.cs new file mode 100644 index 0000000..71c7273 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Application/TelegramCommands/TelegramCommands.cs @@ -0,0 +1,258 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Composition; +using System.Globalization; +using System.Text; +using FluentResults; +using Telegram.Bot.Types; +using TelegramBotApp.Application.Factories; +using TelegramBotApp.Application.Services; +using TelegramBotApp.Caching.Caching; +using TelegramBotApp.Domain.Models; +using TelegramBotApp.Messaging; +using TelegramBotApp.Messaging.Common; +using TelegramBotApp.Messaging.IntegrationContext.UserSubscriptionEvents; +using TelegramBotApp.Messaging.IntegrationContext.WeatherForecastIntegrationEvents; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +// ReSharper disable UnusedType.Global + +namespace TelegramBotApp.Application.TelegramCommands; + +[Export(typeof(ITelegramCommand))] +[ExportMetadata(nameof(Command), "/help")] +[ExportMetadata(nameof(Description), "- get help")] +public class HelpTelegramCommand : ITelegramCommand +{ + public string Command => "/help"; + public string Description => "- get help"; + public IEnumerable> Arguments => Enumerable.Empty>(); + + public Task> Execute(InvocationContext context, + long telegramId, + Func> getArgument, + IEventBus bus, ICacheService cacheService, IResendMessageService messageService, + CancellationToken cancellationToken) + { + var message = new StringBuilder(); + message.AppendLine("Hello! I'm a weather bot. I can provide you with the current weather in your city."); + message.AppendLine("You can use the following commands:"); + + foreach (var command in TelegramCommandFactory.GetCommands()) + { + message.AppendLine(command); + } + + return Task.FromResult(Result.Ok(message.ToString())); + } +} + +[Export(typeof(ITelegramCommand))] +[ExportMetadata(nameof(Command), "/weather")] +[ExportMetadata(nameof(Description), " - get the current weather in the specified city")] +public class WeatherTelegramCommand : ITelegramCommand +{ + public string Command => "/weather"; + public string Description => " - get the current weather in the specified city"; + + public IEnumerable> Arguments + { + get { yield return new("city", "City name"); } + } + + public async Task> Execute(InvocationContext context, + long telegramId, + Func> getArgument, + IEventBus bus, ICacheService cacheService, IResendMessageService messageService, + CancellationToken cancellationToken) + { + var city = context.ParseResult.GetValueForArgument(getArgument("city")); + var response = await bus.Publish(new WeatherForecastRequestIntegrationEvent(city), nameof(UniversalResponse), + CancellationToken.None); // TODO: fix cancellation token + + return !response.IsEmpty + ? Result.Ok(response.Message) + : Result.Fail("An error occurred while processing the request"); + } +} + +[Export(typeof(ITelegramCommand))] +[ExportMetadata(nameof(Command), "/createSubscription")] +[ExportMetadata(nameof(Description), " - create a subscription")] +public class CreateSubscriptionTelegramCommand : ITelegramCommand +{ + public string Command => "/createSubscription"; + public string Description => " - create a subscription"; + + public IEnumerable> Arguments + { + get + { + yield return new("location", "Location"); + yield return new("resendInterval", "Resend interval"); + } + } + + public async Task> Execute(InvocationContext context, + long telegramId, + Func> getArgument, + IEventBus bus, ICacheService cacheService, + IResendMessageService messageService, + CancellationToken cancellationToken) + { + var location = context.ParseResult.GetValueForArgument(getArgument("location")); + + if (!TimeSpan.TryParseExact(context.ParseResult.GetValueForArgument(getArgument("resendInterval")), + @"hh\:mm\:ss", CultureInfo.InvariantCulture, out var resendInterval)) + { + return "Invalid format for resend interval. Please use the format hh:mm:ss".ToResult(); + } + + if (resendInterval < TimeSpan.FromMinutes(30)) // hardcode + { + return "Resend interval should be at least 30 minutes".ToResult(); // TODO: add validation, result fail + } + + messageService.AddOrUpdateResendProcess(telegramId, location, resendInterval); + + var response = await bus.Publish(new CreatedUserSubscriptionIntegrationEvent + { + TelegramId = telegramId, + ResendInterval = resendInterval, + City = location + }, + nameof(UniversalResponse), CancellationToken.None); // TODO: fix cancellation token + + return !response.IsEmpty + ? Result.Ok(response.Message) + : Result.Fail("An error occurred while processing the request"); + } +} + +[Export(typeof(ITelegramCommand))] +[ExportMetadata(nameof(Command), "/getSubscriptions")] +[ExportMetadata(nameof(Description), "- get all subscriptions")] +public class GetUserWeatherSubscriptionsTelegramCommand : ITelegramCommand +{ + public string Command => "/getSubscriptions"; + public string Description => "- get all subscriptions"; + + public IEnumerable> Arguments => Enumerable.Empty>(); + + public async Task> Execute(InvocationContext context, + long telegramId, + Func> getArgument, + IEventBus bus, ICacheService cacheService, IResendMessageService messageService, + CancellationToken cancellationToken) + { + var allSubscriptions = + await cacheService.GetAsync>("allSubscriptions", + CancellationToken.None); // TODO: fix cancellation token + + if (allSubscriptions == null) return Result.Fail("Bad internal state"); + + var message = new StringBuilder(); + + for (var i = 0; i < allSubscriptions.Count; i++) + { + var subscription = allSubscriptions[i]; + message.AppendLine( + $"{i + 1}) Location: {subscription.Location}, resend interval: {subscription.ResendInterval}"); + } + + return message.Length > 0 + ? Result.Ok(message.ToString()) + : Result.Ok("No subscriptions found"); + } +} + +[Export(typeof(ITelegramCommand))] +[ExportMetadata(nameof(Command), "/updateSubscription")] +[ExportMetadata(nameof(Description), " - update a subscription")] +public class UpdateSubscriptionTelegramCommand : ITelegramCommand +{ + public string Command => "/updateSubscription"; + public string Description => " - update a subscription"; + + public IEnumerable> Arguments + { + get + { + yield return new("location", "Location"); + yield return new("resendInterval", "Resend interval"); + } + } + + public async Task> Execute(InvocationContext context, + long telegramId, + Func> getArgument, + IEventBus bus, ICacheService cacheService, IResendMessageService messageService, + CancellationToken cancellationToken) + { + var location = context.ParseResult.GetValueForArgument(getArgument("location")); + + if (!TimeSpan.TryParseExact(context.ParseResult.GetValueForArgument(getArgument("resendInterval")), + @"hh\:mm\:ss", CultureInfo.InvariantCulture, out var resendInterval)) + { + return "Invalid format for resend interval. Please use the format hh:mm:ss".ToResult(); + } + + if (resendInterval < TimeSpan.FromMinutes(30)) // hardcode + { + return "Resend interval should be at least 30 minutes".ToResult(); // TODO: add validation, result fail + } + + messageService.AddOrUpdateResendProcess(telegramId, location, resendInterval); + + var response = await bus.Publish(new UpdatedUserSubscriptionIntegrationEvent + { + TelegramUserId = telegramId, + Location = location, + ResendInterval = resendInterval + }, nameof(UniversalResponse), CancellationToken.None); // TODO: fix cancellation token + + return !response.IsEmpty + ? Result.Ok(response.Message) + : Result.Fail("An error occurred while processing the request"); + } +} + +[Export(typeof(ITelegramCommand))] +[ExportMetadata(nameof(Command), "/deleteSubscription")] +[ExportMetadata(nameof(Description), " - delete a subscription")] +public class DeleteSubscriptionTelegramCommand : ITelegramCommand +{ + public string Command => "/deleteSubscription"; + public string Description => " - delete a subscription"; + + public IEnumerable> Arguments + { + get { yield return new("location", "Location"); } + } + + public async Task> Execute(InvocationContext context, + long telegramId, + Func> getArgument, + IEventBus bus, ICacheService cacheService, IResendMessageService messageService, + CancellationToken cancellationToken) + { + var location = context.ParseResult.GetValueForArgument(getArgument("location")); + + messageService.RemoveResendProcess(telegramId, location); + + var response = await bus.Publish(new DeletedUserSubscriptionIntegrationEvent + { + TelegramUserId = telegramId, + Location = location + }, + nameof(UniversalResponse), CancellationToken.None); // TODO: fix cancellation token + + return !response.IsEmpty + ? Result.Ok(response.Message) + : Result.Fail("An error occurred while processing the request"); + } +} + +/*[Export(typeof(ITelegramCommand))] TODO: create command +[ExportMetadata(nameof(Command), "/deleteAllSubscriptions")] +[ExportMetadata(nameof(Description), "delete all subscriptions")]*/ \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Domain/Models/Interfaces.cs b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Domain/Models/Interfaces.cs new file mode 100644 index 0000000..9303497 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Domain/Models/Interfaces.cs @@ -0,0 +1,33 @@ +using Telegram.Bot.Polling; +using Telegram.Bot.Types; +using TelegramBotApp.Caching.Caching; +using TelegramBotApp.Messaging; + +namespace TelegramBotApp.Domain.Models; + +public interface ITelegramBotSettings +{ + public TimeSpan Timeout { get; } + public CancellationToken Token { get; } +} + +public interface ITelegramBotInitializer +{ + ITelegramBot CreateBot(string token, ReceiverOptions receiverOptions); + ReceiverOptions CreateReceiverOptions(); +} + +public interface ITelegramBot +{ + void StartReceiving(IEventBus bus, ICacheService cacheService, IResendMessageService messageService, + CancellationToken cancellationToken); + + Task SendMessageAsync(long telegramId, string message); + Task GetMeAsync(); +} + +public interface IResendMessageService +{ + void AddOrUpdateResendProcess(long telegramId, string location, TimeSpan interval); + void RemoveResendProcess(long telegramId, string location); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Domain/TelegramBotApp.Domain.csproj b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Domain/TelegramBotApp.Domain.csproj new file mode 100644 index 0000000..15d480b --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Core/TelegramBotApp.Domain/TelegramBotApp.Domain.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/Caching/CacheService.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/Caching/CacheService.cs new file mode 100644 index 0000000..04cadac --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/Caching/CacheService.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; + +namespace TelegramBotApp.Caching.Caching; + +public class CacheService(IDistributedCache distributedCache) : ICacheService +{ + public async Task GetAsync(string key, CancellationToken cancellationToken = default) where T : class + { + var value = await distributedCache.GetStringAsync(key, cancellationToken); + return value == null ? null : JsonSerializer.Deserialize(value); + } + + public async Task SetAsync(string key, T value, CancellationToken cancellationToken = default) where T : class => + await distributedCache.SetStringAsync(key, JsonSerializer.Serialize(value), cancellationToken); + + public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) => + await distributedCache.RemoveAsync(key, cancellationToken); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/Caching/Interfaces.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/Caching/Interfaces.cs new file mode 100644 index 0000000..b29984f --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/Caching/Interfaces.cs @@ -0,0 +1,8 @@ +namespace TelegramBotApp.Caching.Caching; + +public interface ICacheService +{ + public Task GetAsync(string key, CancellationToken cancellationToken = default) where T : class; + public Task SetAsync(string key, T value, CancellationToken cancellationToken = default) where T : class; + public Task RemoveAsync(string key, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/DependencyInjection.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/DependencyInjection.cs new file mode 100644 index 0000000..5c7b780 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/DependencyInjection.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TelegramBotApp.Caching.Caching; + +namespace TelegramBotApp.Caching; + +public static class DependencyInjection +{ + // ReSharper disable once UnusedMethodReturnValue.Global + public static IServiceCollection AddCaching(this IServiceCollection services, IConfiguration configuration) + { + services.AddStackExchangeRedisCache(options => + options.Configuration = configuration.GetConnectionString("Redis")) + .AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/TelegramBotApp.Caching.csproj b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/TelegramBotApp.Caching.csproj new file mode 100644 index 0000000..f731424 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Caching/TelegramBotApp.Caching.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Common/UserSubcriptionInfo.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Common/UserSubcriptionInfo.cs new file mode 100644 index 0000000..97a49da --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Common/UserSubcriptionInfo.cs @@ -0,0 +1,8 @@ +namespace TelegramBotApp.Messaging.Common; + +public class UserSubscriptionInfo +{ + public long TelegramId { get; init; } + public TimeSpan ResendInterval { get; init; } + public required string Location { get; init; } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/CompositionPolymorphicTypeResolver.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/CompositionPolymorphicTypeResolver.cs new file mode 100644 index 0000000..ee75837 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/CompositionPolymorphicTypeResolver.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace TelegramBotApp.Messaging; + +public class CompositionPolymorphicTypeResolver(IEnumerable baseTypes) : DefaultJsonTypeInfoResolver +{ + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) + { + var jsonTypeInfo = base.GetTypeInfo(type, options); + + var baseType = baseTypes.FirstOrDefault(bt => bt.IsAssignableFrom(jsonTypeInfo.Type)); + + if (baseType == null) return jsonTypeInfo; + + var derivedTypes = AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(s => s.GetTypes()) + .Where(p => type.IsAssignableFrom(p) && p is { IsClass: true, IsAbstract: false }) + .Select(t => new JsonDerivedType(t, t.Name)); + + jsonTypeInfo.PolymorphismOptions = new() + { + TypeDiscriminatorPropertyName = $"{jsonTypeInfo.Type.Name.ToLower()}-type", + IgnoreUnrecognizedTypeDiscriminators = true, + UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization + }; + + foreach (var jsonDerivedType in derivedTypes) + { + jsonTypeInfo.PolymorphismOptions.DerivedTypes.Add(jsonDerivedType); + } + + return jsonTypeInfo; + } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Connection/PersistentConnection.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Connection/PersistentConnection.cs new file mode 100644 index 0000000..61b0590 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Connection/PersistentConnection.cs @@ -0,0 +1,116 @@ +using System.Net.Sockets; +using Microsoft.Extensions.Logging; +using Polly; +using RabbitMQ.Client; +using RabbitMQ.Client.Exceptions; +using TelegramBotApp.Messaging.Settings; + +namespace TelegramBotApp.Messaging.Connection; + +public sealed class PersistentConnection( + IMessageSettings messageSettings, + ILogger logger, + int retryCount = 3) + : IPersistentConnection +{ + private IConnection? _connection; + private bool _disposed; + + public bool IsConnected => _connection is { IsOpen: true } && !_disposed; + + ~PersistentConnection() => Dispose(false); + + public bool TryConnect() + { + logger.LogInformation("RabbitMQ Client is trying to connect..."); + + var policy = Policy.Handle() + .Or() + .WaitAndRetry(retryCount, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + (ex, time) => logger.LogWarning(ex, + "RabbitMQ Client could not connect after {TimeOut}s ({ExceptionMessage})", + $"{time.TotalSeconds:n1}", ex.Message)); + + ConnectionFactory connectionFactory; + + if (messageSettings.HasConnectionString) + { + connectionFactory = new() + { + Uri = new(messageSettings.ConnectionString!), + DispatchConsumersAsync = true + }; + } + else + { + connectionFactory = new() + { + HostName = messageSettings.HostName, + Port = messageSettings.Port, + UserName = messageSettings.Username, + Password = messageSettings.Password, + DispatchConsumersAsync = true + }; + } + + policy.Execute(() => _connection = connectionFactory.CreateConnection()); + + if (_connection == null) + { + throw new InvalidOperationException("RabbitMQ connection could not be created and opened"); + } + + if (IsConnected) + { + _connection.ConnectionShutdown += (_, _) => + { + if (_disposed) return; + logger.LogWarning("A RabbitMQ connection is shutdown. Trying to re-connect..."); + TryConnect(); + }; + _connection.CallbackException += (_, _) => + { + if (_disposed) return; + logger.LogWarning("A RabbitMQ connection throw exception. Trying to re-connect..."); + TryConnect(); + }; + _connection.ConnectionBlocked += (_, _) => + { + if (_disposed) return; + logger.LogWarning("A RabbitMQ connection is on shutdown. Trying to re-connect..."); + TryConnect(); + }; + + logger.LogInformation( + "RabbitMQ Client acquired a persistent connection to '{HostName}' and is subscribed to failure events", + _connection.Endpoint.HostName); + + return true; + } + + logger.LogCritical("RabbitMQ connections could not be created and opened"); + + return false; + } + + public IModel CreateModel() => _connection?.CreateModel() ?? + throw new InvalidOperationException("No RabbitMQ connections are available"); + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (_disposed) return; + if (disposing) + { + // Dispose managed resources + } + + _connection?.Dispose(); + _disposed = true; + } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/DependencyInjection.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/DependencyInjection.cs new file mode 100644 index 0000000..659c9d3 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/DependencyInjection.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TelegramBotApp.Messaging.Connection; +using TelegramBotApp.Messaging.EventBusContext; +using TelegramBotApp.Messaging.IntegrationContext; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponseHandlers; +using TelegramBotApp.Messaging.Settings; + +namespace TelegramBotApp.Messaging; + +public static class DependencyInjection +{ + public static IServiceCollection AddMessaging(this IServiceCollection services, IConfiguration configuration) + { + var settings = configuration.GetRequiredSection(nameof(RabbitMqSettings)).Get(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(_ => + settings ?? throw new InvalidOperationException("RabbitMqSettings not found")); + services.AddSingleton(); + + var interfaceType = typeof(IIntegrationEventHandler); + foreach (var type in + AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(s => s.GetTypes()) + .Where(p => interfaceType.IsAssignableFrom(p) && p.IsClass)) services.AddTransient(type); + + return services; + } + + // ReSharper disable once UnusedMethodReturnValue.Global + public static IServiceCollection AddResponseHandlers(this IServiceCollection services) + { + var interfaceType = typeof(IResponseHandler); + foreach (var type in + AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(s => s.GetTypes()) + .Where(p => interfaceType.IsAssignableFrom(p) && p.IsClass)) services.AddTransient(type); + + return services; + } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusContext/EventBus.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusContext/EventBus.cs new file mode 100644 index 0000000..77a7c76 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusContext/EventBus.cs @@ -0,0 +1,262 @@ +using System.Collections.Concurrent; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Polly; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using RabbitMQ.Client.Exceptions; +using TelegramBotApp.Messaging.IntegrationContext; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponseHandlers; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; +using TelegramBotApp.Messaging.Settings; + +namespace TelegramBotApp.Messaging.EventBusContext; + +public class EventBus( + IPersistentConnection persistentConnection, + IMessageSettings messageSettings, + ILogger logger, + IEventBusSubscriptionsManager subscriptionsManager, + IServiceProvider serviceProvider, + IJsonOptions jsonOptions, + int retryCount = 3) + : IEventBus +{ + private IModel? _channel; + private readonly ConcurrentDictionary> _callbackMapper = new(); + private bool _isStandartQueueInitialized; + private bool _isResponseQueueInitialized; + + public Task Publish(IntegrationEventBase eventBase, string? replyTo = null, + CancellationToken cancellationToken = default) + { + if (_channel == null) throw new InvalidOperationException("RabbitMQ channel is not initialized"); + + var policy = Policy.Handle() + .Or() + .WaitAndRetry(retryCount, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + (ex, time) => logger.LogWarning(ex, + "Could not publish event: {EventName} after {Timeout}s ({ExceptionMessage})", + eventBase.Name, $"{time.TotalSeconds:n1}", ex.Message)); + + var body = JsonSerializer.SerializeToUtf8Bytes(eventBase, jsonOptions.Options); + var tcs = new TaskCompletionSource(); + + var properties = _channel.CreateBasicProperties(); + properties.CorrelationId = eventBase.Id.ToString(); + properties.ReplyTo = replyTo; + _callbackMapper.TryAdd(properties.CorrelationId, tcs); + + cancellationToken.Register(() => + { + _callbackMapper.TryRemove(properties.CorrelationId, out _); + tcs.TrySetResult(new("Bad request and was canceled")); + }); + + policy.Execute(() => + { + logger.LogInformation("Publishing event to RabbitMQ: {EventName}", eventBase.Name); + _channel.BasicPublish( + exchange: messageSettings.EventExchangeName, + routingKey: eventBase.Name, + basicProperties: properties, + body: body); + }); + + return tcs.Task; + } + + public void Subscribe() + where T : IntegrationEventBase + where TH : IIntegrationEventHandler + { + if (!_isStandartQueueInitialized) EnsureBasicInitialize(); + + var eventName = subscriptionsManager.GetEventKey(); + + if (subscriptionsManager.HasSubscriptionsForEvent(eventName)) return; + + _channel.QueueBind(queue: messageSettings.EventQueueName, + exchange: messageSettings.EventExchangeName, + routingKey: eventName); + + logger.LogInformation("Subscribing to event {EventName} with {EventHandler}", eventName, + typeof(TH).Name); + + subscriptionsManager.AddSubscription(); + + StartConsume(); + } + + public void SubscribeResponse() + where T : IResponseMessage + where TH : IResponseHandler + { + if (!_isResponseQueueInitialized) InitializeResponseQueue(); + + var replyName = subscriptionsManager.GetEventKey(); + + if (subscriptionsManager.HasSubscriptionsForResponse(replyName)) return; + + _channel.QueueBind(queue: messageSettings.ResponseQueueName, + exchange: messageSettings.ResponseExchangeName, + routingKey: replyName); + + logger.LogInformation("Subscribing to response {ResponseName}", replyName); + + subscriptionsManager.AddResponseSubscription(); + + var responseConsumer = new AsyncEventingBasicConsumer(_channel); + + // TODO: avoid code duplication + responseConsumer.Received += async (_, ea) => + { + if (!_callbackMapper.TryRemove(ea.BasicProperties.CorrelationId, + out var tcs)) + { + logger.LogWarning("Could not find callback for correlation id: {CorrelationId}", + ea.BasicProperties.CorrelationId); + return; + } + + var reply = ea.RoutingKey; + var message = Encoding.UTF8.GetString(ea.Body.Span); + + logger.LogInformation("Processing RabbitMQ event: {ResponseName}", reply); + + if (subscriptionsManager.HasSubscriptionsForResponse(reply)) + { + foreach (var subscription in subscriptionsManager.GetHandlersForResponse(reply)) + { + var handler = serviceProvider.GetService(subscription.HandlerType); + var responseType = subscriptionsManager.GetResponseTypeByName(reply); + + if (handler == null || responseType == null) + { + logger.LogWarning( + "Could not resolve response handler or response type for RabbitMQ event: {ResponseName}", + reply); + continue; + } + + var integrationResponse = JsonSerializer.Deserialize(message, responseType, jsonOptions.Options); + var concreteType = typeof(IResponseHandler<>).MakeGenericType(responseType); + + var task = concreteType.GetMethod("Handle") + ?.Invoke(handler, [integrationResponse]) as Task ?? + throw new InvalidCastException(); + var response = await task; + + tcs.TrySetResult(response); + } + } + else + { + logger.LogWarning("No subscription for RabbitMQ response: {ResponseName}", reply); + } + }; + + _channel.BasicConsume(queue: messageSettings.ResponseQueueName, autoAck: true, consumer: responseConsumer); + _isResponseQueueInitialized = true; + } + + private void EnsureBasicInitialize() + { + if (!TryConnect()) return; + + _channel ??= persistentConnection.CreateModel(); + _channel.ExchangeDeclare(exchange: messageSettings.EventExchangeName, type: ExchangeType.Direct); + _channel.QueueDeclare( + queue: messageSettings.EventQueueName, + durable: true, + exclusive: false, + autoDelete: true, + arguments: null); + _channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + + _isStandartQueueInitialized = true; + } + + private void StartConsume() + { + if (_channel == null) throw new InvalidOperationException("RabbitMQ channel is not initialized"); + + var eventConsumer = new AsyncEventingBasicConsumer(_channel); + + eventConsumer.Received += async (_, ea) => + { + var eventName = ea.RoutingKey; + var message = Encoding.UTF8.GetString(ea.Body.Span); + + logger.LogInformation("Processing RabbitMQ event: {EventName}", eventName); + + if (subscriptionsManager.HasSubscriptionsForEvent(eventName)) + { + foreach (var subscription in subscriptionsManager.GetHandlersForEvent(eventName)) + { + var handler = serviceProvider.GetService(subscription.HandlerType); + var eventType = subscriptionsManager.GetEventTypeByName(eventName); + + if (handler == null || eventType == null) + { + logger.LogWarning("Could not resolve handler or event type for RabbitMQ event: {EventName}", + eventName); + continue; + } + + var integrationEvent = + JsonSerializer.Deserialize(message, jsonOptions.Options); + integrationEvent?.UpdateId(Guid.Parse(ea.BasicProperties.CorrelationId)); + var concreteType = typeof(IIntegrationEventHandler<>).MakeGenericType(eventType); + + var task = concreteType.GetMethod("Handle") + ?.Invoke(handler, [integrationEvent]) as Task ?? + throw new InvalidCastException(); + var response = await task; + + var properties = _channel.CreateBasicProperties(); + properties.CorrelationId = ea.BasicProperties.CorrelationId; + + _channel.BasicPublish( + exchange: messageSettings.ResponseExchangeName, + routingKey: ea.BasicProperties.ReplyTo, + basicProperties: properties, + body: JsonSerializer.SerializeToUtf8Bytes(response, jsonOptions.Options)); + _channel.BasicAck(ea.DeliveryTag, multiple: false); + } + } + else + { + logger.LogWarning("No subscription for RabbitMQ event: {EventName}", eventName); + } + }; + + _channel.BasicConsume(queue: messageSettings.EventQueueName, autoAck: false, consumer: eventConsumer); + } + + private void InitializeResponseQueue() + { + if (!TryConnect()) return; + + _channel ??= persistentConnection.CreateModel(); + _channel.ExchangeDeclare(exchange: messageSettings.ResponseExchangeName, type: ExchangeType.Direct); + _channel.QueueDeclare( + queue: messageSettings.ResponseQueueName, + durable: true, + exclusive: false, + autoDelete: true, + arguments: null); + } + + private bool TryConnect() + { + if (persistentConnection.IsConnected) return true; + if (persistentConnection.TryConnect()) return true; + + logger.LogError("Could not connect to RabbitMQ"); + + return false; + } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusContext/EventBusSubscriptionManager.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusContext/EventBusSubscriptionManager.cs new file mode 100644 index 0000000..e4dc622 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusContext/EventBusSubscriptionManager.cs @@ -0,0 +1,65 @@ +using TelegramBotApp.Messaging.IntegrationContext; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponseHandlers; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +namespace TelegramBotApp.Messaging.EventBusContext; + +public class EventBusSubscriptionManager : IEventBusSubscriptionsManager +{ + private readonly Dictionary> _handlers = []; + private readonly Dictionary> _responseHandlers = []; + private readonly List _eventTypes = []; + private readonly List _responseTypes = []; + + public void AddSubscription() + where T : IntegrationEventBase + where TH : IIntegrationEventHandler + { + var eventName = GetEventKey(); + var handlerType = typeof(TH); + + if (!HasSubscriptionsForEvent(eventName)) _handlers.Add(eventName, []); + + if (_handlers[eventName].Any(s => s.HandlerType == handlerType)) + { + throw new ArgumentException( + $"Handler Type {handlerType.Name} already registered for '{eventName}'", nameof(handlerType)); + } + + _handlers[eventName].Add(SubscriptionInfo.Typed(handlerType)); + + if (!_eventTypes.Contains(typeof(T))) _eventTypes.Add(typeof(T)); + } + + public void AddResponseSubscription() where T : IResponseMessage where TH : IResponseHandler + { + var replyName = GetEventKey(); + var handlerType = typeof(TH); + + if (!HasSubscriptionsForResponse(replyName)) _responseHandlers.Add(replyName, []); + + if (_responseHandlers[replyName].Any(s => s.HandlerType == handlerType)) + { + throw new ArgumentException( + $"Handler Type {handlerType.Name} already registered for '{replyName}'", nameof(handlerType)); + } + + _responseHandlers[replyName].Add(SubscriptionInfo.Typed(handlerType)); + + if (!_responseTypes.Contains(typeof(T))) _responseTypes.Add(typeof(T)); + } + + public IEnumerable GetHandlersForEvent(string eventName) => _handlers[eventName]; + + public IEnumerable GetHandlersForResponse(string replyName) => _responseHandlers[replyName]; + + public bool HasSubscriptionsForEvent(string eventName) => _handlers.ContainsKey(eventName); + + public bool HasSubscriptionsForResponse(string replyName) => _responseHandlers.ContainsKey(replyName); + + public Type? GetEventTypeByName(string eventName) => _eventTypes.Find(t => t.Name == eventName); + + public Type? GetResponseTypeByName(string replyName) => _responseTypes.Find(t => t.Name == replyName); + + public string GetEventKey() => typeof(T).Name; +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusContext/SubscriptionInfo.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusContext/SubscriptionInfo.cs new file mode 100644 index 0000000..db18d33 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusContext/SubscriptionInfo.cs @@ -0,0 +1,10 @@ +namespace TelegramBotApp.Messaging.EventBusContext; + +public class SubscriptionInfo +{ + public Type HandlerType { get; } + + private SubscriptionInfo(Type handlerType) => HandlerType = handlerType; + + public static SubscriptionInfo Typed(Type handlerType) => new(handlerType); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusExtensions.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusExtensions.cs new file mode 100644 index 0000000..95aa175 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/EventBusExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponseHandlers; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +namespace TelegramBotApp.Messaging; + +public static class EventBusExtensions +{ + // ReSharper disable once UnusedMethodReturnValue.Global + public static IHost SubscribeToResponses(this IHost app) + { + var eventBus = app.Services.GetRequiredService(); + + eventBus.SubscribeResponse(); + + return app; + } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/IntegrationEventBase.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/IntegrationEventBase.cs new file mode 100644 index 0000000..9eef980 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/IntegrationEventBase.cs @@ -0,0 +1,9 @@ +namespace TelegramBotApp.Messaging.IntegrationContext; + +public abstract class IntegrationEventBase +{ + public Guid Id { get; private set; } = Guid.NewGuid(); + public abstract string Name { get; } + + public void UpdateId(Guid id) => Id = id; +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/Interfaces.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/Interfaces.cs new file mode 100644 index 0000000..fc7411b --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/Interfaces.cs @@ -0,0 +1,11 @@ +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +namespace TelegramBotApp.Messaging.IntegrationContext; + +public interface IIntegrationEventHandler; + +public interface IIntegrationEventHandler : IIntegrationEventHandler + where TIntegrationEvent : IntegrationEventBase +{ + Task Handle(TIntegrationEvent @event); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserIntegrationEvents/CacheRequestUserSubscriptionsIntegrationEvent.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserIntegrationEvents/CacheRequestUserSubscriptionsIntegrationEvent.cs new file mode 100644 index 0000000..ff23967 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserIntegrationEvents/CacheRequestUserSubscriptionsIntegrationEvent.cs @@ -0,0 +1,6 @@ +namespace TelegramBotApp.Messaging.IntegrationContext.UserIntegrationEvents; + +public class CacheRequestUserSubscriptionsIntegrationEvent : IntegrationEventBase +{ + public override string Name => nameof(CacheRequestUserSubscriptionsIntegrationEvent); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserIntegrationEvents/CreatedUserIntegrationEvent.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserIntegrationEvents/CreatedUserIntegrationEvent.cs new file mode 100644 index 0000000..2f33408 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserIntegrationEvents/CreatedUserIntegrationEvent.cs @@ -0,0 +1,11 @@ +namespace TelegramBotApp.Messaging.IntegrationContext.UserIntegrationEvents; + +public class CreatedUserIntegrationEvent : IntegrationEventBase +{ + public override string Name => nameof(CreatedUserIntegrationEvent); + + public long UserTelegramId { get; init; } + public required string Username { get; init; } + public required string MobileNumber { get; init; } + public DateTime RegisteredAt { get; init; } +} \ No newline at end of file diff --git "a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserIntegrationEvents/\320\241acheRequestUsersIntegrationEvent.cs" "b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserIntegrationEvents/\320\241acheRequestUsersIntegrationEvent.cs" new file mode 100644 index 0000000..0f5575a --- /dev/null +++ "b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserIntegrationEvents/\320\241acheRequestUsersIntegrationEvent.cs" @@ -0,0 +1,6 @@ +namespace TelegramBotApp.Messaging.IntegrationContext.UserIntegrationEvents; + +public class CacheRequestUsersIntegrationEvent : IntegrationEventBase +{ + public override string Name => nameof(CacheRequestUsersIntegrationEvent); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserSubscriptionEvents/CreatedUserSubscriptionIntegrationEvent.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserSubscriptionEvents/CreatedUserSubscriptionIntegrationEvent.cs new file mode 100644 index 0000000..26f3f12 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserSubscriptionEvents/CreatedUserSubscriptionIntegrationEvent.cs @@ -0,0 +1,9 @@ +namespace TelegramBotApp.Messaging.IntegrationContext.UserSubscriptionEvents; + +public class CreatedUserSubscriptionIntegrationEvent : IntegrationEventBase +{ + public override string Name => nameof(CreatedUserSubscriptionIntegrationEvent); + public long TelegramId { get; init; } + public required string City { get; init; } + public TimeSpan ResendInterval { get; init; } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserSubscriptionEvents/DeletedUserSubscriptionIntegrationEvent.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserSubscriptionEvents/DeletedUserSubscriptionIntegrationEvent.cs new file mode 100644 index 0000000..1a08ed5 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserSubscriptionEvents/DeletedUserSubscriptionIntegrationEvent.cs @@ -0,0 +1,8 @@ +namespace TelegramBotApp.Messaging.IntegrationContext.UserSubscriptionEvents; + +public class DeletedUserSubscriptionIntegrationEvent : IntegrationEventBase +{ + public override string Name => nameof(DeletedUserSubscriptionIntegrationEvent); + public long TelegramUserId { get; init; } + public required string Location { get; init; } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserSubscriptionEvents/UpdatedUserSubscriptionIntegrationEvent.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserSubscriptionEvents/UpdatedUserSubscriptionIntegrationEvent.cs new file mode 100644 index 0000000..660b82a --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/UserSubscriptionEvents/UpdatedUserSubscriptionIntegrationEvent.cs @@ -0,0 +1,9 @@ +namespace TelegramBotApp.Messaging.IntegrationContext.UserSubscriptionEvents; + +public class UpdatedUserSubscriptionIntegrationEvent : IntegrationEventBase +{ + public override string Name => nameof(UpdatedUserSubscriptionIntegrationEvent); + 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.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/WeatherForecastIntegrationEvents/WeatherForecastRequestIntegrationEvent.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/WeatherForecastIntegrationEvents/WeatherForecastRequestIntegrationEvent.cs new file mode 100644 index 0000000..80f4b8e --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationContext/WeatherForecastIntegrationEvents/WeatherForecastRequestIntegrationEvent.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace TelegramBotApp.Messaging.IntegrationContext.WeatherForecastIntegrationEvents; + +[method: JsonConstructor] +public class WeatherForecastRequestIntegrationEvent(string location) : IntegrationEventBase +{ + public override string Name => nameof(WeatherForecastRequestIntegrationEvent); + public string Location => location; +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponseHandlers/Interfaces.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponseHandlers/Interfaces.cs new file mode 100644 index 0000000..26acc55 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponseHandlers/Interfaces.cs @@ -0,0 +1,11 @@ +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +namespace TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponseHandlers; + +public interface IResponseHandler; + +public interface IResponseHandler : IResponseHandler + where TRequest : IResponseMessage +{ + Task Handle(TRequest response); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponseHandlers/UniversalResponseHandler.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponseHandlers/UniversalResponseHandler.cs new file mode 100644 index 0000000..bba1bf4 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponseHandlers/UniversalResponseHandler.cs @@ -0,0 +1,8 @@ +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +namespace TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponseHandlers; + +public class UniversalResponseHandler : IResponseHandler +{ + public Task Handle(UniversalResponse response) => Task.FromResult(response); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponses/Interfaces.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponses/Interfaces.cs new file mode 100644 index 0000000..60f46a7 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponses/Interfaces.cs @@ -0,0 +1,3 @@ +namespace TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +public interface IResponseMessage; \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponses/UniversalResponse.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponses/UniversalResponse.cs new file mode 100644 index 0000000..b4b1c23 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/IntegrationResponseContext/IntegrationResponses/UniversalResponse.cs @@ -0,0 +1,9 @@ +namespace TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +public class UniversalResponse(string message) : IResponseMessage +{ + public string Message => message; + public bool IsEmpty => string.IsNullOrEmpty(message); + + public static UniversalResponse Empty => new(string.Empty); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Interfaces.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Interfaces.cs new file mode 100644 index 0000000..1b8dc63 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Interfaces.cs @@ -0,0 +1,54 @@ +using RabbitMQ.Client; +using TelegramBotApp.Messaging.EventBusContext; +using TelegramBotApp.Messaging.IntegrationContext; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponseHandlers; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +namespace TelegramBotApp.Messaging; + +public interface IMessageFormatter where T : class +{ + // ReSharper disable once UnusedMemberInSuper.Global + public string Format(T value); +} + +public interface IEventBus +{ + Task Publish(IntegrationEventBase eventBase, string? replyTo = null, + CancellationToken cancellationToken = default); + + void Subscribe() + where T : IntegrationEventBase + where TH : IIntegrationEventHandler; + + void SubscribeResponse() + where T : IResponseMessage + where TH : IResponseHandler; +} + +public interface IPersistentConnection : IDisposable +{ + bool IsConnected { get; } + + bool TryConnect(); + IModel CreateModel(); +} + +public interface IEventBusSubscriptionsManager +{ + void AddSubscription() + where T : IntegrationEventBase + where TH : IIntegrationEventHandler; + + void AddResponseSubscription() + where T : IResponseMessage + where TH : IResponseHandler; + + bool HasSubscriptionsForEvent(string eventName); + bool HasSubscriptionsForResponse(string replyName); + Type? GetEventTypeByName(string eventName); + Type? GetResponseTypeByName(string replyName); + IEnumerable GetHandlersForEvent(string eventName); + IEnumerable GetHandlersForResponse(string replyName); + string GetEventKey(); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Settings/JsonOptions.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Settings/JsonOptions.cs new file mode 100644 index 0000000..1408b43 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Settings/JsonOptions.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using TelegramBotApp.Messaging.IntegrationContext; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +namespace TelegramBotApp.Messaging.Settings; + +public interface IJsonOptions +{ + JsonSerializerOptions Options { get; } +} + +public class JsonOptions : IJsonOptions +{ + public JsonSerializerOptions Options { get; } = new() + { + TypeInfoResolver = + new CompositionPolymorphicTypeResolver([typeof(IntegrationEventBase), typeof(IResponseMessage)]) + }; +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Settings/RabbitMqSettings.cs b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Settings/RabbitMqSettings.cs new file mode 100644 index 0000000..271b883 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/Settings/RabbitMqSettings.cs @@ -0,0 +1,29 @@ +namespace TelegramBotApp.Messaging.Settings; + +public interface IMessageSettings +{ + string? HostName { get; } + int Port { get; } + string? Username { get; } + string? Password { get; } + string EventQueueName { get; } + string ResponseQueueName { get; } + string EventExchangeName { get; } + string ResponseExchangeName { get; } + string? ConnectionString { get; } + bool HasConnectionString { get; } +} + +public class RabbitMqSettings : IMessageSettings +{ + public string? HostName { get; init; } + public int Port { get; init; } + public string? Username { get; init; } + public string? Password { get; init; } + public required string EventQueueName { get; init; } + public required string ResponseQueueName { get; init; } + public required string EventExchangeName { get; init; } + public required string ResponseExchangeName { get; init; } + public string? ConnectionString { get; init; } + public bool HasConnectionString => !string.IsNullOrWhiteSpace(ConnectionString); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/TelegramBotApp.Messaging.csproj b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/TelegramBotApp.Messaging.csproj new file mode 100644 index 0000000..6736f77 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Infrastructure/TelegramBotApp.Messaging/TelegramBotApp.Messaging.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/AppPipeline/AppPipeline.cs b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/AppPipeline/AppPipeline.cs new file mode 100644 index 0000000..b98d2b6 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/AppPipeline/AppPipeline.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using TelegramBotApp.Application; +using TelegramBotApp.Caching; +using TelegramBotApp.Caching.Caching; +using TelegramBotApp.Domain.Models; +using TelegramBotApp.Messaging; +using TelegramBotApp.Messaging.Common; +using TelegramBotApp.Messaging.IntegrationContext.UserIntegrationEvents; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; + +namespace TelegramBotApp.Api.AppPipeline; + +public class AppPipeline : IPipeline +{ + public async Task Run() + { + try + { + using var host = Host.CreateDefaultBuilder() + .ConfigureAppConfiguration(config => + config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)) + .ConfigureServices((builder, services) => services + .AddApplication(builder.Configuration) + .AddMessaging(builder.Configuration) + .AddResponseHandlers() + .AddCaching(builder.Configuration)) + .Build() + .SubscribeToResponses(); + + var cancellationTokenSource = new CancellationTokenSource(); + var eventBus = host.Services.GetRequiredService(); + var botClient = host.Services.GetRequiredService(); + var cacheService = host.Services.GetRequiredService(); + var resendMessageService = host.Services.GetRequiredService(); + + await UpdateCache(eventBus); + await InitializeResendMessageService(resendMessageService, cacheService); + + botClient.StartReceiving( + eventBus, + cacheService, + resendMessageService, + cancellationTokenSource.Token); + + var me = await botClient.GetMeAsync(); + + Console.WriteLine($"Start listening for @{me.Username}"); + Console.ReadLine(); + + await cancellationTokenSource.CancelAsync(); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + private static async Task UpdateCache(IEventBus bus) + { + var task1 = bus.Publish(new CacheRequestUsersIntegrationEvent(), + replyTo: nameof(UniversalResponse)); // TODO: fix cancellation token (common settings for bus), fix reply + var task2 = bus.Publish(new CacheRequestUserSubscriptionsIntegrationEvent(), + replyTo: nameof(UniversalResponse)); + + await Task.WhenAll(task1, task2); + } + + private static async Task InitializeResendMessageService(IResendMessageService messageService, + ICacheService cacheService) + { + var subscriptionInfos = await cacheService.GetAsync>("allSubscriptions"); + if (subscriptionInfos?.Count == 0) return; + subscriptionInfos?.ForEach(s => + messageService.AddOrUpdateResendProcess(s.TelegramId, s.Location, s.ResendInterval)); + } +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/AppPipeline/Interfaces.cs b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/AppPipeline/Interfaces.cs new file mode 100644 index 0000000..e7efe93 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/AppPipeline/Interfaces.cs @@ -0,0 +1,6 @@ +namespace TelegramBotApp.Api.AppPipeline; + +public interface IPipeline +{ + Task Run(); +} \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/Dockerfile b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/Dockerfile new file mode 100644 index 0000000..1b9ecbf --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/Dockerfile @@ -0,0 +1,27 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env +WORKDIR /app + +COPY *.sln . +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/ +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/ + +RUN dotnet restore + +COPY WeatherBotApi.TelegramBotApp/ ./WeatherBotApi.TelegramBotApp/ +COPY WeatherBotApi.WeatherApp/ ./WeatherBotApi.WeatherApp/ + +WORKDIR /app/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/ +RUN dotnet publish -c Release -o out + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=build-env /app/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/out . +ENTRYPOINT ["dotnet", "TelegramBotApp.Api.dll"] \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/Program.cs b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/Program.cs new file mode 100644 index 0000000..a4a3663 --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/Program.cs @@ -0,0 +1,5 @@ +using TelegramBotApp.Api.AppPipeline; + +var pipeline = new AppPipeline(); + +await pipeline.Run(); \ No newline at end of file diff --git a/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/TelegramBotApp.Api.csproj b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/TelegramBotApp.Api.csproj new file mode 100644 index 0000000..b59c68b --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/TelegramBotApp.Api.csproj @@ -0,0 +1,27 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + + + + Always + + + + diff --git a/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/appsettings.json b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/appsettings.json new file mode 100644 index 0000000..861811d --- /dev/null +++ b/WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information" + } + }, + "RabbitMqSettings": { + "HostName": "localhost", + "Port": 15672, + "Username": "guest", + "Password": "guest", + "EventQueueName": "telegram-bot-event-queue", + "ResponseQueueName": "telegram-bot-response-queue", + "EventExchangeName": "event-exchange", + "ResponseExchangeName": "response-exchange" + }, + "TelegramSettings": { + "BotToken": "6819090366:AAEp-IrmVXY-U2Ie91lZktlkjxPG1IkJTJU" + }, + "ConnectionStrings": { + "Redis": "localhost:6379" + } +} \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Core/WeatherApp.Application/MessageFormatters/WeatherDescriptorFormatter.cs b/WeatherBotApi.WeatherApp/Core/WeatherApp.Application/MessageFormatters/WeatherDescriptorFormatter.cs new file mode 100644 index 0000000..faefb8b --- /dev/null +++ b/WeatherBotApi.WeatherApp/Core/WeatherApp.Application/MessageFormatters/WeatherDescriptorFormatter.cs @@ -0,0 +1,19 @@ +using TelegramBotApp.Messaging; +using WeatherApp.Domain.Models; + +namespace WeatherApp.Application.MessageFormatters; + +public class WeatherDescriptorFormatter : IMessageFormatter +{ + public string Format(WeatherDescriptor value) => + $""" + Location: {value.Location} + Temperature: {value.Temperature}°C + Feels Like: {value.FeelTemperature}°C + Humidity: {value.Humidity}% + Pressure: {value.Pressure} hPa + Visibility: {value.Visibility} m + Wind Speed: {value.Wind} km/h + UV Index: {value.UvIndex} + """; +} \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Core/WeatherApp.Application/WeatherApp.Application.csproj b/WeatherBotApi.WeatherApp/Core/WeatherApp.Application/WeatherApp.Application.csproj index cbae5f1..02c7da3 100644 --- a/WeatherBotApi.WeatherApp/Core/WeatherApp.Application/WeatherApp.Application.csproj +++ b/WeatherBotApi.WeatherApp/Core/WeatherApp.Application/WeatherApp.Application.csproj @@ -7,6 +7,7 @@ + diff --git a/WeatherBotApi.WeatherApp/Core/WeatherApp.Domain/Models/TelegramBotInfo.cs b/WeatherBotApi.WeatherApp/Core/WeatherApp.Domain/Models/TelegramBotInfo.cs deleted file mode 100644 index a459fa8..0000000 --- a/WeatherBotApi.WeatherApp/Core/WeatherApp.Domain/Models/TelegramBotInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace WeatherApp.Domain.Models; - -public class TelegramBotInfo -{ - public string Location { get; init; } - public string User { get; init; } -} \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Core/WeatherApp.Domain/Models/WeatherDescriptor.cs b/WeatherBotApi.WeatherApp/Core/WeatherApp.Domain/Models/WeatherDescriptor.cs index 6c6fcd3..b9a96d9 100644 --- a/WeatherBotApi.WeatherApp/Core/WeatherApp.Domain/Models/WeatherDescriptor.cs +++ b/WeatherBotApi.WeatherApp/Core/WeatherApp.Domain/Models/WeatherDescriptor.cs @@ -2,6 +2,7 @@ public class WeatherDescriptor { + public string? Location { get; private set; } public required int Temperature { get; init; } public required int FeelTemperature { get; init; } public required int Humidity { get; init; } @@ -9,4 +10,6 @@ public class WeatherDescriptor public required int Visibility { get; init; } public required int Wind { get; init; } public required int UvIndex { get; init; } + + public void SetLocation(string? location) => Location = location; } \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/EventBusExtensions.cs b/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/EventBusExtensions.cs new file mode 100644 index 0000000..1adb0a6 --- /dev/null +++ b/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/EventBusExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using TelegramBotApp.Messaging; +using TelegramBotApp.Messaging.IntegrationContext.WeatherForecastIntegrationEvents; +using WeatherApp.IntegrationEvents.IntegrationEventHandlers; + +namespace WeatherApp.IntegrationEvents; + +public static class EventBusExtensions +{ + // ReSharper disable once UnusedMethodReturnValue.Global + public static IApplicationBuilder SubscribeToEvents(this IApplicationBuilder app) + { + var eventBus = app.ApplicationServices.GetRequiredService(); + + eventBus.Subscribe(); + + return app; + } +} \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/IntegrationEventHandlers/RequestWeatherForecastIntegrationEventHandler.cs b/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/IntegrationEventHandlers/RequestWeatherForecastIntegrationEventHandler.cs new file mode 100644 index 0000000..083b62f --- /dev/null +++ b/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/IntegrationEventHandlers/RequestWeatherForecastIntegrationEventHandler.cs @@ -0,0 +1,21 @@ +using TelegramBotApp.Messaging.IntegrationContext; +using TelegramBotApp.Messaging.IntegrationContext.WeatherForecastIntegrationEvents; +using TelegramBotApp.Messaging.IntegrationResponseContext.IntegrationResponses; +using WeatherApp.Application.MessageFormatters; +using WeatherApp.Application.Services; + +namespace WeatherApp.IntegrationEvents.IntegrationEventHandlers; + +// ReSharper disable once ClassNeverInstantiated.Global +public class WeatherForecastRequestIntegrationEventHandler(IWeatherService weatherService) + : IIntegrationEventHandler +{ + private readonly WeatherDescriptorFormatter _messageFormatter = new(); + + public async Task Handle(WeatherForecastRequestIntegrationEvent @event) + { + var weatherDescriptor = await weatherService.GetWeatherForecastAsync(@event.Location); + weatherDescriptor.SetLocation(@event.Location); + return new UniversalResponse(_messageFormatter.Format(weatherDescriptor)); + } +} \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/WeatherApp.IntegrationEvents.csproj b/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/WeatherApp.IntegrationEvents.csproj new file mode 100644 index 0000000..a69aa18 --- /dev/null +++ b/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.IntegrationEvents/WeatherApp.IntegrationEvents.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.MassTransitIntegration/Consumers/TelegramBotInfoConsumer.cs b/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.MassTransitIntegration/Consumers/TelegramBotInfoConsumer.cs deleted file mode 100644 index e5a6e4c..0000000 --- a/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.MassTransitIntegration/Consumers/TelegramBotInfoConsumer.cs +++ /dev/null @@ -1,16 +0,0 @@ -using MassTransit; -using Microsoft.Extensions.Logging; -using WeatherApp.Application.Services; -using WeatherApp.Domain.Models; - -namespace WeatherApp.MassTransitIntegration.Consumers; - -public class TelegramBotInfoConsumer(IWeatherService weatherService, ILogger logger) : IConsumer -{ - public async Task Consume(ConsumeContext context) - { - logger.LogInformation("Received a new message from {User}", context.Message.User); - var weatherDescriptor = await weatherService.GetWeatherForecastAsync(context.Message.Location); - await context.RespondAsync(weatherDescriptor); - } -} \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.MassTransitIntegration/WeatherApp.MassTransitIntegration.csproj b/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.MassTransitIntegration/WeatherApp.MassTransitIntegration.csproj deleted file mode 100644 index 9659a2f..0000000 --- a/WeatherBotApi.WeatherApp/Infrastructure/WeatherApp.MassTransitIntegration/WeatherApp.MassTransitIntegration.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - - - - - - - ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.Extensions.Logging.Abstractions.dll - - - - diff --git a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Controllers/WeatherController.cs b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Controllers/WeatherController.cs index bbd0775..6e5a233 100644 --- a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Controllers/WeatherController.cs +++ b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Controllers/WeatherController.cs @@ -7,12 +7,12 @@ namespace WeatherApp.WebApi.Controllers; [Route("api/[controller]/[action]")] public class WeatherController(IWeatherService weatherService, ILogger logger) : ControllerBase { - [HttpGet] + [HttpGet("{location:required}")] public async Task GetWeatherForecast(string location) { try { - logger.LogInformation("Getting weather forecast for {Location}", location); + logger.LogInformation("Getting weather forecast for {location}", location); var descriptor = await weatherService.GetWeatherForecastAsync(location); logger.LogInformation("Weather forecast for {Location} retrieved successfully", location); diff --git a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Program.cs b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Program.cs index 3c23344..2c0221d 100644 --- a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Program.cs +++ b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Program.cs @@ -1,8 +1,8 @@ using System.Text.Json; using Converters.JsonConverters; -using MassTransit; +using TelegramBotApp.Messaging; using WeatherApp.Application.Services; -using WeatherApp.MassTransitIntegration.Consumers; +using WeatherApp.IntegrationEvents; var builder = WebApplication.CreateBuilder(args); var jsonSerializerOptions = new JsonSerializerOptions @@ -15,12 +15,7 @@ builder.Services.AddHttpClient(); builder.Services.AddControllers(); builder.Services.AddSingleton(jsonSerializerOptions); -builder.Services.AddMassTransit(x => -{ - x.AddConsumer(); - x.UsingRabbitMq((context, cfg) => cfg.ReceiveEndpoint("weather-forecast", e => - e.ConfigureConsumer(context))); -}); +builder.Services.AddMessaging(builder.Configuration); var app = builder.Build(); @@ -35,6 +30,7 @@ }); } +app.SubscribeToEvents(); app.UseHttpsRedirection(); app.UseRouting(); app.MapControllers(); diff --git a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Properties/Dockerfile b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Properties/Dockerfile new file mode 100644 index 0000000..43a0911 --- /dev/null +++ b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Properties/Dockerfile @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /app + +COPY *.sln . +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.WeatherApp/ ./WeatherBotApi.WeatherApp/ +COPY WeatherBotApi.TelegramBotApp/ ./WeatherBotApi.TelegramBotApp/ + +WORKDIR /app/WeatherBotApi.WeatherApp/Presentation/WeatherApp.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.WeatherApp/Presentation/WeatherApp.WebApi/out ./ + +ENTRYPOINT ["dotnet", "WeatherApp.WebApi.dll"] \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/WeatherApp.WebApi.csproj b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/WeatherApp.WebApi.csproj index 7d2772a..cea585a 100644 --- a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/WeatherApp.WebApi.csproj +++ b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/WeatherApp.WebApi.csproj @@ -7,13 +7,14 @@ + + - + - diff --git a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/appsettings.Development.json b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/appsettings.Development.json index 0c208ae..031843d 100644 --- a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/appsettings.Development.json +++ b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/appsettings.Development.json @@ -4,5 +4,15 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "RabbitMqSettings": { + "HostName": "localhost", + "Port": 15672, + "Username": "guest", + "Password": "guest", + "EventQueueName": "weather-event-queue", + "ResponseQueueName": "weather-response-queue", + "EventExchangeName": "event-exchange", + "ResponseExchangeName": "response-exchange" } -} +} \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/appsettings.json b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/appsettings.json index 10f68b8..9485e6c 100644 --- a/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/appsettings.json +++ b/WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/appsettings.json @@ -5,5 +5,15 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" -} + "AllowedHosts": "*", + "RabbitMqSettings": { + "HostName": "localhost", + "Port": 15672, + "Username": "guest", + "Password": "guest", + "EventQueueName": "weather-event-queue", + "ResponseQueueName": "weather-response-queue", + "EventExchangeName": "event-exchange", + "ResponseExchangeName": "response-exchange" + } +} \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/ServiceFixtures/WeatherServiceFixture.cs b/WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/ServiceFixtures/WeatherServiceFixture.cs new file mode 100644 index 0000000..fdccbf4 --- /dev/null +++ b/WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/ServiceFixtures/WeatherServiceFixture.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using Converters.JsonConverters; +using Microsoft.Extensions.DependencyInjection; +using WeatherApp.Application.Services; + +namespace WeatherApp.Tests.ServiceFixtures; + +// ReSharper disable once ClassNeverInstantiated.Global +public class WeatherServiceFixture : IDisposable +{ + private readonly IServiceScope _scope; + private bool _disposed; + + private readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + Converters = { new WeatherDescriptorJsonConverter() } + }; + + public WeatherServiceFixture() + { + var services = new ServiceCollection(); + + services.AddTransient(); + services.AddHttpClient(); + services.AddSingleton(_jsonSerializerOptions); + + var serviceProvider = services.BuildServiceProvider(); + _scope = serviceProvider.CreateScope(); + } + + ~WeatherServiceFixture() => Dispose(false); + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + if (disposing) + { + // Free any other managed objects here. + _scope.Dispose(); + } + + // Free any unmanaged objects here. + _disposed = true; + } + + public T GetService() where T : notnull => _scope.ServiceProvider.GetRequiredService(); +} \ No newline at end of file diff --git a/WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/WeatherApp.Tests.csproj b/WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/WeatherApp.Tests.csproj new file mode 100644 index 0000000..ff9b161 --- /dev/null +++ b/WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/WeatherApp.Tests.csproj @@ -0,0 +1,35 @@ + + + + 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.WeatherApp/Tests/WeatherApp.Tests/WeatherServiceTests.cs b/WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/WeatherServiceTests.cs new file mode 100644 index 0000000..1dc8ba4 --- /dev/null +++ b/WeatherBotApi.WeatherApp/Tests/WeatherApp.Tests/WeatherServiceTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NSubstitute; +using WeatherApp.Application.Services; +using WeatherApp.Tests.ServiceFixtures; +using WeatherApp.WebApi.Controllers; +using Xunit; + +namespace WeatherApp.Tests; + +public class WeatherServiceTests(WeatherServiceFixture weatherServiceFixture) : IClassFixture +{ + private readonly IWeatherService _weatherServiceFixture = weatherServiceFixture.GetService(); + private readonly ILogger _mockLogger = Substitute.For>(); + + [Theory] + [InlineData("London")] + [InlineData("Novosibirsk")] + [InlineData("New York")] + [InlineData("Moscow")] + public async Task GetWeatherForecast_WithValidLocation_ReturnsOkObjectResult(string location) + { + // Arrange + var controller = new WeatherController(_weatherServiceFixture, _mockLogger); + + // Act + var result = await controller.GetWeatherForecast(location); + + // Assert + var okResult = result.Should().BeOfType().Subject; + okResult.Should().NotBeNull(); + okResult?.StatusCode.Should().Be(StatusCodes.Status200OK); + } + + [Fact] + public async Task GetWeatherForecast_WithInvalidLocation_ReturnsBadRequest() + { + // Arrange + var controller = new WeatherController(_weatherServiceFixture, _mockLogger); + + // Act + var result = await controller.GetWeatherForecast("InvalidLocation"); + + // Assert + var badRequestResult = result.Should().BeOfType().Subject; + badRequestResult.Should().NotBeNull(); + badRequestResult?.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..968431c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +version: '3.8' +services: + weatherapp: + image: weatherapp + build: + context: . + dockerfile: WeatherBotApi.WeatherApp/Presentation/WeatherApp.WebApi/Properties/Dockerfile + ports: + - "5555:8080" + depends_on: + rabbitmq: + condition: service_healthy + environment: + - ASPNETCORE_ENVIRONMENT=Development + telegrambot: + image: telegrambot + build: + context: . + dockerfile: WeatherBotApi.TelegramBotApp/Presentation/TelegramBotApp.Api/Dockerfile + depends_on: + rabbitmq: + condition: service_healthy + databaseapi: + image: databaseapi + build: + context: . + dockerfile: WeatherBotApi.DatabaseApp/Presentation/DatabaseApp.WebApi/Properties/Dockerfile + ports: + - "5556:8080" + depends_on: + database: + condition: service_healthy + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DbConnection=Host=database:5432;Database=weather-database;Username=user;Password=pass + rabbitmq: + image: rabbitmq:3.13.0-management + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 30s + timeout: 30s + retries: 3 + ports: + - "5672:5672" + - "15672:15672" + database: + image: postgres:latest + restart: always + environment: + POSTGRES_DB: weather-database + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + volumes: + - ~/data/postgres:/var/lib/postgresql/data/ + healthcheck: + test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ] + interval: 1s + timeout: 5s + retries: 3 + ports: + - "5755:5432" + redis: + image: 'redis:latest' + restart: always + ports: + - "6379:6379" \ No newline at end of file diff --git a/weather-bot-api.sln b/weather-bot-api.sln index 15ffbc1..1ff8d41 100644 --- a/weather-bot-api.sln +++ b/weather-bot-api.sln @@ -19,7 +19,51 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastru EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeatherApp.Converters", "WeatherBotApi.WeatherApp\Infrastructure\WeatherApp.Converters\WeatherApp.Converters.csproj", "{554D3056-100F-48C8-A2B9-823E5C40A0E4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeatherApp.MassTransitIntegration", "WeatherBotApi.WeatherApp\Infrastructure\WeatherApp.MassTransitIntegration\WeatherApp.MassTransitIntegration.csproj", "{41CCB558-732D-4D75-86C7-7063D770CB24}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{C4989028-8048-4063-BE31-B32B1779E439}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeatherApp.Tests", "WeatherBotApi.WeatherApp\Tests\WeatherApp.Tests\WeatherApp.Tests.csproj", "{23C0B88D-22F3-429A-8041-4C257665015F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WeatherBotApi.TelegramBotApp", "WeatherBotApi.TelegramBotApp", "{01A85AC6-EE9B-43CE-8190-704FF9FE3C93}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Presentation", "Presentation", "{38626967-D1CF-4D7E-8BB8-F0ECCEC096D4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramBotApp.Api", "WeatherBotApi.TelegramBotApp\Presentation\TelegramBotApp.Api\TelegramBotApp.Api.csproj", "{C8FEDCC6-3FD8-451B-A510-7BBC65003501}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{84518F33-05CC-4C3E-A423-2089038FBB69}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{100440C4-7D6C-4723-907B-B3951D65859E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramBotApp.Messaging", "WeatherBotApi.TelegramBotApp\Infrastructure\TelegramBotApp.Messaging\TelegramBotApp.Messaging.csproj", "{B6FC47BB-2AA5-44C9-BB08-2FB81EEEDF37}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramBotApp.Domain", "WeatherBotApi.TelegramBotApp\Core\TelegramBotApp.Domain\TelegramBotApp.Domain.csproj", "{4B8FE0E4-D1E3-40D0-90A3-3C4C25222DA3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeatherApp.IntegrationEvents", "WeatherBotApi.WeatherApp\Infrastructure\WeatherApp.IntegrationEvents\WeatherApp.IntegrationEvents.csproj", "{A57C0337-6DB0-4338-9ED8-386E0914F270}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramBotApp.Application", "WeatherBotApi.TelegramBotApp\Core\TelegramBotApp.Application\TelegramBotApp.Application.csproj", "{D1A64E09-7638-4972-A85C-8FD1683A0E37}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WeatherBotApi.DatabaseApp", "WeatherBotApi.DatabaseApp", "{07A2B9AE-A81F-482F-8016-C87CE6F0E9ED}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{7829DEFE-ED08-465B-AF25-3DAC166045C3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseApp.Domain", "WeatherBotApi.DatabaseApp\Core\DatabaseApp.Domain\DatabaseApp.Domain.csproj", "{BC3B4F1F-B796-4A0D-8773-E7BC592DA4B5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseApp.Application", "WeatherBotApi.DatabaseApp\Core\DatabaseApp.Application\DatabaseApp.Application.csproj", "{5CCFA465-C126-436F-8784-FD26D03EDEDF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{CC960349-7D77-4842-BBA9-6979F81C506B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseApp.Persistence", "WeatherBotApi.DatabaseApp\Infrastructure\DatabaseApp.Persistence\DatabaseApp.Persistence.csproj", "{C0DED595-2182-47E2-817B-0DEFFFEBC633}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Presentation", "Presentation", "{F2AB2CBF-FE29-4212-A653-39F357DD645F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{39498A5C-79F0-4F5C-B1B6-7F90648BF340}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseApp.Tests", "WeatherBotApi.DatabaseApp\Tests\DatabaseApp.Tests\DatabaseApp.Tests.csproj", "{682D1A99-FDDF-4568-BE8D-124B5D60409E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseApp.WebApi", "WeatherBotApi.DatabaseApp\Presentation\DatabaseApp.WebApi\DatabaseApp.WebApi.csproj", "{7B4A907F-F6BD-41B1-B9D4-8BC6925CEE9D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseApp.IntegrationEvents", "WeatherBotApi.DatabaseApp\Infrastructure\DatabaseApp.IntegrationEvents\DatabaseApp.IntegrationEvents.csproj", "{C85E42A3-9611-4056-A936-477445439784}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramBotApp.Caching", "WeatherBotApi.TelegramBotApp\Infrastructure\TelegramBotApp.Caching\TelegramBotApp.Caching.csproj", "{F1D43D42-2000-4A0E-AAEB-A5EE40B85B75}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -46,10 +90,58 @@ Global {554D3056-100F-48C8-A2B9-823E5C40A0E4}.Debug|Any CPU.Build.0 = Debug|Any CPU {554D3056-100F-48C8-A2B9-823E5C40A0E4}.Release|Any CPU.ActiveCfg = Release|Any CPU {554D3056-100F-48C8-A2B9-823E5C40A0E4}.Release|Any CPU.Build.0 = Release|Any CPU - {41CCB558-732D-4D75-86C7-7063D770CB24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {41CCB558-732D-4D75-86C7-7063D770CB24}.Debug|Any CPU.Build.0 = Debug|Any CPU - {41CCB558-732D-4D75-86C7-7063D770CB24}.Release|Any CPU.ActiveCfg = Release|Any CPU - {41CCB558-732D-4D75-86C7-7063D770CB24}.Release|Any CPU.Build.0 = Release|Any CPU + {23C0B88D-22F3-429A-8041-4C257665015F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23C0B88D-22F3-429A-8041-4C257665015F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23C0B88D-22F3-429A-8041-4C257665015F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23C0B88D-22F3-429A-8041-4C257665015F}.Release|Any CPU.Build.0 = Release|Any CPU + {C8FEDCC6-3FD8-451B-A510-7BBC65003501}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8FEDCC6-3FD8-451B-A510-7BBC65003501}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8FEDCC6-3FD8-451B-A510-7BBC65003501}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8FEDCC6-3FD8-451B-A510-7BBC65003501}.Release|Any CPU.Build.0 = Release|Any CPU + {B6FC47BB-2AA5-44C9-BB08-2FB81EEEDF37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6FC47BB-2AA5-44C9-BB08-2FB81EEEDF37}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6FC47BB-2AA5-44C9-BB08-2FB81EEEDF37}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6FC47BB-2AA5-44C9-BB08-2FB81EEEDF37}.Release|Any CPU.Build.0 = Release|Any CPU + {4B8FE0E4-D1E3-40D0-90A3-3C4C25222DA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4B8FE0E4-D1E3-40D0-90A3-3C4C25222DA3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B8FE0E4-D1E3-40D0-90A3-3C4C25222DA3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4B8FE0E4-D1E3-40D0-90A3-3C4C25222DA3}.Release|Any CPU.Build.0 = Release|Any CPU + {A57C0337-6DB0-4338-9ED8-386E0914F270}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A57C0337-6DB0-4338-9ED8-386E0914F270}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A57C0337-6DB0-4338-9ED8-386E0914F270}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A57C0337-6DB0-4338-9ED8-386E0914F270}.Release|Any CPU.Build.0 = Release|Any CPU + {D1A64E09-7638-4972-A85C-8FD1683A0E37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1A64E09-7638-4972-A85C-8FD1683A0E37}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1A64E09-7638-4972-A85C-8FD1683A0E37}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1A64E09-7638-4972-A85C-8FD1683A0E37}.Release|Any CPU.Build.0 = Release|Any CPU + {BC3B4F1F-B796-4A0D-8773-E7BC592DA4B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC3B4F1F-B796-4A0D-8773-E7BC592DA4B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC3B4F1F-B796-4A0D-8773-E7BC592DA4B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC3B4F1F-B796-4A0D-8773-E7BC592DA4B5}.Release|Any CPU.Build.0 = Release|Any CPU + {5CCFA465-C126-436F-8784-FD26D03EDEDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CCFA465-C126-436F-8784-FD26D03EDEDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CCFA465-C126-436F-8784-FD26D03EDEDF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CCFA465-C126-436F-8784-FD26D03EDEDF}.Release|Any CPU.Build.0 = Release|Any CPU + {C0DED595-2182-47E2-817B-0DEFFFEBC633}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0DED595-2182-47E2-817B-0DEFFFEBC633}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0DED595-2182-47E2-817B-0DEFFFEBC633}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0DED595-2182-47E2-817B-0DEFFFEBC633}.Release|Any CPU.Build.0 = Release|Any CPU + {682D1A99-FDDF-4568-BE8D-124B5D60409E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {682D1A99-FDDF-4568-BE8D-124B5D60409E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {682D1A99-FDDF-4568-BE8D-124B5D60409E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {682D1A99-FDDF-4568-BE8D-124B5D60409E}.Release|Any CPU.Build.0 = Release|Any CPU + {7B4A907F-F6BD-41B1-B9D4-8BC6925CEE9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B4A907F-F6BD-41B1-B9D4-8BC6925CEE9D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B4A907F-F6BD-41B1-B9D4-8BC6925CEE9D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B4A907F-F6BD-41B1-B9D4-8BC6925CEE9D}.Release|Any CPU.Build.0 = Release|Any CPU + {C85E42A3-9611-4056-A936-477445439784}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C85E42A3-9611-4056-A936-477445439784}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C85E42A3-9611-4056-A936-477445439784}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C85E42A3-9611-4056-A936-477445439784}.Release|Any CPU.Build.0 = Release|Any CPU + {F1D43D42-2000-4A0E-AAEB-A5EE40B85B75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1D43D42-2000-4A0E-AAEB-A5EE40B85B75}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1D43D42-2000-4A0E-AAEB-A5EE40B85B75}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1D43D42-2000-4A0E-AAEB-A5EE40B85B75}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {04A64D44-B072-4D2A-AC2E-E0653EC5DCA4} = {143C729F-74BA-4B93-B9D7-87AFD9897919} @@ -59,6 +151,26 @@ Global {4A0E8AB2-E5D0-4144-B97B-BCA0E78B1CD4} = {530FDE3D-A765-421B-AC23-AF5DCAFD5007} {4FC5365A-ACE4-4C9D-AA15-3C6289035694} = {143C729F-74BA-4B93-B9D7-87AFD9897919} {554D3056-100F-48C8-A2B9-823E5C40A0E4} = {4FC5365A-ACE4-4C9D-AA15-3C6289035694} - {41CCB558-732D-4D75-86C7-7063D770CB24} = {4FC5365A-ACE4-4C9D-AA15-3C6289035694} + {C4989028-8048-4063-BE31-B32B1779E439} = {143C729F-74BA-4B93-B9D7-87AFD9897919} + {23C0B88D-22F3-429A-8041-4C257665015F} = {C4989028-8048-4063-BE31-B32B1779E439} + {38626967-D1CF-4D7E-8BB8-F0ECCEC096D4} = {01A85AC6-EE9B-43CE-8190-704FF9FE3C93} + {C8FEDCC6-3FD8-451B-A510-7BBC65003501} = {38626967-D1CF-4D7E-8BB8-F0ECCEC096D4} + {84518F33-05CC-4C3E-A423-2089038FBB69} = {01A85AC6-EE9B-43CE-8190-704FF9FE3C93} + {100440C4-7D6C-4723-907B-B3951D65859E} = {01A85AC6-EE9B-43CE-8190-704FF9FE3C93} + {B6FC47BB-2AA5-44C9-BB08-2FB81EEEDF37} = {100440C4-7D6C-4723-907B-B3951D65859E} + {4B8FE0E4-D1E3-40D0-90A3-3C4C25222DA3} = {84518F33-05CC-4C3E-A423-2089038FBB69} + {A57C0337-6DB0-4338-9ED8-386E0914F270} = {4FC5365A-ACE4-4C9D-AA15-3C6289035694} + {D1A64E09-7638-4972-A85C-8FD1683A0E37} = {84518F33-05CC-4C3E-A423-2089038FBB69} + {7829DEFE-ED08-465B-AF25-3DAC166045C3} = {07A2B9AE-A81F-482F-8016-C87CE6F0E9ED} + {BC3B4F1F-B796-4A0D-8773-E7BC592DA4B5} = {7829DEFE-ED08-465B-AF25-3DAC166045C3} + {5CCFA465-C126-436F-8784-FD26D03EDEDF} = {7829DEFE-ED08-465B-AF25-3DAC166045C3} + {CC960349-7D77-4842-BBA9-6979F81C506B} = {07A2B9AE-A81F-482F-8016-C87CE6F0E9ED} + {C0DED595-2182-47E2-817B-0DEFFFEBC633} = {CC960349-7D77-4842-BBA9-6979F81C506B} + {F2AB2CBF-FE29-4212-A653-39F357DD645F} = {07A2B9AE-A81F-482F-8016-C87CE6F0E9ED} + {39498A5C-79F0-4F5C-B1B6-7F90648BF340} = {07A2B9AE-A81F-482F-8016-C87CE6F0E9ED} + {682D1A99-FDDF-4568-BE8D-124B5D60409E} = {39498A5C-79F0-4F5C-B1B6-7F90648BF340} + {7B4A907F-F6BD-41B1-B9D4-8BC6925CEE9D} = {F2AB2CBF-FE29-4212-A653-39F357DD645F} + {C85E42A3-9611-4056-A936-477445439784} = {CC960349-7D77-4842-BBA9-6979F81C506B} + {F1D43D42-2000-4A0E-AAEB-A5EE40B85B75} = {100440C4-7D6C-4723-907B-B3951D65859E} EndGlobalSection EndGlobal