diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..055d65b
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,41 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 4
+
+[*.{json,yml,yaml,props,targets,csproj,slnx,xml,webmanifest}]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.cs]
+# Sprache
+csharp_style_namespace_declarations = file_scoped:warning
+csharp_style_var_for_built_in_types = true:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+csharp_style_var_elsewhere = true:suggestion
+csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+csharp_style_expression_bodied_properties = true:suggestion
+csharp_prefer_primary_constructors = true:suggestion
+csharp_using_directive_placement = outside_namespace:suggestion
+dotnet_sort_system_directives_first = true
+csharp_new_line_before_open_brace = all
+
+# Benennung
+dotnet_naming_rule.private_fields_underscore.symbols = private_fields
+dotnet_naming_rule.private_fields_underscore.style = underscore_camel
+dotnet_naming_rule.private_fields_underscore.severity = suggestion
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+dotnet_naming_style.underscore_camel.capitalization = camel_case
+dotnet_naming_style.underscore_camel.required_prefix = _
+
+# Generierter Code
+[**/Migrations/*.cs]
+generated_code = true
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3de05d4
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+* text=auto eol=lf
+
+*.sh text eol=lf
+*.ps1 text eol=crlf
+*.cmd text eol=crlf
+
+*.png binary
+*.ico binary
+*.jpg binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a0e8189
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,29 @@
+## .NET
+bin/
+obj/
+artifacts/
+*.user
+*.suo
+*.userprefs
+.vs/
+.vscode/*
+!.vscode/extensions.json
+.idea/
+*.swp
+TestResults/
+coverage*/
+*.trx
+
+## Secrets / lokale Konfiguration
+.env
+deploy/.env
+*.pfx
+appsettings.*.local.json
+
+## Aspire
+.aspire/
+aspire-output/
+
+## OS
+.DS_Store
+Thumbs.db
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..abfcaed
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,12 @@
+
+
+ net10.0
+ latest
+ enable
+ enable
+ true
+ false
+ de;en
+ false
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..b24a548
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,59 @@
+
+
+ true
+ false
+ 13.6.1
+ 10.0.12
+ 10.0.12
+ 1.19.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FamilyPlanner.slnx b/FamilyPlanner.slnx
new file mode 100644
index 0000000..405ca59
--- /dev/null
+++ b/FamilyPlanner.slnx
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet-tools.json b/dotnet-tools.json
new file mode 100644
index 0000000..5c1165b
--- /dev/null
+++ b/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "10.0.12",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..1d364c6
--- /dev/null
+++ b/global.json
@@ -0,0 +1,9 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature"
+ },
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
+ }
+}
diff --git a/src/FamilyPlanner.AppHost/AppHost.cs b/src/FamilyPlanner.AppHost/AppHost.cs
new file mode 100644
index 0000000..ff3cde6
--- /dev/null
+++ b/src/FamilyPlanner.AppHost/AppHost.cs
@@ -0,0 +1,48 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+// `aspire publish` erzeugt daraus eine docker-compose.yaml (siehe docs/ARCHITECTURE.md §9).
+builder.AddDockerComposeEnvironment("compose");
+
+// Integrationstests setzen FamilyPlanner:EphemeralDatabase=true, damit die Entwicklungsdaten unberührt bleiben.
+var ephemeralDatabase = bool.TryParse(builder.Configuration["FamilyPlanner:EphemeralDatabase"], out var ephemeral) && ephemeral;
+
+var sql = builder.AddSqlServer("sql");
+if (!ephemeralDatabase)
+{
+ sql.WithDataVolume("familyplanner-sql-data")
+ .WithLifetime(ContainerLifetime.Persistent);
+}
+
+var database = sql.AddDatabase("familyplannerdb");
+
+// Initial-Admin (nur beim allerersten Start relevant, Passwortwechsel wird erzwungen).
+var adminUserName = builder.AddParameter("seed-admin-username", value: "admin", publishValueAsDefault: true);
+var adminPassword = builder.AddParameter("seed-admin-password", secret: true);
+var adminEmail = builder.AddParameter("seed-admin-email", value: "", publishValueAsDefault: true);
+
+var migrations = builder.AddProject("migrations")
+ .WithReference(database)
+ .WaitFor(database)
+ .WithEnvironment("Seed__AdminUserName", adminUserName)
+ .WithEnvironment("Seed__AdminPassword", adminPassword)
+ .WithEnvironment("Seed__AdminEmail", adminEmail);
+
+var api = builder.AddProject("api")
+ .WithReference(database)
+ .WaitFor(database)
+ .WaitForCompletion(migrations)
+ .WithHttpHealthCheck("/health");
+
+if (ephemeralDatabase)
+{
+ // Tests melden sich mehrfach an – Rate Limit großzügiger.
+ api.WithEnvironment("RateLimiting__LoginPermitLimit", "100");
+}
+
+builder.AddProject("web")
+ .WithExternalHttpEndpoints()
+ .WithReference(api)
+ .WaitFor(api)
+ .WithHttpHealthCheck("/health");
+
+builder.Build().Run();
diff --git a/src/FamilyPlanner.AppHost/FamilyPlanner.AppHost.csproj b/src/FamilyPlanner.AppHost/FamilyPlanner.AppHost.csproj
new file mode 100644
index 0000000..dfaf74a
--- /dev/null
+++ b/src/FamilyPlanner.AppHost/FamilyPlanner.AppHost.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ 28bccc18-4f99-42dc-a9f2-5cb77c780430
+
+ $(NoWarn);ASPIRE010
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FamilyPlanner.AppHost/Properties/launchSettings.json b/src/FamilyPlanner.AppHost/Properties/launchSettings.json
new file mode 100644
index 0000000..8908b8e
--- /dev/null
+++ b/src/FamilyPlanner.AppHost/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17001;http://localhost:15234",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21215",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22044"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15234",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19252",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20180"
+ }
+ }
+ }
+}
diff --git a/src/FamilyPlanner.AppHost/appsettings.Development.json b/src/FamilyPlanner.AppHost/appsettings.Development.json
new file mode 100644
index 0000000..c8664c7
--- /dev/null
+++ b/src/FamilyPlanner.AppHost/appsettings.Development.json
@@ -0,0 +1,11 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Parameters": {
+ "seed-admin-password": "Admin123!ChangeMe"
+ }
+}
diff --git a/src/FamilyPlanner.AppHost/appsettings.json b/src/FamilyPlanner.AppHost/appsettings.json
new file mode 100644
index 0000000..31c092a
--- /dev/null
+++ b/src/FamilyPlanner.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/src/FamilyPlanner.AppHost/aspire.config.json b/src/FamilyPlanner.AppHost/aspire.config.json
new file mode 100644
index 0000000..119b5be
--- /dev/null
+++ b/src/FamilyPlanner.AppHost/aspire.config.json
@@ -0,0 +1,5 @@
+{
+ "appHost": {
+ "path": "FamilyPlanner.AppHost.csproj"
+ }
+}
diff --git a/src/FamilyPlanner.ServiceDefaults/Extensions.cs b/src/FamilyPlanner.ServiceDefaults/Extensions.cs
new file mode 100644
index 0000000..0be1995
--- /dev/null
+++ b/src/FamilyPlanner.ServiceDefaults/Extensions.cs
@@ -0,0 +1,124 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.ServiceDiscovery;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
+// This project should be referenced by each service project in your solution.
+// To learn more about using this project, see https://aka.ms/aspire/service-defaults
+public static class Extensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
+
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
+
+ // Uncomment the following to restrict the allowed schemes for service discovery.
+ // builder.Services.Configure(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation(tracing =>
+ // Exclude health check requests from tracing
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
+ )
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
+ //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
+ //{
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+ //}
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ // Add a default liveness check to ensure app is responsive
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ // Die Endpunkte werden auch in Produktion gemappt (Docker-Health-Checks). Sie liefern nur den
+ // aggregierten Status (Healthy/Unhealthy) ohne Details und sind daher unkritisch.
+ // Readiness: alle Health Checks (inkl. Datenbank) müssen erfolgreich sein.
+ app.MapHealthChecks(HealthEndpointPath).AllowAnonymous().DisableRateLimiting();
+
+ // Liveness: nur Checks mit dem Tag "live".
+ app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ }).AllowAnonymous().DisableRateLimiting();
+
+ return app;
+ }
+}
diff --git a/src/FamilyPlanner.ServiceDefaults/FamilyPlanner.ServiceDefaults.csproj b/src/FamilyPlanner.ServiceDefaults/FamilyPlanner.ServiceDefaults.csproj
new file mode 100644
index 0000000..c567fa5
--- /dev/null
+++ b/src/FamilyPlanner.ServiceDefaults/FamilyPlanner.ServiceDefaults.csproj
@@ -0,0 +1,19 @@
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+