Skip to content

Commit 8a63072

Browse files
necampaniniNick Campanini
andauthored
DC-152 - Update Mailing Address flow (#104)
* Add backend address update endpoint with stubbed handler and service interfaces * Add address feature scaffold with types, mutation hook, flow context, and route shells * Add address form component with state-specific defaults, validation, and tests * Add dashboard success alerts for address update and card request flows * Add replacement card prompt with address display, SNAP/TANF callout, and Yes/No validation * Add card selection page with children checkboxes, CO card numbers, and validation * Add co-loaded info stub page and card-flow entry TODO comments * Register address handler and validation service in DI, add integration flow test * fix dashboard alert disappearing after URL param cleanup * Address walkthrough findings: flow guard loading state, program name in title, CoLoadedInfo state guard * Pre-populate address form with addressOnFile from household data * Use stub alert copy to signal pending state system integration * add state abbreviation map, stub alert copy, and shared MSW handler * Wire up address update flow with form pre-population, card selection, and replacement card prompt * Wire up CO visual walkthrough fixes: button colors, heading styles, logo sizing, dashboard alerts, font variable * Address update: harden validation, fix ZIP inputMode, satisfy CodeQL logging warning --------- Co-authored-by: Nick Campanini <ncampanini@codeforamerica.org>
1 parent 2a9d4f5 commit 8a63072

51 files changed

Lines changed: 3171 additions & 18 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/SEBT.Portal.Api/Controllers/Household/HouseholdController.cs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
namespace SEBT.Portal.Api.Controllers.Household;
1111

1212
/// <summary>
13-
/// Controller for handling household data retrieval.
13+
/// Controller for household data retrieval and management.
1414
/// Household lookup uses state-configurable preferred household ID type (e.g. email, SNAP ID) resolved from the authenticated user.
1515
/// </summary>
1616
[ApiController]
@@ -49,4 +49,38 @@ public async Task<IActionResult> GetHouseholdData(
4949
_ => StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse("An unexpected error occurred."))
5050
});
5151
}
52+
53+
/// <summary>
54+
/// Updates the mailing address for the authenticated user's household.
55+
/// </summary>
56+
/// <param name="request">The new mailing address.</param>
57+
/// <param name="commandHandler">The use case handler for updating the address.</param>
58+
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
59+
/// <returns>No content on success; otherwise, BadRequest or Unauthorized.</returns>
60+
/// <response code="204">Address updated successfully.</response>
61+
/// <response code="400">Validation failed (missing fields or invalid format).</response>
62+
/// <response code="403">User is not authorized or no household identifier could be resolved from token.</response>
63+
[HttpPut("address")]
64+
[Authorize]
65+
[ProducesResponseType(StatusCodes.Status204NoContent)]
66+
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
67+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
68+
public async Task<IActionResult> UpdateAddress(
69+
[FromBody] UpdateAddressRequest request,
70+
[FromServices] ICommandHandler<UpdateAddressCommand> commandHandler,
71+
CancellationToken cancellationToken = default)
72+
{
73+
var command = new UpdateAddressCommand
74+
{
75+
User = User,
76+
StreetAddress1 = request.StreetAddress1,
77+
StreetAddress2 = request.StreetAddress2,
78+
City = request.City,
79+
State = request.State,
80+
PostalCode = request.PostalCode
81+
};
82+
83+
var result = await commandHandler.Handle(command, cancellationToken);
84+
return result.ToActionResult();
85+
}
5286
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace SEBT.Portal.Api.Models.Household;
4+
5+
/// <summary>
6+
/// Request model for updating a household's mailing address.
7+
/// </summary>
8+
public record UpdateAddressRequest
9+
{
10+
/// <summary>Street address line 1 (e.g., "123 Main St NW").</summary>
11+
[Required(ErrorMessage = "Street address is required.")]
12+
public required string StreetAddress1 { get; init; }
13+
14+
/// <summary>Street address line 2 (e.g., apartment, suite). Optional.</summary>
15+
public string? StreetAddress2 { get; init; }
16+
17+
/// <summary>City name.</summary>
18+
[Required(ErrorMessage = "City is required.")]
19+
public required string City { get; init; }
20+
21+
/// <summary>State or territory name.</summary>
22+
[Required(ErrorMessage = "State is required.")]
23+
public required string State { get; init; }
24+
25+
/// <summary>5- or 9-digit ZIP code (e.g., "20001" or "20001-1234").</summary>
26+
[Required(ErrorMessage = "Postal code is required.")]
27+
[RegularExpression(@"^\d{5}(-\d{4})?$", ErrorMessage = "Postal code must be a valid 5- or 9-digit ZIP code.")]
28+
public required string PostalCode { get; init; }
29+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using SEBT.Portal.Core.Models.Household;
2+
3+
namespace SEBT.Portal.Core.Services;
4+
5+
/// <summary>
6+
/// Validates a mailing address against an external service (e.g., Smarty).
7+
/// Implementations may autocomplete, suggest alternatives, or reject invalid addresses.
8+
/// </summary>
9+
public interface IAddressValidationService
10+
{
11+
/// <summary>
12+
/// Validates the given address and returns a result indicating whether the address is valid,
13+
/// invalid, or has a suggested alternative.
14+
/// </summary>
15+
Task<AddressValidationResult> ValidateAsync(Address address, CancellationToken cancellationToken = default);
16+
}
17+
18+
/// <summary>
19+
/// Result of an address validation call.
20+
/// </summary>
21+
/// <param name="IsValid">Whether the address passed validation.</param>
22+
/// <param name="SuggestedAddress">An alternative address suggested by the validation service, if any.</param>
23+
/// <param name="ErrorMessage">A user-facing error message if validation failed.</param>
24+
public record AddressValidationResult(
25+
bool IsValid,
26+
Address? SuggestedAddress = null,
27+
string? ErrorMessage = null)
28+
{
29+
public static AddressValidationResult Valid() => new(true);
30+
31+
public static AddressValidationResult Invalid(string errorMessage) => new(false, ErrorMessage: errorMessage);
32+
33+
public static AddressValidationResult Suggestion(Address suggested) => new(false, SuggestedAddress: suggested);
34+
}

src/SEBT.Portal.Infrastructure/Dependencies.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public static IServiceCollection AddPortalInfrastructureServices(this IServiceCo
3434

3535
// Household identifier resolution (state-configurable preferred household ID type)
3636
services.AddTransient<IHouseholdIdentifierResolver, HouseholdIdentifierResolver>();
37+
38+
// Address validation — stub for now, swap with Smarty integration in DC-160
39+
services.AddTransient<IAddressValidationService, AlwaysValidAddressValidator>();
3740
services.AddSingleton<IIdentifierHasher, IdentifierHasher>();
3841

3942
// Expose SocureSettings directly for use case injection (avoids IOptions dependency in UseCases layer)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using SEBT.Portal.Core.Models.Household;
2+
using SEBT.Portal.Core.Services;
3+
4+
namespace SEBT.Portal.Infrastructure.Services;
5+
6+
/// <summary>
7+
/// Stub address validation service that always returns valid.
8+
/// Replace with a real Smarty integration when DC-160 is implemented.
9+
/// </summary>
10+
public class AlwaysValidAddressValidator : IAddressValidationService
11+
{
12+
public Task<AddressValidationResult> ValidateAsync(Address address, CancellationToken cancellationToken = default)
13+
{
14+
return Task.FromResult(AddressValidationResult.Valid());
15+
}
16+
}

src/SEBT.Portal.UseCases/Dependencies.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public static IServiceCollection AddUseCases(this IServiceCollection services)
1919
services.RegisterCommandHandler<StartChallengeCommand, StartChallengeResponse, StartChallengeCommandHandler>();
2020
services.RegisterQueryHandler<GetVerificationStatusQuery, VerificationStatusResponse, GetVerificationStatusQueryHandler>();
2121
services.RegisterCommandHandler<ProcessWebhookCommand, ProcessWebhookCommandHandler>();
22+
services.RegisterCommandHandler<UpdateAddressCommand, UpdateAddressCommandHandler>();
2223

2324
return services;
2425
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using System.Security.Claims;
3+
using SEBT.Portal.Kernel;
4+
5+
namespace SEBT.Portal.UseCases.Household;
6+
7+
/// <summary>
8+
/// Command to update the mailing address for an authenticated user's household.
9+
/// </summary>
10+
public class UpdateAddressCommand : ICommand
11+
{
12+
/// <summary>
13+
/// The authenticated user's claims principal, used to resolve household identity.
14+
/// </summary>
15+
[Required]
16+
public required ClaimsPrincipal User { get; init; }
17+
18+
[Required(ErrorMessage = "Street address is required.")]
19+
[RegularExpression(@"\S.*", ErrorMessage = "Street address cannot be whitespace only.")]
20+
public required string StreetAddress1 { get; init; }
21+
22+
public string? StreetAddress2 { get; init; }
23+
24+
[Required(ErrorMessage = "City is required.")]
25+
[RegularExpression(@"\S.*", ErrorMessage = "City cannot be whitespace only.")]
26+
public required string City { get; init; }
27+
28+
[Required(ErrorMessage = "State is required.")]
29+
[RegularExpression(@"\S.*", ErrorMessage = "State cannot be whitespace only.")]
30+
public required string State { get; init; }
31+
32+
[Required(ErrorMessage = "Postal code is required.")]
33+
[RegularExpression(@"^\d{5}(-\d{4})?$", ErrorMessage = "Postal code must be a valid 5- or 9-digit ZIP code.")]
34+
public required string PostalCode { get; init; }
35+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Microsoft.Extensions.Logging;
2+
using SEBT.Portal.Core.Services;
3+
using SEBT.Portal.Kernel;
4+
using SEBT.Portal.Kernel.Results;
5+
6+
namespace SEBT.Portal.UseCases.Household;
7+
8+
/// <summary>
9+
/// 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.
12+
/// </summary>
13+
public class UpdateAddressCommandHandler(
14+
IValidator<UpdateAddressCommand> validator,
15+
IHouseholdIdentifierResolver resolver,
16+
ILogger<UpdateAddressCommandHandler> logger)
17+
: ICommandHandler<UpdateAddressCommand>
18+
{
19+
public async Task<Result> Handle(UpdateAddressCommand command, CancellationToken cancellationToken = default)
20+
{
21+
var validationResult = await validator.Validate(command, cancellationToken);
22+
if (validationResult is ValidationFailedResult validationFailed)
23+
{
24+
logger.LogWarning("Address update validation failed");
25+
return Result.ValidationFailed(validationFailed.Errors);
26+
}
27+
28+
var identifier = await resolver.ResolveAsync(command.User, cancellationToken);
29+
if (identifier == null)
30+
{
31+
logger.LogWarning("Address update attempted but no household identifier could be resolved from claims");
32+
return Result.Unauthorized("Unable to identify user from token.");
33+
}
34+
35+
// Never log raw address fields — PII policy.
36+
// Extract enum name to a local to break CodeQL taint chain (identifier is tainted via .Value).
37+
var identifierKind = identifier.Type.ToString();
38+
39+
logger.LogInformation(
40+
"Address update received for household identifier kind {Kind}",
41+
identifierKind);
42+
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.
47+
48+
logger.LogInformation(
49+
"Address update completed for household identifier kind {Kind}",
50+
identifierKind);
51+
52+
return Result.Success();
53+
}
54+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { readFileSync } from 'node:fs'
2+
import { resolve } from 'node:path'
3+
import { describe, expect, it } from 'vitest'
4+
5+
import { primaryFont } from './fonts'
6+
7+
/**
8+
* Contract test: the SCSS font override must reference the same CSS variable
9+
* that the font generator produces. A mismatch causes a FOUT (flash of unstyled
10+
* text) because the SCSS !important override resolves to an undefined variable,
11+
* falling back to the browser's default serif font.
12+
*
13+
* This caught a real bug: generate-fonts.js was updated in DC-143 to produce
14+
* --font-primary, but _uswds-theme-custom-styles.scss still referenced
15+
* --font-urbanist.
16+
*/
17+
describe('font variable contract', () => {
18+
const scssPath = resolve(__dirname, 'sass/_uswds-theme-custom-styles.scss')
19+
const scssContent = readFileSync(scssPath, 'utf-8')
20+
21+
it('primaryFont exports a CSS variable name', () => {
22+
expect(primaryFont.variable).toBeDefined()
23+
expect(primaryFont.variable).toMatch(/^--font-/)
24+
})
25+
26+
it('SCSS font override references the same variable as primaryFont', () => {
27+
const cssVariable = primaryFont.variable
28+
expect(scssContent).toContain(`var(${cssVariable})`)
29+
})
30+
31+
it('SCSS font override does not reference stale font variable names', () => {
32+
// Guard against the specific bug: old variable names left behind after
33+
// the font generator is updated to produce a different variable.
34+
const fontVarPattern = /var\(--font-([^)]+)\)/g
35+
const referencedVars = [...scssContent.matchAll(fontVarPattern)].map((m) => `--font-${m[1]}`)
36+
37+
for (const varName of referencedVars) {
38+
expect(varName).toBe(primaryFont.variable)
39+
}
40+
})
41+
})

src/SEBT.Portal.Web/design/sass/_uswds-theme-custom-styles.scss

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,22 @@ References:
2323
Custom Font Override
2424
----------------------------------------
2525
Override USWDS default fonts with our custom font from Next.js font loader.
26-
The --font-urbanist CSS variable is set by Next.js in layout.tsx.
26+
The --font-primary CSS variable is set by Next.js in layout.tsx.
2727
This ensures all USWDS components use our design token font.
2828
----------------------------------------
2929
*/
3030

3131
// Override all USWDS font-family declarations to use our custom font
3232
// This is necessary because USWDS sets font-family on many utility classes
33-
// TODO: Register Urbanist as a custom USWDS typeface to remove !important hack
33+
// TODO: Register the design token font as a custom USWDS typeface to remove !important hack
3434
// See: https://designsystem.digital.gov/documentation/settings/#typography-settings
3535
body,
3636
.usa-prose,
3737
[class*='font-'],
3838
[class*='text-'],
3939
[class*='usa-'] {
4040
font-family:
41-
var(--font-urbanist),
41+
var(--font-primary),
4242
system-ui,
4343
-apple-system,
4444
BlinkMacSystemFont,

0 commit comments

Comments
 (0)