add architecture skeleton
This commit is contained in:
@@ -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<OpenRouterOptions>()
|
||||
.Bind(configuration.GetSection(OpenRouterOptions.SectionName));
|
||||
|
||||
services.AddHttpClient<OpenRouterChatClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<OpenRouterOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
});
|
||||
|
||||
services.AddHttpClient<OpenRouterEmbeddingClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<OpenRouterOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
});
|
||||
|
||||
services.AddScoped<IWorkoutParser, WorkoutParserAIService>();
|
||||
services.AddScoped<IWorkoutRecommendationGenerator, WorkoutRecommendationAIService>();
|
||||
services.AddScoped<IEmbeddingService, StubEmbeddingService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GymLogAI.Application\GymLogAI.Application.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<OpenRouterOptions> options,
|
||||
ILogger<OpenRouterChatClient> logger)
|
||||
{
|
||||
private readonly OpenRouterOptions _options = options.Value;
|
||||
|
||||
public async Task<string?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<OpenRouterOptions> options,
|
||||
ILogger<OpenRouterEmbeddingClient> logger)
|
||||
{
|
||||
private readonly OpenRouterOptions _options = options.Value;
|
||||
|
||||
public async Task<float[]?> 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using GymLogAI.Application.Interfaces;
|
||||
|
||||
namespace GymLogAI.AI;
|
||||
|
||||
public sealed class StubEmbeddingService(OpenRouterEmbeddingClient embeddingClient) : IEmbeddingService
|
||||
{
|
||||
public async Task<float[]> 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)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<ParsedWorkoutDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<WorkoutRecommendationDto> GenerateAsync(
|
||||
WorkoutRecommendationContext context,
|
||||
IReadOnlyCollection<Workout> 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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,8 @@
|
||||
using GymLogAI.Core.Enums;
|
||||
|
||||
namespace GymLogAI.API.Contracts;
|
||||
|
||||
public sealed record RecommendWorkoutRequest(
|
||||
Guid UserId,
|
||||
GoalType GoalType,
|
||||
string? AdditionalContext);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace GymLogAI.API.Contracts;
|
||||
|
||||
public sealed record WorkoutHistoryItemResponse(
|
||||
Guid Id,
|
||||
DateTime PerformedAtUtc,
|
||||
string? Title,
|
||||
string? Notes,
|
||||
IReadOnlyCollection<WorkoutHistoryExerciseResponse> Exercises);
|
||||
|
||||
public sealed record WorkoutHistoryExerciseResponse(
|
||||
Guid ExerciseId,
|
||||
string Name,
|
||||
int Order,
|
||||
string? Notes,
|
||||
IReadOnlyCollection<WorkoutHistorySetResponse> Sets);
|
||||
|
||||
public sealed record WorkoutHistorySetResponse(
|
||||
int Order,
|
||||
int? Repetitions,
|
||||
decimal? WeightKg,
|
||||
int? DurationSeconds,
|
||||
decimal? DistanceMeters,
|
||||
string? Notes);
|
||||
@@ -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"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -18,7 +19,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GymLogAI.TgBot\GymLogAI.TgBot.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.Application\GymLogAI.Application.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.AI\GymLogAI.AI.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.Persistence\GymLogAI.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+94
-25
@@ -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<ImportTelegramMessageHandler>();
|
||||
builder.Services.AddScoped<ParseTelegramMessageHandler>();
|
||||
builder.Services.AddScoped<RecommendWorkoutHandler>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "*"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace GymLogAI.Application.DTOs;
|
||||
|
||||
public sealed record ParsedExerciseDto(
|
||||
string Name,
|
||||
int Order,
|
||||
string? Notes,
|
||||
IReadOnlyCollection<ParsedExerciseSetDto> Sets);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace GymLogAI.Application.DTOs;
|
||||
|
||||
public sealed record ParsedExerciseSetDto(
|
||||
int Order,
|
||||
int? Repetitions,
|
||||
decimal? WeightKg,
|
||||
int? DurationSeconds,
|
||||
decimal? DistanceMeters,
|
||||
string? Notes);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace GymLogAI.Application.DTOs;
|
||||
|
||||
public sealed record ParsedWorkoutDto(
|
||||
DateTime PerformedAtUtc,
|
||||
string? Title,
|
||||
string? Notes,
|
||||
IReadOnlyCollection<ParsedExerciseDto> Exercises);
|
||||
@@ -0,0 +1,8 @@
|
||||
using GymLogAI.Core.Enums;
|
||||
|
||||
namespace GymLogAI.Application.DTOs;
|
||||
|
||||
public sealed record WorkoutRecommendationContext(
|
||||
Guid UserId,
|
||||
GoalType GoalType,
|
||||
string? AdditionalContext);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace GymLogAI.Application.DTOs;
|
||||
|
||||
public sealed record WorkoutRecommendationDto(
|
||||
string Title,
|
||||
string Summary,
|
||||
IReadOnlyCollection<string> Exercises,
|
||||
string RecoveryAdvice);
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<Guid> 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);
|
||||
@@ -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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<WorkoutRecommendationDto> 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);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace GymLogAI.Application.Interfaces;
|
||||
|
||||
public interface IEmbeddingService
|
||||
{
|
||||
Task<float[]> GenerateEmbeddingAsync(string input, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using GymLogAI.Core.Entities;
|
||||
|
||||
namespace GymLogAI.Application.Interfaces;
|
||||
|
||||
public interface IExerciseRepository
|
||||
{
|
||||
Task<Exercise?> GetByNameAsync(string name, CancellationToken cancellationToken);
|
||||
Task<Exercise> AddAsync(Exercise exercise, CancellationToken cancellationToken);
|
||||
Task<Exercise> UpdateAsync(Exercise exercise, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using GymLogAI.Core.Entities;
|
||||
|
||||
namespace GymLogAI.Application.Interfaces;
|
||||
|
||||
public interface ITelegramMessageRepository
|
||||
{
|
||||
Task<TelegramMessage> AddAsync(TelegramMessage message, CancellationToken cancellationToken);
|
||||
Task<TelegramMessage?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyCollection<TelegramMessage>> GetPendingAsync(int take, CancellationToken cancellationToken);
|
||||
Task<TelegramMessage> UpdateAsync(TelegramMessage message, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using GymLogAI.Core.Entities;
|
||||
|
||||
namespace GymLogAI.Application.Interfaces;
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<User?> GetByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken);
|
||||
Task<User> AddAsync(User user, CancellationToken cancellationToken);
|
||||
Task<User> UpdateAsync(User user, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using GymLogAI.Application.DTOs;
|
||||
|
||||
namespace GymLogAI.Application.Interfaces;
|
||||
|
||||
public interface IWorkoutParser
|
||||
{
|
||||
Task<ParsedWorkoutDto?> ParseAsync(string text, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using GymLogAI.Application.DTOs;
|
||||
using GymLogAI.Core.Entities;
|
||||
|
||||
namespace GymLogAI.Application.Interfaces;
|
||||
|
||||
public interface IWorkoutRecommendationGenerator
|
||||
{
|
||||
Task<WorkoutRecommendationDto> GenerateAsync(
|
||||
WorkoutRecommendationContext context,
|
||||
IReadOnlyCollection<Workout> workoutHistory,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using GymLogAI.Core.Entities;
|
||||
|
||||
namespace GymLogAI.Application.Interfaces;
|
||||
|
||||
public interface IWorkoutRepository
|
||||
{
|
||||
Task<Workout> AddAsync(Workout workout, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyCollection<Workout>> GetHistoryAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace GymLogAI.Core.Abstractions;
|
||||
|
||||
public interface IClock
|
||||
{
|
||||
DateTime UtcNow { get; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace GymLogAI.Core.Abstractions;
|
||||
|
||||
public interface IIdGenerator
|
||||
{
|
||||
Guid New();
|
||||
}
|
||||
@@ -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<WorkoutExercise> WorkoutExercises { get; set; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<TelegramMessage> TelegramMessages { get; set; } = [];
|
||||
public List<Workout> Workouts { get; set; } = [];
|
||||
}
|
||||
@@ -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<WorkoutExercise> WorkoutExercises { get; set; } = [];
|
||||
}
|
||||
@@ -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<ExerciseSet> Sets { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace GymLogAI.Core.Enums;
|
||||
|
||||
public enum GoalType
|
||||
{
|
||||
Maintain = 0,
|
||||
Strength = 1,
|
||||
Hypertrophy = 2,
|
||||
Endurance = 3,
|
||||
WeightLoss = 4
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace GymLogAI.Core.Enums;
|
||||
|
||||
public enum ParsingStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Processing = 1,
|
||||
Parsed = 2,
|
||||
Failed = 3
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace GymLogAI.Core.Enums;
|
||||
|
||||
public enum WorkoutSourceType
|
||||
{
|
||||
Manual = 0,
|
||||
Telegram = 1,
|
||||
Imported = 2
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using GymLogAI.Core.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GymLogAI.Persistence;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<TelegramMessage> TelegramMessages => Set<TelegramMessage>();
|
||||
public DbSet<Workout> Workouts => Set<Workout>();
|
||||
public DbSet<Exercise> Exercises => Set<Exercise>();
|
||||
public DbSet<WorkoutExercise> WorkoutExercises => Set<WorkoutExercise>();
|
||||
public DbSet<ExerciseSet> ExerciseSets => Set<ExerciseSet>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||
}
|
||||
}
|
||||
@@ -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<Exercise>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Exercise> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<ExerciseSet>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ExerciseSet> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<TelegramMessage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TelegramMessage> 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<Workout>(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();
|
||||
}
|
||||
}
|
||||
@@ -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<User>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<User> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Workout>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Workout> 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 });
|
||||
}
|
||||
}
|
||||
@@ -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<WorkoutExercise>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkoutExercise> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<AppDbContext>(options => options.UseNpgsql(connectionString));
|
||||
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<ITelegramMessageRepository, TelegramMessageRepository>();
|
||||
services.AddScoped<IWorkoutRepository, WorkoutRepository>();
|
||||
services.AddScoped<IExerciseRepository, ExerciseRepository>();
|
||||
services.AddSingleton<IClock, SystemClock>();
|
||||
services.AddSingleton<IIdGenerator, GuidIdGenerator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace GymLogAI.Persistence;
|
||||
|
||||
public sealed class DesignTimeAppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
public AppDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
|
||||
optionsBuilder.UseNpgsql("Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres");
|
||||
|
||||
return new AppDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GymLogAI.Application\GymLogAI.Application.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
using GymLogAI.Core.Abstractions;
|
||||
|
||||
namespace GymLogAI.Persistence.Infrastructure;
|
||||
|
||||
public sealed class GuidIdGenerator : IIdGenerator
|
||||
{
|
||||
public Guid New() => Guid.NewGuid();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using GymLogAI.Core.Abstractions;
|
||||
|
||||
namespace GymLogAI.Persistence.Infrastructure;
|
||||
|
||||
public sealed class SystemClock : IClock
|
||||
{
|
||||
public DateTime UtcNow => DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.PrimitiveCollection<float[]>("Embedding")
|
||||
.HasColumnType("real[]");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal?>("DistanceMeters")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.Property<int?>("DurationSeconds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("Repetitions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal?>("WeightKg")
|
||||
.HasPrecision(8, 2)
|
||||
.HasColumnType("numeric(8,2)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("ImportedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("ParsedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ParsingError")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("ParsingStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("SentAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("TelegramChatId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("TelegramMessageId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<long>("TelegramUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTime>("PerformedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("SourceTelegramMessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("SourceType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ExerciseId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GymLogAI.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "exercises",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
||||
Embedding = table.Column<float[]>(type: "real[]", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
TelegramUserId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Username = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
FirstName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
LastName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TelegramChatId = table.Column<long>(type: "bigint", nullable: false),
|
||||
TelegramMessageId = table.Column<int>(type: "integer", nullable: false),
|
||||
Text = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
|
||||
SentAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ImportedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ParsingStatus = table.Column<int>(type: "integer", nullable: false),
|
||||
ParsingError = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
||||
WorkoutId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ParsedAtUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SourceTelegramMessageId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
PerformedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
Notes = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
|
||||
SourceType = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
WorkoutId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ExerciseId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Order = table.Column<int>(type: "integer", nullable: false),
|
||||
Notes = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
|
||||
WorkoutExerciseId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Order = table.Column<int>(type: "integer", nullable: false),
|
||||
WeightKg = table.Column<decimal>(type: "numeric(8,2)", precision: 8, scale: 2, nullable: true),
|
||||
Repetitions = table.Column<int>(type: "integer", nullable: true),
|
||||
DurationSeconds = table.Column<int>(type: "integer", nullable: true),
|
||||
DistanceMeters = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
|
||||
Notes = table.Column<string>(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" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// <auto-generated />
|
||||
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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.PrimitiveCollection<float[]>("Embedding")
|
||||
.HasColumnType("real[]");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal?>("DistanceMeters")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("numeric(10,2)");
|
||||
|
||||
b.Property<int?>("DurationSeconds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("Repetitions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal?>("WeightKg")
|
||||
.HasPrecision(8, 2)
|
||||
.HasColumnType("numeric(8,2)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("ImportedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("ParsedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ParsingError")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("ParsingStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("SentAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("TelegramChatId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("TelegramMessageId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<long>("TelegramUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTime>("PerformedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("SourceTelegramMessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("SourceType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ExerciseId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Exercise?> GetByNameAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.Exercises.SingleOrDefaultAsync(x => x.Name == name, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Exercise> AddAsync(Exercise exercise, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.Exercises.Add(exercise);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return exercise;
|
||||
}
|
||||
|
||||
public async Task<Exercise> UpdateAsync(Exercise exercise, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.Exercises.Update(exercise);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return exercise;
|
||||
}
|
||||
}
|
||||
@@ -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<TelegramMessage> AddAsync(TelegramMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.TelegramMessages.Add(message);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return message;
|
||||
}
|
||||
|
||||
public async Task<TelegramMessage?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.TelegramMessages.SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<TelegramMessage>> 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<TelegramMessage> UpdateAsync(TelegramMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.TelegramMessages.Update(message);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -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<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.Users.SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<User?> GetByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.Users.SingleOrDefaultAsync(x => x.TelegramUserId == telegramUserId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<User> AddAsync(User user, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.Users.Add(user);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<User> UpdateAsync(User user, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.Users.Update(user);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -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<Workout> AddAsync(Workout workout, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.Workouts.Add(workout);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return workout;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<Workout>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Telegram.Bot" Version="22.9.6.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GymLogAI.Application\GymLogAI.Application.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.AI\GymLogAI.AI.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.Persistence\GymLogAI.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<TelegramBotOptions>()
|
||||
.Bind(builder.Configuration.GetSection(TelegramBotOptions.SectionName));
|
||||
|
||||
builder.Services.AddPersistence(builder.Configuration);
|
||||
builder.Services.AddAiServices(builder.Configuration);
|
||||
builder.Services.AddScoped<ImportTelegramMessageHandler>();
|
||||
builder.Services.AddScoped<ParseTelegramMessageHandler>();
|
||||
builder.Services.AddScoped<RecommendWorkoutHandler>();
|
||||
builder.Services.AddSingleton<ITelegramBotClient>(serviceProvider =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<TelegramBotOptions>>().Value;
|
||||
return new TelegramBotClient(options.BotToken ?? string.Empty);
|
||||
});
|
||||
builder.Services.AddScoped<UpdateHandler>();
|
||||
builder.Services.AddHostedService<TelegramBotHostedService>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
await using (var scope = host.Services.CreateAsyncScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await dbContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
await host.RunAsync();
|
||||
}
|
||||
}
|
||||
@@ -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<TelegramBotOptions> options,
|
||||
ILogger<TelegramBotHostedService> 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<UpdateHandler>();
|
||||
await handler.HandleUpdateAsync(client, update, cancellationToken);
|
||||
},
|
||||
async (client, exception, cancellationToken) =>
|
||||
{
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
var handler = scope.ServiceProvider.GetRequiredService<UpdateHandler>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<UpdateHandler> 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<byte> buffer = stackalloc byte[16];
|
||||
BitConverter.TryWriteBytes(buffer[..8], telegramUserId);
|
||||
BitConverter.TryWriteBytes(buffer[8..], telegramUserId);
|
||||
return new Guid(buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres"
|
||||
},
|
||||
"TelegramBot": {
|
||||
"BotToken": "",
|
||||
"PollingTimeoutSeconds": 5
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<TelegramBotOptions>()
|
||||
.Bind(configuration.GetSection(TelegramBotOptions.SectionName));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-GymLogAI.Worker-bf7dded7-f871-4b7d-a513-0a677ed83bd3</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GymLogAI.Application\GymLogAI.Application.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.AI\GymLogAI.AI.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.Persistence\GymLogAI.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<WorkerOptions> options,
|
||||
ILogger<ParsePendingTelegramMessagesJob> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<ITelegramMessageRepository>();
|
||||
var handler = scope.ServiceProvider.GetRequiredService<ParseTelegramMessageHandler>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<WorkerOptions>()
|
||||
.Bind(builder.Configuration.GetSection(WorkerOptions.SectionName));
|
||||
builder.Services.AddPersistence(builder.Configuration);
|
||||
builder.Services.AddAiServices(builder.Configuration);
|
||||
builder.Services.AddScoped<ImportTelegramMessageHandler>();
|
||||
builder.Services.AddScoped<ParseTelegramMessageHandler>();
|
||||
builder.Services.AddScoped<RecommendWorkoutHandler>();
|
||||
builder.Services.AddHostedService<ParsePendingTelegramMessagesJob>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
await using (var scope = host.Services.CreateAsyncScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await dbContext.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
await host.RunAsync();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"GymLogAI.Worker": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+98
-5
@@ -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
|
||||
|
||||
@@ -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=<guid>`
|
||||
- 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`
|
||||
+45
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user