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 expedited_screening, FPL limits from canopy-snap /v1/params?household_size=N, eligibility/benefit/detail from canopy-snap determinations. Pre-determination state correctly shows "INELIGIBLE — DENY" with explanation "No determination has been run for this application. Click Approve to trigger eligibility evaluation." — that’s the intentional pre-determination UX, not a bug. Verifications panel remains empty pending IEVS match results, tracked under snap-verification-ievs Steps 4-5 (worker discrepancy review endpoints + integration).

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

EligibilityClient

canopy-eligibility: fetch determination by application_id

PersonsClient

canopy-persons: household, members, income, expenses

RenewalsClient

canopy-renewals: active certification, due renewals, interim contacts

NoticesClient

canopy-notices: get notice by id, list notices for household

SnapClient

canopy-snap: ABAWD status, IEVS match flag

SecurityClient

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 &amp; 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

/cases

GET

Case search page (empty state); search results loaded via htmx

/cases/search

GET

htmx endpoint: returns search results partial for ?q= query string. Not a full page.

/cases/{household_id}

GET

Case detail page with household member tab pre-loaded server-side

/cases/{household_id}/tab/household

GET

htmx: household members tab partial

/cases/{household_id}/tab/income

GET

htmx: income and assets tab partial

/cases/{household_id}/tab/applications

GET

htmx: applications list tab partial

/cases/{household_id}/tab/certifications

GET

htmx: active SNAP certifications tab partial

/cases/{household_id}/tab/notices

GET

htmx: recent notices tab partial

/cases/{household_id}/tab/appeals

GET

htmx: appeals tab partial

/cases/{household_id}/tab/activity

GET

htmx: activity log tab partial

/applications/{id}/process

GET

Application processing: determination review, approve/deny form

/applications/{id}/approve

POST

Submit approval. Returns updated form fragment (htmx swap). Role: worker/supervisor only.

/applications/{id}/deny

POST

Submit denial with reason. Returns updated form fragment. Role: worker/supervisor only.

/notices/{id}

GET

Notice preview with 10-day compliance indicator and send button

/notices/{id}/send

POST

Send notice. Role: worker/supervisor only.

/notices

GET

Notice history for a household. Query param: ?household_id={uuid}.

/cases/{household_id}/abawd

GET

ABAWD 36-month tracking, exemption form, waiver area status

/cases/{household_id}/abawd/activity

POST

Record monthly ABAWD activity. Role: worker/supervisor only.

/cases/{household_id}/abawd/exemption

POST

Grant discretionary exemption. Role: supervisor only.

/renewals

GET

Renewal queue with expiry filter (30/60/90 days)

/renewals/{id}/interim-contact

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, store SessionData in 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/ via tower::ServiceExt in 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:

  1. Count of pending SNAP applications from canopy-eligibility

  2. Count of renewal certifications due within 30 days from canopy-renewals

  3. Count of overdue interim contacts from canopy-renewals

  4. 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.

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}:

  1. Fetches the household record from canopy-persons (required; 404 if not found)

  2. Fetches the active SNAP certification from canopy-renewals (optional; shown as "No active certification" if absent)

  3. Renders detail.html with the household header and the household members tab pre-loaded server-side

  4. Stores household_id as active_case_id in 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

/cases/{id}/tab/household

canopy-persons: household members, address, contact info

Income & Assets

/cases/{id}/tab/income

canopy-persons: income records, asset records

Applications

/cases/{id}/tab/applications

canopy-eligibility: list of eligibility requests for household

Certifications

/cases/{id}/tab/certifications

canopy-renewals: all certifications for household (including expired)

Notices

/cases/{id}/tab/notices

canopy-notices: recent 20 notices for household

Appeals

/cases/{id}/tab/appeals

canopy-appeals: open appeals for household

Activity Log

/cases/{id}/tab/activity

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:

  1. Fetch eligibility request from canopy-eligibility (by application_id)

  2. Fetch household from canopy-persons

  3. Fetch SNAP determination result from canopy-snap (if available)

  4. Fetch ruleset evaluation output from canopy-eligibility (the rules evaluation detail)

  5. Render process.html with 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 WorkerOrSupervisor extractor (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}:

  1. Fetch notice from canopy-notices

  2. Render the notice content (HTML or text, depending on notice type)

  3. 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)

  4. 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:

  1. Fetch ABAWD status from canopy-snap (includes 36-month window data, monthly activity records, exempt status)

  2. Fetch waiver area status from a configuration endpoint or static lookup based on household address county

  3. 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:

  1. Fetch certifications due for renewal from canopy-renewals (GET /v1/renewals/snap/due?days={n})

  2. Fetch overdue interim contacts from canopy-renewals

  3. 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

services/canopy-web/src/session.rs

New: SessionData struct, WorkerRole enum, session wiring helpers

services/canopy-web/src/auth.rs

New: /login, /auth/callback, /logout handlers; PKCE flow; JWT validation; JWKS rotation task

services/canopy-web/src/extractors.rs

New: AuthenticatedWorker and WorkerOrSupervisor Axum extractors

services/canopy-web/src/state.rs

New: AppState, KeycloakConfig, JWKS RwLock

services/canopy-web/src/clients/mod.rs

New: ServiceClients aggregate; one client struct per upstream service

services/canopy-web/src/handlers/dashboard.rs

New: dashboard handler with parallel upstream calls

services/canopy-web/src/handlers/cases.rs

New: case search, case detail, and all tab partial handlers

services/canopy-web/src/handlers/applications.rs

New: process page, approve, deny handlers

services/canopy-web/src/handlers/notices.rs

New: notice detail, send, and history handlers

services/canopy-web/src/handlers/abawd.rs

New: ABAWD detail, activity recording, exemption grant handlers

services/canopy-web/src/handlers/renewals.rs

New: renewal queue and interim contact completion handlers

services/canopy-web/src/router.rs

Updated: wire all routes; add session layer; add static file serving

services/canopy-web/src/main.rs

Updated: initialize AppState, PostgresStore, session layer, JWKS rotation task

services/canopy-web/src/errors.rs

New or updated: WebError enum; map upstream errors to HTML error pages

services/canopy-web/templates/base.html

New: base layout with nav, main, footer, htmx and Alpine.js script tags

services/canopy-web/templates/error.html

New: error page template

services/canopy-web/templates/dashboard.html

New: dashboard page template

services/canopy-web/templates/cases/search.html

New: case search page and search results partial

services/canopy-web/templates/cases/detail.html

New: case detail page with tab nav

services/canopy-web/templates/cases/tab_household.html

New: household members tab partial

services/canopy-web/templates/cases/tab_income.html

New: income and assets tab partial

services/canopy-web/templates/cases/tab_applications.html

New: applications list tab partial

services/canopy-web/templates/cases/tab_certifications.html

New: certifications tab partial

services/canopy-web/templates/cases/tab_notices.html

New: notices tab partial

services/canopy-web/templates/cases/tab_appeals.html

New: appeals tab partial (stub)

services/canopy-web/templates/cases/tab_activity.html

New: activity log tab partial

services/canopy-web/templates/applications/process.html

New: application processing page

services/canopy-web/templates/notices/detail.html

New: notice preview and send page

services/canopy-web/templates/notices/list.html

New: notice history page

services/canopy-web/templates/abawd/detail.html

New: ABAWD tracking and management page

services/canopy-web/templates/renewals/queue.html

New: renewal queue page

services/canopy-web/static/htmx.min.js

New: htmx library (vendor)

services/canopy-web/static/alpine.min.js

New: Alpine.js library (vendor)

services/canopy-web/config/abawd_waivers.toml

New: ABAWD waiver area configuration (county list)

Cargo.toml (canopy-web)

Add: tower-sessions, tower-sessions-sqlx-store, askama, jsonwebtoken, wiremock (dev-dep)

services/canopy-web/tests/auth.rs

New: auth flow integration tests

services/canopy-web/tests/handlers.rs

New: handler integration tests with wiremocked clients

e2e/tests/worker_portal.spec.ts

New: Playwright E2E tests for SNAP worker portal UI flows

Verification

  1. cargo nextest run --workspace --lib — unit tests pass (role extraction from JWT claims, JWKS rotation logic, denial reason code validation)

  2. cargo xtask dev start — devstack running; canopy-web at http://localhost:8080

  3. Navigate to http://localhost:8080/ without a session — browser redirects to Keycloak login

  4. Complete Keycloak login as a seeded worker user — redirect to dashboard; workqueue counts visible

  5. Dashboard search: type "test" in the search box — results appear without full page reload; URL does not change

  6. Navigate to /cases/{known_household_id} — page loads with household tab pre-rendered; other tabs load on click without full page reload

  7. Navigate to an application processing page — determination result and benefit amount displayed; Deny button opens Alpine.js modal

  8. Confirm denial with a reason selected — form submits via htmx; success fragment appears inline; no full page reload

  9. Login as auditor role user — navigate to /applications/{id}/approve; POST returns 403 Forbidden

  10. Navigate to /renewals?days=30 — renewal queue table shows; click "60 days" filter — table updates without full page reload

  11. Click "Mark Interim Contact Complete" on a renewal with interim contact due — row updates inline to show "Completed"

  12. cargo nextest run -p canopy-web — all integration tests pass

  13. 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

Edit this page · default