diff --git a/GermanApp/Domain/Entities/User.cs b/GermanApp/Domain/Entities/User.cs new file mode 100644 index 0000000..7f75606 --- /dev/null +++ b/GermanApp/Domain/Entities/User.cs @@ -0,0 +1,78 @@ +namespace GermanApp.Domain.Entities; + +/// +/// Represents a user in the DeutschLernen system. +/// +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; } + + /// + /// Constructor for EF Core deserialization. + /// + private User() { } + + /// + /// Factory method to create a new user. + /// + 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 + }; + } + + /// + /// Updates user's gamification stats. + /// + public void AddPoints(int points) + { + TotalPoints += points; + } + + /// + /// Updates user's streak. + /// + public void UpdateStreak(int streak) + { + Streak = streak; + } + + /// + /// Updates user's current level. + /// + public void UpdateLevel(string level) + { + CurrentLevel = level; + } + + /// + /// Changes user's email. + /// + public void ChangeEmail(string newEmail) + { + Email = newEmail.ToLowerInvariant(); + } + + /// + /// Changes user's password hash. + /// + public void ChangePassword(string newPasswordHash) + { + PasswordHash = newPasswordHash; + } +} diff --git a/GermanApp/GermanApp.csproj b/GermanApp/GermanApp.csproj index 824b0c5..e71a865 100644 --- a/GermanApp/GermanApp.csproj +++ b/GermanApp/GermanApp.csproj @@ -10,7 +10,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -19,6 +19,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs index df79381..aab0cc8 100644 --- a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs +++ b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs @@ -15,6 +15,7 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext // DbSets for domain entities public DbSet Lessons { get; set; } = null!; + public DbSet Users { get; set; } = null!; // Note: Value objects are not stored directly as entities. // 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(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 // In a real application, use migrations or a separate seeding mechanism // modelBuilder.Entity().HasData( diff --git a/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.Designer.cs b/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.Designer.cs new file mode 100644 index 0000000..7667d4d --- /dev/null +++ b/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.Designer.cs @@ -0,0 +1,116 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Lessons"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentLevel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("A1"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Streak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TotalPoints") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("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 + } + } +} diff --git a/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.cs b/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.cs new file mode 100644 index 0000000..18f9d94 --- /dev/null +++ b/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GermanApp.Infrastructure.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Lessons", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + Level = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Username = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Email = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + PasswordHash = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + CurrentLevel = table.Column(type: "character varying(10)", maxLength: 10, nullable: false, defaultValue: "A1"), + Streak = table.Column(type: "integer", nullable: false, defaultValue: 0), + TotalPoints = table.Column(type: "integer", nullable: false, defaultValue: 0), + CreatedAt = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Lessons"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..acaa18e --- /dev/null +++ b/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,113 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Lessons"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentLevel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("A1"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Streak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TotalPoints") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("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 + } + } +} diff --git a/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs b/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs new file mode 100644 index 0000000..7fa8ece --- /dev/null +++ b/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs @@ -0,0 +1,60 @@ +using GermanApp.Domain.Entities; +using GermanApp.Infrastructure.Data.DbContext; +using Microsoft.EntityFrameworkCore; + +namespace GermanApp.Infrastructure.Data.SeedData; + +/// +/// Extension methods for seeding the database. +/// +public static class SeedDataExtension +{ + /// + /// Seeds the database with initial data. + /// + /// The web application + public static void SeedDatabase(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // 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."); + } + } +} diff --git a/GermanApp/Program.cs b/GermanApp/Program.cs index 0133d3f..014fb9c 100644 --- a/GermanApp/Program.cs +++ b/GermanApp/Program.cs @@ -3,94 +3,139 @@ using GermanApp.Application.UseCases.Commands; using GermanApp.Domain.Interfaces; using GermanApp.Infrastructure.Data.DbContext; using GermanApp.Infrastructure.Data.Repositories; +using GermanApp.Infrastructure.Data.SeedData; using GermanApp.Presentation.Endpoints; +using GermanApp.Shared.Middleware; 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. -// 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(options => +try { - // Using SQLite for development - configure in appsettings.json for production - options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection") - ?? "Data Source=germanapp.db"); - - // Enable sensitive data logging in development - if (builder.Environment.IsDevelopment()) + var builder = WebApplication.CreateBuilder(args); + + // Add Serilog to the builder + 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(); + + // Configure CORS + builder.Services.AddCors(options => { - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); + options.AddPolicy("AllowAll", builder => + { + 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(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(); + + // ============================================ + // APPLICATION LAYER - Use Cases & Services + // ============================================ + + // Register command handlers + builder.Services.AddScoped, 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) -builder.Services.AddScoped(); + // Use CORS + app.UseCors("AllowAll"); -// ============================================ -// APPLICATION LAYER - Use Cases & Services -// ============================================ + // Use Health Checks + app.MapHealthChecks("/health"); -// Register command handlers -builder.Services.AddScoped, CreateLessonCommandHandler>(); + // Seed database with initial data + app.SeedDatabase(); -// ============================================ -// PRESENTATION LAYER - API -// ============================================ + // Map Clean Architecture endpoints + app.MapLessonsEndpoints(); -// Add services for Minimal APIs -builder.Services.AddEndpointsApiExplorer(); -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(); - dbContext.Database.EnsureCreated(); -} - -// Map Clean Architecture endpoints -app.MapLessonsEndpoints(); - -// Keep original WeatherForecast endpoint for reference -app.MapGet("/weatherforecast", () => -{ - var summaries = new[] + // Keep original WeatherForecast endpoint for reference + app.MapGet("/weatherforecast", () => { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); + var summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; + }) + .WithName("GetWeatherForecast"); -app.Run(); + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} // Existing record for reference record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) diff --git a/GermanApp/Shared/Middleware/ExceptionMiddleware.cs b/GermanApp/Shared/Middleware/ExceptionMiddleware.cs new file mode 100644 index 0000000..bc25881 --- /dev/null +++ b/GermanApp/Shared/Middleware/ExceptionMiddleware.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Text.Json; +using GermanApp.Shared.Models; + +namespace GermanApp.Shared.Middleware; + +/// +/// Global exception handling middleware. +/// Catches exceptions and returns consistent error responses. +/// +public class ExceptionMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionMiddleware(RequestDelegate next, ILogger 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)); + } +} + +/// +/// Extension method to add exception middleware. +/// +public static class ExceptionMiddlewareExtensions +{ + public static IApplicationBuilder UseExceptionMiddleware(this IApplicationBuilder builder) => + builder.UseMiddleware(); +} diff --git a/GermanApp/Shared/Models/ApiResponse.cs b/GermanApp/Shared/Models/ApiResponse.cs new file mode 100644 index 0000000..a496c52 --- /dev/null +++ b/GermanApp/Shared/Models/ApiResponse.cs @@ -0,0 +1,47 @@ +namespace GermanApp.Shared.Models; + +/// +/// Base API response wrapper for consistent API responses. +/// +/// The data type of the response +public record ApiResponse(T Data, string Message = "Success", bool Success = true) +{ + /// + /// Creates a successful response. + /// + public static ApiResponse Ok(T data, string message = "Success") => + new ApiResponse(data, message, true); +} + +/// +/// API error response for consistent error handling. +/// +public record ApiErrorResponse(string Message, int StatusCode, string? Details = null) +{ + public bool Success => false; + + /// + /// Creates an error response. + /// + public static ApiErrorResponse Error(string message, int statusCode, string? details = null) => + new ApiErrorResponse(message, statusCode, details); +} + +/// +/// Paginated API response. +/// +/// The data type of the items +public record PaginatedResponse(IEnumerable Items, int PageNumber, int PageSize, int TotalCount, int TotalPages) +{ + /// + /// Creates a paginated response. + /// + public static PaginatedResponse Create(IEnumerable items, int pageNumber, int pageSize, int totalCount) => + new PaginatedResponse( + items, + pageNumber, + pageSize, + totalCount, + (int)Math.Ceiling(totalCount / (double)pageSize) + ); +} diff --git a/GermanApp/appsettings.Development.json b/GermanApp/appsettings.Development.json index ff66ba6..045b0f3 100644 --- a/GermanApp/appsettings.Development.json +++ b/GermanApp/appsettings.Development.json @@ -1,8 +1,11 @@ { "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Default": "Debug", + "Microsoft.AspNetCore": "Information" } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres" } } diff --git a/GermanApp/appsettings.Production.json b/GermanApp/appsettings.Production.json new file mode 100644 index 0000000..4dd231b --- /dev/null +++ b/GermanApp/appsettings.Production.json @@ -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}" + } +} diff --git a/GermanApp/appsettings.Staging.json b/GermanApp/appsettings.Staging.json new file mode 100644 index 0000000..4635bcd --- /dev/null +++ b/GermanApp/appsettings.Staging.json @@ -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}" + } +} diff --git a/GermanApp/appsettings.json b/GermanApp/appsettings.json index 4d56694..7b19f94 100644 --- a/GermanApp/appsettings.json +++ b/GermanApp/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres" + } } diff --git a/docs/features/infrastructure-setup.md b/docs/features/infrastructure-setup.md index 5dc7aec..bc68495 100644 --- a/docs/features/infrastructure-setup.md +++ b/docs/features/infrastructure-setup.md @@ -1,6 +1,6 @@ # Feature: Infrastructure Setup -> **Status**: ⏳ Planned +> **Status**: 🚀 In Progress > **Priority**: High > **Complexity**: Medium > **Estimate**: 10-14 hours