Skip to content

Commit 446402d

Browse files
committed
feat: wire up address update handler with validation, policy enforcement, and state connector
1 parent 5aebaa3 commit 446402d

6 files changed

Lines changed: 470 additions & 54 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using SEBT.Portal.StatesPlugins.Interfaces;
2+
using SEBT.Portal.StatesPlugins.Interfaces.Models.Household;
3+
4+
namespace SEBT.Portal.Api.Composition.Defaults;
5+
6+
/// <summary>
7+
/// Default implementation when no state-specific IAddressUpdateService plugin is loaded.
8+
/// Returns a backend error indicating the service is not configured.
9+
/// </summary>
10+
internal class DefaultAddressUpdateService : IAddressUpdateService
11+
{
12+
public Task<AddressUpdateResult> UpdateAddressAsync(
13+
AddressUpdateRequest request,
14+
CancellationToken cancellationToken = default)
15+
{
16+
return Task.FromResult(
17+
AddressUpdateResult.BackendError("NOT_CONFIGURED", "No address update service configured."));
18+
}
19+
}

src/SEBT.Portal.Api/Composition/ServiceCollectionPluginExtensions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public static IServiceCollection AddPlugins(this IServiceCollection services, IC
1616
services.TryAddSingleton<IStateHealthCheckService, Defaults.DefaultStateHealthCheckService>();
1717
services.TryAddSingleton<ISummerEbtCaseService, Defaults.DefaultSummerEbtCaseService>();
1818
services.TryAddSingleton<IEnrollmentCheckService, Defaults.DefaultEnrollmentCheckService>();
19+
services.TryAddSingleton<IAddressUpdateService, Defaults.DefaultAddressUpdateService>();
1920

2021
var healthChecksBuilder = services.AddHealthChecks();
2122

@@ -90,6 +91,11 @@ private static ContainerConfiguration CreateContainerConfiguration(string[] asse
9091
.Export<IEnrollmentCheckService>()
9192
.Shared();
9293

94+
conventions
95+
.ForTypesDerivedFrom<IAddressUpdateService>()
96+
.Export<IAddressUpdateService>()
97+
.Shared();
98+
9399
return new ContainerConfiguration()
94100
.WithExport(configuration)
95101
.WithAssembliesInPath(assemblyPaths, conventions);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System.Security.Claims;
2+
3+
namespace SEBT.Portal.Core.Models.Auth;
4+
5+
/// <summary>
6+
/// Extension methods for resolving <see cref="UserIalLevel"/> from claims.
7+
/// </summary>
8+
public static class UserIalLevelExtensions
9+
{
10+
/// <summary>
11+
/// Resolves the user's Identity Assurance Level from their JWT claims.
12+
/// </summary>
13+
public static UserIalLevel FromClaimsPrincipal(ClaimsPrincipal user)
14+
{
15+
var ialClaim = user.FindFirst(JwtClaimTypes.Ial)?.Value;
16+
if (string.IsNullOrWhiteSpace(ialClaim)) return UserIalLevel.None;
17+
18+
return ialClaim.Trim().ToLowerInvariant() switch
19+
{
20+
"1" => UserIalLevel.IAL1,
21+
"1plus" => UserIalLevel.IAL1plus,
22+
"2" => UserIalLevel.IAL2,
23+
_ => UserIalLevel.None
24+
};
25+
}
26+
}

src/SEBT.Portal.UseCases/Household/GetHouseholdData/GetHouseholdDataQueryHandler.cs

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Security.Claims;
21
using Microsoft.Extensions.Logging;
32
using SEBT.Portal.Core.Models.Auth;
43
using SEBT.Portal.Core.Models.Household;
@@ -32,7 +31,7 @@ public async Task<Result<HouseholdData>> Handle(GetHouseholdDataQuery query, Can
3231

3332
logger.LogDebug("Household data request received for identifier type {Type}", identifier.Type);
3433

35-
var userIalLevel = GetUserIalLevel(query.User);
34+
var userIalLevel = UserIalLevelExtensions.FromClaimsPrincipal(query.User);
3635
var piiVisibility = idProofingRequirementsService.GetPiiVisibility(userIalLevel);
3736

3837
logger.LogDebug(
@@ -57,24 +56,4 @@ public async Task<Result<HouseholdData>> Handle(GetHouseholdDataQuery query, Can
5756
logger.LogDebug("Household data retrieved successfully for identifier type {Type}", identifier.Type);
5857
return Result<HouseholdData>.Success(householdData);
5958
}
60-
61-
private static UserIalLevel GetUserIalLevel(ClaimsPrincipal user)
62-
{
63-
var ialClaim = user.FindFirst(JwtClaimTypes.Ial)?.Value;
64-
65-
if (string.IsNullOrWhiteSpace(ialClaim))
66-
{
67-
return UserIalLevel.None;
68-
}
69-
70-
var normalized = ialClaim.Trim().ToLowerInvariant();
71-
return normalized switch
72-
{
73-
"1" => UserIalLevel.IAL1,
74-
"1plus" => UserIalLevel.IAL1plus,
75-
"2" => UserIalLevel.IAL2,
76-
"0" => UserIalLevel.None,
77-
_ => UserIalLevel.None
78-
};
79-
}
8059
}

src/SEBT.Portal.UseCases/Household/UpdateAddress/UpdateAddressCommandHandler.cs

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
11
using Microsoft.Extensions.Logging;
2+
using SEBT.Portal.Core.Models.Auth;
3+
using SEBT.Portal.Core.Models.Household;
4+
using SEBT.Portal.Core.Repositories;
25
using SEBT.Portal.Core.Services;
36
using SEBT.Portal.Kernel;
47
using SEBT.Portal.Kernel.Results;
8+
using SEBT.Portal.StatesPlugins.Interfaces;
9+
using PluginAddress = SEBT.Portal.StatesPlugins.Interfaces.Models.Household.Address;
10+
using PluginAddressUpdateRequest = SEBT.Portal.StatesPlugins.Interfaces.Models.Household.AddressUpdateRequest;
511

612
namespace SEBT.Portal.UseCases.Household;
713

814
/// <summary>
915
/// Handles mailing address updates for an authenticated user's household.
10-
/// Validates input, resolves household identity, and returns success.
11-
/// State connector call is stubbed — actual address persistence is a future integration.
16+
/// Validates input, checks address validity, enforces benefit-type policy, and persists via state connector.
1217
/// </summary>
1318
public class UpdateAddressCommandHandler(
1419
IValidator<UpdateAddressCommand> validator,
1520
IHouseholdIdentifierResolver resolver,
21+
IAddressValidationService addressValidationService,
22+
IHouseholdRepository householdRepository,
23+
IIdProofingRequirementsService idProofingRequirementsService,
24+
IAddressUpdateService addressUpdateService,
1625
ILogger<UpdateAddressCommandHandler> logger)
1726
: ICommandHandler<UpdateAddressCommand>
1827
{
@@ -40,15 +49,92 @@ public async Task<Result> Handle(UpdateAddressCommand command, CancellationToken
4049
"Address update received for household identifier kind {Kind}",
4150
identifierKind);
4251

43-
// TODO: Call state connector to persist address update.
44-
// This is stubbed — the handler returns success without writing to the state system.
45-
// When DC-160 / state connector work lands, wire up IAddressValidationService and
46-
// the state connector write method here.
52+
// Policy enforcement: SNAP and TANF households must update via case worker, not the portal.
53+
var userIalLevel = UserIalLevelExtensions.FromClaimsPrincipal(command.User);
54+
var piiVisibility = idProofingRequirementsService.GetPiiVisibility(userIalLevel);
55+
var household = await householdRepository.GetHouseholdByIdentifierAsync(
56+
identifier, piiVisibility, userIalLevel, cancellationToken);
4757

48-
logger.LogInformation(
49-
"Address update completed for household identifier kind {Kind}",
50-
identifierKind);
58+
if (household is { BenefitIssuanceType: BenefitIssuanceType.SnapEbtCard or BenefitIssuanceType.TanfEbtCard })
59+
{
60+
logger.LogWarning(
61+
"Address update rejected for household identifier kind {Kind}: benefit type {BenefitType} is not eligible for portal self-service",
62+
identifierKind,
63+
household.BenefitIssuanceType);
64+
return Result.PreconditionFailed(
65+
PreconditionFailedReason.Conflict,
66+
"Address updates are not available for this benefit type. Please contact your case worker.");
67+
}
68+
69+
// Validate address via external service (e.g., Smarty). Currently uses AlwaysValidAddressValidator stub.
70+
var pluginAddress = new PluginAddress
71+
{
72+
StreetAddress1 = command.StreetAddress1,
73+
StreetAddress2 = command.StreetAddress2,
74+
City = command.City,
75+
State = command.State,
76+
PostalCode = command.PostalCode
77+
};
78+
79+
var addressValidation = await addressValidationService.ValidateAsync(
80+
new Address
81+
{
82+
StreetAddress1 = pluginAddress.StreetAddress1,
83+
StreetAddress2 = pluginAddress.StreetAddress2,
84+
City = pluginAddress.City,
85+
State = pluginAddress.State,
86+
PostalCode = pluginAddress.PostalCode
87+
}, cancellationToken);
88+
89+
if (!addressValidation.IsValid)
90+
{
91+
if (addressValidation.SuggestedAddress != null)
92+
{
93+
logger.LogInformation("Address validation returned a suggested address for household identifier kind {Kind}", identifierKind);
94+
return Result.ValidationFailed("Address", "The address could not be verified. A suggested address is available.");
95+
}
96+
97+
logger.LogInformation("Address validation failed for household identifier kind {Kind}", identifierKind);
98+
return Result.ValidationFailed("Address", addressValidation.ErrorMessage ?? "The address could not be verified.");
99+
}
100+
101+
var updateRequest = new PluginAddressUpdateRequest
102+
{
103+
HouseholdIdentifierValue = identifier.Value,
104+
Address = pluginAddress
105+
};
106+
107+
try
108+
{
109+
var updateResult = await addressUpdateService.UpdateAddressAsync(updateRequest, cancellationToken);
51110

52-
return Result.Success();
111+
if (updateResult.IsSuccess)
112+
{
113+
logger.LogInformation("Address update completed for household identifier kind {Kind}", identifierKind);
114+
return Result.Success();
115+
}
116+
117+
if (updateResult.IsPolicyRejection)
118+
{
119+
logger.LogWarning(
120+
"Address update policy rejection for household identifier kind {Kind}: {ErrorCode}",
121+
identifierKind,
122+
updateResult.ErrorCode);
123+
return Result.PreconditionFailed(PreconditionFailedReason.Conflict, updateResult.ErrorMessage);
124+
}
125+
126+
logger.LogError(
127+
"Address update backend error for household identifier kind {Kind}: {ErrorCode}",
128+
identifierKind,
129+
updateResult.ErrorCode);
130+
return Result.DependencyFailed(DependencyFailedReason.ConnectionFailed, updateResult.ErrorMessage);
131+
}
132+
catch (Exception ex)
133+
{
134+
logger.LogError(ex, "Address update plugin failed for household identifier kind {Kind}", identifierKind);
135+
return Result.DependencyFailed(
136+
DependencyFailedReason.ConnectionFailed,
137+
"Address update service is temporarily unavailable.");
138+
}
53139
}
54140
}

0 commit comments

Comments
 (0)