diff --git a/NEW/src/JdeScoping.Api/DependencyInjection.cs b/NEW/src/JdeScoping.Api/DependencyInjection.cs
index 2227c17..32e6810 100644
--- a/NEW/src/JdeScoping.Api/DependencyInjection.cs
+++ b/NEW/src/JdeScoping.Api/DependencyInjection.cs
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.OpenApi.Models;
@@ -117,9 +118,35 @@ public static class ApiDependencyInjection
// Health check endpoint - no authentication required
app.MapHealthChecks("/health", new HealthCheckOptions
{
- AllowCachingResponses = false
+ AllowCachingResponses = false,
+ ResponseWriter = WriteHealthCheckResponse
}).AllowAnonymous();
return app;
}
+
+ ///
+ /// Writes detailed JSON response for health check results.
+ ///
+ private static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
+ {
+ context.Response.ContentType = "application/json";
+
+ var result = new
+ {
+ status = report.Status.ToString(),
+ totalDuration = report.TotalDuration.TotalMilliseconds,
+ checks = report.Entries.Select(e => new
+ {
+ name = e.Key,
+ status = e.Value.Status.ToString(),
+ description = e.Value.Description,
+ duration = e.Value.Duration.TotalMilliseconds,
+ data = e.Value.Data,
+ exception = e.Value.Exception?.Message
+ })
+ };
+
+ await context.Response.WriteAsJsonAsync(result);
+ }
}
diff --git a/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs b/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs
index 428b75e..571e736 100644
--- a/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs
+++ b/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs
@@ -1,5 +1,6 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.DataAccess;
+using JdeScoping.DataAccess.HealthChecks;
using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.QueryBuilders;
@@ -55,6 +56,10 @@ public static class DataAccessDependencyInjection
// Register manual sync request service (scoped - per request lifetime)
services.AddScoped();
+ // Register health check
+ services.AddHealthChecks()
+ .AddCheck("database", tags: ["db", "ready"]);
+
return services;
}
diff --git a/NEW/src/JdeScoping.DataAccess/HealthChecks/DatabaseHealthCheck.cs b/NEW/src/JdeScoping.DataAccess/HealthChecks/DatabaseHealthCheck.cs
new file mode 100644
index 0000000..67424da
--- /dev/null
+++ b/NEW/src/JdeScoping.DataAccess/HealthChecks/DatabaseHealthCheck.cs
@@ -0,0 +1,224 @@
+using System.Diagnostics;
+using JdeScoping.DataAccess.Interfaces;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace JdeScoping.DataAccess.HealthChecks;
+
+///
+/// Health check for database connectivity to all data sources.
+///
+///
+/// Checks connectivity to:
+/// - LotFinder (SQL Server) - Critical, causes Unhealthy if unavailable
+/// - JDE, CMS, GIW (Oracle) - Important but not critical, causes Degraded if unavailable
+///
+public class DatabaseHealthCheck : IHealthCheck
+{
+ private readonly IDbConnectionFactory _connectionFactory;
+ private static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(5);
+ private static readonly long DegradedThresholdMs = 2000;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The database connection factory.
+ public DatabaseHealthCheck(IDbConnectionFactory connectionFactory)
+ {
+ _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
+ }
+
+ ///
+ public async Task CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ var data = new Dictionary();
+ var unhealthyDbs = new List();
+ var degradedDbs = new List();
+
+ // Check LotFinder (SQL Server) - Critical
+ await CheckLotFinderAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
+
+ // Check Oracle databases - Important but not critical
+ await CheckJdeAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
+ await CheckCmsAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
+ await CheckGiwAsync(data, unhealthyDbs, degradedDbs, cancellationToken);
+
+ // Determine overall status
+ if (unhealthyDbs.Count > 0)
+ {
+ return HealthCheckResult.Unhealthy(
+ $"Database(s) unavailable: {string.Join(", ", unhealthyDbs)}",
+ data: data);
+ }
+
+ if (degradedDbs.Count > 0)
+ {
+ return HealthCheckResult.Degraded(
+ $"Database(s) slow/degraded: {string.Join(", ", degradedDbs)}",
+ data: data);
+ }
+
+ return HealthCheckResult.Healthy("All databases connected", data: data);
+ }
+
+ private async Task CheckLotFinderAsync(
+ Dictionary data,
+ List unhealthyDbs,
+ List degradedDbs,
+ CancellationToken cancellationToken)
+ {
+ const string name = "LotFinder";
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(ConnectionTimeout);
+
+ await using var conn = await _connectionFactory.CreateLotFinderConnectionAsync(cts.Token);
+ sw.Stop();
+
+ RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ sw.Stop();
+ RecordTimeout(name, sw.ElapsedMilliseconds, data, unhealthyDbs);
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, unhealthyDbs);
+ }
+ }
+
+ private async Task CheckJdeAsync(
+ Dictionary data,
+ List unhealthyDbs,
+ List degradedDbs,
+ CancellationToken cancellationToken)
+ {
+ const string name = "JDE";
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(ConnectionTimeout);
+
+ await using var conn = await _connectionFactory.CreateJdeConnectionAsync(cts.Token);
+ sw.Stop();
+
+ RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ sw.Stop();
+ RecordTimeout(name, sw.ElapsedMilliseconds, data, degradedDbs);
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, degradedDbs);
+ }
+ }
+
+ private async Task CheckCmsAsync(
+ Dictionary data,
+ List unhealthyDbs,
+ List degradedDbs,
+ CancellationToken cancellationToken)
+ {
+ const string name = "CMS";
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(ConnectionTimeout);
+
+ await using var conn = await _connectionFactory.CreateCmsConnectionAsync(cts.Token);
+ sw.Stop();
+
+ RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ sw.Stop();
+ RecordTimeout(name, sw.ElapsedMilliseconds, data, degradedDbs);
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, degradedDbs);
+ }
+ }
+
+ private async Task CheckGiwAsync(
+ Dictionary data,
+ List unhealthyDbs,
+ List degradedDbs,
+ CancellationToken cancellationToken)
+ {
+ const string name = "GIW";
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(ConnectionTimeout);
+
+ await using var conn = await _connectionFactory.CreateGiwConnectionAsync(cts.Token);
+ sw.Stop();
+
+ RecordSuccess(name, sw.ElapsedMilliseconds, data, degradedDbs);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ sw.Stop();
+ RecordTimeout(name, sw.ElapsedMilliseconds, data, degradedDbs);
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ RecordFailure(name, sw.ElapsedMilliseconds, ex.Message, data, degradedDbs);
+ }
+ }
+
+ private static void RecordSuccess(
+ string name,
+ long elapsedMs,
+ Dictionary data,
+ List degradedDbs)
+ {
+ data[$"{name}_Status"] = "Connected";
+ data[$"{name}_ResponseMs"] = elapsedMs;
+
+ if (elapsedMs > DegradedThresholdMs)
+ {
+ degradedDbs.Add(name);
+ }
+ }
+
+ private static void RecordTimeout(
+ string name,
+ long elapsedMs,
+ Dictionary data,
+ List problemDbs)
+ {
+ data[$"{name}_Status"] = "Timeout";
+ data[$"{name}_ResponseMs"] = elapsedMs;
+ data[$"{name}_Error"] = $"Connection timeout after {ConnectionTimeout.TotalSeconds}s";
+ problemDbs.Add(name);
+ }
+
+ private static void RecordFailure(
+ string name,
+ long elapsedMs,
+ string errorMessage,
+ Dictionary data,
+ List problemDbs)
+ {
+ data[$"{name}_Status"] = "Failed";
+ data[$"{name}_ResponseMs"] = elapsedMs;
+ data[$"{name}_Error"] = errorMessage;
+ problemDbs.Add(name);
+ }
+}
diff --git a/NEW/src/JdeScoping.DataAccess/JdeScoping.DataAccess.csproj b/NEW/src/JdeScoping.DataAccess/JdeScoping.DataAccess.csproj
index f13cff9..b2bfa0e 100644
--- a/NEW/src/JdeScoping.DataAccess/JdeScoping.DataAccess.csproj
+++ b/NEW/src/JdeScoping.DataAccess/JdeScoping.DataAccess.csproj
@@ -15,6 +15,7 @@
+
diff --git a/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs b/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs
index 9e1bf67..60bae97 100644
--- a/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs
+++ b/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs
@@ -2,6 +2,7 @@ using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Options;
using JdeScoping.Core.Validation;
using JdeScoping.Infrastructure.Auth;
+using JdeScoping.Infrastructure.HealthChecks;
using JdeScoping.Infrastructure.Options;
using JdeScoping.Infrastructure.Security;
using JdeScoping.Infrastructure.Validation;
@@ -62,6 +63,10 @@ public static class InfrastructureDependencyInjection
// Register configuration validators
services.AddInfrastructureValidators(configuration);
+ // Register health check
+ services.AddHealthChecks()
+ .AddCheck("ldap", tags: ["auth", "ready"]);
+
return services;
}
diff --git a/NEW/src/JdeScoping.Infrastructure/HealthChecks/LdapHealthCheck.cs b/NEW/src/JdeScoping.Infrastructure/HealthChecks/LdapHealthCheck.cs
new file mode 100644
index 0000000..e8a287b
--- /dev/null
+++ b/NEW/src/JdeScoping.Infrastructure/HealthChecks/LdapHealthCheck.cs
@@ -0,0 +1,130 @@
+using System.DirectoryServices.Protocols;
+using System.Net;
+using JdeScoping.Infrastructure.Options;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Options;
+
+namespace JdeScoping.Infrastructure.HealthChecks;
+
+///
+/// Health check for LDAP server connectivity.
+///
+///
+/// When is true (development mode),
+/// always returns Healthy. Otherwise, checks connectivity to configured LDAP servers.
+///
+public class LdapHealthCheck : IHealthCheck
+{
+ private readonly LdapOptions _options;
+ private static readonly TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The LDAP options.
+ public LdapHealthCheck(IOptions options)
+ {
+ _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ ///
+ public async Task CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ // Skip check if using fake auth (development mode)
+ if (_options.UseFakeAuth)
+ {
+ return HealthCheckResult.Healthy("Using fake auth (development mode)");
+ }
+
+ var servers = _options.ServerUrls;
+
+ if (servers.Length == 0)
+ {
+ return HealthCheckResult.Unhealthy("No LDAP servers configured");
+ }
+
+ var data = new Dictionary();
+
+ // Check connectivity to LDAP servers on a background thread
+ // to avoid blocking the main thread with synchronous LDAP operations
+ return await Task.Run(() => CheckLdapServers(servers, data), cancellationToken);
+ }
+
+ private static HealthCheckResult CheckLdapServers(string[] servers, Dictionary data)
+ {
+ var reachableCount = 0;
+
+ foreach (var serverUrl in servers)
+ {
+ var serverKey = SanitizeServerKey(serverUrl);
+ try
+ {
+ // Parse server URL to extract host and port
+ var identifier = CreateLdapIdentifier(serverUrl);
+ using var connection = new LdapConnection(identifier);
+
+ connection.Timeout = ConnectionTimeout;
+ connection.AuthType = AuthType.Anonymous;
+
+ // Attempt anonymous bind to test connectivity
+ connection.Bind();
+
+ data[$"{serverKey}_Status"] = "Reachable";
+ reachableCount++;
+ }
+ catch (LdapException ex)
+ {
+ data[$"{serverKey}_Status"] = "Unreachable";
+ data[$"{serverKey}_Error"] = $"LDAP error: {ex.Message}";
+ }
+ catch (Exception ex)
+ {
+ data[$"{serverKey}_Status"] = "Unreachable";
+ data[$"{serverKey}_Error"] = ex.Message;
+ }
+ }
+
+ data["ReachableServers"] = reachableCount;
+ data["TotalServers"] = servers.Length;
+
+ if (reachableCount == 0)
+ {
+ return HealthCheckResult.Unhealthy("No LDAP servers reachable", data: data);
+ }
+
+ if (reachableCount < servers.Length)
+ {
+ return HealthCheckResult.Degraded(
+ $"{reachableCount}/{servers.Length} LDAP servers reachable",
+ data: data);
+ }
+
+ return HealthCheckResult.Healthy("All LDAP servers reachable", data: data);
+ }
+
+ private static LdapDirectoryIdentifier CreateLdapIdentifier(string serverUrl)
+ {
+ // Handle both plain hostnames and URLs with scheme
+ if (serverUrl.StartsWith("ldap://", StringComparison.OrdinalIgnoreCase) ||
+ serverUrl.StartsWith("ldaps://", StringComparison.OrdinalIgnoreCase))
+ {
+ var uri = new Uri(serverUrl);
+ var port = uri.Port > 0 ? uri.Port : (uri.Scheme.Equals("ldaps", StringComparison.OrdinalIgnoreCase) ? 636 : 389);
+ return new LdapDirectoryIdentifier(uri.Host, port);
+ }
+
+ // Plain hostname, use default LDAP port
+ return new LdapDirectoryIdentifier(serverUrl);
+ }
+
+ private static string SanitizeServerKey(string serverUrl)
+ {
+ // Create a safe key for the data dictionary
+ return serverUrl
+ .Replace("://", "_")
+ .Replace(":", "_")
+ .Replace("/", "_");
+ }
+}
diff --git a/NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj b/NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj
index 9a37145..f59f613 100644
--- a/NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj
+++ b/NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj
@@ -9,6 +9,7 @@
+