Skip to content

Commit ab4812e

Browse files
jamesmblairJames Blairadbergennoxmwalsh
authored
DC-172/DC-226: Add enrollment checker backend with plugin integration (3/4) (#99)
* DC-172: Add documentation, ADRs, skills, and repo cleanup - Add ADRs for locale section filtering and rich-text rendering - Add TDD design docs, implementation plans, and Figma comparison docs - Add Claude Code skills (figma-verify, multi-repo-git, review-prep, test) - Remove tracked .idea/ files and update .gitignore with blanket .idea/ rule - Update CLAUDE.md with locale generation and design system guidance * DC-172: Extract shared design system into @sebt/design-system workspace package - Create packages/design-system with shared UI components, layout, providers, i18n, design tokens, SASS theme, and content generation scripts - Move components (Alert, Button, InputField, TextLink, Header, Footer, HelpSection, SkipNav, LanguageSelector) from Portal Web to design-system - Move content CSVs, locale generation script, design token scripts, SASS, and state JSON configs to design-system - Add RichText component wrapping markdown-to-jsx - Refactor Portal Web imports from @/components to @sebt/design-system - Configure pnpm workspace, transpilePackages, and webpack alias for shared React - Add workspace package.json, tsconfig, vitest config, and test setup * DC-172: Add enrollment checker backend with plugin integration - Add EnrollmentCheckController with rate-limited POST endpoint - Add CheckEnrollmentCommand/Handler use case with submission logging - Add PluginLoader for deferred MEF assembly loading with IHostBuilder - Add DefaultEnrollmentCheckService fallback when no state plugin loaded - Add EnrollmentCheckSubmission domain model and EF Core entities/migration - Add DesignTimePortalDbContextFactory for EF tooling support - Extract rate-limit policy names to RateLimitPolicies constants - Add plugin integration tests (DC, CO, Default) with PluginIntegrationWebApplicationFactory - Add unit tests for controller, command handler, and submission logger * fix: broken import, next version mismatch, RichText safety comment * fix: generate fonts.ts to caller's cwd so Next.js @/ alias resolves in CI * fix: remove duplicate react/next from design-system devDeps to prevent dual-instance hook errors * fix: restore design-system devDeps and add Turbopack resolveAlias for React deduplication * fix: use production build for E2E tests to avoid Turbopack dev React duplication * fix: split design-system barrel to resolve dual React instance in E2E builds * feat: add max children validation to enrollment check requests * fix: use I18nProvider wrapper to initialize i18next before render * fix: serialize integration tests and use env vars for plugin config --------- Co-authored-by: James Blair <jblair@codeforamerica.org> Co-authored-by: Anthony Bergen <anthonydbergen@gmail.com> Co-authored-by: Michael Walsh <noxiousthing@gmail.com>
1 parent 7c18235 commit ab4812e

39 files changed

Lines changed: 2214 additions & 16 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using SEBT.Portal.StatesPlugins.Interfaces;
2+
using SEBT.Portal.StatesPlugins.Interfaces.Models.EnrollmentCheck;
3+
4+
namespace SEBT.Portal.Api.Composition.Defaults;
5+
6+
/// <summary>
7+
/// Default implementation when no state-specific IEnrollmentCheckService plugin is loaded.
8+
/// Returns NonMatch for every child — a conservative fallback that avoids false positives.
9+
/// </summary>
10+
internal class DefaultEnrollmentCheckService : IEnrollmentCheckService
11+
{
12+
public Task<EnrollmentCheckResult> CheckEnrollmentAsync(
13+
EnrollmentCheckRequest request,
14+
CancellationToken cancellationToken = default)
15+
{
16+
return Task.FromResult(new EnrollmentCheckResult
17+
{
18+
Results = request.Children.Select(c => new ChildCheckResult
19+
{
20+
CheckId = c.CheckId,
21+
FirstName = c.FirstName,
22+
LastName = c.LastName,
23+
DateOfBirth = c.DateOfBirth,
24+
Status = EnrollmentStatus.NonMatch,
25+
SchoolName = c.SchoolName
26+
}).ToList(),
27+
ResponseMessage = "No enrollment check service configured."
28+
});
29+
}
30+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public static IServiceCollection AddPlugins(this IServiceCollection services, IC
1515
services.TryAddSingleton<IStateAuthenticationService, Defaults.DefaultStateAuthenticationService>();
1616
services.TryAddSingleton<IStateHealthCheckService, Defaults.DefaultStateHealthCheckService>();
1717
services.TryAddSingleton<ISummerEbtCaseService, Defaults.DefaultSummerEbtCaseService>();
18+
services.TryAddSingleton<IEnrollmentCheckService, Defaults.DefaultEnrollmentCheckService>();
1819

1920
var healthChecksBuilder = services.AddHealthChecks();
2021

@@ -84,6 +85,11 @@ private static ContainerConfiguration CreateContainerConfiguration(string[] asse
8485
.Export<IStateHealthCheckService>()
8586
.Shared();
8687

88+
conventions
89+
.ForTypesDerivedFrom<IEnrollmentCheckService>()
90+
.Export<IEnrollmentCheckService>()
91+
.Shared();
92+
8793
return new ContainerConfiguration()
8894
.WithExport(configuration)
8995
.WithAssembliesInPath(assemblyPaths, conventions);

src/SEBT.Portal.Api/Controllers/Auth/OtpController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public class OtpController(ILogger<OtpController> logger) : ControllerBase
2626
/// <response code="400">Invalid request.</response>
2727
/// <response code="429">Rate limit exceeded. Maximum 5 OTP requests per minute allowed.</response>
2828
[HttpPost("request")]
29-
[EnableRateLimiting("otp-policy")]
29+
[EnableRateLimiting(RateLimitPolicies.Otp)]
3030
[ProducesResponseType(StatusCodes.Status201Created)]
3131
[ProducesResponseType(StatusCodes.Status400BadRequest)]
3232
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.AspNetCore.RateLimiting;
4+
using SEBT.Portal.Api.Models;
5+
using SEBT.Portal.Api.Models.EnrollmentCheck;
6+
using SEBT.Portal.Kernel;
7+
using SEBT.Portal.Kernel.AspNetCore;
8+
using SEBT.Portal.Kernel.Results;
9+
using SEBT.Portal.StatesPlugins.Interfaces.Models.EnrollmentCheck;
10+
using SEBT.Portal.UseCases.EnrollmentCheck;
11+
12+
namespace SEBT.Portal.Api.Controllers.EnrollmentCheck;
13+
14+
/// <summary>
15+
/// Controller for checking child enrollment in Summer EBT benefits.
16+
/// This is a public, unauthenticated endpoint with rate limiting.
17+
/// </summary>
18+
[ApiController]
19+
[Route("api/enrollment")]
20+
public class EnrollmentCheckController : ControllerBase
21+
{
22+
/// <summary>
23+
/// Checks enrollment status for one or more children.
24+
/// This is a public, unauthenticated endpoint.
25+
/// </summary>
26+
[HttpPost("check")]
27+
[AllowAnonymous]
28+
[EnableRateLimiting(RateLimitPolicies.EnrollmentCheck)]
29+
[ProducesResponseType(typeof(EnrollmentCheckApiResponse), StatusCodes.Status200OK)]
30+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
31+
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
32+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status503ServiceUnavailable)]
33+
public async Task<IActionResult> CheckEnrollment(
34+
[FromServices] ICommandHandler<CheckEnrollmentCommand, EnrollmentCheckResult> handler,
35+
[FromBody] EnrollmentCheckApiRequest request,
36+
CancellationToken cancellationToken = default)
37+
{
38+
// Parse and validate date formats
39+
var children = new List<CheckEnrollmentCommand.ChildInput>();
40+
foreach (var child in request.Children)
41+
{
42+
if (!DateOnly.TryParse(child.DateOfBirth, out var dob))
43+
{
44+
return BadRequest(new ErrorResponse(
45+
$"Invalid date format for child '{child.FirstName} {child.LastName}': '{child.DateOfBirth}'"));
46+
}
47+
48+
children.Add(new CheckEnrollmentCommand.ChildInput
49+
{
50+
FirstName = child.FirstName,
51+
LastName = child.LastName,
52+
DateOfBirth = dob,
53+
SchoolName = child.SchoolName,
54+
SchoolCode = child.SchoolCode,
55+
AdditionalFields = child.AdditionalFields
56+
});
57+
}
58+
59+
var command = new CheckEnrollmentCommand
60+
{
61+
Children = children,
62+
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
63+
};
64+
65+
var result = await handler.Handle(command, cancellationToken);
66+
67+
return result.ToActionResult(
68+
successMap: data => Ok(MapToApiResponse(data)),
69+
failureMap: r => r switch
70+
{
71+
DependencyFailedResult<EnrollmentCheckResult> =>
72+
StatusCode(StatusCodes.Status503ServiceUnavailable,
73+
new ProblemDetails
74+
{
75+
Title = "Enrollment check service is temporarily unavailable.",
76+
Status = StatusCodes.Status503ServiceUnavailable
77+
}),
78+
_ => result.ToActionResult()
79+
});
80+
}
81+
82+
private static EnrollmentCheckApiResponse MapToApiResponse(EnrollmentCheckResult result)
83+
{
84+
return new EnrollmentCheckApiResponse
85+
{
86+
Results = result.Results.Select(r => new ChildCheckApiResponse
87+
{
88+
CheckId = r.CheckId.ToString(),
89+
FirstName = r.FirstName,
90+
LastName = r.LastName,
91+
DateOfBirth = r.DateOfBirth.ToString("yyyy-MM-dd"),
92+
Status = r.Status.ToString(),
93+
MatchConfidence = r.MatchConfidence,
94+
EligibilityType = r.EligibilityType?.ToString(),
95+
SchoolName = r.SchoolName,
96+
StatusMessage = r.StatusMessage
97+
}).ToList(),
98+
Message = result.ResponseMessage
99+
};
100+
}
101+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace SEBT.Portal.Api.Models.EnrollmentCheck;
4+
5+
/// <summary>
6+
/// Request model for checking enrollment status of one or more children.
7+
/// </summary>
8+
public class EnrollmentCheckApiRequest
9+
{
10+
/// <summary>
11+
/// Maximum number of children that can be checked in a single request.
12+
/// </summary>
13+
public const int MaxChildren = 20;
14+
15+
/// <summary>
16+
/// The children to check enrollment for.
17+
/// </summary>
18+
[MaxLength(MaxChildren)]
19+
public IList<ChildCheckApiRequest> Children { get; set; } = new List<ChildCheckApiRequest>();
20+
}
21+
22+
/// <summary>
23+
/// Individual child data for an enrollment check request.
24+
/// </summary>
25+
public class ChildCheckApiRequest
26+
{
27+
/// <summary>
28+
/// Child's first name.
29+
/// </summary>
30+
public string FirstName { get; set; } = string.Empty;
31+
32+
/// <summary>
33+
/// Child's last name.
34+
/// </summary>
35+
public string LastName { get; set; } = string.Empty;
36+
37+
/// <summary>
38+
/// Child's date of birth in yyyy-MM-dd format.
39+
/// </summary>
40+
public string DateOfBirth { get; set; } = string.Empty;
41+
42+
/// <summary>
43+
/// Name of the child's school (optional).
44+
/// </summary>
45+
public string? SchoolName { get; set; }
46+
47+
/// <summary>
48+
/// Code identifying the child's school (optional).
49+
/// </summary>
50+
public string? SchoolCode { get; set; }
51+
52+
/// <summary>
53+
/// State-specific additional fields (optional).
54+
/// </summary>
55+
public IDictionary<string, string> AdditionalFields { get; set; } = new Dictionary<string, string>();
56+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
namespace SEBT.Portal.Api.Models.EnrollmentCheck;
2+
3+
/// <summary>
4+
/// Response model for enrollment check results.
5+
/// </summary>
6+
public class EnrollmentCheckApiResponse
7+
{
8+
/// <summary>
9+
/// Results for each child checked.
10+
/// </summary>
11+
public IList<ChildCheckApiResponse> Results { get; init; } = new List<ChildCheckApiResponse>();
12+
13+
/// <summary>
14+
/// Optional message from the enrollment check service.
15+
/// </summary>
16+
public string? Message { get; init; }
17+
}
18+
19+
/// <summary>
20+
/// Individual child enrollment check result.
21+
/// </summary>
22+
public class ChildCheckApiResponse
23+
{
24+
/// <summary>
25+
/// Unique identifier for this check result.
26+
/// </summary>
27+
public string CheckId { get; init; } = string.Empty;
28+
29+
/// <summary>
30+
/// Child's first name.
31+
/// </summary>
32+
public string FirstName { get; init; } = string.Empty;
33+
34+
/// <summary>
35+
/// Child's last name.
36+
/// </summary>
37+
public string LastName { get; init; } = string.Empty;
38+
39+
/// <summary>
40+
/// Child's date of birth in yyyy-MM-dd format.
41+
/// </summary>
42+
public string DateOfBirth { get; init; } = string.Empty;
43+
44+
/// <summary>
45+
/// Enrollment status (Match, PossibleMatch, NonMatch, Error).
46+
/// </summary>
47+
public string Status { get; init; } = string.Empty;
48+
49+
/// <summary>
50+
/// Confidence score for the match, if applicable.
51+
/// </summary>
52+
public double? MatchConfidence { get; init; }
53+
54+
/// <summary>
55+
/// Type of eligibility (Snap, Tanf, Frp, DirectCert), if matched.
56+
/// </summary>
57+
public string? EligibilityType { get; init; }
58+
59+
/// <summary>
60+
/// Name of the school associated with the enrollment record.
61+
/// </summary>
62+
public string? SchoolName { get; init; }
63+
64+
/// <summary>
65+
/// Human-readable status message with additional details.
66+
/// </summary>
67+
public string? StatusMessage { get; init; }
68+
}

0 commit comments

Comments
 (0)