Plan: Worker Portal Program Action Handlers (Issue #392)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
TANF action bundle. Add 5 handlers in |
Done (2026-05-12) |
2 |
Medicaid action bundle. Same shape as Step 1: |
Done (2026-05-12) |
3 |
CAPS action bundle. |
Done (2026-05-12) |
4 |
WIC action bundle. |
Done (2026-05-12) |
5 |
Action-affordance wiring on existing tabs. |
Done (2026-05-12) |
6 |
Tests + docs. Playwright specs under |
Done (2026-05-12) |
Issue: #392
Branch: feat/worker-portal-program-action-handlers
Labels: type::feature, priority::medium, service::web, program::tanf, program::medicaid, program::caps, program::wic, workflow::ready
As-built deviations (2026-05-12):
-
Form structure: The plan’s example used
hx-getto fetch action forms into a#action-panetarget. As-built uses inline<details>-gated<form action="…" method="post">matching the existingtab_income.htmlprecedent. Reasons: (a) keeps the pattern consistent with the only other action-form surface in the worker portal, (b) avoids 20 GET handlers serving form HTML, (c) avoids introducing a new#action-panetarget on case detail. CSRF is still enforced at the router level bycsrf_middleware; forms carry the standard_csrfhidden field. -
Per-program handler files: split into 4 files (
actions_{tanf,medicaid,caps,wic}.rs) rather than fold into the SNAPactions.rs— the plan permitted either, and 5 handlers × 4 programs = 20 functions made the split cleaner. -
Playwright specs: deferred to follow-up #449. Most handlers' upstream endpoints don’t exist yet (tracked on #448); spec authorship pre-upstream would assert 4xx/5xx which isn’t a useful gate. Specs land once #448 is satisfied.
-
Upstream endpoint gaps: filed as #448 (10+ endpoints across canopy-renewals + canopy-tanf + canopy-medicaid + canopy-caps + canopy-wic). The BFF handlers surface upstream errors via the existing HTML error-fragment path until those endpoints land.
Context
services/canopy-web/src/api/actions.rs (266 lines) contains 5 SNAP-only caseworker action handlers (record_interim_contact, submit_change_report, record_abawd_activity, resolve_discrepancy, download_notice_pdf). Appeal filing has its own module at src/api/appeals.rs. The case-detail page (services/canopy-web/src/api/case_detail.rs:1239-1304’s `render_program_tab()) already dispatches to per-program tab renderers, and the per-program tab partials (templates/cases/tab_determination_{tanf,medicaid,caps,wic}.html, tab_authorization.html, tab_nutrition.html, tab_work_req.html, tab_time_limits.html, tab_categories.html) already render program data. What’s missing is action affordances on those tabs: htmx buttons that POST to handler routes that do not yet exist for TANF / Medicaid / CAPS / WIC.
This plan adds the 20 program-specific handlers + form templates, then adds htmx action buttons to the existing per-program tab partials so they wire through. The PDF download path stays SNAP-only because the form library only has SNAP NOAs registered (rulesets/georgia/notices/manifest.toml); TANF NOAs ship with #405’s Typst form work.
Code references
-
services/canopy-web/src/api/actions.rs:27-63—record_interim_contact(SNAP precedent for the handler shape); 266 lines total, 5 SNAP-only handlers (record_interim_contact,submit_change_report,record_abawd_activity,resolve_discrepancy,download_notice_pdf). -
services/canopy-web/src/api/appeals.rs:44-46—clients.with_service_identity(&svc_token).awaitprecedent for outbound auth. -
services/canopy-web/src/api/case_detail.rs:23-73—Programenum with all five variants. -
services/canopy-web/src/api/case_detail.rs:1239-1304—render_program_tab()dispatch point. -
services/canopy-web/templates/cases/tab_determination_{tanf,medicaid,caps,wic}.html,tab_authorization.html,tab_nutrition.html,tab_work_req.html,tab_time_limits.html,tab_categories.html— existing per-program tabs that need action affordances added. -
services/canopy-web/src/clients.rs—with_service_identity(&svc_token)helper (per ADR-019); reuse for all upstream calls. -
services/canopy-web/src/csrf.rs:46—csrf_middlewareapplied at the router layer; handlers do not extract a CSRF token.
Scope
In scope:
-
20 caseworker action handlers (4 programs × 5 handlers).
-
20 askama templates for the form bodies + htmx error fragments.
-
Action-affordance updates to existing per-program tab partials (no new tab templates).
-
20 Playwright specs.
-
Routing-table + services.md updates.
Out of scope:
-
PDF download for TANF / Medicaid / CAPS / WIC notices — depends on #405 / Typst form library expansion.
-
New domain endpoints in canopy-tanf / canopy-medicaid / canopy-caps / canopy-wic — this plan only adds BFF passthrough; if a target endpoint is missing, file a separate issue.
-
Re-architecting case-detail’s tab dispatch — the existing match arm in
render_program_tab()is the right shape; just add buttons to the existing per-program tab partials. -
New tab partial templates — the per-program tabs already exist; this plan adds action buttons to them.
Dependencies
-
Archived: worker-portal-snap.adoc (predecessor, referenced for SNAP handler shape; not reopened).
-
service-identity-and-on-behalf-of (#424 / ADR-019) —
clients.with_service_identity(&svc_token).awaitpattern reused; supersedes the earlierwith_fresh_tokenapproach from the archived bff-token-refresh plan. -
caps-provider-registry.adoc(#396) — graceful-fallback dependency forswitch_provider_caps; if not landed, the handler accepts any provider_id string.
Design
Each new handler follows this shape (modelled on actions.rs:27-63 record_interim_contact):
#[derive(Debug, Deserialize)]
pub struct FileAppealTanfForm {
pub household_id: String,
pub determination_id: String,
pub appeal_basis: String,
pub continued_benefits_requested: bool,
pub notes: Option<String>,
}
/// POST /actions/tanf/file-appeal
pub async fn file_appeal_tanf(
AuthenticatedWorker(worker): AuthenticatedWorker,
_write: WritePermission,
Extension(clients): Extension<Arc<ServiceClients>>,
Extension(svc_token): Extension<canopy_auth::ServiceTokenSource>,
axum::extract::Form(form): axum::extract::Form<FileAppealTanfForm>,
) -> Result<Redirect, Html<String>> {
let clients = clients.with_service_identity(&svc_token).await;
let body = serde_json::json!({
"determination_id": form.determination_id,
"appeal_basis": form.appeal_basis,
"continued_benefits_requested": form.continued_benefits_requested,
"notes": form.notes.unwrap_or_default(),
});
if let Err(e) = clients
.tanf
.post::<serde_json::Value, serde_json::Value>("/v1/appeals", &body)
.await
{
tracing::error!(error = %e, "failed to file TANF appeal");
return Err(Html(format!(
"<h1>Error</h1><p>Failed to file appeal: {e}</p>"
)));
}
tracing::info!(
household_id = %form.household_id,
worker = %worker.worker_name,
"TANF appeal filed"
);
Ok(Redirect::to(&format!("/cases/{}", form.household_id)))
}
CSRF is enforced at the router layer by csrf::csrf_middleware (services/canopy-web/src/csrf.rs:46); handlers do not extract a token. The middleware checks the X-CSRF-Token header (htmx) or the _csrf form field (standard forms) against the session-stored value.
Templates live alongside their handlers in templates/cases/actions/{tanf,medicaid,caps,wic}/. Each template carries the CSRF token via the _csrf hidden field, uses Orchard form components (<x-orchard-input>, <x-orchard-select>), and posts via hx-post with hx-swap="outerHTML".
Action affordances are added to the existing per-program tab partials. Example diff for templates/cases/tab_determination_tanf.html:
{# existing determination rendering above #}
<section class="orchard-action-list">
<a class="orchard-button" hx-get="/cases/{{ household_id }}/tanf/actions/file-appeal" hx-target="#action-pane">File Appeal</a>
<a class="orchard-button" hx-get="/cases/{{ household_id }}/tanf/actions/record-work-activity" hx-target="#action-pane">Record Work Activity</a>
<!-- … -->
</section>
No changes to the render_program_tab() dispatch arms — they already route to the right per-program tab renderers.
Files Touched
| File | Change |
|---|---|
|
Add 20 handler functions + their |
|
Add per-program file_appeal_* handlers (or fold into actions.rs if cleaner) |
|
Register the 20 new routes in the protected router |
|
Add htmx action-button sections to existing per-program tab partials |
|
20 form templates |
|
20 Playwright specs |
|
Update canopy-web route table |
|
|
|
This plan; moves to archive on merge |
Verification
-
cargo nextest run -p canopy-web --lib— handler unit tests pass. -
cargo xtask dev start— devstack healthy. -
cargo xtask e2e — worker-portal-tanf-actions worker-portal-medicaid-actions worker-portal-caps-actions worker-portal-wic-actions— all 20 new specs pass. -
Manual smoke: log in as caseworker, open a household with all 5 program enrolments, click each program tab, file an appeal in each, confirm the upstream service log shows the request landed.
-
cargo xtask validate— full battery green.
Documentation Updates
-
.claude/docs/services.md— extend canopy-web route table with the 20 new endpoints -
CHANGELOG.adoc— entry under== Unreleased/=== Added -
docs/modules/ROOT/pages/services/canopy-web.adoc— list per-program action coverage -
Plan archive: move this file to
plans/archive/post-merge