Plan: Worker Portal — SNAP Case Management (canopy-web)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Orchard theme system, base template, static assets (htmx/Alpine.js CDN+SRI with npm fallback) |
Done (2026-04-07) |
2 |
Session management, auth extractors (AuthenticatedWorker/WritePermission), 8 service clients |
Done (2026-04-07) |
3 |
Dashboard (/) — stats cards, work queue, activity feed |
Done (2026-04-07) |
4 |
Case search (/cases) — htmx live search with 300ms debounce |
Done (2026-04-07) |
5 |
Case detail (/cases/{household_id}) — 6 htmx tabs (Household, Income & Verify, Determination, Notices, Appeals, Activity) |
Done (2026-04-07) — (view-only; action buttons not wired — see Steps 11-17) |
6 |
Application processing (/applications/{id}/process) — approve/deny with Alpine.js modal |
Done (2026-04-26) — approve/deny POST handlers work; review page renders real data: applicant name + address from canopy-persons, household size + application date from canopy-applications, gross income from |
7 |
Renewal queue (/renewals) — 30/60/90 day filters, interim contact status |
Done (2026-04-07) — (view-only) |
8 |
Error pages (404/500), service error partial with retry, skeleton loading |
Done (2026-04-07) |
9 |
Integration tests (25 unit + 6 integration = 31 tests) |
Done (2026-04-07) |
10 |
Playwright E2E tests (59 tests) |
Done (2026-04-07) |
11 |
Fix application processing — wire rules engine result, FPL limits, verifications, net income into template |
Done (2026-04-07) — fetches from canopy-snap, renders eligible/benefit_amount/basis |
12 |
Appeal filing — POST /appeals/file calls canopy-appeals |
Done (2026-04-07) |
13 |
Interim contact recording — POST /actions/interim-contact calls canopy-renewals |
Done (2026-04-07) |
14 |
Change report submission — POST /actions/change-report calls canopy-renewals |
Done (2026-04-07) |
15 |
ABAWD activity recording — POST /actions/abawd-activity calls canopy-snap |
Done (2026-04-07) |
16 |
Verification discrepancy resolution — POST /actions/resolve-discrepancy calls canopy-snap |
Done (2026-04-07) |
17 |
Notice PDF download — GET /notices/{id}/pdf proxies from canopy-notices |
Done (2026-04-07) |
18 |
Address display — wire canopy-persons GET addresses endpoint into household tab |
Done (2026-04-07) |
Epic: &43
Branch: feature/worker-portal-snap
Context
canopy-web is the worker portal BFF (Backend For Frontend) serving caseworkers, supervisors, and QC reviewers at Georgia DHS. It is a server-side rendered application: Axum handles HTTP, Askama generates HTML from templates, htmx handles partial page updates, and Alpine.js manages lightweight client-side state (modals, show/hide). There is no JavaScript framework; the browser receives complete HTML fragments from the server.
As of the start of this plan, canopy-web has only /healthz and /metrics endpoints.
Session middleware is not yet wired, and there are no Askama templates.
This plan delivers the full SNAP caseworker workflow for UAT.
Workers are the primary UAT participants. The UI must be functional — correct data, correct workflows, no broken flows — but visual polish and full WCAG 2.1 AA compliance are deferred to a post-UAT plan. This plan does include semantic HTML structure (landmarks, labels, headings) as a baseline because it costs nothing to do correctly from the start and is required for compliance.
This plan depends on:
-
SNAP Eligibility — application processing page reads determination results from canopy-snap via canopy-eligibility
-
SNAP Renewals and Certification Period Management — renewal queue and certification detail data from canopy-renewals
-
Person and Household Data Model — household and income data from canopy-persons
-
canopy-notices — notice preview and history (separate plan; stub responses acceptable for UAT if not yet complete)
-
canopy-appeals — appeals tab on case detail (stub acceptable for UAT)
-
canopy-security — activity log tab on case detail (reads from security audit trail)
The session middleware plan (a prerequisite) must be delivered on this branch or as a merge prerequisite.
canopy-web must never use tower_sessions::MemoryStore.
Scope
In scope:
-
Session middleware wiring using
tower-sessions-sqlx-store(PostgreSQL-backed sessions, 8-hour sliding TTL) -
Keycloak OIDC redirect flow: unauthenticated requests redirect to Keycloak; post-login redirect to original URL
-
Role extraction from RS256 JWT:
canopy-snap-worker,canopy-snap-supervisor,canopy-snap-auditor -
Role-based access: auditor role is read-only (no approve/deny/send-notice actions)
-
Askama templates: base layout, all pages listed in the Steps section
-
htmx tab loading for case detail
-
htmx live search for case search (300ms debounce)
-
Alpine.js confirmation modals for destructive actions (deny application, terminate benefits)
-
Internal HTTP clients for canopy-eligibility, canopy-persons, canopy-renewals, canopy-notices, canopy-snap
-
All seven pages listed in the Design section
Out of scope:
-
Applicant portal (canopy-portal — separate plan)
-
TANF or Medicaid case management (later plans)
-
Advanced reporting views (canopy-reporting plan)
-
Full WCAG 2.1 AA compliance audit (post-UAT plan)
-
Case creation by workers (workers process submitted applications; they do not create cases)
-
Bulk actions (bulk approve, bulk terminate) — post-UAT
-
Print/PDF views of notices — canopy-notices handles that
Design
Session and Auth Architecture
Browser canopy-web Keycloak
| | |
|-- GET /cases -------->| |
| (no session) | |
|<-- 302 /login --------| |
| | |
|-- GET /login -------->| |
|<-- 302 Keycloak ------>| |
| | |
|-- GET /auth/callback --> |
| ?code=... |-- token exchange ---->|
| |<-- id_token, access --|
| | token |
| |-- validate RS256 JWT |
| |-- store worker_id, |
| | role in session |
|<-- 302 /cases (orig) --| |
Session data stored in PostgreSQL via tower-sessions-sqlx-store.
Session cookie: HttpOnly, SameSite=Lax, Secure in production.
TTL: 8 hours, sliding (each request extends the session).
SessionData struct stored in the session:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionData {
pub worker_id: Uuid,
pub worker_name: String,
pub role: WorkerRole,
pub active_case_id: Option<Uuid>, // last viewed household_id
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum WorkerRole {
SnapWorker,
SnapSupervisor,
SnapAuditor,
}
A RequireAuth extractor middleware reads the session, returns 302 to /login if absent, and injects SessionData into the handler.
A RequireWorkerOrSupervisor extractor additionally rejects SnapAuditor role on write endpoints (POST routes for approve/deny/send) with a 403.
AppState
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-web/src/state.rs
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub session_store: SqliteStore, // actually PostgresStore
pub clients: Arc<ServiceClients>,
pub keycloak: Arc<KeycloakConfig>,
}
pub struct KeycloakConfig {
pub base_url: String,
pub realm: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub jwks: Arc<RwLock<JwkSet>>, // rotated via background task
}
JWKS rotation: a background tokio::task fetches the Keycloak JWKS endpoint every 30 minutes and updates jwks in the Arc<RwLock<JwkSet>>.
All JWT validation uses the current JWKS.
If JWKS fetch fails, the previous JWKS remains in use (never clear the cache on error).
Internal HTTP Clients
One client per upstream service, following the pattern established in other canopy services.
All use a shared reqwest::Client with 10-second timeout.
Configured from environment variables.
| Client | Upstream service |
|---|---|
|
canopy-eligibility: fetch determination by application_id |
|
canopy-persons: household, members, income, expenses |
|
canopy-renewals: active certification, due renewals, interim contacts |
|
canopy-notices: get notice by id, list notices for household |
|
canopy-snap: ABAWD status, IEVS match flag |
|
canopy-security: activity log for household (audit events) |
Askama Template Structure
services/canopy-web/templates/
├── base.html — base layout: <html>, nav bar, header, footer, Alpine.js/htmx scripts
├── error.html — error page (404, 403, 500)
├── dashboard.html — extends base.html: workqueue, search, alerts
├── cases/
│ ├── search.html — case search with htmx live results
│ ├── detail.html — case detail: tab nav + htmx tab content area
│ ├── tab_household.html — household members partial (htmx target)
│ ├── tab_income.html — income/assets partial
│ ├── tab_applications.html — applications list partial
│ ├── tab_certifications.html — active SNAP certifications partial
│ ├── tab_notices.html — recent notices partial
│ ├── tab_appeals.html — open appeals partial
│ └── tab_activity.html — activity log partial
├── applications/
│ └── process.html — application processing: determination review, approve/deny
├── notices/
│ ├── detail.html — notice preview + send button + 10-day compliance indicator
│ └── list.html — notice history for household
├── abawd/
│ └── detail.html — 36-month tracking window, exemption form, waiver status
└── renewals/
└── queue.html — renewal queue with expiry filter
All templates must extend base.html using Askama’s {% extends "base.html" %} syntax.
Tab partials (tab_*.html) do NOT extend base.html; they are bare HTML fragments loaded by htmx into the tab content <div>.
htmx Patterns
Tab switching in case detail:
<nav role="tablist" aria-label="Case sections">
<button role="tab" aria-selected="true" aria-controls="tab-content"
hx-get="/cases/{{ household_id }}/tab/household"
hx-target="#tab-content"
hx-push-url="false">
Household
</button>
<button role="tab" aria-selected="false" aria-controls="tab-content"
hx-get="/cases/{{ household_id }}/tab/income"
hx-target="#tab-content"
hx-push-url="false">
Income & Assets
</button>
<!-- ... other tabs ... -->
</nav>
<div id="tab-content" role="tabpanel">
<!-- initial content loaded server-side; htmx swaps on tab click -->
</div>
Live search with debounce:
<input type="search"
name="q"
placeholder="Name, SSN (last 4), DOB, Application ID..."
hx-get="/cases/search"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#search-results"
hx-indicator="#search-spinner"
autocomplete="off"
aria-label="Search cases">
<span id="search-spinner" class="htmx-indicator" aria-live="polite">Searching...</span>
<div id="search-results" role="region" aria-label="Search results">
</div>
Form submission with inline error swap:
<form hx-post="/applications/{{ app_id }}/approve"
hx-swap="outerHTML"
hx-target="this">
<button type="submit"
hx-confirm="Approve this application? This action cannot be undone."
class="btn btn-approve">
Approve
</button>
</form>
Loading indicator on slow API calls:
<div hx-get="/cases/{{ household_id }}/tab/activity"
hx-trigger="load"
hx-indicator="#activity-spinner">
<span id="activity-spinner" class="htmx-indicator">Loading activity log...</span>
</div>
Alpine.js Patterns
Confirmation modal for deny action:
<div x-data="{ showDenyModal: false, denialReason: '' }">
<button @click="showDenyModal = true" class="btn btn-deny">Deny</button>
<div x-show="showDenyModal"
x-transition
role="dialog"
aria-modal="true"
aria-labelledby="deny-dialog-title">
<h2 id="deny-dialog-title">Confirm Denial</h2>
<label for="denial-reason">Denial Reason (required)</label>
<select id="denial-reason" x-model="denialReason" required>
<option value="">-- Select reason --</option>
<option value="income_exceeds_limit">Income exceeds gross income limit</option>
<option value="assets_exceed_limit">Assets exceed asset limit</option>
<option value="failed_to_provide_verification">Failed to provide required verification</option>
<option value="not_eligible_categorical">Not categorically eligible</option>
<!-- ... more regulatory basis codes ... -->
</select>
<button @click="showDenyModal = false">Cancel</button>
<button hx-post="/applications/{{ app_id }}/deny"
hx-vals="js:{denial_reason: denialReason}"
hx-swap="outerHTML"
hx-target="closest form"
:disabled="!denialReason">
Confirm Denial
</button>
</div>
</div>
Page Inventory
| Route | Method | Description |
|---|---|---|
|
GET |
Dashboard: workqueue counts (pending applications, overdue renewals, pending adverse actions), search widget, ABAWD alerts |
|
GET |
Case search page (empty state); search results loaded via htmx |
|
GET |
htmx endpoint: returns search results partial for |
|
GET |
Case detail page with household member tab pre-loaded server-side |
|
GET |
htmx: household members tab partial |
|
GET |
htmx: income and assets tab partial |
|
GET |
htmx: applications list tab partial |
|
GET |
htmx: active SNAP certifications tab partial |
|
GET |
htmx: recent notices tab partial |
|
GET |
htmx: appeals tab partial |
|
GET |
htmx: activity log tab partial |
|
GET |
Application processing: determination review, approve/deny form |
|
POST |
Submit approval. Returns updated form fragment (htmx swap). Role: worker/supervisor only. |
|
POST |
Submit denial with reason. Returns updated form fragment. Role: worker/supervisor only. |
|
GET |
Notice preview with 10-day compliance indicator and send button |
|
POST |
Send notice. Role: worker/supervisor only. |
|
GET |
Notice history for a household. Query param: |
|
GET |
ABAWD 36-month tracking, exemption form, waiver area status |
|
POST |
Record monthly ABAWD activity. Role: worker/supervisor only. |
|
POST |
Grant discretionary exemption. Role: supervisor only. |
|
GET |
Renewal queue with expiry filter (30/60/90 days) |
|
POST |
Mark interim contact complete. Role: worker/supervisor only. |
Steps
Step 1: Session Middleware and Keycloak Auth Flow
Files: services/canopy-web/src/session.rs, services/canopy-web/src/auth.rs, services/canopy-web/src/extractors.rs
Wire tower-sessions-sqlx-store in main.rs.
The session store requires a sessions table in the canopy-web PostgreSQL database.
Run the tower-sessions-sqlx migration on startup:
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-web/src/main.rs (session wiring excerpt)
use tower_sessions::{SessionManagerLayer, Expiry};
use tower_sessions_sqlx_store::PostgresStore;
use time::Duration;
let session_store = PostgresStore::new(pool.clone());
session_store.migrate().await
.expect("session store migration failed");
let session_layer = SessionManagerLayer::new(session_store.clone())
.with_secure(cfg.is_production)
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_http_only(true)
.with_expiry(Expiry::OnInactivity(Duration::hours(8)));
Auth handlers in auth.rs:
-
GET /login— build Keycloak authorization URL with state parameter (CSRF token stored in cookie) and redirect -
GET /auth/callback— exchange code for tokens, validate RS256 JWT against JWKS, extract claims, storeSessionDatain session, redirect to original URL (stored in state parameter) -
GET /logout— clear session, redirect to Keycloak logout endpoint
RequireAuth extractor in extractors.rs:
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-web/src/extractors.rs
pub struct AuthenticatedWorker(pub SessionData);
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedWorker
where
S: Send + Sync,
{
type Rejection = Redirect;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let session = Session::from_request_parts(parts, state)
.await
.map_err(|_| Redirect::to("/login"))?;
let session_data: Option<SessionData> = session
.get("worker")
.await
.map_err(|_| Redirect::to("/login"))?;
session_data
.map(AuthenticatedWorker)
.ok_or_else(|| Redirect::to("/login"))
}
}
/// Rejects auditors from write endpoints.
pub struct WorkerOrSupervisor(pub SessionData);
#[axum::async_trait]
impl<S> FromRequestParts<S> for WorkerOrSupervisor
where
S: Send + Sync,
{
type Rejection = (StatusCode, Html<String>);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let AuthenticatedWorker(data) = AuthenticatedWorker::from_request_parts(parts, state)
.await
.map_err(|r| (StatusCode::FOUND, Html(r.into_response().to_string())))?;
if data.role == WorkerRole::SnapAuditor {
return Err((StatusCode::FORBIDDEN, Html(
"<h1>403 Forbidden</h1><p>Auditor role cannot perform this action.</p>".into()
)));
}
Ok(WorkerOrSupervisor(data))
}
}
Step 2: AppState and Base Template
Files: services/canopy-web/src/state.rs, services/canopy-web/src/clients/mod.rs, services/canopy-web/templates/base.html, services/canopy-web/templates/error.html
base.html provides the outer shell used by all full-page templates.
It must include:
-
<html lang="en">with a<head>containing charset, viewport, title block -
<nav>with links to: Dashboard, Case Search, Renewals Queue, and (supervisor only) Admin menu -
<main id="main-content">landmark wrapping the page content block -
<footer>with agency name and accessibility statement link placeholder -
htmx script tag:
<script src="/static/htmx.min.js" defer></script> -
Alpine.js script tag:
<script src="/static/alpine.min.js" defer></script> -
Static file serving for
/static/viatower::ServiceExtin the router
Askama template inheritance example:
{# templates/dashboard.html #}
{% extends "base.html" %}
{% block title %}Dashboard — Canopy Worker Portal{% endblock %}
{% block content %}
<h1>Worker Dashboard</h1>
<section aria-label="Workqueue summary">
<!-- ... workqueue stats ... -->
</section>
{% endblock %}
The error.html template is used by the global error handler.
It extends base.html and accepts status_code: u16 and message: String template variables.
Step 3: Dashboard
Files: services/canopy-web/src/handlers/dashboard.rs, services/canopy-web/templates/dashboard.html
The dashboard handler fetches:
-
Count of pending SNAP applications from canopy-eligibility
-
Count of renewal certifications due within 30 days from canopy-renewals
-
Count of overdue interim contacts from canopy-renewals
-
Count of pending adverse actions (certifications with adverse_action_triggered but not yet terminated) from canopy-renewals
These are four parallel tokio::join! calls to avoid sequential latency.
If any upstream call fails, render the count as "—" with a warning indicator rather than failing the whole page.
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-web/src/handlers/dashboard.rs
pub async fn dashboard(
AuthenticatedWorker(session): AuthenticatedWorker,
State(state): State<AppState>,
) -> Result<Html<String>, WebError> {
let (pending_apps, renewals_due, interim_overdue, adverse_actions) = tokio::join!(
state.clients.eligibility.count_pending_snap_applications(),
state.clients.renewals.count_due_within_days(30),
state.clients.renewals.count_overdue_interim_contacts(),
state.clients.renewals.count_pending_adverse_actions(),
);
let tmpl = DashboardTemplate {
worker_name: session.worker_name.clone(),
role: session.role.clone(),
pending_apps: pending_apps.unwrap_or(None),
renewals_due: renewals_due.unwrap_or(None),
interim_overdue: interim_overdue.unwrap_or(None),
adverse_actions: adverse_actions.unwrap_or(None),
};
Ok(Html(tmpl.render().map_err(WebError::Template)?))
}
The template renders each count in a card layout.
Each card links to the relevant queue (e.g., pending applications → /cases?filter=pending, renewals due → /renewals?days=30).
The search widget in the dashboard is an <input> with hx-get="/cases/search" — same live search as the full case search page.
Step 4: Case Search
Files: services/canopy-web/src/handlers/cases.rs, services/canopy-web/templates/cases/search.html
The search handler at GET /cases/search?q= queries canopy-persons for matches.
Search supports: name (first + last), last 4 of SSN, date of birth (YYYY-MM-DD), application ID (UUID), and household ID (UUID).
canopy-persons is responsible for the search index; canopy-web passes the query string through.
pub async fn case_search_results(
AuthenticatedWorker(_session): AuthenticatedWorker,
State(state): State<AppState>,
Query(params): Query<SearchQuery>,
) -> Result<Html<String>, WebError> {
let q = params.q.trim();
if q.len() < 2 {
// Return empty partial for very short queries to avoid unnecessary upstream calls.
return Ok(Html("<p>Enter at least 2 characters to search.</p>".into()));
}
let results = state.clients.persons
.search_households(q)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "persons search failed");
vec![]
});
let tmpl = CaseSearchResultsTemplate { results };
Ok(Html(tmpl.render().map_err(WebError::Template)?))
}
The results partial renders a <table> with columns: Household ID (last 8 chars displayed, full UUID as link), Head of Household name, Case number, Active programs, Status.
Each row links to /cases/{household_id}.
The full GET /cases page renders the search shell (input + empty results div).
The input has hx-get="/cases/search" with hx-trigger="keyup changed delay:300ms, search" and hx-target="#search-results".
Step 5: Case Detail
Files: services/canopy-web/src/handlers/cases.rs (continued), services/canopy-web/templates/cases/detail.html, services/canopy-web/templates/cases/tab_*.html
The case detail page at GET /cases/{household_id}:
-
Fetches the household record from canopy-persons (required; 404 if not found)
-
Fetches the active SNAP certification from canopy-renewals (optional; shown as "No active certification" if absent)
-
Renders
detail.htmlwith the household header and the household members tab pre-loaded server-side -
Stores
household_idasactive_case_idin the session
Each tab is also available as a separate partial endpoint (GET /cases/{household_id}/tab/{tab_name}).
Tab partials are loaded by htmx on tab button click.
The initial page load pre-renders the household tab to avoid a flash of empty content.
Tab content sources:
| Tab | htmx endpoint | Upstream data source |
|---|---|---|
Household |
|
canopy-persons: household members, address, contact info |
Income & Assets |
|
canopy-persons: income records, asset records |
Applications |
|
canopy-eligibility: list of eligibility requests for household |
Certifications |
|
canopy-renewals: all certifications for household (including expired) |
Notices |
|
canopy-notices: recent 20 notices for household |
Appeals |
|
canopy-appeals: open appeals for household |
Activity Log |
|
canopy-security: recent 50 events for household_id |
For tabs where the upstream service is not yet fully implemented (appeals, notices), render a stub message ("No data available — {service} not yet connected") rather than an error.
Step 6: Application Processing
Files: services/canopy-web/src/handlers/applications.rs, services/canopy-web/templates/applications/process.html
The application processing page at GET /applications/{id}/process:
-
Fetch eligibility request from canopy-eligibility (by application_id)
-
Fetch household from canopy-persons
-
Fetch SNAP determination result from canopy-snap (if available)
-
Fetch ruleset evaluation output from canopy-eligibility (the rules evaluation detail)
-
Render
process.htmlwith all context
The template displays:
-
Household summary: size, members, head of household name
-
Income summary: gross monthly income, deductions applied, net monthly income (from determination)
-
Eligibility result: approved/denied/pending, benefit amount, basis text from ruleset
-
IEVS discrepancy section: if IEVS match status is "discrepancy found" (from canopy-snap), show a highlighted warning box with the discrepancy indicator (not the IEVS data itself — just the flag)
-
Expedited eligibility indicator: shown if application is flagged expedited; includes the 7-day processing deadline
-
Approve/Deny/Pend action area: buttons with Alpine.js confirmation modals
The approve form:
<form hx-post="/applications/{{ app.id }}/approve"
hx-target="#action-result"
hx-swap="innerHTML">
<button type="submit"
class="btn btn-approve"
hx-confirm="Approve this SNAP application? This cannot be undone.">
Approve Application
</button>
</form>
The deny action uses Alpine.js for the modal (denial reason must be selected before submit is enabled):
<div x-data="{ open: false, reason: '' }">
<button @click="open = true" class="btn btn-deny">Deny Application</button>
<div x-show="open" x-transition role="dialog" aria-modal="true">
<h3>Select Denial Reason</h3>
<select x-model="reason" required>
{% for code in denial_reason_codes %}
<option value="{{ code.value }}">{{ code.label }}</option>
{% endfor %}
</select>
<button @click="open = false">Cancel</button>
<button hx-post="/applications/{{ app.id }}/deny"
hx-vals="js:{denial_reason: reason}"
hx-target="#action-result"
hx-swap="innerHTML"
:disabled="reason === ''">
Confirm Denial
</button>
</div>
</div>
<div id="action-result" aria-live="polite"></div>
Denial reason codes (denial_reason_codes) are loaded from a static list in the handler, not from a database.
They map to the regulatory basis codes required by 7 CFR 273.13 (denial notice requirements).
The POST /applications/{id}/approve and POST /applications/{id}/deny handlers:
-
Require
WorkerOrSupervisorextractor (auditors get 403) -
Call canopy-eligibility to update application status
-
Return an HTML fragment (not a redirect) for htmx inline swap:
-
On success: green confirmation message with determination_id and timestamp
-
On error: red error message with RFC 9457 problem detail extracted from upstream response
-
Step 7: Notice Review
Files: services/canopy-web/src/handlers/notices.rs, services/canopy-web/templates/notices/detail.html, services/canopy-web/templates/notices/list.html
The notice detail page at GET /notices/{id}:
-
Fetch notice from canopy-notices
-
Render the notice content (HTML or text, depending on notice type)
-
Show a 10-day advance notice compliance indicator: a green/red badge based on whether
(notice_send_date + 10 days) >= adverse_action_effective_date(data from canopy-notices) -
Show a "Send Notice" button if not yet sent; show "Sent on {date}" if already sent
The send button:
{% if !notice.sent %}
<form hx-post="/notices/{{ notice.id }}/send"
hx-target="#notice-status"
hx-swap="innerHTML"
hx-confirm="Send this notice to the household? This cannot be undone.">
<button type="submit" class="btn btn-send">Send Notice</button>
</form>
{% else %}
<p class="status-sent">Sent on {{ notice.sent_at | date(format="%B %-d, %Y") }}</p>
{% endif %}
<div id="notice-status" aria-live="polite"></div>
The notice history page at GET /notices?household_id={uuid} shows a table of all notices for the household: type, date sent, delivery method, status (sent/pending/failed).
Step 8: ABAWD Management
Files: services/canopy-web/src/handlers/abawd.rs, services/canopy-web/templates/abawd/detail.html
The ABAWD page at GET /cases/{household_id}/abawd:
-
Fetch ABAWD status from canopy-snap (includes 36-month window data, monthly activity records, exempt status)
-
Fetch waiver area status from a configuration endpoint or static lookup based on household address county
-
Render
detail.html
The 36-month tracking window is rendered as a 36-cell grid: one cell per month, colored:
-
Green: countable qualifying activity (work 80+ hours/month, job training, etc.)
-
Yellow: exempt month (illness, caring for incapacitated person, etc.)
-
Gray: non-participating (counts against 3-month clock)
-
Empty: before ABAWD clock began
The monthly activity recording form uses htmx:
<form hx-post="/cases/{{ household_id }}/abawd/activity"
hx-target="#abawd-grid"
hx-swap="outerHTML">
<label for="activity-month">Month</label>
<input type="month" id="activity-month" name="month" required>
<label for="activity-type">Activity Type</label>
<select id="activity-type" name="activity_type">
<option value="employment">Employment (80+ hrs)</option>
<option value="job_training">Job Training Program</option>
<option value="work_program">SNAP E&T or other work program</option>
<option value="exempt_medical">Exempt — Medical condition</option>
<option value="exempt_caring">Exempt — Caring for incapacitated person</option>
<option value="exempt_other">Exempt — Other qualifying reason</option>
<option value="non_participating">Non-participating</option>
</select>
<label for="activity-notes">Notes (optional)</label>
<textarea id="activity-notes" name="notes" rows="2"></textarea>
<button type="submit" class="btn btn-primary">Record Activity</button>
</form>
The exemption grant form is shown only to SnapSupervisor role.
It allows granting a discretionary exemption for up to 12 months with a required reason field.
The waiver area indicator shows whether the household’s county is currently under an ABAWD waiver (automatic exemption from the 3-month time limit for all ABAWDs in a waived area).
Waiver data is loaded from a static configuration file (abawd_waivers.toml) that is updated when FNS grants or terminates Georgia county waivers.
Step 9: Renewal Queue
Files: services/canopy-web/src/handlers/renewals.rs, services/canopy-web/templates/renewals/queue.html
The renewal queue at GET /renewals:
-
Fetch certifications due for renewal from canopy-renewals (
GET /v1/renewals/snap/due?days={n}) -
Fetch overdue interim contacts from canopy-renewals
-
Render
queue.html
The page has a filter bar with three buttons (30 days, 60 days, 90 days) that use htmx to reload the table:
<div role="group" aria-label="Filter by days until expiration">
<button hx-get="/renewals?days=30"
hx-target="#renewal-table"
hx-swap="outerHTML"
aria-pressed="{{ days == 30 }}">
Due within 30 days
</button>
<button hx-get="/renewals?days=60"
hx-target="#renewal-table"
hx-swap="outerHTML"
aria-pressed="{{ days == 60 }}">
Due within 60 days
</button>
<button hx-get="/renewals?days=90"
hx-target="#renewal-table"
hx-swap="outerHTML"
aria-pressed="{{ days == 90 }}">
Due within 90 days
</button>
</div>
<div id="renewal-table">
<!-- table rendered server-side; replaced by htmx on filter click -->
</div>
The renewal table columns: Household ID (link to case detail), Head of Household name, Certification end date, Days remaining, Interim contact status (Complete / Due {date} / Overdue), Actions.
The "Actions" column contains:
-
"View Case" link →
/cases/{household_id} -
"Mark Interim Contact Complete" button (htmx POST to
/renewals/{id}/interim-contact) — shown only if interim contact is due and not yet completed
The POST /renewals/{id}/interim-contact handler calls canopy-renewals and returns an updated table row fragment for inline htmx swap.
Step 10: Integration and E2E Tests
Files: services/canopy-web/tests/auth.rs, services/canopy-web/tests/handlers.rs, e2e/tests/worker_portal.spec.ts
Integration tests use testcontainers-rs for PostgreSQL (session store) and wiremock for all upstream service clients.
A test helper function make_test_app() builds the Axum router with a real PostgreSQL session store and wiremocked clients.
Key integration test scenarios:
// tests/auth.rs
#[tokio::test]
async fn test_unauthenticated_request_redirects_to_login() {
// GET / without session → 302 to /login
}
#[tokio::test]
async fn test_auditor_cannot_post_approve() {
// Set session with role=SnapAuditor
// POST /applications/{id}/approve → 403
}
#[tokio::test]
async fn test_authenticated_worker_sees_dashboard() {
// Set valid worker session; mock upstream counts to return values
// GET / → 200; response contains dashboard heading
}
// tests/handlers.rs
#[tokio::test]
async fn test_case_search_returns_results_partial() {
// Mock canopy-persons search to return 2 households
// GET /cases/search?q=smith → 200; response is an HTML table fragment (no <html> tag)
}
#[tokio::test]
async fn test_case_search_short_query_returns_prompt() {
// GET /cases/search?q=s → 200; "Enter at least 2 characters" text
}
#[tokio::test]
async fn test_case_detail_404_for_unknown_household() {
// Mock canopy-persons to return 404
// GET /cases/{random_uuid} → 404
}
#[tokio::test]
async fn test_approve_application_calls_eligibility() {
// Mock canopy-eligibility approve endpoint to return success
// POST /applications/{id}/approve (worker session) → 200; success fragment
}
#[tokio::test]
async fn test_deny_without_reason_returns_error() {
// POST /applications/{id}/deny with empty denial_reason → 422; error fragment
}
Playwright E2E tests in e2e/tests/worker_portal.spec.ts require the devstack to be running with seed data.
They cover the full UI flows that integration tests cannot:
-
Login redirect: navigating to
/without a session opens the Keycloak login page -
Live search: typing in the search box triggers a results update without full page reload
-
Tab switching: clicking a tab loads the correct partial content
-
Confirmation modal: clicking Deny opens the Alpine.js modal; Cancel closes it; Confirm with a reason submits the form
-
Renewal queue filter: clicking the 30-day button updates the table
Files Touched
| File | Change |
|---|---|
|
New: SessionData struct, WorkerRole enum, session wiring helpers |
|
New: /login, /auth/callback, /logout handlers; PKCE flow; JWT validation; JWKS rotation task |
|
New: AuthenticatedWorker and WorkerOrSupervisor Axum extractors |
|
New: AppState, KeycloakConfig, JWKS RwLock |
|
New: ServiceClients aggregate; one client struct per upstream service |
|
New: dashboard handler with parallel upstream calls |
|
New: case search, case detail, and all tab partial handlers |
|
New: process page, approve, deny handlers |
|
New: notice detail, send, and history handlers |
|
New: ABAWD detail, activity recording, exemption grant handlers |
|
New: renewal queue and interim contact completion handlers |
|
Updated: wire all routes; add session layer; add static file serving |
|
Updated: initialize AppState, PostgresStore, session layer, JWKS rotation task |
|
New or updated: WebError enum; map upstream errors to HTML error pages |
|
New: base layout with nav, main, footer, htmx and Alpine.js script tags |
|
New: error page template |
|
New: dashboard page template |
|
New: case search page and search results partial |
|
New: case detail page with tab nav |
|
New: household members tab partial |
|
New: income and assets tab partial |
|
New: applications list tab partial |
|
New: certifications tab partial |
|
New: notices tab partial |
|
New: appeals tab partial (stub) |
|
New: activity log tab partial |
|
New: application processing page |
|
New: notice preview and send page |
|
New: notice history page |
|
New: ABAWD tracking and management page |
|
New: renewal queue page |
|
New: htmx library (vendor) |
|
New: Alpine.js library (vendor) |
|
New: ABAWD waiver area configuration (county list) |
|
Add: tower-sessions, tower-sessions-sqlx-store, askama, jsonwebtoken, wiremock (dev-dep) |
|
New: auth flow integration tests |
|
New: handler integration tests with wiremocked clients |
|
New: Playwright E2E tests for SNAP worker portal UI flows |
Verification
-
cargo nextest run --workspace --lib— unit tests pass (role extraction from JWT claims, JWKS rotation logic, denial reason code validation) -
cargo xtask dev start— devstack running; canopy-web athttp://localhost:8080 -
Navigate to
http://localhost:8080/without a session — browser redirects to Keycloak login -
Complete Keycloak login as a seeded worker user — redirect to dashboard; workqueue counts visible
-
Dashboard search: type "test" in the search box — results appear without full page reload; URL does not change
-
Navigate to
/cases/{known_household_id}— page loads with household tab pre-rendered; other tabs load on click without full page reload -
Navigate to an application processing page — determination result and benefit amount displayed; Deny button opens Alpine.js modal
-
Confirm denial with a reason selected — form submits via htmx; success fragment appears inline; no full page reload
-
Login as auditor role user — navigate to
/applications/{id}/approve; POST returns 403 Forbidden -
Navigate to
/renewals?days=30— renewal queue table shows; click "60 days" filter — table updates without full page reload -
Click "Mark Interim Contact Complete" on a renewal with interim contact due — row updates inline to show "Completed"
-
cargo nextest run -p canopy-web— all integration tests pass -
cargo xtask e2e— all Playwright tests pass against devstack
Documentation Updates
-
.claude/docs/services.md— update canopy-web row: all route table, session middleware status changed to "wired", upstream service dependencies listed -
CHANGELOG.adoc— entry under== Unreleased: "Add SNAP caseworker portal with dashboard, case search, case detail, application processing, ABAWD management, and renewal queue" -
docs/modules/ROOT/pages/architecture.adoc— add canopy-web BFF diagram showing htmx/Askama rendering pattern and upstream service connections -
.claude/docs/local-dev.md— add worker portal UAT login instructions; Keycloak seed user credentials for caseworker, supervisor, and auditor roles -
docs/modules/ROOT/pages/uat-guide.adoc(new, Tier 3) — UAT scenario walkthrough for SNAP caseworkers: application intake → processing → approval → renewal queue