Skip to content

Commit d2d81c2

Browse files
Merge branch 'dev' of https://github.com/lantanagroup/link-cloud into LEGLINK-186-KafkaRetryImplementation
2 parents 0f0ba50 + f13092b commit d2d81c2

13 files changed

Lines changed: 757 additions & 37 deletions

File tree

Azure_Pipelines/azure-pipelines.db-schema-creation.yaml

Lines changed: 303 additions & 14 deletions
Large diffs are not rendered by default.

DotNet/Automation.UI/Controllers/OrganizationResourceMapsController.cs

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Automation.UI.Models;
22
using Automation.UI.Services.Persistence;
33
using Microsoft.AspNetCore.Mvc;
4+
using MongoDB.Driver;
45

56
namespace Automation.UI.Controllers;
67

@@ -23,22 +24,40 @@ public async Task<IActionResult> GetJson(Guid id, CancellationToken ct)
2324

2425
[HttpPost]
2526
[ValidateAntiForgeryToken]
26-
public async Task<IActionResult> SaveInline([FromBody] OrganizationResourceMapTemplate model, CancellationToken ct)
27+
public async Task<IActionResult> SaveInline(
28+
[FromBody] OrganizationResourceMapTemplate model,
29+
CancellationToken ct)
2730
{
2831
if (string.IsNullOrWhiteSpace(model.Name))
2932
return BadRequest("Template name is required.");
33+
3034
if (model.Conditions == null || model.Conditions.Count == 0)
3135
return BadRequest("At least one mapping condition is required.");
36+
3237
if (model.Conditions.Any(c => string.IsNullOrWhiteSpace(c.FhirPath)))
3338
return BadRequest("All mapping conditions must include a FHIRPath.");
3439

40+
model.Name = model.Name.Trim();
41+
42+
var templates = await store.GetAllAsync(ct);
43+
44+
if (HasDuplicateName(templates, model.Name, model.Id))
45+
{
46+
return Conflict(
47+
$"An Organization Resource Map named '{model.Name}' already exists.");
48+
}
49+
3550
var existing = await store.GetByIdAsync(model.Id, ct);
51+
3652
if (existing is { IsSystem: true })
37-
return StatusCode(StatusCodes.Status403Forbidden, "System template cannot be modified.");
53+
return StatusCode(
54+
StatusCodes.Status403Forbidden,
55+
"System template cannot be modified.");
3856

3957
model.IsSystem = false;
4058
model.IsDefault = existing?.IsDefault ?? model.IsDefault;
4159
model.UpdatedAt = DateTimeOffset.UtcNow;
60+
4261
await store.UpsertAsync(model, ct);
4362
return Json(new { id = model.Id });
4463
}
@@ -71,10 +90,21 @@ public async Task<IActionResult> CloneInline([FromBody] IdRequest request, Cance
7190
var source = await store.GetByIdAsync(request.Id, ct);
7291
if (source == null) return NotFound();
7392

93+
var templates = await store.GetAllAsync(ct);
94+
95+
var cloneName = BuildCloneName(source.Name);
96+
var copyNumber = 2;
97+
98+
while (HasDuplicateName(templates, cloneName))
99+
{
100+
cloneName = BuildCloneName(source.Name, copyNumber);
101+
copyNumber++;
102+
}
103+
74104
var clone = new OrganizationResourceMapTemplate
75105
{
76106
Id = Guid.NewGuid(),
77-
Name = $"{source.Name} (Copy)",
107+
Name = cloneName,
78108
Description = source.Description,
79109
Conditions = source.Conditions.Select(c => new OrganizationResourceMapCondition
80110
{
@@ -87,7 +117,8 @@ public async Task<IActionResult> CloneInline([FromBody] IdRequest request, Cance
87117
};
88118

89119
await store.UpsertAsync(clone, ct);
90-
return Json(new { id = clone.Id });
120+
121+
return CreatedAtAction(nameof(GetJson), new { id = clone.Id }, new { id = clone.Id });
91122
}
92123

93124
[HttpPost]
@@ -104,4 +135,26 @@ public async Task<IActionResult> SetDefaultInline([FromBody] IdRequest request,
104135
return Ok();
105136
}
106137

138+
private static bool HasDuplicateName(
139+
IEnumerable<OrganizationResourceMapTemplate> templates,
140+
string name,
141+
Guid? excludeId = null)
142+
{
143+
return templates.Any(t =>
144+
(!excludeId.HasValue || t.Id != excludeId.Value) &&
145+
string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase));
146+
}
147+
148+
private static string BuildCloneName(
149+
string sourceName,
150+
int? copyNumber = null)
151+
{
152+
var suffix = copyNumber.HasValue
153+
? $" (Copy {copyNumber.Value})"
154+
: " (Copy)";
155+
156+
var baseName = sourceName.Trim();
157+
158+
return baseName + suffix;
159+
}
107160
}

DotNet/Automation.UI/Services/ApiHealth/TestSuites/ApiEndPointLibrary.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,4 +540,4 @@ public static class ValidationSteps
540540
public const string ResultsGet200Seeded = "Results GET → 200 (seeded)";
541541
public const string ResultsGet200Empty = "Results GET → 200 (empty)";
542542
}
543-
}
543+
}

DotNet/Automation.UI/Services/Persistence/MongoIndexManager.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,21 @@ public void EnsureAllIndexes()
4747

4848
private void EnsureOrganizationResourceMapTemplateIndexes()
4949
{
50-
var collection = _database.GetCollection<BsonDocument>("automation_org_resource_map_templates");
51-
CreateIndexSafe(collection, new BsonDocument { { "Name", 1 } }, unique: false, "idx_name_asc");
52-
CreateIndexSafe(collection, new BsonDocument { { "IsDefault", 1 } }, unique: false, "idx_isDefault");
50+
var collection = _database.GetCollection<BsonDocument>(
51+
"automation_org_resource_map_templates");
52+
53+
// Retain Name index because GetAllAsync sorts by display Name.
54+
CreateIndexSafe(
55+
collection,
56+
new BsonDocument { { "Name", 1 } },
57+
unique: false,
58+
"idx_name_asc");
59+
60+
CreateIndexSafe(
61+
collection,
62+
new BsonDocument { { "IsDefault", 1 } },
63+
unique: false,
64+
"idx_isDefault");
5365
}
5466

5567
// --- automation_runs ---

DotNet/Automation.UI/Services/Persistence/MongoOrganizationResourceMapTemplateStore.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
using System.Text.Json;
2-
using Automation.UI.Models;
1+
using Automation.UI.Models;
32
using MongoDB.Driver;
3+
using System.Text.Json;
44

55
namespace Automation.UI.Services.Persistence;
66

@@ -72,7 +72,7 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
7272
private static OrganizationResourceMapTemplateDocument ToDocument(OrganizationResourceMapTemplate model) => new()
7373
{
7474
Id = model.Id,
75-
Name = model.Name,
75+
Name = model.Name.Trim(),
7676
Description = model.Description,
7777
IsSystem = model.IsSystem,
7878
IsDefault = model.IsDefault,
@@ -87,7 +87,8 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
8787
Description = doc.Description,
8888
IsSystem = doc.IsSystem,
8989
IsDefault = doc.IsDefault,
90-
Conditions = Deserialize<List<OrganizationResourceMapCondition>>(doc.ConditionsJson) ?? [],
90+
Conditions = Deserialize<List<OrganizationResourceMapCondition>>(
91+
doc.ConditionsJson) ?? [],
9192
UpdatedAt = doc.UpdatedAt
9293
};
9394

DotNet/Automation.UI/Services/Persistence/OrganizationResourceMapTemplateDocument.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ internal sealed class OrganizationResourceMapTemplateDocument
88
[BsonId]
99
[BsonRepresentation(BsonType.String)]
1010
public Guid Id { get; set; }
11-
1211
public string Name { get; set; } = string.Empty;
1312
public string? Description { get; set; }
1413
public bool IsSystem { get; set; }

DotNet/Automation.UI/Views/OrganizationResourceMaps/Index.cshtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@
462462
const conditions = collectConditions();
463463
const model={
464464
id: document.getElementById('MapId').value || crypto.randomUUID(),
465-
name: document.getElementById('MapName').value,
465+
name: document.getElementById('MapName').value.trim(),
466466
description: document.getElementById('MapDescription').value || null,
467467
conditions: conditions,
468468
isSystem:false,

DotNet/Automation.UI/Views/Runs/Index.cshtml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,10 @@
265265
</button>
266266
</div>
267267
<div class="col-md-2">
268-
<a asp-controller="Scenarios" asp-action="Index" class="btn btn-outline-secondary w-100">
268+
<a asp-controller="Scenarios"
269+
asp-action="Index"
270+
class="btn btn-outline-secondary w-100"
271+
id="btnManageScenario">
269272
<i class="bi bi-journal-text me-1"></i> Manage
270273
</a>
271274
</div>
@@ -302,6 +305,7 @@
302305
var recentRunsPartialUrl = '@Url.Action("RecentRunsPartial", "Runs")';
303306
var cancelUrl = '@Url.Action("CancelJson", "Runs")';
304307
var deleteUrl = '@Url.Action("DeleteJson", "Runs")';
308+
var scenariosUrl = '@Url.Action("Index", "Scenarios")';
305309
306310
// --- KPI elements ---
307311
var kpiActive = document.getElementById('kpiActive');
@@ -612,6 +616,7 @@
612616
var noResults = document.getElementById('quickLaunchNoResults');
613617
var quickTypeFilter = document.getElementById('quickLaunchTypeFilter');
614618
var quickSort = document.getElementById('quickLaunchSort');
619+
var manageLink = document.getElementById('btnManageScenario');
615620
616621
function getQuickLaunchOptions() {
617622
return Array.from(
@@ -689,6 +694,12 @@
689694
dropdownButton.textContent =
690695
label || '-- Select a scenario --';
691696
}
697+
698+
if (manageLink) {
699+
manageLink.href = id
700+
? scenariosUrl + '?editScenarioId=' + encodeURIComponent(id)
701+
: scenariosUrl;
702+
}
692703
}
693704
694705
if (quickSearch) {
@@ -1019,6 +1030,7 @@
10191030
refreshRecentRunsCard({ pageSize: sel.value, pageNumber: '1' });
10201031
});
10211032
})();
1033+
10221034
</script>
10231035

10241036
@await Html.PartialAsync("_ScenarioEditorModal")

DotNet/Automation.UI/Views/Scenarios/Index.cshtml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,5 +247,18 @@
247247
}
248248
249249
applyScenarioView();
250+
251+
// Open a scenario passed from Quick Launch directly in the editor.
252+
var editScenarioId = new URLSearchParams(window.location.search).get('editScenarioId');
253+
254+
if (editScenarioId && typeof window.openScenarioEditor === 'function') {
255+
window.openScenarioEditor(editScenarioId, 'edit');
256+
257+
// Remove the one-time navigation parameter so saving/reloading the
258+
// Scenarios page does not automatically reopen the editor.
259+
var url = new URL(window.location.href);
260+
url.searchParams.delete('editScenarioId');
261+
window.history.replaceState(null, '', url.toString());
262+
}
250263
})();
251264
</script>

Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/BlobStorageService.java

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import com.lantanagroup.link.shared.entities.ReportScheduleModel;
1111
import com.lantanagroup.link.shared.exceptions.ValidationException;
1212
import com.lantanagroup.link.shared.services.ReportClient;
13+
import com.lantanagroup.link.shared.utils.LogUtils;
14+
import org.hl7.fhir.instance.model.api.IIdType;
1315
import org.hl7.fhir.r4.model.IdType;
1416
import org.hl7.fhir.r4.model.MeasureReport;
1517
import org.hl7.fhir.r4.model.Reference;
@@ -19,10 +21,10 @@
1921

2022
import java.net.URI;
2123
import java.util.ArrayList;
24+
import java.util.HashMap;
2225
import java.util.List;
2326
import java.util.Map;
2427
import java.util.UUID;
25-
import java.util.function.Function;
2628
import java.util.stream.Collectors;
2729

2830
public class BlobStorageService {
@@ -114,9 +116,18 @@ private List<Resource> normalize(MeasureReport measureReport) {
114116
}
115117

116118
List<Resource> contained = measureReport.getContained();
117-
Map<String, Resource> containedByIdPart = contained.stream().collect(Collectors.toMap(
118-
resource -> stripHash(resource.getIdPart()),
119-
Function.identity()));
119+
120+
// Contained resource IDs are only guaranteed unique within a resource type (e.g. Condition/A and
121+
// Observation/A can legally coexist), so the primary lookup is type-qualified. A secondary,
122+
// id-part-only index is kept to resolve untyped "#id" references, but only when unambiguous.
123+
Map<String, Resource> containedByTypedId = new HashMap<>();
124+
Map<String, List<Resource>> containedByIdPart = new HashMap<>();
125+
for (Resource resource : contained) {
126+
String idPart = stripHash(resource.getIdPart());
127+
containedByTypedId.put(resource.getResourceType().name() + "/" + idPart, resource);
128+
containedByIdPart.computeIfAbsent(idPart, key -> new ArrayList<>()).add(resource);
129+
}
130+
120131
measureReport.setContained(null);
121132
measureReport.setEvaluatedResource(null);
122133
for (Resource resource : contained) {
@@ -127,8 +138,26 @@ private List<Resource> normalize(MeasureReport measureReport) {
127138
}
128139
for (Reference reference :
129140
fhirContext.newTerser().getAllPopulatedChildElementsOfType(measureReport, Reference.class)) {
130-
String idPart = stripHash(reference.getReferenceElement().getIdPart());
131-
Resource resource = containedByIdPart.get(idPart);
141+
IIdType referenceElement = reference.getReferenceElement();
142+
String idPart = stripHash(referenceElement.getIdPart());
143+
String resourceType = referenceElement.getResourceType();
144+
145+
Resource resource;
146+
if (resourceType != null) {
147+
resource = containedByTypedId.get(resourceType + "/" + idPart);
148+
} else {
149+
List<Resource> candidates = containedByIdPart.getOrDefault(idPart, List.of());
150+
if (candidates.size() > 1) {
151+
String candidateTypes = candidates.stream()
152+
.map(candidate -> candidate.getResourceType().name())
153+
.collect(Collectors.joining(", "));
154+
logger.warn("Contained reference '#{}' is ambiguous between {} resources ({}); leaving reference unresolved",
155+
LogUtils.sanitize(idPart), candidates.size(), LogUtils.sanitize(candidateTypes));
156+
continue;
157+
}
158+
resource = candidates.isEmpty() ? null : candidates.get(0);
159+
}
160+
132161
if (resource == null) {
133162
continue;
134163
}

0 commit comments

Comments
 (0)