Skip to content

Commit 728bd30

Browse files
LEGLINK-620: Add-Secret-Key-to-Vendor (#1788)
* LEGLINK-620: record the vendor signing-key UI design Captures the decisions behind associating a Key Vault secret with a vendor, so the reasoning survives outside a chat log: why the association is vendor-scoped rather than facility-scoped (Veradigm needs a different key generation algorithm than Epic and Cerner), and why the stored value is the Key Vault secret id rather than a JWKS kid, which LEGLINK-63's title conflates. Also records what this ticket deliberately cannot do. The update endpoint that persists a secret id is owned by neither LEGLINK-620 nor LEGLINK-743, so the UI is built against a single isolated service method. LEGLINK-63's audit-trail acceptance criterion is produced from backend managers onto Kafka and no UI change can satisfy it. Claude-Session: https://claude.ai/code/session_01STFKxmDaJenKVny6WUoWKo * LEGLINK-620: associate a Key Vault secret with a vendor in the UI Link signs the JWT that Data Acquisition presents to Epic and Cerner during client-credentials auth. LEGLINK-14 moved the PEM into Key Vault; this makes the association explicit and vendor-scoped, because Veradigm needs a different key generation algorithm and so cannot share a key. Add an edit path to Vendor Management. The dashboard grows a Secret ID column that reads "Not set" when empty and a per-row edit action; the form grows a JWT / Authentication panel holding the Key Vault Secret ID, expanded when a vendor already has one so existing configuration is visible without hunting. An emptied box travels as undefined rather than "", so clearing the association reads as absent rather than set-to-empty. No update endpoint exists yet. The Vendor model is moving out of Normalization into Tenant under LEGLINK-743, whose acceptance criteria cover list, add and delete but not update, so this operation is owned by neither ticket. VendorService.updateVendor is the single place that knows the route: when the contract lands, that method is the only edit required. A config-flagged dual path was considered and rejected as permanent complexity bought against a decision expected within days. Two defects in files this already touches: createVendor was typed as IVendorConfigModel while callers pass a name string, which interpolated "[object Object]" into the URL for anything else; and getVendors never cleared its loading flag on success. Deferred: the mocked Playwright spec the design calls for. That harness arrives with PR #1773, which is not yet merged into dev, so there is nowhere on this branch for the spec to live. LEGLINK-63's audit-trail criterion is also outstanding -- audit events are produced from backend managers onto Kafka, so it belongs with the update endpoint rather than here. 16 unit specs pass; the app builds clean. Claude-Session: https://claude.ai/code/session_01STFKxmDaJenKVny6WUoWKo * LEGLINK-620: report a failed vendor save once, and clear a key explicitly Two defects found reviewing 4b894e1 against the design. ErrorHandlingService raises its own toastr before rethrowing, so once the save paths began emitting failure to the dialog -- which shows a snackbar and stays open so the admin's input survives -- one failed save reported itself twice, toastr bottom-full-width and snackbar top-right. Saves now route through handleSaveError, which suppresses the toastr and leaves the dialog as the single surface. List and delete keep theirs, having no dialog to carry the news. The rethrown error still carries the sanitized message either way. Clearing a key sent secretId as undefined, which JSON.stringify drops, so the field never reached the wire. An absent field reads as "leave unchanged" to any endpoint with partial-update semantics, which would have made clearing an association succeed visibly and do nothing. It now travels as an explicit null, and the design's open items record that the backend must honour null as "remove the association" when the contract is settled. Adds vendor.service.spec.ts, the service having had no direct coverage: the update route and body, a cleared key surviving serialization, name escaping in the create route, and the toastr suppressed for saves but kept for list and delete. Both new behaviours fail against the previous code -- args[1] was absent rather than false, and secretId was undefined rather than null. 21 unit specs pass; the app builds clean. Claude-Session: https://claude.ai/code/session_01CX9BzMrPhTzGSakXYDaAVa * LEGLINK-620: cover the vendor create failure branch A failed createVendor had no test. Assert it emits a single failure with the error message and does not fall through into the update path. Claude-Session: https://claude.ai/code/session_01TDPYGwkQDCXConLTuyU4is * LEGLINK-620: gate vendor editing until an update endpoint exists VendorController exposes list, add and delete only -- no PUT -- so the edit dialog added on this branch would save into a 404. Put the edit button and onEdit behind a vendorEditEnabled config flag, shipped off, following the existing AppConfig boolean pattern. Flip it once the update contract, including clearing secretId with null, is confirmed. Claude-Session: https://claude.ai/code/session_01TDPYGwkQDCXConLTuyU4is * LEGLINK-566: design for validating a vendor secret id against Key Vault Validation lives on Admin.BFF, which already holds an ISecretManager and is independent of the Vendor model's move to Tenant. Adds ISecretInspector and PemSigningKeyValidator to Shared; the UI warns inline on blur and on save without ever blocking the save. Claude-Session: https://claude.ai/code/session_01TDPYGwkQDCXConLTuyU4is * LEGLINK-566: implementation plan for vendor secret id validation Eight tasks, TDD throughout: characterize EpicAuth's PKCS#8 behavior, then PemSigningKeyValidator and ISecretInspector in Shared, the Admin.BFF endpoint, the Angular service call, and the form's blur/save warnings. Claude-Session: https://claude.ai/code/session_01TDPYGwkQDCXConLTuyU4is * LEGLINK-620: store a vendor's Key Vault signing key secret id Vendor moved into Tenant under LEGLINK-743 carrying only Id and Name, so there was nowhere to record the Key Vault secret holding a vendor's PEM signing key. LEGLINK-63 scopes that key to the vendor rather than the facility, because the key generation algorithm differs by EHR. Stored as a JSON column rather than a plain one so later vendor-level auth settings need no migration, following the AuthenticationConfiguration precedent in DataAcquisitionDbContext. Only the signing key lives here: TokenUrl, Audience and ClientId are per-EHR-instance and stay on the facility's authentication configuration, where EpicAuth already reads them. The value converter carries an explicit ValueComparer. Without one EF snapshots the property by reference, so mutating a field on the existing instance is never detected and SaveChanges writes nothing -- a test covers that specifically. Update treats a missing authentication object as "leave unchanged", matching how Name already behaves, so a caller that omits it cannot wipe a configured key. Clearing a key means sending the object with a null inside it. Vendor versions expose the parent vendor's settings as a read-only projection, so consumers holding only a vendor version id -- as Data Acquisition will -- can resolve the key in one call while writes stay on the vendor. EpicAuth is untouched: it still derives {facilityId}-pem, so nothing changes at runtime until the fallback rule between vendor and facility keys is settled. Claude-Session: https://claude.ai/code/session_01QoUHyt1ALkruCbuYxHSXiB * LEGLINK-620: point the vendor screens at the Tenant API LEGLINK-743 deleted Normalization's VendorController when it moved the model to Tenant, but left this service calling the old routes. Every vendor operation in Admin.UI has been hitting a controller that no longer exists. The API nests the signing key under authentication; the vendor screens work with a flat secretId. Translating at the gateway keeps that difference out of the components, so a second vendor-level setting only touches this file. Create now carries the secret id. The add form has always shown the field, but the create branch sent the name alone, so anything typed there was silently discarded behind a success message. Both write paths build the same payload before branching, which is what stops them drifting apart again. authentication is always sent, including when the key is being cleared: the Tenant manager reads an absent object as "leave unchanged", so omitting it would make a clear no-op. Null inside the object is what removes the association. The vendorEditEnabled flag is gone with it. It existed only to keep the edit button hidden while no update endpoint existed, and PUT /api/vendor/{id} now does, so the gate has nothing left to protect. Claude-Session: https://claude.ai/code/session_01QoUHyt1ALkruCbuYxHSXiB * LEGLINK-620: drop the superpowers design and plan docs The vendor signing-key design and the secret-id validation design and plan were working notes for this change, not reference material the repository needs to carry. They stay recoverable through history. Claude-Session: https://claude.ai/code/session_01QoUHyt1ALkruCbuYxHSXiB * LEGLINK-620: Add Secret Key to Vendor screen * LEGLINK-620: revert local environment files off the branch The previous commit swept in five files that belong to a local development setup rather than to this change. Both appsettings.Development.json files in particular replaced the committed SQLEXPRESS defaults with a machine-specific SQL Server instance and a plaintext sa password, which would have become the checked-in default for everyone. Restores all five to their dev contents so the branch carries only the vendor signing key work. The AGENTS.md documentation, the check_health.sh compose fix and the Admin.UI Dockerfile npm layer-caching fix are worth landing, but each on its own terms rather than inside this ticket. Claude-Session: https://claude.ai/code/session_01QoUHyt1ALkruCbuYxHSXiB
1 parent 10cb5f5 commit 728bd30

29 files changed

Lines changed: 1273 additions & 325 deletions

DotNet/ServiceTests/IntegrationTests/Tenant/VendorControllerTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,36 @@ public VendorControllerTests(TenantIntegrationTestFixture fixture)
3333

3434
public void Dispose() => _scope.Dispose();
3535

36+
[Fact]
37+
public async Task Post_WithAuthentication_ReturnsTheSavedSigningKeySecretId()
38+
{
39+
var result = await _controller.Post(new CreateVendorModel
40+
{
41+
Name = $"Vendor-{Guid.NewGuid():N}",
42+
Authentication = new VendorAuthenticationSettings { SigningKeySecretId = "epic-signing-key" }
43+
});
44+
45+
var created = Assert.IsType<CreatedAtActionResult>(result.Result);
46+
var vendor = Assert.IsType<VendorModel>(created.Value);
47+
Assert.Equal("epic-signing-key", vendor.Authentication?.SigningKeySecretId);
48+
}
49+
50+
[Fact]
51+
public async Task Put_WithAuthentication_ReturnsTheSavedSigningKeySecretId()
52+
{
53+
var created = await _vendorManager.CreateVendorAsync(new VendorModel { Name = $"Vendor-{Guid.NewGuid():N}" });
54+
55+
var result = await _controller.Put(created.Id!.Value.ToString(), new UpdateVendorModel
56+
{
57+
Name = created.Name,
58+
Authentication = new VendorAuthenticationSettings { SigningKeySecretId = "epic-signing-key" }
59+
});
60+
61+
var ok = Assert.IsType<OkObjectResult>(result.Result);
62+
var vendor = Assert.IsType<VendorModel>(ok.Value);
63+
Assert.Equal("epic-signing-key", vendor.Authentication?.SigningKeySecretId);
64+
}
65+
3666
[Fact]
3767
public async Task Post_DuplicateVendor_ReturnsConflict()
3868
{

DotNet/ServiceTests/IntegrationTests/Tenant/VendorManagerTests.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using LantanaGroup.Link.Sdk.ApiClient;
22
using LantanaGroup.Link.Sdk.Clients;
33
using LantanaGroup.Link.Shared.Application.Models.Integration.Normalization;
4+
using LantanaGroup.Link.Shared.Application.Models.Tenant;
45
using LantanaGroup.Link.Tenant.Business.Managers;
56
using LantanaGroup.Link.Tenant.Entities;
67
using LantanaGroup.Link.Tenant.Repository.Context;
@@ -75,6 +76,96 @@ public async Task DeleteVendorVersion_WhenNormalizationHasNoReferences_DeletesVe
7576
.SingleOrDefaultAsync(version => version.Id == vendorVersion.Id));
7677
}
7778

79+
[Fact]
80+
public async Task CreateVendor_WithAuthentication_PersistsSigningKeySecretId()
81+
{
82+
var created = await _vendorManager.CreateVendorAsync(new VendorModel
83+
{
84+
Name = $"Vendor-{Guid.NewGuid():N}",
85+
Authentication = new VendorAuthenticationSettings { SigningKeySecretId = "epic-signing-key" }
86+
});
87+
88+
var persisted = await _dbContext.Vendors
89+
.AsNoTracking()
90+
.SingleAsync(v => v.Id == created.Id);
91+
92+
Assert.Equal("epic-signing-key", persisted.Authentication?.SigningKeySecretId);
93+
}
94+
95+
[Fact]
96+
public async Task UpdateVendor_WithAuthentication_PersistsSigningKeySecretId()
97+
{
98+
var vendor = await CreateVendorAsync();
99+
100+
await _vendorManager.UpdateVendorAsync(vendor.Id, new VendorModel
101+
{
102+
Id = vendor.Id,
103+
Name = vendor.Name,
104+
Authentication = new VendorAuthenticationSettings { SigningKeySecretId = "epic-signing-key" }
105+
});
106+
107+
var persisted = await _dbContext.Vendors
108+
.AsNoTracking()
109+
.SingleAsync(v => v.Id == vendor.Id);
110+
111+
Assert.Equal("epic-signing-key", persisted.Authentication?.SigningKeySecretId);
112+
}
113+
114+
[Fact]
115+
public async Task UpdateVendor_WithoutAuthentication_LeavesTheConfiguredSecretIdIntact()
116+
{
117+
var vendor = await CreateVendorAsync();
118+
await _vendorManager.UpdateVendorAsync(vendor.Id, new VendorModel
119+
{
120+
Name = vendor.Name,
121+
Authentication = new VendorAuthenticationSettings { SigningKeySecretId = "epic-signing-key" }
122+
});
123+
124+
await _vendorManager.UpdateVendorAsync(vendor.Id, new VendorModel { Name = "Renamed Vendor" });
125+
126+
var persisted = await _dbContext.Vendors
127+
.AsNoTracking()
128+
.SingleAsync(v => v.Id == vendor.Id);
129+
130+
Assert.Equal("epic-signing-key", persisted.Authentication?.SigningKeySecretId);
131+
}
132+
133+
[Fact]
134+
public async Task SaveChanges_AfterMutatingAuthenticationInPlace_PersistsTheChange()
135+
{
136+
var vendor = await CreateVendorAsync();
137+
await _vendorManager.UpdateVendorAsync(vendor.Id, new VendorModel
138+
{
139+
Name = vendor.Name,
140+
Authentication = new VendorAuthenticationSettings { SigningKeySecretId = "original-key" }
141+
});
142+
143+
var tracked = await _dbContext.Vendors.SingleAsync(v => v.Id == vendor.Id);
144+
tracked.Authentication!.SigningKeySecretId = "rotated-key";
145+
await _dbContext.SaveChangesAsync();
146+
147+
_dbContext.ChangeTracker.Clear();
148+
var persisted = await _dbContext.Vendors
149+
.AsNoTracking()
150+
.SingleAsync(v => v.Id == vendor.Id);
151+
152+
Assert.Equal("rotated-key", persisted.Authentication?.SigningKeySecretId);
153+
}
154+
155+
private async Task<Vendor> CreateVendorAsync()
156+
{
157+
var vendor = new Vendor
158+
{
159+
Id = Guid.NewGuid(),
160+
Name = $"Vendor-{Guid.NewGuid():N}"
161+
};
162+
163+
await _dbContext.Vendors.AddAsync(vendor);
164+
await _dbContext.SaveChangesAsync();
165+
166+
return vendor;
167+
}
168+
78169
private async Task<VendorVersion> CreateVendorVersionAsync()
79170
{
80171
var vendor = new Vendor
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using LantanaGroup.Link.Shared.Application.Models.Tenant;
2+
using LantanaGroup.Link.Tenant.Business.Managers;
3+
using LantanaGroup.Link.Tenant.Business.Queries;
4+
using LantanaGroup.Link.Tenant.Entities;
5+
using LantanaGroup.Link.Tenant.Repository.Context;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Task = System.Threading.Tasks.Task;
8+
9+
namespace IntegrationTests.Tenant;
10+
11+
[Collection("IntegrationTests")]
12+
[Trait("Category", "IntegrationTests")]
13+
public class VendorQueriesTests : IDisposable
14+
{
15+
private readonly IServiceScope _scope;
16+
private readonly IVendorQueries _vendorQueries;
17+
private readonly IVendorManager _vendorManager;
18+
private readonly TenantDbContext _dbContext;
19+
20+
public VendorQueriesTests(TenantIntegrationTestFixture fixture)
21+
{
22+
_scope = fixture.ServiceProvider.CreateScope();
23+
var serviceProvider = _scope.ServiceProvider;
24+
25+
_vendorQueries = serviceProvider.GetRequiredService<IVendorQueries>();
26+
_vendorManager = serviceProvider.GetRequiredService<IVendorManager>();
27+
_dbContext = serviceProvider.GetRequiredService<TenantDbContext>();
28+
}
29+
30+
public void Dispose() => _scope.Dispose();
31+
32+
[Fact]
33+
public async Task GetVendor_ReturnsTheConfiguredSigningKeySecretId()
34+
{
35+
var vendorVersion = await CreateVendorWithSigningKeyAsync("epic-signing-key");
36+
37+
var vendor = await _vendorQueries.GetVendor(vendorVersion.VendorId);
38+
39+
Assert.Equal("epic-signing-key", vendor?.Authentication?.SigningKeySecretId);
40+
}
41+
42+
[Fact]
43+
public async Task GetVendorVersion_ExposesTheParentVendorsSigningKeySecretId()
44+
{
45+
var vendorVersion = await CreateVendorWithSigningKeyAsync("cerner-signing-key");
46+
47+
var version = await _vendorQueries.GetVendorVersion(vendorVersion.Id);
48+
49+
Assert.Equal("cerner-signing-key", version?.Authentication?.SigningKeySecretId);
50+
}
51+
52+
private async Task<VendorVersion> CreateVendorWithSigningKeyAsync(string signingKeySecretId)
53+
{
54+
var vendor = new Vendor
55+
{
56+
Id = Guid.NewGuid(),
57+
Name = $"Vendor-{Guid.NewGuid():N}"
58+
};
59+
var vendorVersion = new VendorVersion
60+
{
61+
Id = Guid.NewGuid(),
62+
VendorId = vendor.Id,
63+
Version = "default"
64+
};
65+
66+
await _dbContext.Vendors.AddAsync(vendor);
67+
await _dbContext.VendorVersions.AddAsync(vendorVersion);
68+
await _dbContext.SaveChangesAsync();
69+
70+
await _vendorManager.UpdateVendorAsync(vendor.Id, new VendorModel
71+
{
72+
Name = vendor.Name,
73+
Authentication = new VendorAuthenticationSettings { SigningKeySecretId = signingKeySecretId }
74+
});
75+
76+
return vendorVersion;
77+
}
78+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using LantanaGroup.Link.Shared.Application.Models.Tenant;
2+
using System.Text.Json;
3+
4+
namespace UnitTests.Tenant;
5+
6+
public class VendorModelBindingTests
7+
{
8+
private static readonly JsonSerializerOptions WebDefaults = new(JsonSerializerDefaults.Web);
9+
10+
[Fact]
11+
public void CreateVendorModel_BindsTheNestedSigningKeySecretId()
12+
{
13+
const string payload = """
14+
{ "name": "Epic", "authentication": { "signingKeySecretId": "epic-signing-key" } }
15+
""";
16+
17+
var model = JsonSerializer.Deserialize<CreateVendorModel>(payload, WebDefaults);
18+
19+
Assert.Equal("Epic", model?.Name);
20+
Assert.Equal("epic-signing-key", model?.Authentication?.SigningKeySecretId);
21+
}
22+
23+
[Fact]
24+
public void UpdateVendorModel_BindsTheNestedSigningKeySecretId()
25+
{
26+
const string payload = """
27+
{ "name": "Epic", "authentication": { "signingKeySecretId": "epic-signing-key" } }
28+
""";
29+
30+
var model = JsonSerializer.Deserialize<UpdateVendorModel>(payload, WebDefaults);
31+
32+
Assert.Equal("epic-signing-key", model?.Authentication?.SigningKeySecretId);
33+
}
34+
35+
[Fact]
36+
public void UpdateVendorModel_DistinguishesAClearedKeyFromAnOmittedOne()
37+
{
38+
var cleared = JsonSerializer.Deserialize<UpdateVendorModel>(
39+
"""{ "name": "Epic", "authentication": { "signingKeySecretId": null } }""", WebDefaults);
40+
var omitted = JsonSerializer.Deserialize<UpdateVendorModel>(
41+
"""{ "name": "Epic" }""", WebDefaults);
42+
43+
Assert.NotNull(cleared?.Authentication);
44+
Assert.Null(cleared!.Authentication!.SigningKeySecretId);
45+
Assert.Null(omitted?.Authentication);
46+
}
47+
}

DotNet/Shared/Application/Models/Tenant/CreateVendorModel.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,8 @@ public class CreateVendorModel
99
[Required]
1010
[DataMember]
1111
public string? Name { get; set; }
12+
13+
[DataMember]
14+
public VendorAuthenticationSettings? Authentication { get; set; }
1215
}
1316
}

DotNet/Shared/Application/Models/Tenant/UpdateVendorModel.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,8 @@ public class UpdateVendorModel
99
[Required]
1010
[DataMember]
1111
public string? Name { get; set; }
12+
13+
[DataMember]
14+
public VendorAuthenticationSettings? Authentication { get; set; }
1215
}
1316
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System.Runtime.Serialization;
2+
using System.Text.Json.Serialization;
3+
4+
namespace LantanaGroup.Link.Shared.Application.Models.Tenant
5+
{
6+
[DataContract]
7+
public class VendorAuthenticationSettings
8+
{
9+
[DataMember]
10+
[JsonPropertyName("signingKeySecretId")]
11+
public string? SigningKeySecretId { get; set; }
12+
}
13+
}

DotNet/Shared/Application/Models/Tenant/VendorModel.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,9 @@ public class VendorModel
1313
[DataMember]
1414
[JsonPropertyName("name")]
1515
public string? Name { get; set; }
16+
17+
[DataMember]
18+
[JsonPropertyName("authentication")]
19+
public VendorAuthenticationSettings? Authentication { get; set; }
1620
}
1721
}

DotNet/Shared/Application/Models/Tenant/VendorVersionModel.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,9 @@ public class VendorVersionModel
2323
[JsonPropertyName("version")]
2424
[Required]
2525
public string? Version { get; set; }
26+
27+
[DataMember]
28+
[JsonPropertyName("authentication")]
29+
public VendorAuthenticationSettings? Authentication { get; set; }
2630
}
2731
}

DotNet/Tenant/Business/Managers/VendorManager.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ public async Task<VendorModel> CreateVendorAsync(VendorModel newVendor, Cancella
7777
var vendorEntity = new Vendor
7878
{
7979
Id = Guid.NewGuid(),
80-
Name = newVendor.Name
80+
Name = newVendor.Name,
81+
Authentication = newVendor.Authentication
8182
};
8283
var vendorVersionEntity = new VendorVersion
8384
{
@@ -91,7 +92,8 @@ public async Task<VendorModel> CreateVendorAsync(VendorModel newVendor, Cancella
9192
return new VendorModel
9293
{
9394
Id = vendorEntity.Id,
94-
Name = vendorEntity.Name
95+
Name = vendorEntity.Name,
96+
Authentication = vendorEntity.Authentication
9597
};
9698
}
9799

@@ -187,12 +189,15 @@ public async Task<VendorModel> UpdateVendorAsync(Guid id, VendorModel vendor, Ca
187189

188190
existingVendor.Name = vendor.Name ?? existingVendor.Name;
189191

192+
existingVendor.Authentication = vendor.Authentication ?? existingVendor.Authentication;
193+
190194
_dbContext.Vendors.Update(existingVendor);
191195
await _dbContext.SaveChangesAsync(cancellationToken);
192196
return new VendorModel
193197
{
194198
Id = existingVendor.Id,
195-
Name = existingVendor.Name
199+
Name = existingVendor.Name,
200+
Authentication = existingVendor.Authentication
196201
};
197202
}
198203

0 commit comments

Comments
 (0)