feat: scaffold backend and shared library
This commit is contained in:
21
.github/copilot-instructions.md
vendored
Normal file
21
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
- [x] Verify that the copilot-instructions.md file in the .github directory is created.
|
||||||
|
|
||||||
|
- [x] Clarify Project Requirements
|
||||||
|
|
||||||
|
- [x] Scaffold the Project
|
||||||
|
|
||||||
|
- [x] Customize the Project
|
||||||
|
|
||||||
|
- [x] Install Required Extensions
|
||||||
|
|
||||||
|
- [x] Compile the Project
|
||||||
|
|
||||||
|
- [x] Create and Run Task
|
||||||
|
|
||||||
|
- [ ] Launch the Project
|
||||||
|
|
||||||
|
- [x] Ensure Documentation is Complete
|
||||||
|
|
||||||
|
- Work through each checklist item systematically.
|
||||||
|
- Keep communication concise and focused.
|
||||||
|
- Follow development best practices.
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/out/
|
||||||
|
**/.vs/
|
||||||
|
**/.vscode/
|
||||||
|
!.vscode/
|
||||||
|
!.vscode/launch.json
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.cache
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
src/ASTRAIN.Api/Data/*.db
|
||||||
|
src/ASTRAIN.Api/Data/*.db-shm
|
||||||
|
src/ASTRAIN.Api/Data/*.db-wal
|
||||||
|
src/ASTRAIN.Api/ASTRAIN.Api.http
|
||||||
50
.vscode/launch.json
vendored
Normal file
50
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ASTRAIN.Api",
|
||||||
|
"type": "coreclr",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "build",
|
||||||
|
"program": "${workspaceFolder}/src/ASTRAIN.Api/bin/Debug/net10.0/ASTRAIN.Api.dll",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}/src/ASTRAIN.Api",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"serverReadyAction": {
|
||||||
|
"action": "openExternally",
|
||||||
|
"pattern": "Now listening on: (https?://\\S+)",
|
||||||
|
"uriFormat": "%s"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ASTRAIN.Client",
|
||||||
|
"type": "coreclr",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "build",
|
||||||
|
"program": "${workspaceFolder}/src/ASTRAIN.Client/bin/Debug/net10.0/ASTRAIN.Client.dll",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}/src/ASTRAIN.Client",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"serverReadyAction": {
|
||||||
|
"action": "openExternally",
|
||||||
|
"pattern": "Now listening on: (https?://\\S+)",
|
||||||
|
"uriFormat": "%s"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compounds": [
|
||||||
|
{
|
||||||
|
"name": "ASTRAIN.Full",
|
||||||
|
"configurations": [
|
||||||
|
"ASTRAIN.Api",
|
||||||
|
"ASTRAIN.Client"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
ASTRAIN.slnx
Normal file
7
ASTRAIN.slnx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/ASTRAIN.Api/ASTRAIN.Api.csproj" />
|
||||||
|
<Project Path="src/ASTRAIN.Client/ASTRAIN.Client.csproj" />
|
||||||
|
<Project Path="src/ASTRAIN.Shared/ASTRAIN.Shared.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
astrain:
|
||||||
|
build:
|
||||||
|
context: ./src
|
||||||
|
dockerfile: ASTRAIN.Api/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- astrain-data:/app/Data
|
||||||
|
volumes:
|
||||||
|
astrain-data:
|
||||||
21
src/ASTRAIN.Api/ASTRAIN.Api.csproj
Normal file
21
src/ASTRAIN.Api/ASTRAIN.Api.csproj
Normal 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>
|
||||||
63
src/ASTRAIN.Api/Data/AppDbContext.cs
Normal file
63
src/ASTRAIN.Api/Data/AppDbContext.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/ASTRAIN.Api/Dockerfile
Normal file
29
src/ASTRAIN.Api/Dockerfile
Normal 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
331
src/ASTRAIN.Api/Program.cs
Normal 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}$");
|
||||||
|
}
|
||||||
23
src/ASTRAIN.Api/Properties/launchSettings.json
Normal file
23
src/ASTRAIN.Api/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/ASTRAIN.Api/Services/UserKeyGenerator.cs
Normal file
22
src/ASTRAIN.Api/Services/UserKeyGenerator.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/ASTRAIN.Api/appsettings.Development.json
Normal file
8
src/ASTRAIN.Api/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/ASTRAIN.Api/appsettings.json
Normal file
12
src/ASTRAIN.Api/appsettings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Default": "Data Source=Data/astrain.db"
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
9
src/ASTRAIN.Shared/ASTRAIN.Shared.csproj
Normal file
9
src/ASTRAIN.Shared/ASTRAIN.Shared.csproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
139
src/ASTRAIN.Shared/Class1.cs
Normal file
139
src/ASTRAIN.Shared/Class1.cs
Normal 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);
|
||||||
Reference in New Issue
Block a user