-
Notifications
You must be signed in to change notification settings - Fork 0
DC-153 - Implement “Request replacement card” flow in self-service portal #108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 24 commits
Commits
Show all changes
58 commits
Select commit
Hold shift + click to select a range
98cae26
Fix CardStatus integer-to-string mapping to match backend enum values
52cb693
Move CardSelection component to features/cards
ac71d15
Add stubbed RequestCardReplacement command, handler, and tests
367ba8f
Add POST /api/household/cards/replace endpoint and controller tests
822c5d9
Add card replacement API client hook and request schema
c0b93de
Add ConfirmRequest pre-submission review component
3eb01e4
Wire CardSelection to ConfirmRequest with sibling auto-select
c06753c
Add standalone card replacement flow with ConfirmAddress and routes
414b3b9
Add co-loaded card info route reusing existing CoLoadedInfo component
9202bdb
Add replacement card links to ChildCard and update ActionButtons CTA
83a6ad5
Add card replacement success alert to DashboardAlerts with PII-safe f…
ca946d5
Add 2-week cooldown utility and wire into ChildCard and CardSelection
825289c
Seed MockHouseholdRepository Faker for stable application numbers
3ec57b6
Fix mock data instability and remove co-loaded Continue button
dc22564
Set IssuanceType on mock household applications for realistic card di…
42bc374
Replace FIS phone content with DHS EBT Card Office locations in CoLoa…
3d89261
Show SEBT ID in ChildCard and fix cardTableActionRequestReplacement key
9ffc67a
Remove counters-sm class so timeline step labels render
113e647
Add dashboard navigation link to card info page alert
d218ed0
Register RequestCardReplacementCommand handler and add MSW stub
a87a667
Fix CardSelection confirm navigation to resolve as child route
d185074
Set Last4DigitsOfCard on all approved mock household scenarios
62eb5c4
Set explicit CardRequestedAt on all approved mock scenarios to avoid …
24a4a43
Add Playwright E2E tests for card replacement flow (42 tests)
083f07e
Fix E2E CardStatus enum values, remove double decodeURIComponent, add…
96c01c0
Merge main into DC-153 (design-system extraction + DC-130 dashboard)
e395546
Fix replacement card link routing and cooldown bypass on dashboard
7c56e8e
Use IssuerSigningKeyResolver for kid-less JWTs and suppress USWDS sas…
bc59ebf
Update locale CSVs and support new Google Sheet column headers
e9f5200
Add commented helper for CO OIDC local testing in mock data
3633f95
Merge main into DC-153
63b3cac
Extract shared IAL resolution, fix stub log message, fix office address
16ac3f8
Reject card replacement requests with application numbers not in hous…
1514a94
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
0bf8d88
Address PR review: inject TimeProvider, add null-household test, remo…
3732888
Adopt UserIalLevelExtensions from DC-194 to avoid merge conflict
7e19f00
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
bdc9c83
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini 840f560
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini 7a75664
Fix E2E workflow timeout and add test observability
942bedd
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
08f16a7
Split Playwright and Pa11y into parallel CI jobs
3e2596a
Remove stub page that shadows address form route
6228bc6
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini e7f68c8
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini 29ffe26
Use real addresses in mock data for Smarty validation compatibility
e624f35
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
2d2d3ac
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
03cef78
Fix CoLoadedInfo test fixture addresses to match updated MSW mock data
a80c060
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
7e3aed1
split mixed-state E2E tests and add per-state CI matrix
d47db49
fix EnrolledChildren field mapping and update E2E fixtures for summer…
55c83d3
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
587614e
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
4a8c6ef
gate replacement link behind feature flag, improve CardSelection empt…
3a0cce7
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
304b5fc
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
5e0457c
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
14 changes: 14 additions & 0 deletions
14
src/SEBT.Portal.Api/Models/Household/RequestCardReplacementRequest.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| using System.ComponentModel.DataAnnotations; | ||
|
|
||
| namespace SEBT.Portal.Api.Models.Household; | ||
|
|
||
| /// <summary> | ||
| /// Request model for requesting replacement cards for one or more applications. | ||
| /// </summary> | ||
| public record RequestCardReplacementRequest | ||
| { | ||
| /// <summary>Application numbers identifying which cards to replace.</summary> | ||
| [Required(ErrorMessage = "At least one application number is required.")] | ||
| [MinLength(1, ErrorMessage = "At least one application number is required.")] | ||
| public required List<string> ApplicationNumbers { get; init; } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,7 +64,7 @@ | |
|
|
||
| if (household == null) | ||
| { | ||
| _logger.LogInformation("Mock household not found for identifier {Type}={Value}", identifier.Type, normalizedEmail); | ||
Check warningCode scanning / CodeQL Exposure of private information Medium
Private data returned by
access to parameter email Error loading related location Loading Private data returned by access to parameter email Error loading related location Loading Private data returned by access to parameter email is written to an external location. Private data returned by access to parameter email is written to an external location. Private data returned by access to local variable normalizedEmail is written to an external location. Private data returned by access to parameter email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to parameter email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by call to method BuildEmail is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by call to method BuildEmail is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable normalizedEmail is written to an external location. |
||
| return Task.FromResult<HouseholdData?>(null); | ||
| } | ||
|
|
||
|
|
@@ -72,7 +72,7 @@ | |
| _logger.LogDebug( | ||
| "Returning mock household data for identifier {Type}={Value}, PII visibility: Address={IncludeAddress}, Email={IncludeEmail}, Phone={IncludePhone}", | ||
| identifier.Type, | ||
| normalizedEmail, | ||
Check warningCode scanning / CodeQL Exposure of private information Medium
Private data returned by
access to parameter email Error loading related location Loading Private data returned by access to parameter email Error loading related location Loading Private data returned by access to parameter email is written to an external location. Private data returned by access to parameter email is written to an external location. Private data returned by access to local variable normalizedEmail is written to an external location. Private data returned by access to parameter email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to parameter email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by call to method BuildEmail is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by call to method BuildEmail is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable email is written to an external location. Private data returned by access to local variable normalizedEmail is written to an external location. |
||
| piiVisibility.IncludeAddress, | ||
| piiVisibility.IncludeEmail, | ||
| piiVisibility.IncludePhone); | ||
|
|
@@ -131,9 +131,11 @@ | |
| var app = h.Applications.FirstOrDefault(); | ||
| if (app != null) | ||
| { | ||
| app.IssuanceType = IssuanceType.SnapEbtCard; | ||
| app.BenefitIssueDate = now.AddDays(-20); | ||
| app.BenefitExpirationDate = now.AddDays(70); | ||
| app.Last4DigitsOfCard = "0000"; | ||
| app.CardRequestedAt = now.AddDays(-60); | ||
| // Set specific children names for test | ||
| app.Children = new List<Child> | ||
| { | ||
|
|
@@ -162,6 +164,9 @@ | |
| var app = h.Applications.FirstOrDefault(); | ||
| if (app != null) | ||
| { | ||
| app.ApplicationNumber = "APP-2025-01-100001"; | ||
| app.CaseNumber = "CASE-100001"; | ||
| app.IssuanceType = IssuanceType.SummerEbt; | ||
| app.BenefitIssueDate = now.AddDays(-30); | ||
| app.BenefitExpirationDate = now.AddDays(60); | ||
| app.Last4DigitsOfCard = "1234"; // Specific value for test | ||
|
|
@@ -322,8 +327,11 @@ | |
| var app = h.Applications.FirstOrDefault(); | ||
| if (app != null) | ||
| { | ||
| app.IssuanceType = IssuanceType.SummerEbt; | ||
| app.BenefitIssueDate = now.AddDays(-15); | ||
| app.BenefitExpirationDate = now.AddDays(75); | ||
| app.Last4DigitsOfCard = "4321"; | ||
| app.CardRequestedAt = now.AddDays(-45); | ||
| // Use Bogus to generate child name | ||
| var childFaker = new Faker<Child>() | ||
| .RuleFor(c => c.FirstName, f => f.Name.FirstName()) | ||
|
|
@@ -343,8 +351,11 @@ | |
| var app = h.Applications.FirstOrDefault(); | ||
| if (app != null) | ||
| { | ||
| app.IssuanceType = IssuanceType.TanfEbtCard; | ||
| app.BenefitIssueDate = now.AddDays(-45); | ||
| app.BenefitExpirationDate = now.AddDays(45); | ||
| app.Last4DigitsOfCard = "8765"; | ||
| app.CardRequestedAt = now.AddDays(-30); | ||
| // Set specific children names for test | ||
| app.Children = new List<Child> | ||
| { | ||
|
|
@@ -385,6 +396,8 @@ | |
| { | ||
| app.BenefitIssueDate = now.AddDays(-120); | ||
| app.BenefitExpirationDate = now.AddDays(-10); // Expired | ||
| app.Last4DigitsOfCard = "9012"; | ||
| app.CardRequestedAt = now.AddDays(-90); | ||
| // Use Bogus to generate child name | ||
| var childFaker = new Faker<Child>() | ||
| .RuleFor(c => c.FirstName, f => f.Name.FirstName()) | ||
|
|
@@ -416,12 +429,13 @@ | |
| var multipleApps = HouseholdFactory.CreateHouseholdDataWithStatus(ApplicationStatus.Approved, h => | ||
| { | ||
| h.BenefitIssuanceType = BenefitIssuanceType.SnapEbtCard; | ||
| var faker = new Faker(); | ||
| var faker = new Faker { Random = new Randomizer(42) }; | ||
| var approvedApp = new Application | ||
| { | ||
| ApplicationNumber = $"APP-{now.AddDays(-30):yyyy-MM}-{faker.Random.Number(100000, 999999)}", | ||
| CaseNumber = $"CASE-{faker.Random.Number(100000, 999999)}", | ||
| ApplicationStatus = ApplicationStatus.Approved, | ||
| IssuanceType = IssuanceType.SummerEbt, | ||
| BenefitIssueDate = now.AddDays(-30), | ||
| BenefitExpirationDate = now.AddDays(60), | ||
| Last4DigitsOfCard = "5678", | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
src/SEBT.Portal.UseCases/Household/RequestCardReplacement/RequestCardReplacementCommand.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| using System.ComponentModel.DataAnnotations; | ||
| using System.Security.Claims; | ||
| using SEBT.Portal.Kernel; | ||
|
|
||
| namespace SEBT.Portal.UseCases.Household; | ||
|
|
||
| /// <summary> | ||
| /// Command to request replacement cards for one or more applications. | ||
| /// </summary> | ||
| public class RequestCardReplacementCommand : ICommand | ||
| { | ||
| /// <summary> | ||
| /// The authenticated user's claims principal, used to resolve household identity. | ||
| /// </summary> | ||
| [Required] | ||
| public required ClaimsPrincipal User { get; init; } | ||
|
|
||
| /// <summary> | ||
| /// Application numbers identifying which cards to replace. | ||
| /// All children on a selected application share the same card. | ||
| /// </summary> | ||
| [Required(ErrorMessage = "At least one application number is required.")] | ||
| [MinLength(1, ErrorMessage = "At least one application number is required.")] | ||
| public required List<string> ApplicationNumbers { get; init; } | ||
| } |
107 changes: 107 additions & 0 deletions
107
....Portal.UseCases/Household/RequestCardReplacement/RequestCardReplacementCommandHandler.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| using Microsoft.Extensions.Logging; | ||
| using SEBT.Portal.Core.Models; | ||
| using SEBT.Portal.Core.Models.Auth; | ||
| using SEBT.Portal.Core.Repositories; | ||
| using SEBT.Portal.Core.Services; | ||
| using SEBT.Portal.Kernel; | ||
| using SEBT.Portal.Kernel.Results; | ||
|
|
||
| namespace SEBT.Portal.UseCases.Household; | ||
|
|
||
| /// <summary> | ||
| /// Handles card replacement requests for an authenticated user's household. | ||
| /// Validates input, resolves household identity, enforces 2-week cooldown, and returns success. | ||
| /// State connector call is stubbed — actual card replacement is a future integration. | ||
| /// </summary> | ||
| public class RequestCardReplacementCommandHandler( | ||
| IValidator<RequestCardReplacementCommand> validator, | ||
| IHouseholdIdentifierResolver resolver, | ||
| IHouseholdRepository repository, | ||
| ILogger<RequestCardReplacementCommandHandler> logger) | ||
| : ICommandHandler<RequestCardReplacementCommand> | ||
| { | ||
| private static readonly TimeSpan CooldownPeriod = TimeSpan.FromDays(14); | ||
|
|
||
| public async Task<Result> Handle( | ||
| RequestCardReplacementCommand command, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| var validationResult = await validator.Validate(command, cancellationToken); | ||
| if (validationResult is ValidationFailedResult validationFailed) | ||
| { | ||
| logger.LogWarning("Card replacement validation failed"); | ||
| return Result.ValidationFailed(validationFailed.Errors); | ||
| } | ||
|
|
||
| var identifier = await resolver.ResolveAsync(command.User, cancellationToken); | ||
| if (identifier == null) | ||
| { | ||
| logger.LogWarning( | ||
| "Card replacement attempted but no household identifier could be resolved from claims"); | ||
| return Result.Unauthorized("Unable to identify user from token."); | ||
| } | ||
|
|
||
| var household = await repository.GetHouseholdByIdentifierAsync( | ||
| identifier, | ||
| new PiiVisibility(IncludeAddress: false, IncludeEmail: false, IncludePhone: false), | ||
| UserIalLevel.None, | ||
| cancellationToken); | ||
necampanini marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (household == null) | ||
| { | ||
| logger.LogWarning("Card replacement attempted but household data not found"); | ||
| return Result.PreconditionFailed(PreconditionFailedReason.NotFound, "Household data not found."); | ||
| } | ||
|
|
||
| var cooldownErrors = CheckCooldown(command.ApplicationNumbers, household); | ||
| if (cooldownErrors.Count > 0) | ||
| { | ||
| logger.LogInformation( | ||
| "Card replacement rejected: {Count} application(s) within cooldown period", | ||
| cooldownErrors.Count); | ||
| return Result.ValidationFailed(cooldownErrors); | ||
| } | ||
|
|
||
| var identifierKind = identifier.Type.ToString(); | ||
| logger.LogInformation( | ||
| "Card replacement request received for household identifier kind {Kind}, {Count} application(s)", | ||
| identifierKind, | ||
| command.ApplicationNumbers.Count); | ||
|
|
||
| // TODO: Call state connector to process card replacement. | ||
| // Stubbed — returns success without calling the state system. | ||
|
|
||
| logger.LogInformation( | ||
| "Card replacement completed for household identifier kind {Kind}", | ||
necampanini marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| identifierKind); | ||
|
|
||
| return Result.Success(); | ||
| } | ||
|
|
||
| private static List<ValidationError> CheckCooldown( | ||
| List<string> requestedApplicationNumbers, | ||
| Core.Models.Household.HouseholdData household) | ||
| { | ||
| var errors = new List<ValidationError>(); | ||
| var now = DateTime.UtcNow; | ||
necampanini marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| foreach (var appNumber in requestedApplicationNumbers) | ||
| { | ||
| var application = household.Applications | ||
| .FirstOrDefault(a => a.ApplicationNumber == appNumber); | ||
|
|
||
| if (application?.CardRequestedAt == null) | ||
| continue; | ||
|
|
||
necampanini marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| var elapsed = now - application.CardRequestedAt.Value; | ||
| if (elapsed < CooldownPeriod) | ||
| { | ||
| errors.Add(new ValidationError( | ||
| "ApplicationNumbers", | ||
| $"Application {appNumber} was requested within the last 14 days.")); | ||
| } | ||
| } | ||
|
|
||
| return errors; | ||
| } | ||
| } | ||
17 changes: 17 additions & 0 deletions
17
...ortal.UseCases/Household/RequestCardReplacement/RequestCardReplacementCommandValidator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| using SEBT.Portal.Kernel; | ||
| using SEBT.Portal.Kernel.Results; | ||
|
|
||
| namespace SEBT.Portal.UseCases.Household; | ||
|
|
||
| /// <summary> | ||
| /// Validates <see cref="RequestCardReplacementCommand"/> using data annotations. | ||
| /// </summary> | ||
| public class RequestCardReplacementCommandValidator( | ||
necampanini marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| IValidator<RequestCardReplacementCommand> validator) | ||
| : IValidator<RequestCardReplacementCommand> | ||
| { | ||
| public Task<ValidationResult> Validate( | ||
| RequestCardReplacementCommand command, | ||
| CancellationToken cancellationToken = default) | ||
| => validator.Validate(command, cancellationToken); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.