Skip to content

LNK-4714: Replicate MVP Report Output - #1379

Open
edward-miller-lcg wants to merge 26 commits into
devfrom
connectathon/Jan2026
Open

LNK-4714: Replicate MVP Report Output#1379
edward-miller-lcg wants to merge 26 commits into
devfrom
connectathon/Jan2026

Conversation

@edward-miller-lcg

@edward-miller-lcg edward-miller-lcg commented Jan 16, 2026

Copy link
Copy Markdown
Contributor

🛠️ Description of Changes

Replcates MVP output in BOTW based on flag useNdJson (bool).

🧪 Testing Performed

Tested at connectathon

🧑‍🔬 Unit Testing

  • I have written or updated unit tests to cover my changes

📓 Documentation Updated

Please update any relevant sections in the project documentation that were impacted by the changes in the PR.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added configurable workflow toggle to switch between NDJSON and expanded bundle processing strategies for data submissions.
    • Enhanced JSON parser to support more lenient FHIR resource validation.
  • Chores

    • Removed formatting artifact from source file.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai

coderabbitai Bot commented Jan 16, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Introduces configuration flag useNdJson to toggle between NDJSON and manifest-expanded bundle workflows. When disabled, extracts resources from manifest NDJSON, builds patient-specific bundles, and outputs structured JSON files matching MVP format. Enables permissive FHIR parsing and removes a BOM character.

Changes

Cohort / File(s) Summary
Configuration
DotNet/Submission/appsettings.json, DotNet/Submission/appsettings.Docker.json
Added new top-level useNdJson configuration flag (true in default config, false in Docker config) to control workflow behavior.
FHIR Parsing
DotNet/Normalization/Application/FhirJsonConverter.cs
Enabled permissive FHIR JSON parsing by setting PermissiveParsing = true on the parser before processing input, allowing more lenient parsing.
Manifest-Expanded Bundle Processing
DotNet/Submission/Application/Services/BlobStorageService.cs
Major expansion adding configuration-driven workflow toggle. New logic includes: manifest NDJSON parsing and resource extraction (Organization, Device, List, MeasureReports); internal file discovery for patient ID computation; patient-specific expanded bundle construction; structured JSON output files (census.json, submitting-org.json, submitting-device.json, validation.json, aggregate measures); helper methods for device patching, folder mapping, acronym derivation, and reference inspection; constructor extended with IConfiguration parameter.
Code Cleanup
DotNet/DataAcquisition.Domain/Extensions/GeneralStartupExtensions.cs
Removed hidden BOM character before first using directive.

Sequence Diagram(s)

sequenceDiagram
    actor Client as Submission<br/>Service
    participant BS as BlobStorageService
    participant IBS as Internal Blob<br/>Storage
    participant EBS as External Blob<br/>Storage
    
    Client->>BS: Upload(entry, settings)
    alt useNdJson = false
        BS->>IBS: Download manifest.ndjson
        BS->>BS: Parse manifest & extract<br/>resources (Org, Device,<br/>List, MeasureReports)
        BS->>IBS: Download patient files<br/>under root prefix
        BS->>BS: Compute unique patient IDs<br/>from manifest & files
        loop For each patient
            BS->>IBS: Load patient NDJSON
            BS->>BS: Parse resources &<br/>locate MeasureReport
            BS->>BS: Build expanded Bundle<br/>(patient resources +<br/>related resources)
            BS->>EBS: Upload patient bundle<br/>as JSON
        end
        BS->>EBS: Upload aggregate<br/>MeasureReports as JSON
        BS->>EBS: Upload metadata files<br/>(census.json, org, device,<br/>validation, shared-resources)
    else useNdJson = true
        BS->>EBS: Upload using NDJSON<br/>with application/fhir+ndjson
    end
    BS-->>Client: Complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • dvargaslantana
  • smailliwcs
  • nvmLantana

Poem

🐰 hops with glee

A config flag to toggle the way,
Manifest bundles bright as the day,
Patient-specific files all aligned,
BOTW output format, beautifully designed! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the feature being implemented (Replicate MVP Report Output) and includes the issue reference (LNK-4714), directly relating to the main change in the changeset.
Description check ✅ Passed The description partially follows the template with changes overview and testing notes, but lacks unit testing updates and proper documentation update details despite checking the relevant checkbox.
Linked Issues check ✅ Passed The code changes implement most objectives from LNK-4714 including patient bundle creation, census.json, aggregate reports, submitting-devices/orgs files, and the useNdJson toggle for enabling/disabling MVP output.
Out of Scope Changes check ✅ Passed Changes are within scope: BOM character removal in GeneralStartupExtensions.cs, PermissiveParsing flag in FhirJsonConverter.cs, and extensive MVP output logic in BlobStorageService align with LNK-4714 requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch connectathon/Jan2026

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@DotNet/Submission/Application/Services/BlobStorageService.cs`:
- Around line 388-404: In BlobStorageService.cs inside the block handling the
fallback aggregate (variable fallbackAgg) you currently serialize fallbackAgg
twice (once to aggregateJson/aggregateBytes and again in the stream.WriteAsync
call). Replace the second serialization by writing the already-created
aggregateBytes (or Encoding.UTF8.GetBytes(aggregateJson)) to the stream; remove
the duplicate JsonSerializer.Serialize(...) call so stream.WriteAsync uses
aggregateBytes and keep the existing jsonOptions and blobOptions/HttpHeaders
intact.
- Around line 228-232: The code uses value.PayloadUri without null-check in
BlobStorageService (BlobUriBuilder new Uri(value.PayloadUri!)), which can throw;
update the logic in the method that constructs BlobUriBuilder to first validate
value.PayloadUri (or ReportSchedule payload) is not null/empty, handle the null
case (return early, throw a clear ArgumentException, or skip processing) and
only call new Uri(...) when non-null, keeping references to BlobUriBuilder,
manifestBlobName and rootPrefix intact so the derived rootPrefix is only
computed from a valid PayloadUri.
🧹 Nitpick comments (5)
DotNet/Submission/Application/Services/BlobStorageService.cs (5)

57-59: Use appropriate log level for configuration info.

LogWarning is inappropriate for logging a configuration value at startup. This is informational, not a warning.

♻️ Suggested fix
-            _logger.LogWarning("_useNdJson is set to {}", _useNdJson);
+            _logger.LogInformation("useNdJson configuration is set to {UseNdJson}", _useNdJson);

165-170: Consider refactoring this large method.

ProcessAndUploadExpandedBundlesAsync is ~360 lines long with multiple responsibilities:

  • Parsing manifest NDJSON
  • Extracting resources by type
  • Downloading patient files
  • Building and uploading patient bundles
  • Uploading aggregate reports
  • Uploading supporting files (census, org, device, validation)

Consider extracting logical sections into smaller private methods for improved maintainability and testability.


458-472: Hardcoded dummy address may need configuration or documentation.

The fallback address ("1 Center Drive, Ann Arbor, MI 48109, USA") is hardcoded. If this is intentional for MVP/connectathon compliance, consider:

  1. Adding a code comment explaining why this specific address is used
  2. Making this configurable for different deployment scenarios
  3. Documenting this behavior in the configuration documentation

561-562: Improve placeholder comment.

The "🔑 PLACEHOLDER" comment doesn't explain why "unknown" is used as the replacement value. Consider a more descriptive comment.

♻️ Suggested improvement
-                            // 🔑 PLACEHOLDER
+                            // Replace empty/whitespace version value with "unknown" to pass FHIR validation
                             writer.WriteString("value", "unknown");

604-623: Consider consolidating measure type mappings.

Both GetMeasureFolderPath and GetMeasureAcronym contain similar pattern-matching logic for report types. Consider extracting the measure type detection into a shared helper or using a lookup dictionary to avoid duplication and improve maintainability.

Also applies to: 725-751

Comment thread DotNet/Submission/Application/Services/BlobStorageService.cs
Comment thread DotNet/Submission/Application/Services/BlobStorageService.cs

public override DomainResource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
_fhirParser.Settings.PermissiveParsing = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to/required for JSON output?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Permissive Parsing is to allow for invalid json objects to make it through as a C# object. Otherwise, if it's invalid FHIR, it will throw an error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guess my question was more ... does MVP-style report output require Normalization to parse more leniently? Seems unrelated to the rest of the PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to move the JSON translation logic into a service that BlobStorageService can take as a dependency?

_externalSettings = externalSettings.Value;
_internalContainerClient = GetContainerClient(_internalSettings);
_externalContainerClient = GetContainerClient(_externalSettings);
_useNdJson = configuration.GetValue<bool>("useNdJson");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend moving this out of top-level configuration. Maybe under ExternalBlobStorage? Or else a new section dedicated to the JSON translation service (if you end up creating that).


// Parse manifest NDJSON
string manifestContent = Encoding.UTF8.GetString(content);
var jsonOptions = new JsonSerializerOptions().ForFhir(new FhirJsonPocoDeserializerSettings { Validator = null });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Firely recommends caching instances of JsonSerializerOptions statically for performance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note: there's now a lenient JsonSerializerOptions instance available for project-wide use in Shared's LinkFhirSerializerOptions.

string reportName = ReportHelpers.GetReportName(key.ReportScheduleId, key.FacilityId, value.ReportTypes, value.StartDate);

string? nhsnOrgId = organization?.Identifier
.FirstOrDefault(i => i.System == "https://www.cdc.gov/nhsn/OrgID")?.Value;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NHSN OrgID identifier system is already a constant in Report's ReportConstants.BundleSettings. Consolidate those with anything that should be a constant here, then move everything to Shared?

_logger.LogInformation("Completed processing and uploading {PatientCount} expanded patient bundles", patientIds.Count);
}

private static string PatchEmptyDeviceVersionValue(string json)

@smailliwcs smailliwcs Feb 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this? If so, can we do it in ReportManifestProducer.CreateDevice? (And can we use Firely instead of direct JSON manipulation?)

_externalSettings = externalSettings.Value;
_internalContainerClient = GetContainerClient(_internalSettings);
_externalContainerClient = GetContainerClient(_externalSettings);
_useNdJson = configuration.GetValue<bool>("useNdJson");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use UseNdJson casing instead of useNdJson


public override DomainResource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
_fhirParser.Settings.PermissiveParsing = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guess my question was more ... does MVP-style report output require Normalization to parse more leniently? Seems unrelated to the rest of the PR.


// Parse manifest NDJSON
string manifestContent = Encoding.UTF8.GetString(content);
var jsonOptions = new JsonSerializerOptions().ForFhir(new FhirJsonPocoDeserializerSettings { Validator = null });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note: there's now a lenient JsonSerializerOptions instance available for project-wide use in Shared's LinkFhirSerializerOptions.

Comment on lines +251 to +265
if (censusList != null)
{
foreach (var entry in censusList.Entry ?? new List<Hl7.Fhir.Model.List.EntryComponent>())
{
var refId = entry.Item?.Reference?.Split('/').Last();
if (!string.IsNullOrEmpty(refId))
{
patientIds.Add(refId);
}
}
}
else
{
_logger.LogWarning("No patient List found in manifest. Falling back to file names.");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to consult the census? We can only submit patients that we have files for, not to mention that we'll expect missing files in some cases (specifically, for non-reportable patients).

return DownloadAsync(_externalContainerClient, prefix, cancellationToken);
}

private string GetMeasureAcronym(List<string> reportTypes)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Shared's MeasureNameShortener instead?

Comment on lines +310 to +311
var patientMR = patientResources.OfType<MeasureReport>()
.FirstOrDefault(mr => !IsAggregateMeasureReport(mr) && mr.Subject?.Reference?.Contains(patientId) == true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to handle the multi-measure use case (where there could be multiple individual measure reports in a patient file).

Comment on lines +411 to +415
var sharedBundle = new Bundle
{
Type = Bundle.BundleType.Collection,
Timestamp = DateTimeOffset.UtcNow
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need to add the shared resources here since we're not adding them to the patient bundles above?

Comment on lines +353 to +367
var bundleJson = JsonSerializer.Serialize(expandedBundle, jsonOptions);
byte[] bundleBytes = Encoding.UTF8.GetBytes(bundleJson);

string bundleName = $"{nhsnOrgId}_{measureAcronym}_{startDateStr}_patient-{patientId}.json";
string blobName = GetBlobName(_externalSettings.BlobRoot, measureFolder, reportName, bundleName);
_logger.LogDebug("Uploading expanded patient bundle: {BlobName}", blobName);

BlockBlobClient blobClient = _externalContainerClient!.GetBlockBlobClient(blobName);
BlockBlobOpenWriteOptions blobOptions = new()
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" }
};

await using var stream = await blobClient.OpenWriteAsync(true, blobOptions, cancellationToken);
await stream.WriteAsync(bundleBytes, cancellationToken);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could benefit from a helper method that takes care of serialization and writing to blob storage ... then call into that method here and below, once per blob. And might be worth looking into whether we can JsonSerializer.Serialize directly to the blob stream, rather than first serializing to a string, then converting to a byte array, then writing to the blob stream.

Comment on lines +455 to +460
bool needsDummyAddress = organization.Address == null ||
!organization.Address.Any() ||
organization.Address.All(a =>
string.IsNullOrEmpty(a.City) &&
string.IsNullOrEmpty(a.State) &&
(a.Line == null || !a.Line.Any()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a problem with submitting an Organization with no address? If so, can we skip the file entirely? Or if it's essential, throw an exception (probably earlier in this method)?

Comment on lines +616 to +623
if (reportTypesStr.Contains("ach") && reportTypesStr.Contains("hypo"))
{
return "NHSNdQMAcuteCareHospitalInitialPopulation_NHSNGlycemicControlHypoglycemicInitialPopulation";
}
else if (reportTypesStr.Contains("rps"))
{
return "NHSNRespiratoryPathogensSurveillanceInitialPopulation";
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably revise (even if this matches the existing upload_file.py) to use the "modern" dQM names.

  • NHSNAcuteCareHospitalMonthlyInitialPopulation
  • NHSNAcuteCareHospitalDailyInitialPopulation (instead of RPS)

return reportTypes.FirstOrDefault() ?? string.Empty;
}

private HashSet<(string ResourceType, string ResourceId)> FindReferencesInResources(List<Resource> resources)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method doesn't seem to be used. Nor does FindReferencesInJson, except by this method. Do we need them?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants