Plan: Worker Portal Program Action Handlers (Issue #392)

On this page

Status

Step Description Status

1

TANF action bundle. Add 5 handlers in services/canopy-web/src/api/actions.rs (or split into actions/tanf.rs if file grows beyond ~500 lines): file_appeal_tanf, record_interim_contact_tanf, submit_change_report_tanf, record_work_activity_tanf, resolve_discrepancy_tanf. Each takes a Form<…Form> request matching the existing SNAP pattern at actions.rs:27-63, calls the appropriate canopy-tanf endpoint via clients.with_service_identity(&svc_token).await (per ADR-019), returns Result<Redirect, Html<String>> (htmx-error fragment on failure). CSRF is enforced by router-level csrf::csrf_middleware, not a per-handler extractor. Add 5 askama templates under services/canopy-web/templates/cases/actions/tanf/ matching the SNAP form templates' structure.

Done (2026-05-12)

2

Medicaid action bundle. Same shape as Step 1: file_appeal_medicaid, record_interim_contact_medicaid, submit_change_report_medicaid, ingest_cmd_update_medicaid, resolve_quarantined_determination_medicaid. Calls flow through clients::Medicaid. The resolve_quarantined_determination_medicaid handler proxies to canopy-medicaid’s existing requeue endpoint (the orchestrator’s quarantine path lives in services/canopy-eligibility/src/orchestrator.rs:464-503); this handler triggers re-determination after operator review.

Done (2026-05-12)

3

CAPS action bundle. file_appeal_caps, record_interim_contact_caps, submit_change_report_caps, update_authorization_caps, switch_provider_caps. Calls flow through clients::Caps. switch_provider_caps validates the new provider_id against canopy-caps’s provider registry (introduced in #396 / caps-provider-registry.adoc); if that plan has not landed yet, accept any string and let canopy-caps reject (degrade gracefully).

Done (2026-05-12)

4

WIC action bundle. file_appeal_wic, record_interim_contact_wic, submit_change_report_wic, schedule_certification_appointment_wic, record_nutritional_risk_wic. Calls flow through clients::Wic. record_nutritional_risk_wic posts to canopy-wic’s nutritional-risk endpoint (per the canopy-wic service’s existing API surface).

Done (2026-05-12)

5

Action-affordance wiring on existing tabs. services/canopy-web/templates/cases/tab_determination_{tanf,medicaid,caps,wic}.html already exist alongside per-program tabs (tab_authorization.html, tab_nutrition.html, tab_work_req.html, tab_time_limits.html, tab_categories.html); the work is to add htmx action buttons to these existing tabs, not to create them. services/canopy-web/src/api/case_detail.rs:1239-1304’s `render_program_tab() already dispatches to per-program tab renderers; the action-pane targets and hx-get URLs each tab links to are what’s missing. The Program enum (case_detail.rs:23-73) already has variants for all four programs.

Done (2026-05-12)

6

Tests + docs. Playwright specs under tests/e2e/specs/worker-portal-{tanf,medicaid,caps,wic}-actions.spec.ts — one spec per handler, exercising the golden path (load case detail → click action button → submit form → assert redirect / htmx success fragment). 20 specs total (4 programs × 5 handlers). Update .claude/docs/services.md route table to list the 20 new endpoints. CHANGELOG entry under === Added. Plan moves to plans/archive/worker-portal-program-action-handlers.adoc post-merge.

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

  1. Form structure: The plan’s example used hx-get to fetch action forms into a #action-pane target. As-built uses inline <details>-gated <form action="…​" method="post"> matching the existing tab_income.html precedent. 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-pane target on case detail. CSRF is still enforced at the router level by csrf_middleware; forms carry the standard _csrf hidden field.

  2. Per-program handler files: split into 4 files (actions_{tanf,medicaid,caps,wic}.rs) rather than fold into the SNAP actions.rs — the plan permitted either, and 5 handlers × 4 programs = 20 functions made the split cleaner.

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

  4. 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-63record_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-46clients.with_service_identity(&svc_token).await precedent for outbound auth.

  • services/canopy-web/src/api/case_detail.rs:23-73Program enum with all five variants.

  • services/canopy-web/src/api/case_detail.rs:1239-1304render_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.rswith_service_identity(&svc_token) helper (per ADR-019); reuse for all upstream calls.

  • services/canopy-web/src/csrf.rs:46csrf_middleware applied 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).await pattern reused; supersedes the earlier with_fresh_token approach from the archived bff-token-refresh plan.

  • caps-provider-registry.adoc (#396) — graceful-fallback dependency for switch_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

services/canopy-web/src/api/actions.rs

Add 20 handler functions + their *Form request structs

services/canopy-web/src/api/appeals.rs

Add per-program file_appeal_* handlers (or fold into actions.rs if cleaner)

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

Register the 20 new routes in the protected router

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

Add htmx action-button sections to existing per-program tab partials

services/canopy-web/templates/cases/actions/{tanf,medicaid,caps,wic}/*.html

20 form templates

tests/e2e/specs/worker-portal-{tanf,medicaid,caps,wic}-actions.spec.ts

20 Playwright specs

.claude/docs/services.md

Update canopy-web route table

CHANGELOG.adoc

=== Added entry

docs/modules/ROOT/pages/plans/worker-portal-program-action-handlers.adoc

This plan; moves to archive on merge

Verification

  1. cargo nextest run -p canopy-web --lib — handler unit tests pass.

  2. cargo xtask dev start — devstack healthy.

  3. cargo xtask e2e — worker-portal-tanf-actions worker-portal-medicaid-actions worker-portal-caps-actions worker-portal-wic-actions — all 20 new specs pass.

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

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

Edit this page · default