# Task Summary: Fix Code-Review Issues for Questionnaire HPO (Lazy Option Loading)

**Date:** 2026-06-04 12:44:11
**Status:** ✅ Completed

## Task Overview
Addressed the two non-critical findings from the code review of the
"questionnaire HPO lazy option loading" change:

1. **🟠 High** — The public, token-based patient questionnaire page
   (`WebController::showOrderQuestionnaire` → `result-questionnaire.blade.php`)
   ended up reusing the operations partial whose new `data-url` pointed at
   `/operations/questionnaire/options/{id}`. That endpoint is behind
   `OperationsAuth`, so any select2 call from a patient browsing the public
   page would have 401'd and no options would have loaded.

2. **🟡 Medium** — `OperationsController::SearchQuestionnaireOptions`
   queried `LABCATEGORYPACKAGEQUESTIONNAIREOPTIONS` only by
   `QUESTIONNAIRE_ID`, with no profile scoping. Any authenticated operations
   user could enumerate option names for any question id across all
   profiles.

## Changes Made

### Files Created
- `task-summaries/task-summary-2026-06-04-124411.md` — this summary.

### Files Modified
- `routes/web.php` — added a public, token-scoped route
  `GET /orders/user-results/{OrderToken}/questionnaire/options/{questionId}`
  (`OrderQuestionnaireOptions`), placed alongside the existing public
  order/result token routes.
- `app/Http/Controllers/WebController.php`
  - Added `LABCATEGORYPACKAGEQUESTIONNAIRE` and
    `LABCATEGORYPACKAGEQUESTIONNAIREOPTIONS` use statements.
  - Passed `labOrderIdToken` through to the view from
    `showOrderQuestionnaire`.
  - Added `searchOrderQuestionnaireOptions($labOrderIdToken, $questionId)`
    that authorizes via the order token, resolves the order's `PROFILE_ID`,
    verifies the requested question id belongs to that profile, and then
    returns the same paginated `{results, pagination}` payload select2
    expects.
- `app/Http/Controllers/OperationsController.php`
  - `SearchQuestionnaireOptions` now scopes by `session('profile_id')` via a
    `LABCATEGORYPACKAGEQUESTIONNAIRE` existence check and aborts with 404
    when the question id doesn't belong to the logged-in profile, then runs
    the same option query as before.
- `resources/views/operations/questionnaire/questionnaire-type.blade.php`
  - The select2 partial now reads its endpoint from a new `$optionsDataUrl`
    variable, defaulting to the existing
    `/operations/questionnaire/options/{id}` URL so operations callers keep
    working without changes. Two `data-url="..."` attributes (the `SELECT`/
    `MULTIPLE_SELECT` block and the `IF_YES` block) now render
    `{{ $optionsDataUrl }}`.
- `resources/views/result-questionnaire.blade.php`
  - Both `@include` blocks for the questionnaire-type partial now pass
    `optionsDataUrl => '/orders/user-results/' . $labOrderIdToken .
    '/questionnaire/options/' . $questionnaire->ID`.

### Files Deleted
None.

## How It Works

The shared `operations/questionnaire/questionnaire-type` partial is reused by
three callers: the operations panel, the contract panel, and the public
patient `result-questionnaire` page. Previously, the partial hard-coded the
select2 AJAX URL to the operations-auth-protected endpoint, which broke the
public page.

The partial now accepts an `$optionsDataUrl` Blade variable. The operations
panels continue to omit it and inherit the default
`/operations/questionnaire/options/{questionId}` URL (no behavioral change).
The public patient view computes a token-scoped URL per question and passes
it explicitly.

The new public endpoint validates that the incoming order token actually
resolves to an order via `LabService::GetOrderByToken`, then derives the
order's `PROFILE_ID` and confirms the requested `questionId` belongs to a
`LAB_CATEGORY_PACKAGE_QUESTIONNAIRE` row scoped to that profile before
exposing its options. This prevents cross-profile enumeration even though
the route is unauthenticated.

For the operations endpoint, the same profile-scoping check (this time
against `session('profile_id')`) gates the options query, closing the
medium-severity info-disclosure finding.

## Key Decisions

- **Reuse the shared partial via a Blade variable rather than fork it.**
  Adds one `$optionsDataUrl = $optionsDataUrl ?? ...` line and a single
  `data-url` substitution per select; operations callers don't need to
  change. Forking would have duplicated 130+ lines for one URL difference.
- **Authorize the public endpoint by token round-trip + profile match.**
  The existing public order routes already use the same token model. A
  per-question profile check is the cheapest way to make sure a leaked
  token can't be combined with random question ids to learn options that
  don't belong to that profile.
- **Use 404 (not 401) on profile-scoping failure.** Matches Laravel's
  default behavior for missing model bindings and avoids leaking whether
  a given question id exists in some other profile.
- **Single integer-cast + simplePaginate(50)** retained from the original
  endpoint so the JSON response shape is identical for select2.

## Verification Completed
- [x] PHP syntax check: `php -l` clean on `WebController.php`,
      `OperationsController.php`, `routes/web.php`.
- [x] Blade compile: all four affected views
      (`questionnaire-type`, `result-questionnaire`, `panel`,
      `contract-panel`) compile via `Blade::compileString` after
      `view:clear`.
- [x] Route registration: `php artisan route:list` shows both
      `operations/questionnaire/options/{questionId}` (OperationsAuth) and
      the new public
      `orders/user-results/{OrderToken}/questionnaire/options/{questionId}`
      (`OrderQuestionnaireOptions`).
- [x] No stale references: a project-wide grep for
      `/operations/questionnaire/options/` returns only the safe default in
      the partial.
- [x] Test suite: no questionnaire-specific PHPUnit tests existed; running
      `vendor/bin/phpunit --filter Questionnaire` reports "No tests
      executed" rather than failures.

## Dependencies
None added. Uses existing models
(`LABCATEGORYPACKAGEQUESTIONNAIRE`, `LABCATEGORYPACKAGEQUESTIONNAIREOPTIONS`),
existing service (`LabService::GetOrderByToken`), and Laravel's standard
`abort()` / `simplePaginate()` helpers.

## Usage

- **Operations users (unchanged URL, now profile-scoped):**
  `GET /operations/questionnaire/options/{questionId}?search=&page=1`
  Returns `{results: [{id, text}, ...], pagination: {more}}` for questions
  inside the logged-in operations profile; 404 otherwise.

- **Public patient page (new):**
  `GET /orders/user-results/{OrderToken}/questionnaire/options/{questionId}?search=&page=1`
  Returns the same payload, but the token's resolved order must own the
  question's profile or the endpoint 404s.

- **Custom Blade callers:** include the partial with an explicit
  `optionsDataUrl` to override the AJAX endpoint:

  ```blade
  @include('operations.questionnaire.questionnaire-type', [
      'questionnaire' => $question,
      'key'           => $key,
      'optionsDataUrl' => route('OrderQuestionnaireOptions', [
          'OrderToken' => $token,
          'questionId' => $question->ID,
      ]),
  ])
  ```

## Notes

- The operations-side options endpoint now responds with 404 (instead of a
  silent empty result) when a question id from another profile is
  requested. This is intentional — silent success would still leak the
  fact that an id "looks valid". If any operations UI relies on a JSON
  empty-result for unknown ids, that path will need to handle the 404.
- The public endpoint deliberately uses `abort(404)` for both an invalid
  token and a profile mismatch so the two failure modes are
  indistinguishable from an attacker's perspective.
- Profile scoping uses `LAB_CATEGORY_PACKAGE_QUESTIONNAIRE.PROFILE_ID`
  directly (the column already exists per migration
  `2022_12_18_105635_add_profile_id_to_lab_category_package_questionnaire_table`),
  so no extra JOIN through `PROFILE_QUESTIONNAIRE` is needed.
- Future improvement: extract the shared "build options query + paginate +
  jsonify" block into a small helper to keep the two controllers in sync.
