Skip to content

Commit 128cc9d

Browse files
authored
DC-260 Setup rollment checker in AWS with tofu IaC & CI/CD pipeline (#146)
* Add Route53 hosted zone for CO enrollment checker Create hosted zone for co.sebt-enrollment.codeforamerica.app and output NS records for delegation. NS delegation has been set up with CfA DNS account. * Add enrollment checker tofu module with S3 bucket * Add ACM certificate with DNS validation for enrollment checker * Add CloudFront distribution with S3 origin for enrollment checker * Wire enrollment checker module into dev-co environment * Add CORS support for enrollment checker cross-origin API calls * Fix enrollment checker build: resolve TypeScript errors and Vite config The enrollment checker failed to build (SSG) and failed to run tests due to several issues: - generate-fonts.js crashed when the output directory didn't exist (ENOENT - exactOptionalPropertyTypes violations across enrollment feature components - Vitest couldn't resolve @sebt/design-system subpath exports due to a hard alias that bypassed the package's exports field - Dockerfile.ssg didn't exclude SSR-only API proxy routes from static export * Add GitHub Actions workflow for enrollment checker static site deployment * Move enrollment checker hosted zone to bootstrap config * Fix S3 logging bucket reference and add logging_bucket_name variable * Handle CORS preflight for enrollment checker API endpoints
1 parent 4f817dc commit 128cc9d

29 files changed

Lines changed: 683 additions & 71 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
name: Deploy Enrollment Checker
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- src/SEBT.EnrollmentChecker.Web/**
8+
- packages/**
9+
- pnpm-lock.yaml
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
15+
concurrency:
16+
group: deploy-enrollment-checker
17+
cancel-in-progress: true
18+
19+
jobs:
20+
build:
21+
runs-on: ubuntu-latest
22+
environment: dev-co
23+
steps:
24+
- name: Checkout
25+
uses: actions/checkout@v6
26+
27+
- name: Setup Node.js
28+
uses: actions/setup-node@v4
29+
with:
30+
node-version: 24
31+
32+
- name: Setup pnpm
33+
uses: pnpm/action-setup@v4
34+
35+
- name: Get pnpm store directory
36+
id: pnpm-cache
37+
shell: bash
38+
run: echo "store=$(pnpm store path)" >> "$GITHUB_OUTPUT"
39+
40+
- name: Cache pnpm dependencies
41+
uses: actions/cache@v4
42+
with:
43+
path: ${{ steps.pnpm-cache.outputs.store }}
44+
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
45+
restore-keys: pnpm-${{ runner.os }}-
46+
47+
- name: Install dependencies
48+
run: pnpm install --frozen-lockfile
49+
50+
- name: Remove SSR-only API routes
51+
run: rm -rf src/SEBT.EnrollmentChecker.Web/src/app/api
52+
53+
- name: Build static export
54+
run: BUILD_STATIC=true pnpm --filter @sebt/enrollment-checker build
55+
env:
56+
STATE: co
57+
NEXT_PUBLIC_STATE: co
58+
NEXT_PUBLIC_API_BASE_URL: ${{ vars.ENROLLMENT_CHECKER_API_BASE_URL }}
59+
NEXT_PUBLIC_PORTAL_URL: ${{ vars.ENROLLMENT_CHECKER_PORTAL_URL }}
60+
NEXT_PUBLIC_APPLICATION_URL: ${{ vars.ENROLLMENT_CHECKER_APPLICATION_URL }}
61+
62+
- name: Upload static site artifact
63+
uses: actions/upload-artifact@v6
64+
with:
65+
name: enrollment-checker-out
66+
path: src/SEBT.EnrollmentChecker.Web/out/
67+
retention-days: 1
68+
69+
deploy-co:
70+
needs: build
71+
runs-on: ubuntu-latest
72+
environment: dev-co
73+
env:
74+
AWS_REGION: us-east-1
75+
steps:
76+
- name: Download static site artifact
77+
uses: actions/download-artifact@v7
78+
with:
79+
name: enrollment-checker-out
80+
path: out/
81+
82+
- name: Configure AWS credentials
83+
uses: aws-actions/configure-aws-credentials@v6
84+
with:
85+
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
86+
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
87+
aws-region: ${{ env.AWS_REGION }}
88+
89+
- name: Sync to S3
90+
run: |
91+
aws s3 sync out/ "s3://${{ vars.ENROLLMENT_CHECKER_S3_BUCKET }}" \
92+
--delete
93+
94+
- name: Invalidate CloudFront cache
95+
run: |
96+
aws cloudfront create-invalidation \
97+
--distribution-id "${{ vars.ENROLLMENT_CHECKER_DISTRIBUTION_ID }}" \
98+
--paths "/*"

packages/design-system/design/scripts/generate-fonts.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import './load-env.js'
19-
import { readFileSync, writeFileSync, existsSync } from 'fs'
19+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
2020
import { join, dirname, relative } from 'path'
2121
import { fileURLToPath } from 'url'
2222

@@ -170,6 +170,7 @@ function main() {
170170
console.log(`✅ Found ${fonts.size} font(s): ${Array.from(fonts).join(', ')}`)
171171

172172
const fontsTs = generateFontsTs(fonts, state)
173+
mkdirSync(dirname(outputPath), { recursive: true })
173174
writeFileSync(outputPath, fontsTs, 'utf8')
174175

175176
console.log(`✅ Generated fonts.ts for ${state.toUpperCase()}`)

src/SEBT.EnrollmentChecker.Web/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ test-results/
6060
# Pa11y Accessibility Test Screenshots
6161
pa11y-screenshots/
6262

63+
# Generated Design Tokens (auto-generated from design/states/*.json)
64+
design/fonts.ts
65+
6366
# Generated i18n Locale Files
6467
content/locales/
6568
!content/locales/**/common.json

src/SEBT.EnrollmentChecker.Web/Dockerfile.ssg

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ ENV STATE=$STATE \
1818
NEXT_PUBLIC_PORTAL_URL=$NEXT_PUBLIC_PORTAL_URL \
1919
NEXT_PUBLIC_APPLICATION_URL=$NEXT_PUBLIC_APPLICATION_URL
2020

21+
# API routes are SSR-only proxies — remove them before static export
22+
RUN rm -rf src/SEBT.EnrollmentChecker.Web/src/app/api
23+
2124
RUN BUILD_STATIC=true pnpm --filter @sebt/enrollment-checker build
2225
# Output is in src/SEBT.EnrollmentChecker.Web/out/
2326
# Upload to S3: aws s3 sync src/SEBT.EnrollmentChecker.Web/out/ s3://your-bucket/

src/SEBT.EnrollmentChecker.Web/src/features/enrollment/api/checkEnrollment.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { http, HttpResponse } from 'msw'
2-
import { beforeEach, describe, expect, it } from 'vitest'
2+
import { describe, expect, it } from 'vitest'
33
import { server } from '../../../mocks/server'
44
import type { Child } from '../context/EnrollmentContext'
55
import { enrollmentCheckRequestSchema } from '../schemas/enrollmentSchema'

src/SEBT.EnrollmentChecker.Web/src/features/enrollment/components/ChildForm.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export function ChildForm({
7777
label={tCommon('labelFirstName')}
7878
value={values.firstName ?? ''}
7979
onChange={e => set('firstName', e.target.value)}
80-
error={errors.firstName}
80+
{...(errors.firstName && { error: errors.firstName })}
8181
isRequired
8282
hint={nameHint}
8383
/>
@@ -91,7 +91,7 @@ export function ChildForm({
9191
label={tCommon('labelLastName')}
9292
value={values.lastName ?? ''}
9393
onChange={e => set('lastName', e.target.value)}
94-
error={errors.lastName}
94+
{...(errors.lastName && { error: errors.lastName })}
9595
isRequired
9696
hint={nameHint}
9797
/>

src/SEBT.EnrollmentChecker.Web/src/features/enrollment/components/ChildFormPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function ChildFormPage({ showSchoolField, apiBaseUrl }: ChildFormPageProp
6767
<p className="usa-prose">{t('body')}</p>
6868
<p className="usa-hint">{t('requiredFields', { ns: 'common' })}</p>
6969
<ChildForm
70-
initialValues={editingChild}
70+
{...(editingChild && { initialValues: editingChild })}
7171
onSubmit={handleSubmit}
7272
onCancel={handleCancel}
7373
showSchoolField={showSchoolField}

src/SEBT.EnrollmentChecker.Web/src/features/enrollment/components/ChildReviewCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ interface ChildReviewCardProps {
1010

1111
/** Format ISO date (YYYY-MM-DD) as a locale-aware date string (e.g., "April 12, 2015"). */
1212
function formatBirthdate(dateOfBirth: string, locale: string): string {
13-
const [year, month, day] = dateOfBirth.split('-').map(Number)
14-
const date = new Date(year, month - 1, day)
13+
const parts = dateOfBirth.split('-').map(Number)
14+
const date = new Date(parts[0]!, parts[1]! - 1, parts[2]!)
1515
return date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' })
1616
}
1717

src/SEBT.EnrollmentChecker.Web/src/features/enrollment/components/ResultsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function ResultsPage({ results, applicationUrl }: ResultsPageProps) {
4343
firstName={child.firstName}
4444
lastName={child.lastName}
4545
displayStatus="error"
46-
errorMessage={child.statusMessage}
46+
{...(child.statusMessage !== undefined && { errorMessage: child.statusMessage })}
4747
/>
4848
))}
4949
</section>

src/SEBT.EnrollmentChecker.Web/src/features/enrollment/context/EnrollmentContext.tsx

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,22 @@ function loadFromStorage(): EnrollmentState {
7676
if (!raw) return initialState
7777
const parsed: unknown = JSON.parse(raw)
7878
const result = enrollmentStorageSchema.safeParse(parsed)
79-
return result.success ? result.data : initialState
79+
if (!result.success) return initialState
80+
return {
81+
editingChildId: result.data.editingChildId,
82+
children: result.data.children.map(parsedChild => {
83+
const child: Child = {
84+
id: parsedChild.id,
85+
firstName: parsedChild.firstName,
86+
lastName: parsedChild.lastName,
87+
dateOfBirth: parsedChild.dateOfBirth,
88+
...(parsedChild.middleName && { middleName: parsedChild.middleName }),
89+
...(parsedChild.schoolName && { schoolName: parsedChild.schoolName }),
90+
...(parsedChild.schoolCode && { schoolCode: parsedChild.schoolCode })
91+
}
92+
return child
93+
})
94+
}
8095
} catch {
8196
return initialState
8297
}
@@ -106,33 +121,36 @@ export function EnrollmentProvider({ children }: { children: ReactNode }) {
106121
const actions: EnrollmentActions = {
107122
addChild: (values) => update(s => {
108123
if (s.children.length >= MAX_CHILDREN) return s
109-
return {
110-
...s,
111-
children: [...s.children, {
124+
const child: Child = {
112125
id: uuidv4(),
113126
firstName: values.firstName,
114-
middleName: values.middleName,
115127
lastName: values.lastName,
116128
dateOfBirth: toDateOfBirth(values),
117-
schoolName: values.schoolName,
118-
schoolCode: values.schoolCode
119-
}]
120-
}}),
129+
...(values.middleName && { middleName: values.middleName }),
130+
...(values.schoolName && { schoolName: values.schoolName }),
131+
...(values.schoolCode && { schoolCode: values.schoolCode })
132+
}
133+
return { ...s, children: [...s.children, child] }
134+
}),
121135
updateChild: (id, values) => update(s => ({
122136
...s,
123-
children: s.children.map(c => c.id === id ? {
124-
id,
125-
firstName: values.firstName,
126-
middleName: values.middleName,
127-
lastName: values.lastName,
128-
dateOfBirth: toDateOfBirth(values),
129-
schoolName: values.schoolName,
130-
schoolCode: values.schoolCode
131-
} : c)
137+
children: s.children.map(child => {
138+
if (child.id !== id) return child
139+
const updated: Child = {
140+
id,
141+
firstName: values.firstName,
142+
lastName: values.lastName,
143+
dateOfBirth: toDateOfBirth(values),
144+
...(values.middleName && { middleName: values.middleName }),
145+
...(values.schoolName && { schoolName: values.schoolName }),
146+
...(values.schoolCode && { schoolCode: values.schoolCode })
147+
}
148+
return updated
149+
})
132150
})),
133151
removeChild: (id) => update(s => ({
134152
...s,
135-
children: s.children.filter(c => c.id !== id)
153+
children: s.children.filter(child => child.id !== id)
136154
})),
137155
setEditingChildId: (id) => update(s => ({ ...s, editingChildId: id })),
138156
clearState: () => {

0 commit comments

Comments
 (0)