From 2738f31f26a7c44d89b402ca3134fe695c0af00f Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Sun, 19 Apr 2026 13:12:04 +0300 Subject: [PATCH] add architecture skeleton --- GymLogAI.AI/DependencyInjection.cs | 33 ++ GymLogAI.AI/GymLogAI.AI.csproj | 19 + GymLogAI.AI/OpenRouterChatClient.cs | 36 ++ GymLogAI.AI/OpenRouterEmbeddingClient.cs | 37 ++ GymLogAI.AI/OpenRouterOptions.cs | 11 + GymLogAI.AI/StubEmbeddingService.cs | 23 ++ GymLogAI.AI/WorkoutParserAIService.cs | 36 ++ GymLogAI.AI/WorkoutRecommendationAIService.cs | 34 ++ .../Contracts/ImportTelegramMessageRequest.cs | 14 + .../Contracts/RecommendWorkoutRequest.cs | 8 + .../Contracts/WorkoutHistoryResponses.cs | 23 ++ GymLogAI.API/Dockerfile | 4 + GymLogAI.API/GymLogAI.API.csproj | 6 +- GymLogAI.API/Program.cs | 119 +++++-- GymLogAI.API/WeatherForecast.cs | 12 - GymLogAI.API/appsettings.Development.json | 10 +- GymLogAI.API/appsettings.json | 10 +- .../DTOs/ParsedExerciseDto.cs | 7 + .../DTOs/ParsedExerciseSetDto.cs | 9 + GymLogAI.Application/DTOs/ParsedWorkoutDto.cs | 7 + .../DTOs/WorkoutRecommendationContext.cs | 8 + .../DTOs/WorkoutRecommendationDto.cs | 7 + .../GymLogAI.Application.csproj | 4 +- .../Handlers/ImportTelegramMessageHandler.cs | 66 ++++ .../Handlers/ParseTelegramMessageHandler.cs | 108 ++++++ .../Handlers/RecommendWorkoutHandler.cs | 26 ++ .../Interfaces/IEmbeddingService.cs | 6 + .../Interfaces/IExerciseRepository.cs | 10 + .../Interfaces/ITelegramMessageRepository.cs | 11 + .../Interfaces/IUserRepository.cs | 11 + .../Interfaces/IWorkoutParser.cs | 8 + .../IWorkoutRecommendationGenerator.cs | 12 + .../Interfaces/IWorkoutRepository.cs | 9 + GymLogAI.Core/Abstractions/IClock.cs | 6 + GymLogAI.Core/Abstractions/IIdGenerator.cs | 6 + GymLogAI.Core/Entities/Exercise.cs | 12 + GymLogAI.Core/Entities/ExerciseSet.cs | 15 + GymLogAI.Core/Entities/TelegramMessage.cs | 21 ++ GymLogAI.Core/Entities/User.cs | 14 + GymLogAI.Core/Entities/Workout.cs | 19 + GymLogAI.Core/Entities/WorkoutExercise.cs | 14 + GymLogAI.Core/Enums/GoalType.cs | 10 + GymLogAI.Core/Enums/ParsingStatus.cs | 9 + GymLogAI.Core/Enums/WorkoutSourceType.cs | 8 + GymLogAI.Core/GymLogAI.Core.csproj | 9 + GymLogAI.Persistence/AppDbContext.cs | 19 + .../Configurations/ExerciseConfiguration.cs | 20 ++ .../ExerciseSetConfiguration.cs | 20 ++ .../TelegramMessageConfiguration.cs | 31 ++ .../Configurations/UserConfiguration.cs | 22 ++ .../Configurations/WorkoutConfiguration.cs | 29 ++ .../WorkoutExerciseConfiguration.cs | 28 ++ GymLogAI.Persistence/DependencyInjection.cs | 29 ++ .../DesignTimeAppDbContextFactory.cs | 15 + .../GymLogAI.Persistence.csproj | 23 ++ .../Infrastructure/GuidIdGenerator.cs | 8 + .../Infrastructure/SystemClock.cs | 8 + .../20260419100551_InitialCreate.Designer.cs | 326 ++++++++++++++++++ .../20260419100551_InitialCreate.cs | 227 ++++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 323 +++++++++++++++++ .../Repositories/ExerciseRepository.cs | 27 ++ .../Repositories/TelegramMessageRepository.cs | 37 ++ .../Repositories/UserRepository.cs | 32 ++ .../Repositories/WorkoutRepository.cs | 28 ++ GymLogAI.TelegramBot/Dockerfile | 21 ++ .../GymLogAI.TelegramBot.csproj | 25 ++ GymLogAI.TelegramBot/Program.cs | 44 +++ .../TelegramBotHostedService.cs | 53 +++ .../TelegramBotOptions.cs | 3 +- GymLogAI.TelegramBot/UpdateHandler.cs | 82 +++++ .../appsettings.Development.json | 9 + GymLogAI.TelegramBot/appsettings.json | 21 ++ .../TgBotServiceCollectionExtensions.cs | 19 - GymLogAI.Worker/Dockerfile | 21 ++ GymLogAI.Worker/GymLogAI.Worker.csproj | 20 ++ .../ParsePendingTelegramMessagesJob.cs | 40 +++ GymLogAI.Worker/Program.cs | 26 ++ .../Properties/launchSettings.json | 12 + GymLogAI.Worker/WorkerOptions.cs | 9 + GymLogAI.Worker/appsettings.Development.json | 15 + GymLogAI.Worker/appsettings.json | 21 ++ GymLogAI.sln | 103 +++++- README.md | 51 +++ compose.yaml | 46 ++- 84 files changed, 2710 insertions(+), 70 deletions(-) create mode 100644 GymLogAI.AI/DependencyInjection.cs create mode 100644 GymLogAI.AI/GymLogAI.AI.csproj create mode 100644 GymLogAI.AI/OpenRouterChatClient.cs create mode 100644 GymLogAI.AI/OpenRouterEmbeddingClient.cs create mode 100644 GymLogAI.AI/OpenRouterOptions.cs create mode 100644 GymLogAI.AI/StubEmbeddingService.cs create mode 100644 GymLogAI.AI/WorkoutParserAIService.cs create mode 100644 GymLogAI.AI/WorkoutRecommendationAIService.cs create mode 100644 GymLogAI.API/Contracts/ImportTelegramMessageRequest.cs create mode 100644 GymLogAI.API/Contracts/RecommendWorkoutRequest.cs create mode 100644 GymLogAI.API/Contracts/WorkoutHistoryResponses.cs delete mode 100644 GymLogAI.API/WeatherForecast.cs create mode 100644 GymLogAI.Application/DTOs/ParsedExerciseDto.cs create mode 100644 GymLogAI.Application/DTOs/ParsedExerciseSetDto.cs create mode 100644 GymLogAI.Application/DTOs/ParsedWorkoutDto.cs create mode 100644 GymLogAI.Application/DTOs/WorkoutRecommendationContext.cs create mode 100644 GymLogAI.Application/DTOs/WorkoutRecommendationDto.cs rename GymLogAI.TgBot/GymLogAI.TgBot.csproj => GymLogAI.Application/GymLogAI.Application.csproj (64%) create mode 100644 GymLogAI.Application/Handlers/ImportTelegramMessageHandler.cs create mode 100644 GymLogAI.Application/Handlers/ParseTelegramMessageHandler.cs create mode 100644 GymLogAI.Application/Handlers/RecommendWorkoutHandler.cs create mode 100644 GymLogAI.Application/Interfaces/IEmbeddingService.cs create mode 100644 GymLogAI.Application/Interfaces/IExerciseRepository.cs create mode 100644 GymLogAI.Application/Interfaces/ITelegramMessageRepository.cs create mode 100644 GymLogAI.Application/Interfaces/IUserRepository.cs create mode 100644 GymLogAI.Application/Interfaces/IWorkoutParser.cs create mode 100644 GymLogAI.Application/Interfaces/IWorkoutRecommendationGenerator.cs create mode 100644 GymLogAI.Application/Interfaces/IWorkoutRepository.cs create mode 100644 GymLogAI.Core/Abstractions/IClock.cs create mode 100644 GymLogAI.Core/Abstractions/IIdGenerator.cs create mode 100644 GymLogAI.Core/Entities/Exercise.cs create mode 100644 GymLogAI.Core/Entities/ExerciseSet.cs create mode 100644 GymLogAI.Core/Entities/TelegramMessage.cs create mode 100644 GymLogAI.Core/Entities/User.cs create mode 100644 GymLogAI.Core/Entities/Workout.cs create mode 100644 GymLogAI.Core/Entities/WorkoutExercise.cs create mode 100644 GymLogAI.Core/Enums/GoalType.cs create mode 100644 GymLogAI.Core/Enums/ParsingStatus.cs create mode 100644 GymLogAI.Core/Enums/WorkoutSourceType.cs create mode 100644 GymLogAI.Core/GymLogAI.Core.csproj create mode 100644 GymLogAI.Persistence/AppDbContext.cs create mode 100644 GymLogAI.Persistence/Configurations/ExerciseConfiguration.cs create mode 100644 GymLogAI.Persistence/Configurations/ExerciseSetConfiguration.cs create mode 100644 GymLogAI.Persistence/Configurations/TelegramMessageConfiguration.cs create mode 100644 GymLogAI.Persistence/Configurations/UserConfiguration.cs create mode 100644 GymLogAI.Persistence/Configurations/WorkoutConfiguration.cs create mode 100644 GymLogAI.Persistence/Configurations/WorkoutExerciseConfiguration.cs create mode 100644 GymLogAI.Persistence/DependencyInjection.cs create mode 100644 GymLogAI.Persistence/DesignTimeAppDbContextFactory.cs create mode 100644 GymLogAI.Persistence/GymLogAI.Persistence.csproj create mode 100644 GymLogAI.Persistence/Infrastructure/GuidIdGenerator.cs create mode 100644 GymLogAI.Persistence/Infrastructure/SystemClock.cs create mode 100644 GymLogAI.Persistence/Migrations/20260419100551_InitialCreate.Designer.cs create mode 100644 GymLogAI.Persistence/Migrations/20260419100551_InitialCreate.cs create mode 100644 GymLogAI.Persistence/Migrations/AppDbContextModelSnapshot.cs create mode 100644 GymLogAI.Persistence/Repositories/ExerciseRepository.cs create mode 100644 GymLogAI.Persistence/Repositories/TelegramMessageRepository.cs create mode 100644 GymLogAI.Persistence/Repositories/UserRepository.cs create mode 100644 GymLogAI.Persistence/Repositories/WorkoutRepository.cs create mode 100644 GymLogAI.TelegramBot/Dockerfile create mode 100644 GymLogAI.TelegramBot/GymLogAI.TelegramBot.csproj create mode 100644 GymLogAI.TelegramBot/Program.cs create mode 100644 GymLogAI.TelegramBot/TelegramBotHostedService.cs rename {GymLogAI.TgBot => GymLogAI.TelegramBot}/TelegramBotOptions.cs (61%) create mode 100644 GymLogAI.TelegramBot/UpdateHandler.cs create mode 100644 GymLogAI.TelegramBot/appsettings.Development.json create mode 100644 GymLogAI.TelegramBot/appsettings.json delete mode 100644 GymLogAI.TgBot/TgBotServiceCollectionExtensions.cs create mode 100644 GymLogAI.Worker/Dockerfile create mode 100644 GymLogAI.Worker/GymLogAI.Worker.csproj create mode 100644 GymLogAI.Worker/ParsePendingTelegramMessagesJob.cs create mode 100644 GymLogAI.Worker/Program.cs create mode 100644 GymLogAI.Worker/Properties/launchSettings.json create mode 100644 GymLogAI.Worker/WorkerOptions.cs create mode 100644 GymLogAI.Worker/appsettings.Development.json create mode 100644 GymLogAI.Worker/appsettings.json create mode 100644 README.md diff --git a/GymLogAI.AI/DependencyInjection.cs b/GymLogAI.AI/DependencyInjection.cs new file mode 100644 index 0000000..020d533 --- /dev/null +++ b/GymLogAI.AI/DependencyInjection.cs @@ -0,0 +1,33 @@ +using GymLogAI.Application.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace GymLogAI.AI; + +public static class DependencyInjection +{ + public static IServiceCollection AddAiServices(this IServiceCollection services, IConfiguration configuration) + { + services + .AddOptions() + .Bind(configuration.GetSection(OpenRouterOptions.SectionName)); + + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + }); + + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/GymLogAI.AI/GymLogAI.AI.csproj b/GymLogAI.AI/GymLogAI.AI.csproj new file mode 100644 index 0000000..1140ea8 --- /dev/null +++ b/GymLogAI.AI/GymLogAI.AI.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/GymLogAI.AI/OpenRouterChatClient.cs b/GymLogAI.AI/OpenRouterChatClient.cs new file mode 100644 index 0000000..3b61e61 --- /dev/null +++ b/GymLogAI.AI/OpenRouterChatClient.cs @@ -0,0 +1,36 @@ +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace GymLogAI.AI; + +public sealed class OpenRouterChatClient( + HttpClient httpClient, + IOptions options, + ILogger logger) +{ + private readonly OpenRouterOptions _options = options.Value; + + public async Task CompleteAsync(string prompt, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + { + logger.LogInformation("OpenRouter API key is not configured. Returning stubbed chat completion."); + return null; + } + + using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = JsonContent.Create(new + { + model = _options.ChatModel, + messages = new[] { new { role = "user", content = prompt } } + }) + }; + + request.Headers.Authorization = new("Bearer", _options.ApiKey); + var response = await httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken); + } +} diff --git a/GymLogAI.AI/OpenRouterEmbeddingClient.cs b/GymLogAI.AI/OpenRouterEmbeddingClient.cs new file mode 100644 index 0000000..ca472d0 --- /dev/null +++ b/GymLogAI.AI/OpenRouterEmbeddingClient.cs @@ -0,0 +1,37 @@ +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace GymLogAI.AI; + +public sealed class OpenRouterEmbeddingClient( + HttpClient httpClient, + IOptions options, + ILogger logger) +{ + private readonly OpenRouterOptions _options = options.Value; + + public async Task GenerateAsync(string input, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + { + logger.LogInformation("OpenRouter API key is not configured. Returning stubbed embedding."); + return null; + } + + using var request = new HttpRequestMessage(HttpMethod.Post, "embeddings") + { + Content = JsonContent.Create(new + { + model = _options.EmbeddingModel, + input + }) + }; + + request.Headers.Authorization = new("Bearer", _options.ApiKey); + var response = await httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + return null; + } +} diff --git a/GymLogAI.AI/OpenRouterOptions.cs b/GymLogAI.AI/OpenRouterOptions.cs new file mode 100644 index 0000000..7bd596b --- /dev/null +++ b/GymLogAI.AI/OpenRouterOptions.cs @@ -0,0 +1,11 @@ +namespace GymLogAI.AI; + +public sealed class OpenRouterOptions +{ + public const string SectionName = "OpenRouter"; + + public string BaseUrl { get; init; } = "https://openrouter.ai/api/v1/"; + public string? ApiKey { get; init; } + public string ChatModel { get; init; } = "openai/gpt-4.1-mini"; + public string EmbeddingModel { get; init; } = "text-embedding-3-small"; +} diff --git a/GymLogAI.AI/StubEmbeddingService.cs b/GymLogAI.AI/StubEmbeddingService.cs new file mode 100644 index 0000000..df69b53 --- /dev/null +++ b/GymLogAI.AI/StubEmbeddingService.cs @@ -0,0 +1,23 @@ +using GymLogAI.Application.Interfaces; + +namespace GymLogAI.AI; + +public sealed class StubEmbeddingService(OpenRouterEmbeddingClient embeddingClient) : IEmbeddingService +{ + public async Task GenerateEmbeddingAsync(string input, CancellationToken cancellationToken) + { + var remoteEmbedding = await embeddingClient.GenerateAsync(input, cancellationToken); + if (remoteEmbedding is not null) + { + return remoteEmbedding; + } + + return + [ + input.Length, + input.Count(char.IsLetter), + input.Count(char.IsDigit), + input.Count(char.IsWhiteSpace) + ]; + } +} diff --git a/GymLogAI.AI/WorkoutParserAIService.cs b/GymLogAI.AI/WorkoutParserAIService.cs new file mode 100644 index 0000000..9d2e810 --- /dev/null +++ b/GymLogAI.AI/WorkoutParserAIService.cs @@ -0,0 +1,36 @@ +using GymLogAI.Application.DTOs; +using GymLogAI.Application.Interfaces; + +namespace GymLogAI.AI; + +public sealed class WorkoutParserAIService(OpenRouterChatClient chatClient) : IWorkoutParser +{ + public async Task ParseAsync(string text, CancellationToken cancellationToken) + { + await chatClient.CompleteAsync($"Parse workout:\n{text}", cancellationToken); + + var lines = text + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToArray(); + + if (lines.Length == 0) + { + return null; + } + + var exercises = lines + .Select((line, index) => new ParsedExerciseDto( + line, + index + 1, + null, + [new ParsedExerciseSetDto(1, 10, null, null, null, "Stub set")])) + .ToArray(); + + return new ParsedWorkoutDto( + DateTime.UtcNow, + "Imported workout", + "Stubbed parser result", + exercises); + } +} diff --git a/GymLogAI.AI/WorkoutRecommendationAIService.cs b/GymLogAI.AI/WorkoutRecommendationAIService.cs new file mode 100644 index 0000000..726dc4f --- /dev/null +++ b/GymLogAI.AI/WorkoutRecommendationAIService.cs @@ -0,0 +1,34 @@ +using GymLogAI.Application.DTOs; +using GymLogAI.Application.Interfaces; +using GymLogAI.Core.Entities; + +namespace GymLogAI.AI; + +public sealed class WorkoutRecommendationAIService(OpenRouterChatClient chatClient) : IWorkoutRecommendationGenerator +{ + public async Task GenerateAsync( + WorkoutRecommendationContext context, + IReadOnlyCollection workoutHistory, + CancellationToken cancellationToken) + { + await chatClient.CompleteAsync( + $"Recommend workout for goal {context.GoalType} with {workoutHistory.Count} historical workouts.", + cancellationToken); + + var lastWorkout = workoutHistory.FirstOrDefault(); + var fallbackExercises = lastWorkout?.WorkoutExercises + .OrderBy(x => x.Order) + .Select(x => x.Exercise?.Name ?? "Bodyweight movement") + .Distinct() + .Take(4) + .ToArray(); + + return new WorkoutRecommendationDto( + "Today's training recommendation", + $"Goal: {context.GoalType}. History count: {workoutHistory.Count}.", + fallbackExercises is { Length: > 0 } + ? fallbackExercises + : ["Squat", "Bench Press", "Row", "Plank"], + "Keep the session moderate and leave 1-2 reps in reserve."); + } +} diff --git a/GymLogAI.API/Contracts/ImportTelegramMessageRequest.cs b/GymLogAI.API/Contracts/ImportTelegramMessageRequest.cs new file mode 100644 index 0000000..a79fa70 --- /dev/null +++ b/GymLogAI.API/Contracts/ImportTelegramMessageRequest.cs @@ -0,0 +1,14 @@ +namespace GymLogAI.API.Contracts; + +public sealed record ImportTelegramMessageRequest( + Guid UserId, + long TelegramUserId, + long TelegramChatId, + int TelegramMessageId, + string Text, + DateTime SentAtUtc, + string? Username, + string? FirstName, + string? LastName); + +public sealed record ImportTelegramMessageResponse(Guid MessageId); diff --git a/GymLogAI.API/Contracts/RecommendWorkoutRequest.cs b/GymLogAI.API/Contracts/RecommendWorkoutRequest.cs new file mode 100644 index 0000000..f735a29 --- /dev/null +++ b/GymLogAI.API/Contracts/RecommendWorkoutRequest.cs @@ -0,0 +1,8 @@ +using GymLogAI.Core.Enums; + +namespace GymLogAI.API.Contracts; + +public sealed record RecommendWorkoutRequest( + Guid UserId, + GoalType GoalType, + string? AdditionalContext); diff --git a/GymLogAI.API/Contracts/WorkoutHistoryResponses.cs b/GymLogAI.API/Contracts/WorkoutHistoryResponses.cs new file mode 100644 index 0000000..ed43717 --- /dev/null +++ b/GymLogAI.API/Contracts/WorkoutHistoryResponses.cs @@ -0,0 +1,23 @@ +namespace GymLogAI.API.Contracts; + +public sealed record WorkoutHistoryItemResponse( + Guid Id, + DateTime PerformedAtUtc, + string? Title, + string? Notes, + IReadOnlyCollection Exercises); + +public sealed record WorkoutHistoryExerciseResponse( + Guid ExerciseId, + string Name, + int Order, + string? Notes, + IReadOnlyCollection Sets); + +public sealed record WorkoutHistorySetResponse( + int Order, + int? Repetitions, + decimal? WeightKg, + int? DurationSeconds, + decimal? DistanceMeters, + string? Notes); diff --git a/GymLogAI.API/Dockerfile b/GymLogAI.API/Dockerfile index 3cd76ea..34a7c07 100644 --- a/GymLogAI.API/Dockerfile +++ b/GymLogAI.API/Dockerfile @@ -8,6 +8,10 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["GymLogAI.API/GymLogAI.API.csproj", "GymLogAI.API/"] +COPY ["GymLogAI.Application/GymLogAI.Application.csproj", "GymLogAI.Application/"] +COPY ["GymLogAI.AI/GymLogAI.AI.csproj", "GymLogAI.AI/"] +COPY ["GymLogAI.Core/GymLogAI.Core.csproj", "GymLogAI.Core/"] +COPY ["GymLogAI.Persistence/GymLogAI.Persistence.csproj", "GymLogAI.Persistence/"] RUN dotnet restore "GymLogAI.API/GymLogAI.API.csproj" COPY . . WORKDIR "/src/GymLogAI.API" diff --git a/GymLogAI.API/GymLogAI.API.csproj b/GymLogAI.API/GymLogAI.API.csproj index 85bd4c4..b01e8da 100644 --- a/GymLogAI.API/GymLogAI.API.csproj +++ b/GymLogAI.API/GymLogAI.API.csproj @@ -9,6 +9,7 @@ + @@ -18,7 +19,10 @@ - + + + + diff --git a/GymLogAI.API/Program.cs b/GymLogAI.API/Program.cs index e49e0e6..c36190d 100644 --- a/GymLogAI.API/Program.cs +++ b/GymLogAI.API/Program.cs @@ -1,51 +1,120 @@ -using GymLogAI.TgBot; +using GymLogAI.AI; +using GymLogAI.API.Contracts; +using GymLogAI.Application.Handlers; +using GymLogAI.Application.Interfaces; +using GymLogAI.Persistence; +using Microsoft.EntityFrameworkCore; namespace GymLogAI.API; public class Program { - public static void Main(string[] args) + public static async Task Main(string[] args) { var builder = WebApplication.CreateBuilder(args); - // Add services to the container. + builder.Services.AddProblemDetails(); builder.Services.AddAuthorization(); - builder.Services.AddTgBot(builder.Configuration); - - // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + builder.Services.AddPersistence(builder.Configuration); + builder.Services.AddAiServices(builder.Configuration); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); var app = builder.Build(); - // Configure the HTTP request pipeline. + await using (var scope = app.Services.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + } + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } + app.UseExceptionHandler(); + app.UseSwagger(); + app.UseSwaggerUI(); app.UseHttpsRedirection(); - app.UseAuthorization(); - var summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; + var api = app.MapGroup("/api"); - app.MapGet("/weatherforecast", (HttpContext httpContext) => + api.MapPost("/messages/import", async ( + ImportTelegramMessageRequest request, + ImportTelegramMessageHandler handler, + CancellationToken cancellationToken) => { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] - }) - .ToArray(); - return forecast; - }) - .WithName("GetWeatherForecast"); + var id = await handler.HandleAsync( + new ImportTelegramMessageCommand( + request.UserId, + request.TelegramUserId, + request.TelegramChatId, + request.TelegramMessageId, + request.Text, + request.SentAtUtc, + request.Username, + request.FirstName, + request.LastName), + cancellationToken); - app.Run(); + return TypedResults.Accepted($"/api/messages/{id}", new ImportTelegramMessageResponse(id)); + }) + .WithName("ImportTelegramMessage"); + + api.MapPost("/workouts/recommend", async ( + RecommendWorkoutRequest request, + RecommendWorkoutHandler handler, + CancellationToken cancellationToken) => + { + var result = await handler.HandleAsync( + new RecommendWorkoutCommand(request.UserId, request.GoalType, request.AdditionalContext), + cancellationToken); + + return TypedResults.Ok(result); + }) + .WithName("RecommendWorkout"); + + api.MapGet("/workouts/history", async ( + Guid userId, + IWorkoutRepository workoutRepository, + CancellationToken cancellationToken) => + { + var history = await workoutRepository.GetHistoryAsync(userId, cancellationToken); + + var response = history.Select(workout => new WorkoutHistoryItemResponse( + workout.Id, + workout.PerformedAtUtc, + workout.Title, + workout.Notes, + workout.WorkoutExercises + .OrderBy(x => x.Order) + .Select(x => new WorkoutHistoryExerciseResponse( + x.ExerciseId, + x.Exercise?.Name ?? "Unknown exercise", + x.Order, + x.Notes, + x.Sets.OrderBy(s => s.Order) + .Select(s => new WorkoutHistorySetResponse( + s.Order, + s.Repetitions, + s.WeightKg, + s.DurationSeconds, + s.DistanceMeters, + s.Notes)) + .ToArray())) + .ToArray())) + .ToArray(); + + return TypedResults.Ok(response); + }) + .WithName("GetWorkoutHistory"); + + await app.RunAsync(); } } diff --git a/GymLogAI.API/WeatherForecast.cs b/GymLogAI.API/WeatherForecast.cs deleted file mode 100644 index efa2658..0000000 --- a/GymLogAI.API/WeatherForecast.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace GymLogAI.API; - -public class WeatherForecast -{ - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } -} \ No newline at end of file diff --git a/GymLogAI.API/appsettings.Development.json b/GymLogAI.API/appsettings.Development.json index 8d4f7b7..ec2c896 100644 --- a/GymLogAI.API/appsettings.Development.json +++ b/GymLogAI.API/appsettings.Development.json @@ -5,7 +5,13 @@ "Microsoft.AspNetCore": "Warning" } }, - "TelegramBot": { - "BotToken": "" + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres" + }, + "OpenRouter": { + "BaseUrl": "https://openrouter.ai/api/v1/", + "ApiKey": "", + "ChatModel": "openai/gpt-4.1-mini", + "EmbeddingModel": "text-embedding-3-small" } } diff --git a/GymLogAI.API/appsettings.json b/GymLogAI.API/appsettings.json index 7afdb19..579d1ea 100644 --- a/GymLogAI.API/appsettings.json +++ b/GymLogAI.API/appsettings.json @@ -5,8 +5,14 @@ "Microsoft.AspNetCore": "Warning" } }, - "TelegramBot": { - "BotToken": "" + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres" + }, + "OpenRouter": { + "BaseUrl": "https://openrouter.ai/api/v1/", + "ApiKey": "", + "ChatModel": "openai/gpt-4.1-mini", + "EmbeddingModel": "text-embedding-3-small" }, "AllowedHosts": "*" } diff --git a/GymLogAI.Application/DTOs/ParsedExerciseDto.cs b/GymLogAI.Application/DTOs/ParsedExerciseDto.cs new file mode 100644 index 0000000..23de637 --- /dev/null +++ b/GymLogAI.Application/DTOs/ParsedExerciseDto.cs @@ -0,0 +1,7 @@ +namespace GymLogAI.Application.DTOs; + +public sealed record ParsedExerciseDto( + string Name, + int Order, + string? Notes, + IReadOnlyCollection Sets); diff --git a/GymLogAI.Application/DTOs/ParsedExerciseSetDto.cs b/GymLogAI.Application/DTOs/ParsedExerciseSetDto.cs new file mode 100644 index 0000000..06e13bb --- /dev/null +++ b/GymLogAI.Application/DTOs/ParsedExerciseSetDto.cs @@ -0,0 +1,9 @@ +namespace GymLogAI.Application.DTOs; + +public sealed record ParsedExerciseSetDto( + int Order, + int? Repetitions, + decimal? WeightKg, + int? DurationSeconds, + decimal? DistanceMeters, + string? Notes); diff --git a/GymLogAI.Application/DTOs/ParsedWorkoutDto.cs b/GymLogAI.Application/DTOs/ParsedWorkoutDto.cs new file mode 100644 index 0000000..5d62e60 --- /dev/null +++ b/GymLogAI.Application/DTOs/ParsedWorkoutDto.cs @@ -0,0 +1,7 @@ +namespace GymLogAI.Application.DTOs; + +public sealed record ParsedWorkoutDto( + DateTime PerformedAtUtc, + string? Title, + string? Notes, + IReadOnlyCollection Exercises); diff --git a/GymLogAI.Application/DTOs/WorkoutRecommendationContext.cs b/GymLogAI.Application/DTOs/WorkoutRecommendationContext.cs new file mode 100644 index 0000000..dabfa9b --- /dev/null +++ b/GymLogAI.Application/DTOs/WorkoutRecommendationContext.cs @@ -0,0 +1,8 @@ +using GymLogAI.Core.Enums; + +namespace GymLogAI.Application.DTOs; + +public sealed record WorkoutRecommendationContext( + Guid UserId, + GoalType GoalType, + string? AdditionalContext); diff --git a/GymLogAI.Application/DTOs/WorkoutRecommendationDto.cs b/GymLogAI.Application/DTOs/WorkoutRecommendationDto.cs new file mode 100644 index 0000000..39969a6 --- /dev/null +++ b/GymLogAI.Application/DTOs/WorkoutRecommendationDto.cs @@ -0,0 +1,7 @@ +namespace GymLogAI.Application.DTOs; + +public sealed record WorkoutRecommendationDto( + string Title, + string Summary, + IReadOnlyCollection Exercises, + string RecoveryAdvice); diff --git a/GymLogAI.TgBot/GymLogAI.TgBot.csproj b/GymLogAI.Application/GymLogAI.Application.csproj similarity index 64% rename from GymLogAI.TgBot/GymLogAI.TgBot.csproj rename to GymLogAI.Application/GymLogAI.Application.csproj index 418d4b4..ae0e559 100644 --- a/GymLogAI.TgBot/GymLogAI.TgBot.csproj +++ b/GymLogAI.Application/GymLogAI.Application.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,7 +7,7 @@ - + diff --git a/GymLogAI.Application/Handlers/ImportTelegramMessageHandler.cs b/GymLogAI.Application/Handlers/ImportTelegramMessageHandler.cs new file mode 100644 index 0000000..328729f --- /dev/null +++ b/GymLogAI.Application/Handlers/ImportTelegramMessageHandler.cs @@ -0,0 +1,66 @@ +using GymLogAI.Application.Interfaces; +using GymLogAI.Core.Abstractions; +using GymLogAI.Core.Entities; +using GymLogAI.Core.Enums; + +namespace GymLogAI.Application.Handlers; + +public sealed class ImportTelegramMessageHandler( + IUserRepository userRepository, + ITelegramMessageRepository telegramMessageRepository, + IClock clock, + IIdGenerator idGenerator) +{ + public async Task HandleAsync(ImportTelegramMessageCommand command, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(command.UserId, cancellationToken); + if (user is null) + { + user = new User + { + Id = command.UserId, + TelegramUserId = command.TelegramUserId, + Username = command.Username, + FirstName = command.FirstName, + LastName = command.LastName, + CreatedAtUtc = clock.UtcNow + }; + + await userRepository.AddAsync(user, cancellationToken); + } + else + { + user.TelegramUserId = command.TelegramUserId; + user.Username = command.Username ?? user.Username; + user.FirstName = command.FirstName ?? user.FirstName; + user.LastName = command.LastName ?? user.LastName; + await userRepository.UpdateAsync(user, cancellationToken); + } + + var message = new TelegramMessage + { + Id = idGenerator.New(), + UserId = user.Id, + TelegramChatId = command.TelegramChatId, + TelegramMessageId = command.TelegramMessageId, + Text = command.Text, + SentAtUtc = command.SentAtUtc, + ImportedAtUtc = clock.UtcNow, + ParsingStatus = ParsingStatus.Pending + }; + + await telegramMessageRepository.AddAsync(message, cancellationToken); + return message.Id; + } +} + +public sealed record ImportTelegramMessageCommand( + Guid UserId, + long TelegramUserId, + long TelegramChatId, + int TelegramMessageId, + string Text, + DateTime SentAtUtc, + string? Username, + string? FirstName, + string? LastName); diff --git a/GymLogAI.Application/Handlers/ParseTelegramMessageHandler.cs b/GymLogAI.Application/Handlers/ParseTelegramMessageHandler.cs new file mode 100644 index 0000000..d57ecbb --- /dev/null +++ b/GymLogAI.Application/Handlers/ParseTelegramMessageHandler.cs @@ -0,0 +1,108 @@ +using GymLogAI.Application.Interfaces; +using GymLogAI.Core.Abstractions; +using GymLogAI.Core.Entities; +using GymLogAI.Core.Enums; + +namespace GymLogAI.Application.Handlers; + +public sealed class ParseTelegramMessageHandler( + ITelegramMessageRepository telegramMessageRepository, + IWorkoutRepository workoutRepository, + IExerciseRepository exerciseRepository, + IWorkoutParser workoutParser, + IEmbeddingService embeddingService, + IClock clock, + IIdGenerator idGenerator) +{ + public async Task HandleAsync(Guid telegramMessageId, CancellationToken cancellationToken) + { + var message = await telegramMessageRepository.GetByIdAsync(telegramMessageId, cancellationToken); + if (message is null) + { + return false; + } + + message.ParsingStatus = ParsingStatus.Processing; + message.ParsingError = null; + await telegramMessageRepository.UpdateAsync(message, cancellationToken); + + try + { + var parsedWorkout = await workoutParser.ParseAsync(message.Text, cancellationToken); + if (parsedWorkout is null || parsedWorkout.Exercises.Count == 0) + { + message.ParsingStatus = ParsingStatus.Failed; + message.ParsingError = "Unable to parse workout from message."; + message.ParsedAtUtc = clock.UtcNow; + await telegramMessageRepository.UpdateAsync(message, cancellationToken); + return false; + } + + var workout = new Workout + { + Id = idGenerator.New(), + UserId = message.UserId, + SourceTelegramMessageId = message.Id, + PerformedAtUtc = parsedWorkout.PerformedAtUtc, + Title = parsedWorkout.Title, + Notes = parsedWorkout.Notes, + SourceType = WorkoutSourceType.Telegram, + CreatedAtUtc = clock.UtcNow + }; + + foreach (var parsedExercise in parsedWorkout.Exercises.OrderBy(x => x.Order)) + { + var exercise = await exerciseRepository.GetByNameAsync(parsedExercise.Name, cancellationToken) + ?? await exerciseRepository.AddAsync(new Exercise + { + Id = idGenerator.New(), + Name = parsedExercise.Name.Trim(), + CreatedAtUtc = clock.UtcNow, + Embedding = await embeddingService.GenerateEmbeddingAsync(parsedExercise.Name, cancellationToken) + }, cancellationToken); + + var workoutExercise = new WorkoutExercise + { + Id = idGenerator.New(), + WorkoutId = workout.Id, + ExerciseId = exercise.Id, + Order = parsedExercise.Order, + Notes = parsedExercise.Notes + }; + + foreach (var parsedSet in parsedExercise.Sets.OrderBy(x => x.Order)) + { + workoutExercise.Sets.Add(new ExerciseSet + { + Id = idGenerator.New(), + WorkoutExerciseId = workoutExercise.Id, + Order = parsedSet.Order, + Repetitions = parsedSet.Repetitions, + WeightKg = parsedSet.WeightKg, + DurationSeconds = parsedSet.DurationSeconds, + DistanceMeters = parsedSet.DistanceMeters, + Notes = parsedSet.Notes + }); + } + + workout.WorkoutExercises.Add(workoutExercise); + } + + await workoutRepository.AddAsync(workout, cancellationToken); + + message.WorkoutId = workout.Id; + message.ParsingStatus = ParsingStatus.Parsed; + message.ParsedAtUtc = clock.UtcNow; + await telegramMessageRepository.UpdateAsync(message, cancellationToken); + return true; + } + catch (Exception ex) + { + message.ParsingStatus = ParsingStatus.Failed; + message.ParsingError = ex.Message; + message.ParsedAtUtc = clock.UtcNow; + await telegramMessageRepository.UpdateAsync(message, cancellationToken); + return false; + } + } +} diff --git a/GymLogAI.Application/Handlers/RecommendWorkoutHandler.cs b/GymLogAI.Application/Handlers/RecommendWorkoutHandler.cs new file mode 100644 index 0000000..d0d6b6a --- /dev/null +++ b/GymLogAI.Application/Handlers/RecommendWorkoutHandler.cs @@ -0,0 +1,26 @@ +using GymLogAI.Application.DTOs; +using GymLogAI.Application.Interfaces; +using GymLogAI.Core.Enums; + +namespace GymLogAI.Application.Handlers; + +public sealed class RecommendWorkoutHandler( + IWorkoutRepository workoutRepository, + IWorkoutRecommendationGenerator workoutRecommendationGenerator) +{ + public async Task HandleAsync( + RecommendWorkoutCommand command, + CancellationToken cancellationToken) + { + var history = await workoutRepository.GetHistoryAsync(command.UserId, cancellationToken); + + var context = new WorkoutRecommendationContext( + command.UserId, + command.GoalType, + command.AdditionalContext); + + return await workoutRecommendationGenerator.GenerateAsync(context, history, cancellationToken); + } +} + +public sealed record RecommendWorkoutCommand(Guid UserId, GoalType GoalType, string? AdditionalContext); diff --git a/GymLogAI.Application/Interfaces/IEmbeddingService.cs b/GymLogAI.Application/Interfaces/IEmbeddingService.cs new file mode 100644 index 0000000..af071be --- /dev/null +++ b/GymLogAI.Application/Interfaces/IEmbeddingService.cs @@ -0,0 +1,6 @@ +namespace GymLogAI.Application.Interfaces; + +public interface IEmbeddingService +{ + Task GenerateEmbeddingAsync(string input, CancellationToken cancellationToken); +} diff --git a/GymLogAI.Application/Interfaces/IExerciseRepository.cs b/GymLogAI.Application/Interfaces/IExerciseRepository.cs new file mode 100644 index 0000000..69193be --- /dev/null +++ b/GymLogAI.Application/Interfaces/IExerciseRepository.cs @@ -0,0 +1,10 @@ +using GymLogAI.Core.Entities; + +namespace GymLogAI.Application.Interfaces; + +public interface IExerciseRepository +{ + Task GetByNameAsync(string name, CancellationToken cancellationToken); + Task AddAsync(Exercise exercise, CancellationToken cancellationToken); + Task UpdateAsync(Exercise exercise, CancellationToken cancellationToken); +} diff --git a/GymLogAI.Application/Interfaces/ITelegramMessageRepository.cs b/GymLogAI.Application/Interfaces/ITelegramMessageRepository.cs new file mode 100644 index 0000000..b5fe2e6 --- /dev/null +++ b/GymLogAI.Application/Interfaces/ITelegramMessageRepository.cs @@ -0,0 +1,11 @@ +using GymLogAI.Core.Entities; + +namespace GymLogAI.Application.Interfaces; + +public interface ITelegramMessageRepository +{ + Task AddAsync(TelegramMessage message, CancellationToken cancellationToken); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task> GetPendingAsync(int take, CancellationToken cancellationToken); + Task UpdateAsync(TelegramMessage message, CancellationToken cancellationToken); +} diff --git a/GymLogAI.Application/Interfaces/IUserRepository.cs b/GymLogAI.Application/Interfaces/IUserRepository.cs new file mode 100644 index 0000000..bfdb5a8 --- /dev/null +++ b/GymLogAI.Application/Interfaces/IUserRepository.cs @@ -0,0 +1,11 @@ +using GymLogAI.Core.Entities; + +namespace GymLogAI.Application.Interfaces; + +public interface IUserRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task GetByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken); + Task AddAsync(User user, CancellationToken cancellationToken); + Task UpdateAsync(User user, CancellationToken cancellationToken); +} diff --git a/GymLogAI.Application/Interfaces/IWorkoutParser.cs b/GymLogAI.Application/Interfaces/IWorkoutParser.cs new file mode 100644 index 0000000..fa00c0e --- /dev/null +++ b/GymLogAI.Application/Interfaces/IWorkoutParser.cs @@ -0,0 +1,8 @@ +using GymLogAI.Application.DTOs; + +namespace GymLogAI.Application.Interfaces; + +public interface IWorkoutParser +{ + Task ParseAsync(string text, CancellationToken cancellationToken); +} diff --git a/GymLogAI.Application/Interfaces/IWorkoutRecommendationGenerator.cs b/GymLogAI.Application/Interfaces/IWorkoutRecommendationGenerator.cs new file mode 100644 index 0000000..8f964af --- /dev/null +++ b/GymLogAI.Application/Interfaces/IWorkoutRecommendationGenerator.cs @@ -0,0 +1,12 @@ +using GymLogAI.Application.DTOs; +using GymLogAI.Core.Entities; + +namespace GymLogAI.Application.Interfaces; + +public interface IWorkoutRecommendationGenerator +{ + Task GenerateAsync( + WorkoutRecommendationContext context, + IReadOnlyCollection workoutHistory, + CancellationToken cancellationToken); +} diff --git a/GymLogAI.Application/Interfaces/IWorkoutRepository.cs b/GymLogAI.Application/Interfaces/IWorkoutRepository.cs new file mode 100644 index 0000000..6cd922e --- /dev/null +++ b/GymLogAI.Application/Interfaces/IWorkoutRepository.cs @@ -0,0 +1,9 @@ +using GymLogAI.Core.Entities; + +namespace GymLogAI.Application.Interfaces; + +public interface IWorkoutRepository +{ + Task AddAsync(Workout workout, CancellationToken cancellationToken); + Task> GetHistoryAsync(Guid userId, CancellationToken cancellationToken); +} diff --git a/GymLogAI.Core/Abstractions/IClock.cs b/GymLogAI.Core/Abstractions/IClock.cs new file mode 100644 index 0000000..aa6d21a --- /dev/null +++ b/GymLogAI.Core/Abstractions/IClock.cs @@ -0,0 +1,6 @@ +namespace GymLogAI.Core.Abstractions; + +public interface IClock +{ + DateTime UtcNow { get; } +} diff --git a/GymLogAI.Core/Abstractions/IIdGenerator.cs b/GymLogAI.Core/Abstractions/IIdGenerator.cs new file mode 100644 index 0000000..e65a422 --- /dev/null +++ b/GymLogAI.Core/Abstractions/IIdGenerator.cs @@ -0,0 +1,6 @@ +namespace GymLogAI.Core.Abstractions; + +public interface IIdGenerator +{ + Guid New(); +} diff --git a/GymLogAI.Core/Entities/Exercise.cs b/GymLogAI.Core/Entities/Exercise.cs new file mode 100644 index 0000000..a17c554 --- /dev/null +++ b/GymLogAI.Core/Entities/Exercise.cs @@ -0,0 +1,12 @@ +namespace GymLogAI.Core.Entities; + +public class Exercise +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public float[]? Embedding { get; set; } + public DateTime CreatedAtUtc { get; set; } + + public List WorkoutExercises { get; set; } = []; +} diff --git a/GymLogAI.Core/Entities/ExerciseSet.cs b/GymLogAI.Core/Entities/ExerciseSet.cs new file mode 100644 index 0000000..307612e --- /dev/null +++ b/GymLogAI.Core/Entities/ExerciseSet.cs @@ -0,0 +1,15 @@ +namespace GymLogAI.Core.Entities; + +public class ExerciseSet +{ + public Guid Id { get; set; } + public Guid WorkoutExerciseId { get; set; } + public int Order { get; set; } + public decimal? WeightKg { get; set; } + public int? Repetitions { get; set; } + public int? DurationSeconds { get; set; } + public decimal? DistanceMeters { get; set; } + public string? Notes { get; set; } + + public WorkoutExercise? WorkoutExercise { get; set; } +} diff --git a/GymLogAI.Core/Entities/TelegramMessage.cs b/GymLogAI.Core/Entities/TelegramMessage.cs new file mode 100644 index 0000000..8292265 --- /dev/null +++ b/GymLogAI.Core/Entities/TelegramMessage.cs @@ -0,0 +1,21 @@ +using GymLogAI.Core.Enums; + +namespace GymLogAI.Core.Entities; + +public class TelegramMessage +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public long TelegramChatId { get; set; } + public int TelegramMessageId { get; set; } + public string Text { get; set; } = string.Empty; + public DateTime SentAtUtc { get; set; } + public DateTime ImportedAtUtc { get; set; } + public ParsingStatus ParsingStatus { get; set; } + public string? ParsingError { get; set; } + public Guid? WorkoutId { get; set; } + public DateTime? ParsedAtUtc { get; set; } + + public User? User { get; set; } + public Workout? Workout { get; set; } +} diff --git a/GymLogAI.Core/Entities/User.cs b/GymLogAI.Core/Entities/User.cs new file mode 100644 index 0000000..2cb1529 --- /dev/null +++ b/GymLogAI.Core/Entities/User.cs @@ -0,0 +1,14 @@ +namespace GymLogAI.Core.Entities; + +public class User +{ + public Guid Id { get; set; } + public long TelegramUserId { get; set; } + public string? Username { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public DateTime CreatedAtUtc { get; set; } + + public List TelegramMessages { get; set; } = []; + public List Workouts { get; set; } = []; +} diff --git a/GymLogAI.Core/Entities/Workout.cs b/GymLogAI.Core/Entities/Workout.cs new file mode 100644 index 0000000..3342023 --- /dev/null +++ b/GymLogAI.Core/Entities/Workout.cs @@ -0,0 +1,19 @@ +using GymLogAI.Core.Enums; + +namespace GymLogAI.Core.Entities; + +public class Workout +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public Guid? SourceTelegramMessageId { get; set; } + public DateTime PerformedAtUtc { get; set; } + public string? Title { get; set; } + public string? Notes { get; set; } + public WorkoutSourceType SourceType { get; set; } + public DateTime CreatedAtUtc { get; set; } + + public User? User { get; set; } + public TelegramMessage? SourceTelegramMessage { get; set; } + public List WorkoutExercises { get; set; } = []; +} diff --git a/GymLogAI.Core/Entities/WorkoutExercise.cs b/GymLogAI.Core/Entities/WorkoutExercise.cs new file mode 100644 index 0000000..cf318ba --- /dev/null +++ b/GymLogAI.Core/Entities/WorkoutExercise.cs @@ -0,0 +1,14 @@ +namespace GymLogAI.Core.Entities; + +public class WorkoutExercise +{ + public Guid Id { get; set; } + public Guid WorkoutId { get; set; } + public Guid ExerciseId { get; set; } + public int Order { get; set; } + public string? Notes { get; set; } + + public Workout? Workout { get; set; } + public Exercise? Exercise { get; set; } + public List Sets { get; set; } = []; +} diff --git a/GymLogAI.Core/Enums/GoalType.cs b/GymLogAI.Core/Enums/GoalType.cs new file mode 100644 index 0000000..800f4fe --- /dev/null +++ b/GymLogAI.Core/Enums/GoalType.cs @@ -0,0 +1,10 @@ +namespace GymLogAI.Core.Enums; + +public enum GoalType +{ + Maintain = 0, + Strength = 1, + Hypertrophy = 2, + Endurance = 3, + WeightLoss = 4 +} diff --git a/GymLogAI.Core/Enums/ParsingStatus.cs b/GymLogAI.Core/Enums/ParsingStatus.cs new file mode 100644 index 0000000..449370f --- /dev/null +++ b/GymLogAI.Core/Enums/ParsingStatus.cs @@ -0,0 +1,9 @@ +namespace GymLogAI.Core.Enums; + +public enum ParsingStatus +{ + Pending = 0, + Processing = 1, + Parsed = 2, + Failed = 3 +} diff --git a/GymLogAI.Core/Enums/WorkoutSourceType.cs b/GymLogAI.Core/Enums/WorkoutSourceType.cs new file mode 100644 index 0000000..61d7e0b --- /dev/null +++ b/GymLogAI.Core/Enums/WorkoutSourceType.cs @@ -0,0 +1,8 @@ +namespace GymLogAI.Core.Enums; + +public enum WorkoutSourceType +{ + Manual = 0, + Telegram = 1, + Imported = 2 +} diff --git a/GymLogAI.Core/GymLogAI.Core.csproj b/GymLogAI.Core/GymLogAI.Core.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/GymLogAI.Core/GymLogAI.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/GymLogAI.Persistence/AppDbContext.cs b/GymLogAI.Persistence/AppDbContext.cs new file mode 100644 index 0000000..f38a2b1 --- /dev/null +++ b/GymLogAI.Persistence/AppDbContext.cs @@ -0,0 +1,19 @@ +using GymLogAI.Core.Entities; +using Microsoft.EntityFrameworkCore; + +namespace GymLogAI.Persistence; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + public DbSet TelegramMessages => Set(); + public DbSet Workouts => Set(); + public DbSet Exercises => Set(); + public DbSet WorkoutExercises => Set(); + public DbSet ExerciseSets => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } +} diff --git a/GymLogAI.Persistence/Configurations/ExerciseConfiguration.cs b/GymLogAI.Persistence/Configurations/ExerciseConfiguration.cs new file mode 100644 index 0000000..d2850c0 --- /dev/null +++ b/GymLogAI.Persistence/Configurations/ExerciseConfiguration.cs @@ -0,0 +1,20 @@ +using GymLogAI.Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GymLogAI.Persistence.Configurations; + +public sealed class ExerciseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("exercises"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.Name).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(2000); + builder.Property(x => x.Embedding).HasColumnType("real[]"); + + builder.HasIndex(x => x.Name).IsUnique(); + } +} diff --git a/GymLogAI.Persistence/Configurations/ExerciseSetConfiguration.cs b/GymLogAI.Persistence/Configurations/ExerciseSetConfiguration.cs new file mode 100644 index 0000000..9bbd36f --- /dev/null +++ b/GymLogAI.Persistence/Configurations/ExerciseSetConfiguration.cs @@ -0,0 +1,20 @@ +using GymLogAI.Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GymLogAI.Persistence.Configurations; + +public sealed class ExerciseSetConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("exercise_sets"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.WeightKg).HasPrecision(8, 2); + builder.Property(x => x.DistanceMeters).HasPrecision(10, 2); + builder.Property(x => x.Notes).HasMaxLength(1000); + + builder.HasIndex(x => new { x.WorkoutExerciseId, x.Order }).IsUnique(); + } +} diff --git a/GymLogAI.Persistence/Configurations/TelegramMessageConfiguration.cs b/GymLogAI.Persistence/Configurations/TelegramMessageConfiguration.cs new file mode 100644 index 0000000..73cb5c4 --- /dev/null +++ b/GymLogAI.Persistence/Configurations/TelegramMessageConfiguration.cs @@ -0,0 +1,31 @@ +using GymLogAI.Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GymLogAI.Persistence.Configurations; + +public sealed class TelegramMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("telegram_messages"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.Text).HasMaxLength(4000).IsRequired(); + builder.Property(x => x.ParsingError).HasMaxLength(2000); + + builder.HasOne(x => x.User) + .WithMany(x => x.TelegramMessages) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(x => x.Workout) + .WithOne(x => x.SourceTelegramMessage) + .HasForeignKey(x => x.SourceTelegramMessageId) + .IsRequired(false) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(x => new { x.UserId, x.ParsingStatus }); + builder.HasIndex(x => new { x.TelegramChatId, x.TelegramMessageId }).IsUnique(); + } +} diff --git a/GymLogAI.Persistence/Configurations/UserConfiguration.cs b/GymLogAI.Persistence/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..e282b37 --- /dev/null +++ b/GymLogAI.Persistence/Configurations/UserConfiguration.cs @@ -0,0 +1,22 @@ +using GymLogAI.Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GymLogAI.Persistence.Configurations; + +public sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.TelegramUserId).IsRequired(); + builder.Property(x => x.Username).HasMaxLength(128); + builder.Property(x => x.FirstName).HasMaxLength(128); + builder.Property(x => x.LastName).HasMaxLength(128); + builder.Property(x => x.CreatedAtUtc).IsRequired(); + + builder.HasIndex(x => x.TelegramUserId).IsUnique(); + } +} diff --git a/GymLogAI.Persistence/Configurations/WorkoutConfiguration.cs b/GymLogAI.Persistence/Configurations/WorkoutConfiguration.cs new file mode 100644 index 0000000..d78edae --- /dev/null +++ b/GymLogAI.Persistence/Configurations/WorkoutConfiguration.cs @@ -0,0 +1,29 @@ +using GymLogAI.Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GymLogAI.Persistence.Configurations; + +public sealed class WorkoutConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("workouts"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.Title).HasMaxLength(256); + builder.Property(x => x.Notes).HasMaxLength(4000); + + builder.HasOne(x => x.User) + .WithMany(x => x.Workouts) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(x => x.WorkoutExercises) + .WithOne(x => x.Workout) + .HasForeignKey(x => x.WorkoutId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => new { x.UserId, x.PerformedAtUtc }); + } +} diff --git a/GymLogAI.Persistence/Configurations/WorkoutExerciseConfiguration.cs b/GymLogAI.Persistence/Configurations/WorkoutExerciseConfiguration.cs new file mode 100644 index 0000000..85ec86f --- /dev/null +++ b/GymLogAI.Persistence/Configurations/WorkoutExerciseConfiguration.cs @@ -0,0 +1,28 @@ +using GymLogAI.Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GymLogAI.Persistence.Configurations; + +public sealed class WorkoutExerciseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("workout_exercises"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedNever(); + builder.Property(x => x.Notes).HasMaxLength(2000); + + builder.HasOne(x => x.Exercise) + .WithMany(x => x.WorkoutExercises) + .HasForeignKey(x => x.ExerciseId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(x => x.Sets) + .WithOne(x => x.WorkoutExercise) + .HasForeignKey(x => x.WorkoutExerciseId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => new { x.WorkoutId, x.Order }).IsUnique(); + } +} diff --git a/GymLogAI.Persistence/DependencyInjection.cs b/GymLogAI.Persistence/DependencyInjection.cs new file mode 100644 index 0000000..360aba6 --- /dev/null +++ b/GymLogAI.Persistence/DependencyInjection.cs @@ -0,0 +1,29 @@ +using GymLogAI.Application.Interfaces; +using GymLogAI.Core.Abstractions; +using GymLogAI.Persistence.Infrastructure; +using GymLogAI.Persistence.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace GymLogAI.Persistence; + +public static class DependencyInjection +{ + public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("Postgres") + ?? "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres"; + + services.AddDbContext(options => options.UseNpgsql(connectionString)); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/GymLogAI.Persistence/DesignTimeAppDbContextFactory.cs b/GymLogAI.Persistence/DesignTimeAppDbContextFactory.cs new file mode 100644 index 0000000..9ce11b1 --- /dev/null +++ b/GymLogAI.Persistence/DesignTimeAppDbContextFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace GymLogAI.Persistence; + +public sealed class DesignTimeAppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql("Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres"); + + return new AppDbContext(optionsBuilder.Options); + } +} diff --git a/GymLogAI.Persistence/GymLogAI.Persistence.csproj b/GymLogAI.Persistence/GymLogAI.Persistence.csproj new file mode 100644 index 0000000..c25d45a --- /dev/null +++ b/GymLogAI.Persistence/GymLogAI.Persistence.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/GymLogAI.Persistence/Infrastructure/GuidIdGenerator.cs b/GymLogAI.Persistence/Infrastructure/GuidIdGenerator.cs new file mode 100644 index 0000000..dd311af --- /dev/null +++ b/GymLogAI.Persistence/Infrastructure/GuidIdGenerator.cs @@ -0,0 +1,8 @@ +using GymLogAI.Core.Abstractions; + +namespace GymLogAI.Persistence.Infrastructure; + +public sealed class GuidIdGenerator : IIdGenerator +{ + public Guid New() => Guid.NewGuid(); +} diff --git a/GymLogAI.Persistence/Infrastructure/SystemClock.cs b/GymLogAI.Persistence/Infrastructure/SystemClock.cs new file mode 100644 index 0000000..239bb00 --- /dev/null +++ b/GymLogAI.Persistence/Infrastructure/SystemClock.cs @@ -0,0 +1,8 @@ +using GymLogAI.Core.Abstractions; + +namespace GymLogAI.Persistence.Infrastructure; + +public sealed class SystemClock : IClock +{ + public DateTime UtcNow => DateTime.UtcNow; +} diff --git a/GymLogAI.Persistence/Migrations/20260419100551_InitialCreate.Designer.cs b/GymLogAI.Persistence/Migrations/20260419100551_InitialCreate.Designer.cs new file mode 100644 index 0000000..5278ce9 --- /dev/null +++ b/GymLogAI.Persistence/Migrations/20260419100551_InitialCreate.Designer.cs @@ -0,0 +1,326 @@ +// +using System; +using GymLogAI.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GymLogAI.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260419100551_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GymLogAI.Core.Entities.Exercise", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.PrimitiveCollection("Embedding") + .HasColumnType("real[]"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("exercises", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.ExerciseSet", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DistanceMeters") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Repetitions") + .HasColumnType("integer"); + + b.Property("WeightKg") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)"); + + b.Property("WorkoutExerciseId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkoutExerciseId", "Order") + .IsUnique(); + + b.ToTable("exercise_sets", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ParsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ParsingError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ParsingStatus") + .HasColumnType("integer"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramChatId") + .HasColumnType("bigint"); + + b.Property("TelegramMessageId") + .HasColumnType("integer"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WorkoutId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TelegramChatId", "TelegramMessageId") + .IsUnique(); + + b.HasIndex("UserId", "ParsingStatus"); + + b.ToTable("telegram_messages", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("Username") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PerformedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceTelegramMessageId") + .HasColumnType("uuid"); + + b.Property("SourceType") + .HasColumnType("integer"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceTelegramMessageId") + .IsUnique(); + + b.HasIndex("UserId", "PerformedAtUtc"); + + b.ToTable("workouts", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExerciseId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("WorkoutId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExerciseId"); + + b.HasIndex("WorkoutId", "Order") + .IsUnique(); + + b.ToTable("workout_exercises", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.ExerciseSet", b => + { + b.HasOne("GymLogAI.Core.Entities.WorkoutExercise", "WorkoutExercise") + .WithMany("Sets") + .HasForeignKey("WorkoutExerciseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkoutExercise"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b => + { + b.HasOne("GymLogAI.Core.Entities.User", "User") + .WithMany("TelegramMessages") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b => + { + b.HasOne("GymLogAI.Core.Entities.TelegramMessage", "SourceTelegramMessage") + .WithOne("Workout") + .HasForeignKey("GymLogAI.Core.Entities.Workout", "SourceTelegramMessageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GymLogAI.Core.Entities.User", "User") + .WithMany("Workouts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SourceTelegramMessage"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b => + { + b.HasOne("GymLogAI.Core.Entities.Exercise", "Exercise") + .WithMany("WorkoutExercises") + .HasForeignKey("ExerciseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GymLogAI.Core.Entities.Workout", "Workout") + .WithMany("WorkoutExercises") + .HasForeignKey("WorkoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Exercise"); + + b.Navigation("Workout"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.Exercise", b => + { + b.Navigation("WorkoutExercises"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b => + { + b.Navigation("Workout"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.User", b => + { + b.Navigation("TelegramMessages"); + + b.Navigation("Workouts"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b => + { + b.Navigation("WorkoutExercises"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b => + { + b.Navigation("Sets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GymLogAI.Persistence/Migrations/20260419100551_InitialCreate.cs b/GymLogAI.Persistence/Migrations/20260419100551_InitialCreate.cs new file mode 100644 index 0000000..444ea83 --- /dev/null +++ b/GymLogAI.Persistence/Migrations/20260419100551_InitialCreate.cs @@ -0,0 +1,227 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GymLogAI.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "exercises", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + Embedding = table.Column(type: "real[]", nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_exercises", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TelegramUserId = table.Column(type: "bigint", nullable: false), + Username = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + FirstName = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + LastName = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "telegram_messages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TelegramChatId = table.Column(type: "bigint", nullable: false), + TelegramMessageId = table.Column(type: "integer", nullable: false), + Text = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + SentAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ImportedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ParsingStatus = table.Column(type: "integer", nullable: false), + ParsingError = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + WorkoutId = table.Column(type: "uuid", nullable: true), + ParsedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_telegram_messages", x => x.Id); + table.ForeignKey( + name: "FK_telegram_messages_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "workouts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + SourceTelegramMessageId = table.Column(type: "uuid", nullable: true), + PerformedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + Title = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Notes = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + SourceType = table.Column(type: "integer", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_workouts", x => x.Id); + table.ForeignKey( + name: "FK_workouts_telegram_messages_SourceTelegramMessageId", + column: x => x.SourceTelegramMessageId, + principalTable: "telegram_messages", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_workouts_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "workout_exercises", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkoutId = table.Column(type: "uuid", nullable: false), + ExerciseId = table.Column(type: "uuid", nullable: false), + Order = table.Column(type: "integer", nullable: false), + Notes = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_workout_exercises", x => x.Id); + table.ForeignKey( + name: "FK_workout_exercises_exercises_ExerciseId", + column: x => x.ExerciseId, + principalTable: "exercises", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_workout_exercises_workouts_WorkoutId", + column: x => x.WorkoutId, + principalTable: "workouts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "exercise_sets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkoutExerciseId = table.Column(type: "uuid", nullable: false), + Order = table.Column(type: "integer", nullable: false), + WeightKg = table.Column(type: "numeric(8,2)", precision: 8, scale: 2, nullable: true), + Repetitions = table.Column(type: "integer", nullable: true), + DurationSeconds = table.Column(type: "integer", nullable: true), + DistanceMeters = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true), + Notes = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_exercise_sets", x => x.Id); + table.ForeignKey( + name: "FK_exercise_sets_workout_exercises_WorkoutExerciseId", + column: x => x.WorkoutExerciseId, + principalTable: "workout_exercises", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_exercise_sets_WorkoutExerciseId_Order", + table: "exercise_sets", + columns: new[] { "WorkoutExerciseId", "Order" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_exercises_Name", + table: "exercises", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_telegram_messages_TelegramChatId_TelegramMessageId", + table: "telegram_messages", + columns: new[] { "TelegramChatId", "TelegramMessageId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_telegram_messages_UserId_ParsingStatus", + table: "telegram_messages", + columns: new[] { "UserId", "ParsingStatus" }); + + migrationBuilder.CreateIndex( + name: "IX_users_TelegramUserId", + table: "users", + column: "TelegramUserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_workout_exercises_ExerciseId", + table: "workout_exercises", + column: "ExerciseId"); + + migrationBuilder.CreateIndex( + name: "IX_workout_exercises_WorkoutId_Order", + table: "workout_exercises", + columns: new[] { "WorkoutId", "Order" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_workouts_SourceTelegramMessageId", + table: "workouts", + column: "SourceTelegramMessageId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_workouts_UserId_PerformedAtUtc", + table: "workouts", + columns: new[] { "UserId", "PerformedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "exercise_sets"); + + migrationBuilder.DropTable( + name: "workout_exercises"); + + migrationBuilder.DropTable( + name: "exercises"); + + migrationBuilder.DropTable( + name: "workouts"); + + migrationBuilder.DropTable( + name: "telegram_messages"); + + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/GymLogAI.Persistence/Migrations/AppDbContextModelSnapshot.cs b/GymLogAI.Persistence/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..9d1afe3 --- /dev/null +++ b/GymLogAI.Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,323 @@ +// +using System; +using GymLogAI.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GymLogAI.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GymLogAI.Core.Entities.Exercise", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.PrimitiveCollection("Embedding") + .HasColumnType("real[]"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("exercises", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.ExerciseSet", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DistanceMeters") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Repetitions") + .HasColumnType("integer"); + + b.Property("WeightKg") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)"); + + b.Property("WorkoutExerciseId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkoutExerciseId", "Order") + .IsUnique(); + + b.ToTable("exercise_sets", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ParsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ParsingError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ParsingStatus") + .HasColumnType("integer"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramChatId") + .HasColumnType("bigint"); + + b.Property("TelegramMessageId") + .HasColumnType("integer"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WorkoutId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TelegramChatId", "TelegramMessageId") + .IsUnique(); + + b.HasIndex("UserId", "ParsingStatus"); + + b.ToTable("telegram_messages", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("Username") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PerformedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceTelegramMessageId") + .HasColumnType("uuid"); + + b.Property("SourceType") + .HasColumnType("integer"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceTelegramMessageId") + .IsUnique(); + + b.HasIndex("UserId", "PerformedAtUtc"); + + b.ToTable("workouts", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExerciseId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("WorkoutId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExerciseId"); + + b.HasIndex("WorkoutId", "Order") + .IsUnique(); + + b.ToTable("workout_exercises", (string)null); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.ExerciseSet", b => + { + b.HasOne("GymLogAI.Core.Entities.WorkoutExercise", "WorkoutExercise") + .WithMany("Sets") + .HasForeignKey("WorkoutExerciseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkoutExercise"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b => + { + b.HasOne("GymLogAI.Core.Entities.User", "User") + .WithMany("TelegramMessages") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b => + { + b.HasOne("GymLogAI.Core.Entities.TelegramMessage", "SourceTelegramMessage") + .WithOne("Workout") + .HasForeignKey("GymLogAI.Core.Entities.Workout", "SourceTelegramMessageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GymLogAI.Core.Entities.User", "User") + .WithMany("Workouts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SourceTelegramMessage"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b => + { + b.HasOne("GymLogAI.Core.Entities.Exercise", "Exercise") + .WithMany("WorkoutExercises") + .HasForeignKey("ExerciseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GymLogAI.Core.Entities.Workout", "Workout") + .WithMany("WorkoutExercises") + .HasForeignKey("WorkoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Exercise"); + + b.Navigation("Workout"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.Exercise", b => + { + b.Navigation("WorkoutExercises"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b => + { + b.Navigation("Workout"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.User", b => + { + b.Navigation("TelegramMessages"); + + b.Navigation("Workouts"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b => + { + b.Navigation("WorkoutExercises"); + }); + + modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b => + { + b.Navigation("Sets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GymLogAI.Persistence/Repositories/ExerciseRepository.cs b/GymLogAI.Persistence/Repositories/ExerciseRepository.cs new file mode 100644 index 0000000..5c5d381 --- /dev/null +++ b/GymLogAI.Persistence/Repositories/ExerciseRepository.cs @@ -0,0 +1,27 @@ +using GymLogAI.Application.Interfaces; +using GymLogAI.Core.Entities; +using Microsoft.EntityFrameworkCore; + +namespace GymLogAI.Persistence.Repositories; + +public sealed class ExerciseRepository(AppDbContext dbContext) : IExerciseRepository +{ + public async Task GetByNameAsync(string name, CancellationToken cancellationToken) + { + return await dbContext.Exercises.SingleOrDefaultAsync(x => x.Name == name, cancellationToken); + } + + public async Task AddAsync(Exercise exercise, CancellationToken cancellationToken) + { + dbContext.Exercises.Add(exercise); + await dbContext.SaveChangesAsync(cancellationToken); + return exercise; + } + + public async Task UpdateAsync(Exercise exercise, CancellationToken cancellationToken) + { + dbContext.Exercises.Update(exercise); + await dbContext.SaveChangesAsync(cancellationToken); + return exercise; + } +} diff --git a/GymLogAI.Persistence/Repositories/TelegramMessageRepository.cs b/GymLogAI.Persistence/Repositories/TelegramMessageRepository.cs new file mode 100644 index 0000000..d72d920 --- /dev/null +++ b/GymLogAI.Persistence/Repositories/TelegramMessageRepository.cs @@ -0,0 +1,37 @@ +using GymLogAI.Application.Interfaces; +using GymLogAI.Core.Entities; +using GymLogAI.Core.Enums; +using Microsoft.EntityFrameworkCore; + +namespace GymLogAI.Persistence.Repositories; + +public sealed class TelegramMessageRepository(AppDbContext dbContext) : ITelegramMessageRepository +{ + public async Task AddAsync(TelegramMessage message, CancellationToken cancellationToken) + { + dbContext.TelegramMessages.Add(message); + await dbContext.SaveChangesAsync(cancellationToken); + return message; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await dbContext.TelegramMessages.SingleOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + public async Task> GetPendingAsync(int take, CancellationToken cancellationToken) + { + return await dbContext.TelegramMessages + .Where(x => x.ParsingStatus == ParsingStatus.Pending) + .OrderBy(x => x.ImportedAtUtc) + .Take(take) + .ToListAsync(cancellationToken); + } + + public async Task UpdateAsync(TelegramMessage message, CancellationToken cancellationToken) + { + dbContext.TelegramMessages.Update(message); + await dbContext.SaveChangesAsync(cancellationToken); + return message; + } +} diff --git a/GymLogAI.Persistence/Repositories/UserRepository.cs b/GymLogAI.Persistence/Repositories/UserRepository.cs new file mode 100644 index 0000000..4224974 --- /dev/null +++ b/GymLogAI.Persistence/Repositories/UserRepository.cs @@ -0,0 +1,32 @@ +using GymLogAI.Application.Interfaces; +using GymLogAI.Core.Entities; +using Microsoft.EntityFrameworkCore; + +namespace GymLogAI.Persistence.Repositories; + +public sealed class UserRepository(AppDbContext dbContext) : IUserRepository +{ + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await dbContext.Users.SingleOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + public async Task GetByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken) + { + return await dbContext.Users.SingleOrDefaultAsync(x => x.TelegramUserId == telegramUserId, cancellationToken); + } + + public async Task AddAsync(User user, CancellationToken cancellationToken) + { + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(cancellationToken); + return user; + } + + public async Task UpdateAsync(User user, CancellationToken cancellationToken) + { + dbContext.Users.Update(user); + await dbContext.SaveChangesAsync(cancellationToken); + return user; + } +} diff --git a/GymLogAI.Persistence/Repositories/WorkoutRepository.cs b/GymLogAI.Persistence/Repositories/WorkoutRepository.cs new file mode 100644 index 0000000..5843707 --- /dev/null +++ b/GymLogAI.Persistence/Repositories/WorkoutRepository.cs @@ -0,0 +1,28 @@ +using GymLogAI.Application.Interfaces; +using GymLogAI.Core.Entities; +using Microsoft.EntityFrameworkCore; + +namespace GymLogAI.Persistence.Repositories; + +public sealed class WorkoutRepository(AppDbContext dbContext) : IWorkoutRepository +{ + public async Task AddAsync(Workout workout, CancellationToken cancellationToken) + { + dbContext.Workouts.Add(workout); + await dbContext.SaveChangesAsync(cancellationToken); + return workout; + } + + public async Task> GetHistoryAsync(Guid userId, CancellationToken cancellationToken) + { + return await dbContext.Workouts + .AsNoTracking() + .Include(x => x.WorkoutExercises) + .ThenInclude(x => x.Exercise) + .Include(x => x.WorkoutExercises) + .ThenInclude(x => x.Sets) + .Where(x => x.UserId == userId) + .OrderByDescending(x => x.PerformedAtUtc) + .ToListAsync(cancellationToken); + } +} diff --git a/GymLogAI.TelegramBot/Dockerfile b/GymLogAI.TelegramBot/Dockerfile new file mode 100644 index 0000000..23ec5eb --- /dev/null +++ b/GymLogAI.TelegramBot/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["GymLogAI.TelegramBot/GymLogAI.TelegramBot.csproj", "GymLogAI.TelegramBot/"] +COPY ["GymLogAI.Application/GymLogAI.Application.csproj", "GymLogAI.Application/"] +COPY ["GymLogAI.AI/GymLogAI.AI.csproj", "GymLogAI.AI/"] +COPY ["GymLogAI.Core/GymLogAI.Core.csproj", "GymLogAI.Core/"] +COPY ["GymLogAI.Persistence/GymLogAI.Persistence.csproj", "GymLogAI.Persistence/"] +RUN dotnet restore "GymLogAI.TelegramBot/GymLogAI.TelegramBot.csproj" +COPY . . +WORKDIR "/src/GymLogAI.TelegramBot" +RUN dotnet publish "./GymLogAI.TelegramBot.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "GymLogAI.TelegramBot.dll"] diff --git a/GymLogAI.TelegramBot/GymLogAI.TelegramBot.csproj b/GymLogAI.TelegramBot/GymLogAI.TelegramBot.csproj new file mode 100644 index 0000000..2596783 --- /dev/null +++ b/GymLogAI.TelegramBot/GymLogAI.TelegramBot.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + Exe + enable + enable + + + + + + + + + + + + + + + + + + diff --git a/GymLogAI.TelegramBot/Program.cs b/GymLogAI.TelegramBot/Program.cs new file mode 100644 index 0000000..d1ad2bf --- /dev/null +++ b/GymLogAI.TelegramBot/Program.cs @@ -0,0 +1,44 @@ +using GymLogAI.AI; +using GymLogAI.Application.Handlers; +using GymLogAI.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Telegram.Bot; + +namespace GymLogAI.TelegramBot; + +public static class Program +{ + public static async Task Main(string[] args) + { + var builder = Host.CreateApplicationBuilder(args); + + builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(TelegramBotOptions.SectionName)); + + builder.Services.AddPersistence(builder.Configuration); + builder.Services.AddAiServices(builder.Configuration); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(serviceProvider => + { + var options = serviceProvider.GetRequiredService>().Value; + return new TelegramBotClient(options.BotToken ?? string.Empty); + }); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); + + var host = builder.Build(); + + await using (var scope = host.Services.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + } + + await host.RunAsync(); + } +} diff --git a/GymLogAI.TelegramBot/TelegramBotHostedService.cs b/GymLogAI.TelegramBot/TelegramBotHostedService.cs new file mode 100644 index 0000000..19f18aa --- /dev/null +++ b/GymLogAI.TelegramBot/TelegramBotHostedService.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types.Enums; + +namespace GymLogAI.TelegramBot; + +public sealed class TelegramBotHostedService( + ITelegramBotClient botClient, + IServiceProvider serviceProvider, + IOptions options, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (string.IsNullOrWhiteSpace(options.Value.BotToken)) + { + logger.LogWarning("Telegram bot token is not configured. Bot polling is disabled."); + return; + } + + var receiverOptions = new ReceiverOptions + { + AllowedUpdates = [UpdateType.Message] + }; + + botClient.StartReceiving( + async (client, update, cancellationToken) => + { + await using var scope = serviceProvider.CreateAsyncScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + await handler.HandleUpdateAsync(client, update, cancellationToken); + }, + async (client, exception, cancellationToken) => + { + await using var scope = serviceProvider.CreateAsyncScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + await handler.HandleErrorAsync(client, exception, cancellationToken); + }, + receiverOptions, + stoppingToken); + + logger.LogInformation("Telegram bot long polling started."); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(options.Value.PollingTimeoutSeconds), stoppingToken); + } + } +} diff --git a/GymLogAI.TgBot/TelegramBotOptions.cs b/GymLogAI.TelegramBot/TelegramBotOptions.cs similarity index 61% rename from GymLogAI.TgBot/TelegramBotOptions.cs rename to GymLogAI.TelegramBot/TelegramBotOptions.cs index c737dd5..8ae37c3 100644 --- a/GymLogAI.TgBot/TelegramBotOptions.cs +++ b/GymLogAI.TelegramBot/TelegramBotOptions.cs @@ -1,8 +1,9 @@ -namespace GymLogAI.TgBot; +namespace GymLogAI.TelegramBot; public sealed record TelegramBotOptions { public const string SectionName = "TelegramBot"; public string? BotToken { get; init; } + public int PollingTimeoutSeconds { get; init; } = 5; } diff --git a/GymLogAI.TelegramBot/UpdateHandler.cs b/GymLogAI.TelegramBot/UpdateHandler.cs new file mode 100644 index 0000000..ae5286c --- /dev/null +++ b/GymLogAI.TelegramBot/UpdateHandler.cs @@ -0,0 +1,82 @@ +using GymLogAI.Application.Handlers; +using GymLogAI.Core.Enums; +using Microsoft.Extensions.Logging; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace GymLogAI.TelegramBot; + +public sealed class UpdateHandler( + ImportTelegramMessageHandler importTelegramMessageHandler, + RecommendWorkoutHandler recommendWorkoutHandler, + ILogger logger) +{ + public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) + { + if (update.Type != UpdateType.Message || update.Message?.Text is null) + { + return; + } + + var message = update.Message; + var text = message.Text.Trim(); + + if (text.Equals("/start", StringComparison.OrdinalIgnoreCase)) + { + await botClient.SendMessage( + message.Chat.Id, + "GymLogAI bot is ready. Send workout text or use /today for a recommendation.", + cancellationToken: cancellationToken); + return; + } + + if (text.Equals("/today", StringComparison.OrdinalIgnoreCase)) + { + var userId = CreateUserId(message.From?.Id ?? message.Chat.Id); + var recommendation = await recommendWorkoutHandler.HandleAsync( + new RecommendWorkoutCommand(userId, GoalType.Maintain, "Requested from Telegram command /today"), + cancellationToken); + + await botClient.SendMessage( + message.Chat.Id, + $"{recommendation.Title}\n{recommendation.Summary}\nExercises: {string.Join(", ", recommendation.Exercises)}", + cancellationToken: cancellationToken); + return; + } + + var importedMessageId = await importTelegramMessageHandler.HandleAsync( + new ImportTelegramMessageCommand( + CreateUserId(message.From?.Id ?? message.Chat.Id), + message.From?.Id ?? message.Chat.Id, + message.Chat.Id, + message.MessageId, + text, + message.Date.ToUniversalTime(), + message.From?.Username, + message.From?.FirstName, + message.From?.LastName), + cancellationToken); + + logger.LogInformation("Imported telegram message {MessageId} from chat {ChatId}", importedMessageId, message.Chat.Id); + + await botClient.SendMessage( + message.Chat.Id, + "Message imported. It will be parsed by the worker shortly.", + cancellationToken: cancellationToken); + } + + public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, CancellationToken cancellationToken) + { + logger.LogError(exception, "Telegram bot polling error"); + return Task.CompletedTask; + } + + private static Guid CreateUserId(long telegramUserId) + { + Span buffer = stackalloc byte[16]; + BitConverter.TryWriteBytes(buffer[..8], telegramUserId); + BitConverter.TryWriteBytes(buffer[8..], telegramUserId); + return new Guid(buffer); + } +} diff --git a/GymLogAI.TelegramBot/appsettings.Development.json b/GymLogAI.TelegramBot/appsettings.Development.json new file mode 100644 index 0000000..28b89b4 --- /dev/null +++ b/GymLogAI.TelegramBot/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres" + }, + "TelegramBot": { + "BotToken": "", + "PollingTimeoutSeconds": 5 + } +} diff --git a/GymLogAI.TelegramBot/appsettings.json b/GymLogAI.TelegramBot/appsettings.json new file mode 100644 index 0000000..9a5389d --- /dev/null +++ b/GymLogAI.TelegramBot/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres" + }, + "TelegramBot": { + "BotToken": "", + "PollingTimeoutSeconds": 5 + }, + "OpenRouter": { + "BaseUrl": "https://openrouter.ai/api/v1/", + "ApiKey": "", + "ChatModel": "openai/gpt-4.1-mini", + "EmbeddingModel": "text-embedding-3-small" + } +} diff --git a/GymLogAI.TgBot/TgBotServiceCollectionExtensions.cs b/GymLogAI.TgBot/TgBotServiceCollectionExtensions.cs deleted file mode 100644 index 509a339..0000000 --- a/GymLogAI.TgBot/TgBotServiceCollectionExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace GymLogAI.TgBot; - -public static class TgBotServiceCollectionExtensions -{ - public static IServiceCollection AddTgBot(this IServiceCollection services, IConfiguration configuration) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configuration); - - services - .AddOptions() - .Bind(configuration.GetSection(TelegramBotOptions.SectionName)); - - return services; - } -} diff --git a/GymLogAI.Worker/Dockerfile b/GymLogAI.Worker/Dockerfile new file mode 100644 index 0000000..4e40431 --- /dev/null +++ b/GymLogAI.Worker/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["GymLogAI.Worker/GymLogAI.Worker.csproj", "GymLogAI.Worker/"] +COPY ["GymLogAI.Application/GymLogAI.Application.csproj", "GymLogAI.Application/"] +COPY ["GymLogAI.AI/GymLogAI.AI.csproj", "GymLogAI.AI/"] +COPY ["GymLogAI.Core/GymLogAI.Core.csproj", "GymLogAI.Core/"] +COPY ["GymLogAI.Persistence/GymLogAI.Persistence.csproj", "GymLogAI.Persistence/"] +RUN dotnet restore "GymLogAI.Worker/GymLogAI.Worker.csproj" +COPY . . +WORKDIR "/src/GymLogAI.Worker" +RUN dotnet publish "./GymLogAI.Worker.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "GymLogAI.Worker.dll"] diff --git a/GymLogAI.Worker/GymLogAI.Worker.csproj b/GymLogAI.Worker/GymLogAI.Worker.csproj new file mode 100644 index 0000000..46bb8d2 --- /dev/null +++ b/GymLogAI.Worker/GymLogAI.Worker.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + dotnet-GymLogAI.Worker-bf7dded7-f871-4b7d-a513-0a677ed83bd3 + + + + + + + + + + + + + diff --git a/GymLogAI.Worker/ParsePendingTelegramMessagesJob.cs b/GymLogAI.Worker/ParsePendingTelegramMessagesJob.cs new file mode 100644 index 0000000..58f4265 --- /dev/null +++ b/GymLogAI.Worker/ParsePendingTelegramMessagesJob.cs @@ -0,0 +1,40 @@ +using GymLogAI.Application.Handlers; +using GymLogAI.Application.Interfaces; +using Microsoft.Extensions.Options; + +namespace GymLogAI.Worker; + +public sealed class ParsePendingTelegramMessagesJob( + IServiceProvider serviceProvider, + IOptions options, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await using var scope = serviceProvider.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var handler = scope.ServiceProvider.GetRequiredService(); + + var pendingMessages = await repository.GetPendingAsync(options.Value.BatchSize, stoppingToken); + foreach (var pendingMessage in pendingMessages) + { + await handler.HandleAsync(pendingMessage.Id, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process pending telegram messages."); + } + + await Task.Delay(TimeSpan.FromSeconds(options.Value.PollingIntervalSeconds), stoppingToken); + } + } +} diff --git a/GymLogAI.Worker/Program.cs b/GymLogAI.Worker/Program.cs new file mode 100644 index 0000000..7c5e069 --- /dev/null +++ b/GymLogAI.Worker/Program.cs @@ -0,0 +1,26 @@ +using GymLogAI.AI; +using GymLogAI.Application.Handlers; +using GymLogAI.Persistence; +using GymLogAI.Worker; +using Microsoft.EntityFrameworkCore; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(WorkerOptions.SectionName)); +builder.Services.AddPersistence(builder.Configuration); +builder.Services.AddAiServices(builder.Configuration); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + +var host = builder.Build(); + +await using (var scope = host.Services.CreateAsyncScope()) +{ + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); +} + +await host.RunAsync(); diff --git a/GymLogAI.Worker/Properties/launchSettings.json b/GymLogAI.Worker/Properties/launchSettings.json new file mode 100644 index 0000000..be139ec --- /dev/null +++ b/GymLogAI.Worker/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "GymLogAI.Worker": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/GymLogAI.Worker/WorkerOptions.cs b/GymLogAI.Worker/WorkerOptions.cs new file mode 100644 index 0000000..4ff2538 --- /dev/null +++ b/GymLogAI.Worker/WorkerOptions.cs @@ -0,0 +1,9 @@ +namespace GymLogAI.Worker; + +public sealed record WorkerOptions +{ + public const string SectionName = "Worker"; + + public int BatchSize { get; init; } = 20; + public int PollingIntervalSeconds { get; init; } = 10; +} diff --git a/GymLogAI.Worker/appsettings.Development.json b/GymLogAI.Worker/appsettings.Development.json new file mode 100644 index 0000000..0f9873b --- /dev/null +++ b/GymLogAI.Worker/appsettings.Development.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres" + }, + "Worker": { + "BatchSize": 20, + "PollingIntervalSeconds": 10 + } +} diff --git a/GymLogAI.Worker/appsettings.json b/GymLogAI.Worker/appsettings.json new file mode 100644 index 0000000..ab5bdc8 --- /dev/null +++ b/GymLogAI.Worker/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres" + }, + "Worker": { + "BatchSize": 20, + "PollingIntervalSeconds": 10 + }, + "OpenRouter": { + "BaseUrl": "https://openrouter.ai/api/v1/", + "ApiKey": "", + "ChatModel": "openai/gpt-4.1-mini", + "EmbeddingModel": "text-embedding-3-small" + } +} diff --git a/GymLogAI.sln b/GymLogAI.sln index 48be9c8..8fa5ea9 100644 --- a/GymLogAI.sln +++ b/GymLogAI.sln @@ -7,21 +7,114 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution compose.yaml = compose.yaml EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.TgBot", "GymLogAI.TgBot\GymLogAI.TgBot.csproj", "{BAF381C4-F522-4A59-B86D-3B9D3DA3D6AD}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.Core", "GymLogAI.Core\GymLogAI.Core.csproj", "{B8D6F354-28CF-4432-8C09-04CBA164D68D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.Application", "GymLogAI.Application\GymLogAI.Application.csproj", "{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.Persistence", "GymLogAI.Persistence\GymLogAI.Persistence.csproj", "{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.AI", "GymLogAI.AI\GymLogAI.AI.csproj", "{D6691FB8-1EFA-428E-AB7C-562496650A43}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.TelegramBot", "GymLogAI.TelegramBot\GymLogAI.TelegramBot.csproj", "{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.Worker", "GymLogAI.Worker\GymLogAI.Worker.csproj", "{70B0AFD7-6714-42F1-B754-FF9010687416}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|x64.ActiveCfg = Debug|Any CPU + {42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|x64.Build.0 = Debug|Any CPU + {42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|x86.ActiveCfg = Debug|Any CPU + {42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|x86.Build.0 = Debug|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|Any CPU.ActiveCfg = Release|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|Any CPU.Build.0 = Release|Any CPU - {BAF381C4-F522-4A59-B86D-3B9D3DA3D6AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BAF381C4-F522-4A59-B86D-3B9D3DA3D6AD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BAF381C4-F522-4A59-B86D-3B9D3DA3D6AD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BAF381C4-F522-4A59-B86D-3B9D3DA3D6AD}.Release|Any CPU.Build.0 = Release|Any CPU + {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|x64.ActiveCfg = Release|Any CPU + {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|x64.Build.0 = Release|Any CPU + {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|x86.ActiveCfg = Release|Any CPU + {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|x86.Build.0 = Release|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|x64.ActiveCfg = Debug|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|x64.Build.0 = Debug|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|x86.ActiveCfg = Debug|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|x86.Build.0 = Debug|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|Any CPU.Build.0 = Release|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|x64.ActiveCfg = Release|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|x64.Build.0 = Release|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|x86.ActiveCfg = Release|Any CPU + {B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|x86.Build.0 = Release|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|x64.ActiveCfg = Debug|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|x64.Build.0 = Debug|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|x86.Build.0 = Debug|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|Any CPU.Build.0 = Release|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|x64.ActiveCfg = Release|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|x64.Build.0 = Release|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|x86.ActiveCfg = Release|Any CPU + {DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|x86.Build.0 = Release|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|x64.ActiveCfg = Debug|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|x64.Build.0 = Debug|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|x86.ActiveCfg = Debug|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|x86.Build.0 = Debug|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|Any CPU.Build.0 = Release|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|x64.ActiveCfg = Release|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|x64.Build.0 = Release|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|x86.ActiveCfg = Release|Any CPU + {5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|x86.Build.0 = Release|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|x64.ActiveCfg = Debug|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|x64.Build.0 = Debug|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|x86.ActiveCfg = Debug|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|x86.Build.0 = Debug|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|Any CPU.Build.0 = Release|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|x64.ActiveCfg = Release|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|x64.Build.0 = Release|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|x86.ActiveCfg = Release|Any CPU + {D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|x86.Build.0 = Release|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|x64.ActiveCfg = Debug|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|x64.Build.0 = Debug|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|x86.ActiveCfg = Debug|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|x86.Build.0 = Debug|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|Any CPU.Build.0 = Release|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|x64.ActiveCfg = Release|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|x64.Build.0 = Release|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|x86.ActiveCfg = Release|Any CPU + {1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|x86.Build.0 = Release|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|x64.ActiveCfg = Debug|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|x64.Build.0 = Debug|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|x86.ActiveCfg = Debug|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|x86.Build.0 = Debug|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Release|Any CPU.Build.0 = Release|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Release|x64.ActiveCfg = Release|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Release|x64.Build.0 = Release|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Release|x86.ActiveCfg = Release|Any CPU + {70B0AFD7-6714-42F1-B754-FF9010687416}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..77d9f26 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# GymLogAI + +GymLogAI is a minimal multi-project skeleton for importing workout messages, parsing them into structured training history, and generating workout recommendations. + +## Architecture + +- `GymLogAI.Core`: domain entities, enums, and primitive abstractions like `IClock` and `IIdGenerator` +- `GymLogAI.Application`: repository contracts, AI contracts, DTOs, and use-case handlers +- `GymLogAI.Persistence`: EF Core `AppDbContext`, PostgreSQL mappings, repositories, infrastructure services, and the first migration +- `GymLogAI.AI`: OpenRouter-ready options and stubbed AI implementations for parsing, recommendations, and embeddings +- `GymLogAI.API`: minimal API composition root with Swagger, import/recommend/history endpoints, and database migration on startup +- `GymLogAI.TelegramBot`: Telegram long-polling host with `/start`, `/today`, and text message import +- `GymLogAI.Worker`: background worker that polls pending telegram messages and parses them into workouts + +## Runtime Flow + +1. API or Telegram bot imports a raw Telegram message into `telegram_messages` with `Pending` status. +2. Worker polls pending messages and runs `ParseTelegramMessageHandler`. +3. Parsed workouts are stored in `workouts`, `workout_exercises`, and `exercise_sets`. +4. API and Telegram bot can request workout recommendations from the application layer. + +## Key Endpoints + +- `POST /api/messages/import` +- `POST /api/workouts/recommend` +- `GET /api/workouts/history?userId=` +- Swagger UI: `/swagger` + +## Database + +- Provider: PostgreSQL +- Migration project: `GymLogAI.Persistence` +- Initial migration is included in `GymLogAI.Persistence/Migrations` +- `Exercise` includes an `Embedding` field stored as nullable `real[]` + +## Run + +```bash +dotnet build +dotnet run --project GymLogAI.API/ +dotnet run --project GymLogAI.TelegramBot/ +dotnet run --project GymLogAI.Worker/ +docker compose -f compose.yaml up --build +``` + +## Configuration + +- Connection string: `ConnectionStrings:Postgres` +- Telegram bot token: `TelegramBot:BotToken` +- OpenRouter settings: `OpenRouter:*` +- Worker polling settings: `Worker:BatchSize`, `Worker:PollingIntervalSeconds` diff --git a/compose.yaml b/compose.yaml index 51ba784..f192ddd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,51 @@ -services: +services: + postgres: + image: postgres:17 + environment: + POSTGRES_DB: gymlogai + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + gymlogai.api: image: gymlogai.api build: context: . dockerfile: GymLogAI.API/Dockerfile + depends_on: + - postgres + environment: + ASPNETCORE_ENVIRONMENT: Development + ConnectionStrings__Postgres: Host=postgres;Port=5432;Database=gymlogai;Username=postgres;Password=postgres + ports: + - "5202:8080" + - "7225:8081" + gymlogai.telegrambot: + image: gymlogai.telegrambot + build: + context: . + dockerfile: GymLogAI.TelegramBot/Dockerfile + depends_on: + - postgres + environment: + DOTNET_ENVIRONMENT: Development + ConnectionStrings__Postgres: Host=postgres;Port=5432;Database=gymlogai;Username=postgres;Password=postgres + TelegramBot__BotToken: "" + + gymlogai.worker: + image: gymlogai.worker + build: + context: . + dockerfile: GymLogAI.Worker/Dockerfile + depends_on: + - postgres + environment: + DOTNET_ENVIRONMENT: Development + ConnectionStrings__Postgres: Host=postgres;Port=5432;Database=gymlogai;Username=postgres;Password=postgres + +volumes: + postgres_data: