Compare commits

..

5 commits

Author SHA1 Message Date
84e4991030 Merge pull request 'feature/infrastructure-setup' (#1) from feature/infrastructure-setup into main
Reviewed-on: #1
2026-06-05 11:30:00 +02:00
Lasse Rune Hansen
4ee2b2568a feat(backend/infra): add health checks, CORS, Serilog logging, and exception middleware
- Add Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore package
- Add Serilog.AspNetCore and Serilog.Sinks.Console packages
- Configure Serilog with bootstrap logger and configuration
- Add Health Checks endpoint at /health with DbContext check
- Configure CORS with AllowAll policy for development
- Create ApiResponse, ApiErrorResponse, and PaginatedResponse models
- Create ExceptionMiddleware for global error handling
- Restructure Program.cs with try-catch-finally for proper logging

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-05-31 19:52:00 +02:00
Lasse Rune Hansen
accb06bc7c feat(backend/infra): add User entity, database migrations, and seed data
- Create User entity with gamification fields (Level, Streak, Points)
- Add User DbSet to AppDbContext with proper configuration
- Create initial database migration with Users and Lessons tables
- Add SeedData extension for initial admin user and sample lessons
- Update Program.cs to use seed data method

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-05-31 19:44:29 +02:00
Lasse Rune Hansen
0772989b10 feat(backend/infra): configure PostgreSQL database for EF Core
- Replace SQLite with Npgsql provider
- Update Program.cs to use UseNpgsql
- Add connection strings to appsettings files
- Create appsettings.Staging.json and appsettings.Production.json

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-05-31 19:40:34 +02:00
Lasse Rune Hansen
cd6bc443a3 feat(backend): start infrastructure setup feature
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-05-31 19:38:52 +02:00
15 changed files with 724 additions and 79 deletions

View file

@ -0,0 +1,78 @@
namespace GermanApp.Domain.Entities;
/// <summary>
/// Represents a user in the DeutschLernen system.
/// </summary>
public class User
{
public int Id { get; private set; }
public string Username { get; private set; } = string.Empty;
public string Email { get; private set; } = string.Empty;
public string PasswordHash { get; private set; } = string.Empty;
public string CurrentLevel { get; private set; } = "A1";
public int Streak { get; private set; }
public int TotalPoints { get; private set; }
public DateTime CreatedAt { get; private set; }
/// <summary>
/// Constructor for EF Core deserialization.
/// </summary>
private User() { }
/// <summary>
/// Factory method to create a new user.
/// </summary>
public static User Create(string username, string email, string passwordHash)
{
return new User
{
Username = username,
Email = email.ToLowerInvariant(),
PasswordHash = passwordHash,
CurrentLevel = "A1",
Streak = 0,
TotalPoints = 0,
CreatedAt = DateTime.UtcNow
};
}
/// <summary>
/// Updates user's gamification stats.
/// </summary>
public void AddPoints(int points)
{
TotalPoints += points;
}
/// <summary>
/// Updates user's streak.
/// </summary>
public void UpdateStreak(int streak)
{
Streak = streak;
}
/// <summary>
/// Updates user's current level.
/// </summary>
public void UpdateLevel(string level)
{
CurrentLevel = level;
}
/// <summary>
/// Changes user's email.
/// </summary>
public void ChangeEmail(string newEmail)
{
Email = newEmail.ToLowerInvariant();
}
/// <summary>
/// Changes user's password hash.
/// </summary>
public void ChangePassword(string newPasswordHash)
{
PasswordHash = newPasswordHash;
}
}

View file

@ -10,7 +10,7 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.16" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.16" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -19,6 +19,10 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -15,6 +15,7 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
// DbSets for domain entities // DbSets for domain entities
public DbSet<Lesson> Lessons { get; set; } = null!; public DbSet<Lesson> Lessons { get; set; } = null!;
public DbSet<User> Users { get; set; } = null!;
// Note: Value objects are not stored directly as entities. // Note: Value objects are not stored directly as entities.
// They are owned by entities and stored as part of the entity's data. // They are owned by entities and stored as part of the entity's data.
@ -43,6 +44,23 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
// }); // });
}); });
// Configure User entity
modelBuilder.Entity<User>(builder =>
{
builder.HasKey(u => u.Id);
builder.Property(u => u.Username).IsRequired().HasMaxLength(50);
builder.Property(u => u.Email).IsRequired().HasMaxLength(100);
builder.Property(u => u.PasswordHash).IsRequired().HasMaxLength(255);
builder.Property(u => u.CurrentLevel).HasMaxLength(10).HasDefaultValue("A1");
builder.Property(u => u.Streak).HasDefaultValue(0);
builder.Property(u => u.TotalPoints).HasDefaultValue(0);
builder.Property(u => u.CreatedAt).IsRequired();
// Ensure unique constraints
builder.HasIndex(u => u.Username).IsUnique();
builder.HasIndex(u => u.Email).IsUnique();
});
// Seed data (optional) - Note: For EF Core, we need to set properties directly // Seed data (optional) - Note: For EF Core, we need to set properties directly
// In a real application, use migrations or a separate seeding mechanism // In a real application, use migrations or a separate seeding mechanism
// modelBuilder.Entity<Lesson>().HasData( // modelBuilder.Entity<Lesson>().HasData(

View file

@ -0,0 +1,116 @@
// <auto-generated />
using System;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GermanApp.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260531174249_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Lessons");
});
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CurrentLevel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasDefaultValue("A1");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("Streak")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<int>("TotalPoints")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GermanApp.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Lessons",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
Level = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Lessons", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Username = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Email = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
PasswordHash = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
CurrentLevel = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false, defaultValue: "A1"),
Streak = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
TotalPoints = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Users_Email",
table: "Users",
column: "Email",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_Username",
table: "Users",
column: "Username",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Lessons");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View file

@ -0,0 +1,113 @@
// <auto-generated />
using System;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GermanApp.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Lessons");
});
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CurrentLevel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasDefaultValue("A1");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("Streak")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<int>("TotalPoints")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,60 @@
using GermanApp.Domain.Entities;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
namespace GermanApp.Infrastructure.Data.SeedData;
/// <summary>
/// Extension methods for seeding the database.
/// </summary>
public static class SeedDataExtension
{
/// <summary>
/// Seeds the database with initial data.
/// </summary>
/// <param name="app">The web application</param>
public static void SeedDatabase(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Apply pending migrations
dbContext.Database.Migrate();
// Only seed if there are no users
if (!dbContext.Users.Any())
{
// Seed an admin user
var adminUser = User.Create(
"admin",
"admin@deutschlernen.com",
"$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Admin@123!"
);
adminUser.UpdateLevel("C1");
dbContext.Users.Add(adminUser);
// Seed a test user
var testUser = User.Create(
"testuser",
"test@deutschlernen.com",
"$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Test@123!"
);
dbContext.Users.Add(testUser);
// Seed some lessons
var lessons = new[]
{
Lesson.Create("Greetings", "Basic German greetings and introductions", 1),
Lesson.Create("Numbers", "German numbers 1-100", 1),
Lesson.Create("Grammar Basics", "Basic German grammar rules", 2),
Lesson.Create("Everyday Phrases", "Common phrases for daily conversations", 2),
Lesson.Create("Advanced Grammar", "Complex German grammar", 4)
};
dbContext.Lessons.AddRange(lessons);
dbContext.SaveChanges();
Console.WriteLine("Database seeded with admin user, test user, and sample lessons.");
}
}
}

View file

@ -3,94 +3,139 @@ using GermanApp.Application.UseCases.Commands;
using GermanApp.Domain.Interfaces; using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Data.DbContext; using GermanApp.Infrastructure.Data.DbContext;
using GermanApp.Infrastructure.Data.Repositories; using GermanApp.Infrastructure.Data.Repositories;
using GermanApp.Infrastructure.Data.SeedData;
using GermanApp.Presentation.Endpoints; using GermanApp.Presentation.Endpoints;
using GermanApp.Shared.Middleware;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Serilog;
var builder = WebApplication.CreateBuilder(args); // Configure Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateBootstrapLogger();
// Add services to the container. try
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// ============================================
// INFRASTRUCTURE LAYER - Database & External Services
// ============================================
// Add DbContext with SQLite (can be changed to SQL Server, PostgreSQL, etc.)
builder.Services.AddDbContext<AppDbContext>(options =>
{ {
// Using SQLite for development - configure in appsettings.json for production var builder = WebApplication.CreateBuilder(args);
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
?? "Data Source=germanapp.db");
// Enable sensitive data logging in development // Add Serilog to the builder
if (builder.Environment.IsDevelopment()) builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Debug()
.WriteTo.Console()
.ReadFrom.Configuration(ctx.Configuration));
// ============================================
// PRESENTATION LAYER - API Configuration
// ============================================
// Add services to the container.
builder.Services.AddOpenApi();
// Add Health Checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
// Configure CORS
builder.Services.AddCors(options =>
{ {
options.EnableSensitiveDataLogging(); options.AddPolicy("AllowAll", builder =>
options.EnableDetailedErrors(); {
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// Add services for Minimal APIs
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ============================================
// INFRASTRUCTURE LAYER - Database & External Services
// ============================================
// Add DbContext with PostgreSQL
builder.Services.AddDbContext<AppDbContext>(options =>
{
// Using PostgreSQL for production and development
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")
?? "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres");
// Enable sensitive data logging in development
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});
// Register repositories (Infrastructure implementations of Domain interfaces)
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
// ============================================
// APPLICATION LAYER - Use Cases & Services
// ============================================
// Register command handlers
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
var app = builder.Build();
// Configure the HTTP request pipeline.
// Use exception middleware first (to catch all exceptions)
app.UseExceptionMiddleware();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
} }
});
// Register repositories (Infrastructure implementations of Domain interfaces) // Use CORS
builder.Services.AddScoped<ILessonRepository, LessonRepository>(); app.UseCors("AllowAll");
// ============================================ // Use Health Checks
// APPLICATION LAYER - Use Cases & Services app.MapHealthChecks("/health");
// ============================================
// Register command handlers // Seed database with initial data
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>(); app.SeedDatabase();
// ============================================ // Map Clean Architecture endpoints
// PRESENTATION LAYER - API app.MapLessonsEndpoints();
// ============================================
// Add services for Minimal APIs // Keep original WeatherForecast endpoint for reference
builder.Services.AddEndpointsApiExplorer(); app.MapGet("/weatherforecast", () =>
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
}
// Initialize database (in development)
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
dbContext.Database.EnsureCreated();
}
// Map Clean Architecture endpoints
app.MapLessonsEndpoints();
// Keep original WeatherForecast endpoint for reference
app.MapGet("/weatherforecast", () =>
{
var summaries = new[]
{ {
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" var summaries = new[]
}; {
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
var forecast = Enumerable.Range(1, 5).Select(index => var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast new WeatherForecast
( (
DateOnly.FromDateTime(DateTime.Now.AddDays(index)), DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55), Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)] summaries[Random.Shared.Next(summaries.Length)]
)) ))
.ToArray(); .ToArray();
return forecast; return forecast;
}) })
.WithName("GetWeatherForecast"); .WithName("GetWeatherForecast");
app.Run(); app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
// Existing record for reference // Existing record for reference
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)

View file

@ -0,0 +1,62 @@
using System.Net;
using System.Text.Json;
using GermanApp.Shared.Models;
namespace GermanApp.Shared.Middleware;
/// <summary>
/// Global exception handling middleware.
/// Catches exceptions and returns consistent error responses.
/// </summary>
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext httpContext)
{
try
{
await _next(httpContext);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception occurred: {Message}", ex.Message);
await HandleExceptionAsync(httpContext, ex);
}
}
private static Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var response = new ApiErrorResponse(
"An unexpected error occurred. Please try again later.",
context.Response.StatusCode,
exception.Message
);
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return context.Response.WriteAsync(JsonSerializer.Serialize(response, options));
}
}
/// <summary>
/// Extension method to add exception middleware.
/// </summary>
public static class ExceptionMiddlewareExtensions
{
public static IApplicationBuilder UseExceptionMiddleware(this IApplicationBuilder builder) =>
builder.UseMiddleware<ExceptionMiddleware>();
}

View file

@ -0,0 +1,47 @@
namespace GermanApp.Shared.Models;
/// <summary>
/// Base API response wrapper for consistent API responses.
/// </summary>
/// <typeparam name="T">The data type of the response</typeparam>
public record ApiResponse<T>(T Data, string Message = "Success", bool Success = true)
{
/// <summary>
/// Creates a successful response.
/// </summary>
public static ApiResponse<T> Ok(T data, string message = "Success") =>
new ApiResponse<T>(data, message, true);
}
/// <summary>
/// API error response for consistent error handling.
/// </summary>
public record ApiErrorResponse(string Message, int StatusCode, string? Details = null)
{
public bool Success => false;
/// <summary>
/// Creates an error response.
/// </summary>
public static ApiErrorResponse Error(string message, int statusCode, string? details = null) =>
new ApiErrorResponse(message, statusCode, details);
}
/// <summary>
/// Paginated API response.
/// </summary>
/// <typeparam name="T">The data type of the items</typeparam>
public record PaginatedResponse<T>(IEnumerable<T> Items, int PageNumber, int PageSize, int TotalCount, int TotalPages)
{
/// <summary>
/// Creates a paginated response.
/// </summary>
public static PaginatedResponse<T> Create(IEnumerable<T> items, int pageNumber, int pageSize, int totalCount) =>
new PaginatedResponse<T>(
items,
pageNumber,
pageSize,
totalCount,
(int)Math.Ceiling(totalCount / (double)pageSize)
);
}

View file

@ -1,8 +1,11 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Information"
} }
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres"
} }
} }

View file

@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Error"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=prod-db;Port=5432;Database=DeutschLernen;Username=postgres;Password=${DB_PASSWORD}"
}
}

View file

@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=staging-db;Port=5432;Database=DeutschLernen;Username=postgres;Password=${DB_PASSWORD}"
}
}

View file

@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres"
}
} }

View file

@ -1,6 +1,6 @@
# Feature: Infrastructure Setup # Feature: Infrastructure Setup
> **Status**: ⏳ Planned > **Status**: 🚀 In Progress
> **Priority**: High > **Priority**: High
> **Complexity**: Medium > **Complexity**: Medium
> **Estimate**: 10-14 hours > **Estimate**: 10-14 hours