feat: scaffold backend and shared library

This commit is contained in:
Andre Beging
2026-01-29 10:17:16 +01:00
commit b6ce21b1b8
15 changed files with 763 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ASTRAIN.Shared\ASTRAIN.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,63 @@
using ASTRAIN.Shared;
using Microsoft.EntityFrameworkCore;
namespace ASTRAIN.Api.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<User> Users => Set<User>();
public DbSet<Exercise> Exercises => Set<Exercise>();
public DbSet<Routine> Routines => Set<Routine>();
public DbSet<RoutineExercise> RoutineExercises => Set<RoutineExercise>();
public DbSet<RoutineRun> RoutineRuns => Set<RoutineRun>();
public DbSet<RoutineRunEntry> RoutineRunEntries => Set<RoutineRunEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>()
.HasMany(u => u.Exercises)
.WithOne(e => e.User)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<User>()
.HasMany(u => u.Routines)
.WithOne(r => r.User)
.HasForeignKey(r => r.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RoutineExercise>()
.HasIndex(re => new { re.RoutineId, re.Order })
.IsUnique();
modelBuilder.Entity<RoutineExercise>()
.HasOne(re => re.Routine)
.WithMany(r => r.Exercises)
.HasForeignKey(re => re.RoutineId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RoutineExercise>()
.HasOne(re => re.Exercise)
.WithMany(e => e.RoutineExercises)
.HasForeignKey(re => re.ExerciseId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RoutineRun>()
.HasOne(rr => rr.Routine)
.WithMany(r => r.Runs)
.HasForeignKey(rr => rr.RoutineId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RoutineRunEntry>()
.HasOne(rre => rre.RoutineRun)
.WithMany(rr => rr.Entries)
.HasForeignKey(rre => rre.RoutineRunId)
.OnDelete(DeleteBehavior.Cascade);
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -0,0 +1,29 @@
# Build API
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ASTRAIN.Api/ASTRAIN.Api.csproj ASTRAIN.Api/
COPY ASTRAIN.Shared/ASTRAIN.Shared.csproj ASTRAIN.Shared/
RUN dotnet restore ASTRAIN.Api/ASTRAIN.Api.csproj
COPY ASTRAIN.Api/ ASTRAIN.Api/
COPY ASTRAIN.Shared/ ASTRAIN.Shared/
RUN dotnet publish ASTRAIN.Api/ASTRAIN.Api.csproj -c Release -o /app/publish
# Build Client
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build-client
WORKDIR /src
COPY ASTRAIN.Client/ASTRAIN.Client.csproj ASTRAIN.Client/
COPY ASTRAIN.Shared/ASTRAIN.Shared.csproj ASTRAIN.Shared/
RUN dotnet restore ASTRAIN.Client/ASTRAIN.Client.csproj
COPY ASTRAIN.Client/ ASTRAIN.Client/
COPY ASTRAIN.Shared/ ASTRAIN.Shared/
RUN dotnet publish ASTRAIN.Client/ASTRAIN.Client.csproj -c Release -o /app/client
# Final runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
RUN mkdir -p /app/Data
COPY --from=build /app/publish/ ./
COPY --from=build-client /app/client/wwwroot ./wwwroot
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "ASTRAIN.Api.dll"]

331
src/ASTRAIN.Api/Program.cs Normal file
View File

@@ -0,0 +1,331 @@
using System.Text.RegularExpressions;
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins("http://localhost:5014", "https://localhost:7252")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddDbContext<AppDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Data Source=Data/astrain.db";
options.UseSqlite(connectionString);
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseCors();
app.UseDefaultFiles();
app.UseStaticFiles();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
}
var api = app.MapGroup("/api");
api.MapGet("/health", () => Results.Ok(new { status = "ok" }));
api.MapGet("/users/ensure", async (string? userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
return Results.Ok(new EnsureUserResponse(user.Id));
});
api.MapGet("/users/{userId}", async (string userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
return Results.Ok(new EnsureUserResponse(user.Id));
});
api.MapGet("/users/{userId}/exercises", async (string userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var items = await db.Exercises
.Where(e => e.UserId == user.Id)
.OrderBy(e => e.Name)
.Select(e => new ExerciseDto(e.Id, e.Name))
.ToListAsync();
return Results.Ok(items);
});
api.MapPost("/users/{userId}/exercises", async (string userId, ExerciseUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var exercise = new Exercise
{
Name = request.Name.Trim(),
UserId = user.Id
};
db.Exercises.Add(exercise);
await db.SaveChangesAsync();
return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name));
});
api.MapPut("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, ExerciseUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id);
if (exercise is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
exercise.Name = request.Name.Trim();
await db.SaveChangesAsync();
return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name));
});
api.MapGet("/users/{userId}/routines", async (string userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routines = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
.Where(r => r.UserId == user.Id)
.OrderBy(r => r.Name)
.ToListAsync();
var payload = routines.Select(r => new RoutineDto(
r.Id,
r.Name,
r.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order))
.ToList()
));
return Results.Ok(payload);
});
api.MapGet("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routine = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
var payload = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order))
.ToList());
return Results.Ok(payload);
});
api.MapPost("/users/{userId}/routines", async (string userId, RoutineUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var routine = new Routine
{
Name = request.Name.Trim(),
UserId = user.Id
};
var exercises = await db.Exercises
.Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id))
.ToListAsync();
routine.Exercises = request.ExerciseIds
.Select((exerciseId, index) => new RoutineExercise
{
ExerciseId = exerciseId,
Order = index
})
.ToList();
db.Routines.Add(routine);
await db.SaveChangesAsync();
var dto = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.Select((re, index) => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, index))
.ToList());
return Results.Ok(dto);
});
api.MapPut("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, RoutineUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routine = await db.Routines
.Include(r => r.Exercises)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
routine.Name = request.Name.Trim();
routine.Exercises.Clear();
foreach (var exerciseId in request.ExerciseIds)
{
routine.Exercises.Add(new RoutineExercise
{
ExerciseId = exerciseId,
Order = routine.Exercises.Count
});
}
await db.SaveChangesAsync();
var exercises = await db.Exercises
.Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id))
.ToListAsync();
var dto = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, re.Order))
.ToList());
return Results.Ok(dto);
});
api.MapGet("/users/{userId}/routines/{routineId:int}/last-run", async (string userId, int routineId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var lastRun = await db.RoutineRuns
.Include(rr => rr.Entries)
.Where(rr => rr.UserId == user.Id && rr.RoutineId == routineId)
.OrderByDescending(rr => rr.PerformedAt)
.FirstOrDefaultAsync();
if (lastRun is null)
{
return Results.Ok(new RoutineRunSummaryDto(DateTime.MinValue, new List<RoutineRunEntryDto>()));
}
var summary = new RoutineRunSummaryDto(
lastRun.PerformedAt,
lastRun.Entries
.Select(e => new RoutineRunEntryDto(e.ExerciseId, e.Weight, e.Completed))
.ToList());
return Results.Ok(summary);
});
api.MapPost("/users/{userId}/routines/{routineId:int}/runs", async (string userId, int routineId, RoutineRunRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
var run = new RoutineRun
{
RoutineId = routine.Id,
UserId = user.Id,
PerformedAt = DateTime.UtcNow,
Entries = request.Entries.Select(entry => new RoutineRunEntry
{
ExerciseId = entry.ExerciseId,
Weight = entry.Weight,
Completed = entry.Completed
}).ToList()
};
db.RoutineRuns.Add(run);
await db.SaveChangesAsync();
return Results.Ok(new { run.Id, run.PerformedAt });
});
app.MapFallbackToFile("index.html");
app.Run();
static async Task<User> EnsureUserAsync(AppDbContext db, string? userId)
{
if (!string.IsNullOrWhiteSpace(userId) && IsValidUserId(userId))
{
var existing = await db.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (existing is not null)
{
return existing;
}
var created = new User { Id = userId };
db.Users.Add(created);
await db.SaveChangesAsync();
return created;
}
while (true)
{
var newId = UserKeyGenerator.Generate(8);
var exists = await db.Users.AnyAsync(u => u.Id == newId);
if (exists)
{
continue;
}
var user = new User { Id = newId };
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
}
static bool IsValidUserId(string userId)
{
return Regex.IsMatch(userId, "^[A-Za-z0-9]{8}$");
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5055",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7049;http://localhost:5055",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,22 @@
using System.Security.Cryptography;
using System.Text;
namespace ASTRAIN.Api.Services;
public static class UserKeyGenerator
{
private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
public static string Generate(int length = 8)
{
var buffer = new byte[length];
RandomNumberGenerator.Fill(buffer);
var chars = new char[length];
for (var i = 0; i < length; i++)
{
chars[i] = Alphabet[buffer[i] % Alphabet.Length];
}
return new string(chars);
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"Default": "Data Source=Data/astrain.db"
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,139 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ASTRAIN.Shared;
public class User
{
[Key]
[MaxLength(8)]
public string Id { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public List<Exercise> Exercises { get; set; } = new();
public List<Routine> Routines { get; set; } = new();
}
public class Exercise
{
public int Id { get; set; }
[Required]
[MaxLength(120)]
public string Name { get; set; } = string.Empty;
[Required]
[MaxLength(8)]
public string UserId { get; set; } = string.Empty;
public User? User { get; set; }
public List<RoutineExercise> RoutineExercises { get; set; } = new();
}
public class Routine
{
public int Id { get; set; }
[Required]
[MaxLength(120)]
public string Name { get; set; } = string.Empty;
[Required]
[MaxLength(8)]
public string UserId { get; set; } = string.Empty;
public User? User { get; set; }
public List<RoutineExercise> Exercises { get; set; } = new();
public List<RoutineRun> Runs { get; set; } = new();
}
public class RoutineExercise
{
public int Id { get; set; }
[Required]
public int RoutineId { get; set; }
public Routine? Routine { get; set; }
[Required]
public int ExerciseId { get; set; }
public Exercise? Exercise { get; set; }
public int Order { get; set; }
}
public class RoutineRun
{
public int Id { get; set; }
[Required]
public int RoutineId { get; set; }
public Routine? Routine { get; set; }
[Required]
[MaxLength(8)]
public string UserId { get; set; } = string.Empty;
public DateTime PerformedAt { get; set; } = DateTime.UtcNow;
public List<RoutineRunEntry> Entries { get; set; } = new();
}
public class RoutineRunEntry
{
public int Id { get; set; }
[Required]
public int RoutineRunId { get; set; }
public RoutineRun? RoutineRun { get; set; }
[Required]
public int ExerciseId { get; set; }
public Exercise? Exercise { get; set; }
public double Weight { get; set; }
public bool Completed { get; set; }
}
public record EnsureUserResponse(string UserId);
public record ExerciseDto(int Id, string Name);
public record ExerciseUpsertRequest(string Name);
public record RoutineExerciseDto(int ExerciseId, string Name, int Order);
public record RoutineDto(int Id, string Name, List<RoutineExerciseDto> Exercises);
public record RoutineUpsertRequest(string Name, List<int> ExerciseIds);
public class RoutineRunEntryDto
{
public int ExerciseId { get; set; }
public double Weight { get; set; }
public bool Completed { get; set; }
public RoutineRunEntryDto()
{
}
public RoutineRunEntryDto(int exerciseId, double weight, bool completed)
{
ExerciseId = exerciseId;
Weight = weight;
Completed = completed;
}
}
public record RoutineRunRequest(List<RoutineRunEntryDto> Entries);
public record RoutineRunSummaryDto(DateTime PerformedAt, List<RoutineRunEntryDto> Entries);