|
| 1 | +using FhirService.Services; |
| 2 | +using Microsoft.AspNetCore.Authorization; |
| 3 | +using Microsoft.AspNetCore.Mvc; |
| 4 | + |
| 5 | +namespace FhirService.Controllers; |
| 6 | + |
| 7 | +/// <summary> |
| 8 | +/// CMS-0057-F compliance self-assessment endpoint. |
| 9 | +/// Returns a structured report of which CMS-0057-F requirements are met/unmet |
| 10 | +/// for the current tenant — a key differentiator for CHO health plans. |
| 11 | +/// </summary> |
| 12 | +[Route("fhir/r4")] |
| 13 | +[Authorize] |
| 14 | +public class ComplianceController : FhirControllerBase |
| 15 | +{ |
| 16 | + private readonly IConfiguration _config; |
| 17 | + private readonly ICms0057ComplianceChecker _complianceChecker; |
| 18 | + |
| 19 | + public ComplianceController(IConfiguration config, ICms0057ComplianceChecker complianceChecker) |
| 20 | + { |
| 21 | + _config = config; |
| 22 | + _complianceChecker = complianceChecker; |
| 23 | + } |
| 24 | + |
| 25 | + /// <summary> |
| 26 | + /// GET /fhir/r4/compliance-status |
| 27 | + /// Returns a structured report of CMS-0057-F compliance posture for the current tenant. |
| 28 | + /// </summary> |
| 29 | + [HttpGet("compliance-status")] |
| 30 | + [Produces("application/json")] |
| 31 | + [ProducesResponseType(typeof(Cms0057ComplianceReport), 200)] |
| 32 | + public IActionResult GetComplianceStatus() |
| 33 | + { |
| 34 | + var tenantId = TenantId; |
| 35 | + |
| 36 | + var patientAccessCheck = CheckPatientAccessApi(); |
| 37 | + var providerDirectoryCheck = CheckProviderDirectoryApi(); |
| 38 | + var priorAuthCheck = CheckPriorAuthorizationApi(); |
| 39 | + var payerToPayerCheck = CheckPayerToPayerExchange(); |
| 40 | + var smartScopesCheck = CheckSmartOnFhirScopes(); |
| 41 | + |
| 42 | + var requirements = new List<Cms0057Requirement> |
| 43 | + { |
| 44 | + patientAccessCheck, |
| 45 | + providerDirectoryCheck, |
| 46 | + priorAuthCheck, |
| 47 | + payerToPayerCheck, |
| 48 | + smartScopesCheck |
| 49 | + }; |
| 50 | + |
| 51 | + var metCount = requirements.Count(r => r.Met); |
| 52 | + |
| 53 | + var supportedResourceTypes = _complianceChecker.SupportedResourceTypes; |
| 54 | + |
| 55 | + var report = new Cms0057ComplianceReport( |
| 56 | + TenantId: tenantId, |
| 57 | + OverallCompliant: requirements.All(r => r.Met), |
| 58 | + RequirementsMet: metCount, |
| 59 | + TotalRequirements: requirements.Count, |
| 60 | + CompliancePercentage: (int)Math.Round(100.0 * metCount / requirements.Count), |
| 61 | + Requirements: requirements, |
| 62 | + SupportedValidationResources: supportedResourceTypes, |
| 63 | + AssessedAt: DateTimeOffset.UtcNow, |
| 64 | + FhirVersion: "4.0.1", |
| 65 | + RuleName: "CMS-0057-F", |
| 66 | + RuleDescription: "CMS Interoperability and Prior Authorization Final Rule"); |
| 67 | + |
| 68 | + return Ok(report); |
| 69 | + } |
| 70 | + |
| 71 | + /// <summary> |
| 72 | + /// Patient Access API: must be enabled and return valid FHIR R4. |
| 73 | + /// </summary> |
| 74 | + private Cms0057Requirement CheckPatientAccessApi() |
| 75 | + { |
| 76 | + var issues = new List<string>(); |
| 77 | + |
| 78 | + var patientAccessEnabled = _config.GetValue("Cms0057:PatientAccessApi:Enabled", true); |
| 79 | + if (!patientAccessEnabled) |
| 80 | + issues.Add("Patient Access API is not enabled"); |
| 81 | + |
| 82 | + var fhirVersion = _config["Cms0057:PatientAccessApi:FhirVersion"] ?? "4.0.1"; |
| 83 | + if (!fhirVersion.StartsWith("4.", StringComparison.Ordinal)) |
| 84 | + issues.Add($"FHIR version {fhirVersion} is not R4-compliant; must be 4.x"); |
| 85 | + |
| 86 | + var requiredResources = new[] { "Patient", "Coverage", "ExplanationOfBenefit", "Encounter" }; |
| 87 | + var enabledResources = _config.GetSection("Cms0057:PatientAccessApi:Resources") |
| 88 | + .GetChildren().Select(c => c.Value).ToList(); |
| 89 | + |
| 90 | + // If no explicit config, assume all required resources are available (convention over configuration) |
| 91 | + if (enabledResources.Count > 0) |
| 92 | + { |
| 93 | + var missing = requiredResources.Except(enabledResources!, StringComparer.OrdinalIgnoreCase).ToList(); |
| 94 | + if (missing.Count > 0) |
| 95 | + issues.Add($"Missing required resources: {string.Join(", ", missing)}"); |
| 96 | + } |
| 97 | + |
| 98 | + return new Cms0057Requirement( |
| 99 | + Id: "CMS-0057-F-01", |
| 100 | + Name: "Patient Access API", |
| 101 | + Description: "Patient Access API is enabled and returns valid FHIR R4 resources (Patient, Coverage, EOB, Encounter)", |
| 102 | + Met: issues.Count == 0, |
| 103 | + Issues: issues); |
| 104 | + } |
| 105 | + |
| 106 | + /// <summary> |
| 107 | + /// Provider Directory API: must be enabled. |
| 108 | + /// </summary> |
| 109 | + private Cms0057Requirement CheckProviderDirectoryApi() |
| 110 | + { |
| 111 | + var issues = new List<string>(); |
| 112 | + |
| 113 | + var enabled = _config.GetValue("Cms0057:ProviderDirectoryApi:Enabled", true); |
| 114 | + if (!enabled) |
| 115 | + issues.Add("Provider Directory API is not enabled"); |
| 116 | + |
| 117 | + // Program.cs registers NppesApi HttpClient with a default base URL when Nppes:BaseUrl |
| 118 | + // is absent, so the provider directory is functional even without explicit config. |
| 119 | + // Only flag as non-compliant if the config explicitly disables NPPES. |
| 120 | + var nppesDisabled = _config.GetValue("Nppes:Disabled", false); |
| 121 | + if (nppesDisabled) |
| 122 | + issues.Add("NPPES integration is explicitly disabled for provider directory lookups"); |
| 123 | + |
| 124 | + return new Cms0057Requirement( |
| 125 | + Id: "CMS-0057-F-02", |
| 126 | + Name: "Provider Directory API", |
| 127 | + Description: "Provider Directory API is enabled and supports provider lookups", |
| 128 | + Met: issues.Count == 0, |
| 129 | + Issues: issues); |
| 130 | + } |
| 131 | + |
| 132 | + /// <summary> |
| 133 | + /// Prior Authorization API: must support required operations ($submit, $inquire, status polling). |
| 134 | + /// </summary> |
| 135 | + private Cms0057Requirement CheckPriorAuthorizationApi() |
| 136 | + { |
| 137 | + var issues = new List<string>(); |
| 138 | + |
| 139 | + var enabled = _config.GetValue("Cms0057:PriorAuthorizationApi:Enabled", true); |
| 140 | + if (!enabled) |
| 141 | + { |
| 142 | + issues.Add("Prior Authorization API is not enabled"); |
| 143 | + return new Cms0057Requirement( |
| 144 | + Id: "CMS-0057-F-03", |
| 145 | + Name: "Prior Authorization API", |
| 146 | + Description: "Prior Authorization API supports $submit, $inquire operations and status polling per Da Vinci PAS", |
| 147 | + Met: false, |
| 148 | + Issues: issues); |
| 149 | + } |
| 150 | + |
| 151 | + var requiredOperations = new[] { "$submit", "$inquire" }; |
| 152 | + var supportedOperations = _config.GetSection("Cms0057:PriorAuthorizationApi:Operations") |
| 153 | + .GetChildren().Select(c => c.Value).ToList(); |
| 154 | + |
| 155 | + if (supportedOperations.Count == 0) |
| 156 | + { |
| 157 | + issues.Add($"Required operations are not explicitly configured: {string.Join(", ", requiredOperations)}"); |
| 158 | + } |
| 159 | + else |
| 160 | + { |
| 161 | + var missing = requiredOperations.Except(supportedOperations!, StringComparer.OrdinalIgnoreCase).ToList(); |
| 162 | + if (missing.Count > 0) |
| 163 | + issues.Add($"Missing required operations: {string.Join(", ", missing)}"); |
| 164 | + } |
| 165 | + |
| 166 | + var timelineEnforcement = _config.GetValue("Cms0057:PriorAuthorizationApi:TimelineEnforcement", true); |
| 167 | + if (!timelineEnforcement) |
| 168 | + issues.Add("Timeline enforcement (72h urgent / 7d standard) is not enabled"); |
| 169 | + |
| 170 | + return new Cms0057Requirement( |
| 171 | + Id: "CMS-0057-F-03", |
| 172 | + Name: "Prior Authorization API", |
| 173 | + Description: "Prior Authorization API supports $submit, $inquire operations and status polling per Da Vinci PAS", |
| 174 | + Met: issues.Count == 0, |
| 175 | + Issues: issues); |
| 176 | + } |
| 177 | + |
| 178 | + /// <summary> |
| 179 | + /// Payer-to-Payer data exchange: must be configured. |
| 180 | + /// </summary> |
| 181 | + private Cms0057Requirement CheckPayerToPayerExchange() |
| 182 | + { |
| 183 | + var issues = new List<string>(); |
| 184 | + |
| 185 | + var enabled = _config.GetValue("Cms0057:PayerToPayerExchange:Enabled", false); |
| 186 | + if (!enabled) |
| 187 | + issues.Add("Payer-to-Payer data exchange is not enabled"); |
| 188 | + |
| 189 | + var hasEndpoint = !string.IsNullOrEmpty(_config["Cms0057:PayerToPayerExchange:Endpoint"]); |
| 190 | + if (!hasEndpoint) |
| 191 | + issues.Add("Payer-to-Payer exchange endpoint is not configured"); |
| 192 | + |
| 193 | + return new Cms0057Requirement( |
| 194 | + Id: "CMS-0057-F-04", |
| 195 | + Name: "Payer-to-Payer Data Exchange", |
| 196 | + Description: "Payer-to-Payer data exchange is configured for member transitions between health plans", |
| 197 | + Met: issues.Count == 0, |
| 198 | + Issues: issues); |
| 199 | + } |
| 200 | + |
| 201 | + /// <summary> |
| 202 | + /// SMART on FHIR scopes: required scopes must be registered. |
| 203 | + /// </summary> |
| 204 | + private Cms0057Requirement CheckSmartOnFhirScopes() |
| 205 | + { |
| 206 | + var issues = new List<string>(); |
| 207 | + |
| 208 | + var requiredScopes = new[] |
| 209 | + { |
| 210 | + "patient/Patient.read", |
| 211 | + "patient/Coverage.read", |
| 212 | + "patient/ExplanationOfBenefit.read", |
| 213 | + "user/Patient.read", |
| 214 | + "user/Coverage.read", |
| 215 | + "user/ExplanationOfBenefit.read", |
| 216 | + "launch/patient" |
| 217 | + }; |
| 218 | + |
| 219 | + var registeredScopes = _config.GetSection("Cms0057:SmartScopes:Registered") |
| 220 | + .GetChildren().Select(c => c.Value).ToList(); |
| 221 | + |
| 222 | + if (registeredScopes.Count > 0) |
| 223 | + { |
| 224 | + var missing = requiredScopes.Except(registeredScopes!, StringComparer.OrdinalIgnoreCase).ToList(); |
| 225 | + if (missing.Count > 0) |
| 226 | + issues.Add($"Missing required SMART scopes: {string.Join(", ", missing)}"); |
| 227 | + } |
| 228 | + |
| 229 | + // Check that SMART configuration endpoint exists |
| 230 | + var smartConfigured = !string.IsNullOrEmpty(_config["SmartAuth:Issuer"]); |
| 231 | + if (!smartConfigured) |
| 232 | + issues.Add("SMART on FHIR authorization server (SmartAuth:Issuer) is not configured"); |
| 233 | + |
| 234 | + return new Cms0057Requirement( |
| 235 | + Id: "CMS-0057-F-05", |
| 236 | + Name: "SMART on FHIR Scopes", |
| 237 | + Description: "Required SMART on FHIR scopes are registered for patient and user access", |
| 238 | + Met: issues.Count == 0, |
| 239 | + Issues: issues); |
| 240 | + } |
| 241 | +} |
| 242 | + |
| 243 | +// ── Response DTOs ──────────────────────────────────────────────────────────── |
| 244 | + |
| 245 | +public record Cms0057ComplianceReport( |
| 246 | + string TenantId, |
| 247 | + bool OverallCompliant, |
| 248 | + int RequirementsMet, |
| 249 | + int TotalRequirements, |
| 250 | + int CompliancePercentage, |
| 251 | + IReadOnlyList<Cms0057Requirement> Requirements, |
| 252 | + IReadOnlyList<string> SupportedValidationResources, |
| 253 | + DateTimeOffset AssessedAt, |
| 254 | + string FhirVersion, |
| 255 | + string RuleName, |
| 256 | + string RuleDescription); |
| 257 | + |
| 258 | +public record Cms0057Requirement( |
| 259 | + string Id, |
| 260 | + string Name, |
| 261 | + string Description, |
| 262 | + bool Met, |
| 263 | + IReadOnlyList<string> Issues); |
0 commit comments