Repository navigation
Description
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
- Create a remote assignment, open its link with
?lang=fr, verify, and submit the instrument. - Reload the page straight away.
- Wait one
GATEWAY_REFRESH_INTERVAL(10 s by default) and reload again. - 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')andit('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
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing has touchedapps/gateway/srcsince this was filed, and no open PR touchesroot.router.tsorRoot.tsx.Most patients who come back will see the
not-foundcopyThe synchronizer deletes a completed row as soon as its records are created (
apps/api/src/gateway/gateway.synchronizer.ts:168-216). Socompletedonly 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, getsnot-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-foundcopy 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:117renders<Landing />for any kind other than'assignment', so a third variant compiles and shows the landing page. Replace the ternary with aswitch (props.kind)that returns in every case.noImplicitReturnsis on, so a missing case then failstsc. Key the copy by reason withsatisfies 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./assignments/:id. A catch-all GET inrootRouterwould shadow every static asset, becauseProductionServermountssirvafter the routers (server.production.ts:18-19) andDevelopmentServermounts Vite's middlewares after them (server.development.ts:23). Soapps/gateway/AGENTS.md:54needs an exception for/assignments/:id, not removal. Line 7 ("both pages shareRoot's shell") needs updating too.completedfor 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 (initialSeriesIndexat or past the item count). That row is still outstanding, so the API never imports it, and "your answers were received" would be false.root.router.ts:30-36),rootLoader's 500 (server.base.ts:37) and render errors that reacherrorHandlerMiddlewarestill 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 (loadRootnot called, the JSON body). Rewrite them instead of adding the new cases next to them. TheloadRootstub echoes the props as the body, so one fetch gives the status,text/htmland{ kind: 'unavailable', reason }. Add one?lang=frcase.entry-server.test.ts: one case per reason and one infr. Give the componentdata-testid="gateway-unavailable", likeLanding.gateway-assignment.spec.ts: in the smoke test, replacegetByText('Assignment already completed')(line 119) with the test id in both branches, and assert each heading. Step 4 of the repro needs no Cap. CallseedAssignment(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 fromgatewayURLinstead 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 editRoot.tsx, so expect small conflicts.Triage note written with Claude Code. Code references point at
main@d86740e.