Skip to content

Reopening a completed or removed assignment link shows raw JSON or Express's "Cannot GET" page instead of a translated notice #1710

Open

Description

@joshunrau

A patient who reopens an assignment link after finishing it gets an English error page from the server instead of a page saying the questionnaire is done. Patients do this routinely, for example to check that the answers went through or after clicking the email link again. For about one sync interval after submitting, GET /assignments/:id answers with a raw JSON body: {"error":"Conflict","message":"Assignment already completed","statusCode":409}. Once GatewaySynchronizer has imported the assignment and deleted it from the gateway, the same link falls through next() to Express's built-in 404, a bare page reading Cannot GET /assignments/<id>. A link the clinician has cancelled, or a mistyped one, gets the same bare page. None of these pages has the gateway's header, branding or language toggle, none is translated, and none tells the patient whether their answers were saved or what to do next. A French- or Spanish-speaking patient who has just finished sees what looks like a broken site.

Where

apps/gateway/src/routers/root.router.ts:20-36:

if (!assignment) {
  return next();
} else if (assignment.completedAt) {
  return res
    .status(409)
    .set({ 'Content-Type': 'application/json' })
    .json({ error: 'Conflict', message: 'Assignment already completed', statusCode: 409 });
}

const targetParseResult = await $InstrumentBundleContainer.safeParseAsync(JSON.parse(assignment.targetStringified));
if (!targetParseResult.success) {
  logger.error(targetParseResult.error.issues);
  return res.status(500).set({ 'Content-Type': 'application/json' }).json({
    error: 'Internal Server Error',
    message: 'Failed to parse target instrument from database',
    statusCode: 500
  });
}

Reproduce

  1. Create a remote assignment, open its link with ?lang=fr, verify, and submit the instrument.
  2. Reload the page straight away.
  3. Wait one GATEWAY_REFRESH_INTERVAL (10 s by default) and reload again.
  4. Cancel a different outstanding assignment from the subject's assignment list and open its link.

Actual: step 2 shows a JSON object, while steps 3 and 4 show Express's plain-text Cannot GET /assignments/<id>. All of these are English and have none of the gateway's page shell.
Expected: each case renders inside Root's shell, in the requested language, with a short explanation: "You have already completed this questionnaire; your answers were received" for a completed one, and "This link is no longer valid; contact your clinician" for an unknown or cancelled one. Each keeps its current status code (409 or 404).

Tests

  • apps/gateway/src/__tests__/entry-server.test.ts: it('should render a translated notice for an assignment that is already completed, rather than the instrument').
  • apps/gateway/src/routers/__tests__/root.router.test.ts: it('should render the completed notice as HTML with status 409, so a patient who reopens their link sees a page instead of JSON') and it('should render the unavailable notice as HTML with status 404 for an unknown assignment').
  • e2e, testing/src/specs/gateway-assignment.spec.ts: the revisit step in the smoke test already accepts 404 or 409. It should also assert the notice heading for each, and the "nonexistent id" test should assert the notice is shown in the page shell.

Suggested fix

Add a third RootProps variant, for example { kind: 'unavailable'; reason: 'completed' | 'not-found' }, rendered by a small translated component next to Landing. The page that #1703 needs for an expired link would become a third reason. Have the root router render it with res.locals.loadRoot(...) and the existing status code instead of res.json(...) / next(). Then update the line in apps/gateway/AGENTS.md that says an unknown path gets Express's 404, since /assignments/:id with an unknown id would no longer do so.

Activity

  1. added
    EnhancementType: an improvement to an existing feature, including performance
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Gatewayapps/gateway (patient-facing app for remote assignments)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Nothing has touched apps/gateway/src since this was filed, and no open PR touches root.router.ts or Root.tsx.

    Most patients who come back will see the not-found copy

    The synchronizer deletes a completed row as soon as its records are created (apps/api/src/gateway/gateway.synchronizer.ts:168-216). So completed only shows until the next pass, or longer if the import fails or the API is down. A patient who checks back later, or clicks the email link again the next day, gets not-found. The proposed copy ("This link is no longer valid; contact your clinician") then sends them to their clinician about answers that were imported. #1703 will do the same to expired links: its suggested sync change deletes them from the gateway, so 'expired' also only shows until the next pass.

    So the not-found copy has to cover all three cases. Something like: "This link is no longer active. If you already completed this questionnaire, your answers were received. If not, contact your clinician for a new link." That is true because a completed row only leaves the gateway after its records exist. The one exception is a clinician who cancels the assignment (apps/api/src/assignments/assignments.service.ts:203-205) or bulk-deletes it (:158-162) before the next pass.

    Telling the cases apart after the row is gone would need a tombstone, written by the gateway's DELETE /api/assignments/:id (apps/gateway/src/routers/api.router.ts:158-173). That means a new model and a retention policy, on an app that otherwise forgets an assignment once it is imported. I would not do that here.

    Gotchas

    • Root.tsx:117 renders <Landing /> for any kind other than 'assignment', so a third variant compiles and shows the landing page. Replace the ternary with a switch (props.kind) that returns in every case. noImplicitReturns is on, so a missing case then fails tsc. Key the copy by reason with satisfies Record<UnavailableReason, …>, so Remote assignments never expire: the gateway accepts submissions after expiresAt and nothing marks an assignment EXPIRED #1703's 'expired' cannot land without copy.
    • Keep the notice on /assignments/:id. A catch-all GET in rootRouter would shadow every static asset, because ProductionServer mounts sirv after the routers (server.production.ts:18-19) and DevelopmentServer mounts Vite's middlewares after them (server.development.ts:23). So apps/gateway/AGENTS.md:54 needs an exception for /assignments/:id, not removal. Line 7 ("both pages share Root's shell") needs updating too.
    • Do not render completed for Gateway appends series answers without checking which item they answer, so a second tab locks the patient out or makes the series unimportable #1750's lockout state (initialSeriesIndex at or past the item count). That row is still outstanding, so the API never imports it, and "your answers were received" would be false.
    • The bundle-parse 500 (root.router.ts:30-36), rootLoader's 500 (server.base.ts:37) and render errors that reach errorHandlerMiddleware still answer the page route with JSON. Fixing those is a separate change.

    Tests

    • root.router.test.ts: the cases at lines 69-74 and 76-85 assert today's behaviour (loadRoot not called, the JSON body). Rewrite them instead of adding the new cases next to them. The loadRoot stub echoes the props as the body, so one fetch gives the status, text/html and { kind: 'unavailable', reason }. Add one ?lang=fr case.
    • entry-server.test.ts: one case per reason and one in fr. Give the component data-testid="gateway-unavailable", like Landing.
    • gateway-assignment.spec.ts: in the smoke test, replace getByText('Assignment already completed') (line 119) with the test id in both branches, and assert each heading. Step 4 of the repro needs no Cap. Call seedAssignment(api), cancel the assignment as admin as at line 178, go to `${assignment.url}?lang=fr`, and assert 404 and the French copy in the raw body. The nonexistent-id test can build its URL from gatewayURL instead of creating a link through the UI.

    Ordering

    Land this before #1703's gateway half, which then only adds 'expired'. Also land it before #1750: its suggested reload on 409 sends a tab whose assignment is already complete to this page, which today is the JSON body. #1715, #1722, #1726 and #1758 also edit Root.tsx, so expect small conflicts.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Gatewayapps/gateway (patient-facing app for remote assignments)Difficulty: LowIsolated change, about 2 hours or lessEnhancementType: an improvement to an existing feature, including performancePriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions