From 2d73c3117c4ea92ceaaf53f98c4747d58a2fff0d Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 4 Nov 2026 15:37:07 -0500 Subject: [PATCH 1/4] feat(react-core): show a progress bar while a series is administered The only position feedback a series gave was an "Instruments Completed" counter on the interstitial screen, which never appears while a form is showing and never appears at all for a skipProgress series. Replace it with a bar above every item, filled by the items completed and labelled with the item in progress. It starts at a resumed assignment's position and stays mounted between items so it animates each advance. Closes #1849 Co-Authored-By: Claude Opus 5.5 --- .../SeriesInstrumentRenderer.tsx | 22 +++--- .../InstrumentRenderer/SeriesProgressBar.tsx | 46 ++++++++++++ .../SeriesInstrumentRenderer.test.tsx | 39 ++++++++-- .../__tests__/SeriesProgressBar.test.tsx | 41 +++++++++++ .../pages/_app/instruments/render/$id.page.ts | 5 ++ testing/src/specs/series-instrument.spec.ts | 71 +++++++++++++++---- 6 files changed, 197 insertions(+), 27 deletions(-) create mode 100644 packages/react-core/src/components/InstrumentRenderer/SeriesProgressBar.tsx create mode 100644 packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesProgressBar.test.tsx diff --git a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx index 3acc4ab1d..5a16dfcb8 100644 --- a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx @@ -16,6 +16,7 @@ import { InstrumentOverview } from '../InstrumentOverview'; import { InteractiveContent } from '../InteractiveContent'; import { ContentPlaceholder } from './ContentPlaceholder'; import { InstrumentRendererContainer } from './InstrumentRendererContainer'; +import { SeriesProgressBar } from './SeriesProgressBar'; import { validateSubmission } from './validateSubmission'; import type { LocalizedText } from '../../types'; @@ -146,7 +147,7 @@ export const SeriesInstrumentRenderer = ({ /> )) .with({ status: 'DONE' }, ({ instrument }) => { - return match({ index, instrument, isInstrumentInProgress }) + const content = match({ index, instrument, isInstrumentInProgress }) .with({ index: 0 }, () => ( -

- {t({ - en: `Instruments Completed: ${currentItemIndex}/${target.items.length}`, - es: `Instrumentos completados: ${currentItemIndex}/${target.items.length}`, - fr: `Nombre d'instruments complétés : ${currentItemIndex}/${target.items.length}` - })} -

)) .exhaustive(); + // Rendered outside `content` so it stays mounted between items and animates each advance. + return ( + <> + {index === 1 && ( + + )} + {content} + + ); }) .exhaustive()} diff --git a/packages/react-core/src/components/InstrumentRenderer/SeriesProgressBar.tsx b/packages/react-core/src/components/InstrumentRenderer/SeriesProgressBar.tsx new file mode 100644 index 000000000..a5377046f --- /dev/null +++ b/packages/react-core/src/components/InstrumentRenderer/SeriesProgressBar.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { motion, useSpring, useTransform } from 'motion/react'; + +type SeriesProgressBarProps = { + completed: number; + total: number; +}; + +export const SeriesProgressBar = ({ completed, total }: SeriesProgressBarProps) => { + const { t } = useTranslation(); + const fill = (completed / total) * 100; + + // Seeded with the current fill so a resumed series starts where the subject left off. + const spring = useSpring(fill, { damping: 20, restDelta: 0.001, stiffness: 50 }); + const width = useTransform(spring, (latest: number) => `${latest}%`); + + useEffect(() => { + spring.set(fill); + }, [fill]); + + const position = Math.min(completed + 1, total); + + return ( +
+

+ {t({ + en: `Instrument ${position} of ${total}`, + es: `Instrumento ${position} de ${total}`, + fr: `Instrument ${position} sur ${total}` + })} +

+
+ +
+
+ ); +}; diff --git a/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx b/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx index 05a27dce0..5136ef58b 100644 --- a/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx @@ -154,6 +154,10 @@ async function beginSeries({ skipProgress }: { skipProgress: boolean }) { return { onSubmit }; } +function getProgressBar() { + return screen.getByRole('progressbar', { name: 'Series progress' }); +} + async function answerAndSubmit(value: string, onSubmit: ReturnType) { const submissionCount = onSubmit.mock.calls.length; fireEvent.change(getAnswerInput(), { target: { value } }); @@ -322,7 +326,34 @@ describe('SeriesInstrumentRenderer', () => { /> ); fireEvent.click(await screen.findByRole('button', { name: 'Begin' })); - expect(await screen.findByText('Instruments Completed: 1/2')).toBeTruthy(); + expect(await screen.findByText('Instrument 2 of 2')).toBeTruthy(); + expect(getProgressBar().getAttribute('aria-valuenow')).toBe('1'); + }); + + it('should show progress on the form screen of a skipProgress series, which has no interstitial to show it', async () => { + const { onSubmit } = await beginSeries({ skipProgress: true }); + expect(screen.getByText('Instrument 1 of 2')).toBeTruthy(); + expect(getProgressBar().getAttribute('aria-valuenow')).toBe('0'); + await answerAndSubmit('first administration', onSubmit); + expect(await screen.findByText('Instrument 2 of 2')).toBeTruthy(); + expect(getProgressBar().getAttribute('aria-valuenow')).toBe('1'); + }); + + it('should show no progress on the overview screen, before any item is administered', async () => { + render(); + await screen.findByRole('button', { name: 'Begin' }); + expect(screen.queryByRole('progressbar')).toBeNull(); + }); + + it('should show no progress on the completion screen, once every item is administered', async () => { + const { onSubmit } = await beginSeries({ skipProgress: true }); + await answerAndSubmit('first administration', onSubmit); + await waitFor(() => { + expect(getAnswerInput().value).toBe(''); + }); + await answerAndSubmit('second administration', onSubmit); + await screen.findByText('Thank You!'); + expect(screen.queryByRole('progressbar')).toBeNull(); }); it('should refuse an initial series index past the last item', () => { @@ -409,10 +440,10 @@ describe('SeriesInstrumentRenderer', () => { ); fireEvent.click(await screen.findByRole('button', { name: 'Begin' })); - // The container's only child left is its step header once the content area renders nothing, - // which a spinner, form, iframe or error placeholder would each add to. + // The container's only children left are its step header and the progress bar once the content + // area renders nothing, which a spinner, form, iframe or error placeholder would each add to. await waitFor(() => { - expect(container.firstElementChild?.childElementCount).toBe(1); + expect(container.firstElementChild?.childElementCount).toBe(2); }); expect(container.querySelector('.animate-spinner, form, iframe')).toBeNull(); expect(screen.queryByText('Failed to Load Instrument')).toBeNull(); diff --git a/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesProgressBar.test.tsx b/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesProgressBar.test.tsx new file mode 100644 index 000000000..2431ff33f --- /dev/null +++ b/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesProgressBar.test.tsx @@ -0,0 +1,41 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { SeriesProgressBar } from '../SeriesProgressBar'; + +describe('SeriesProgressBar', () => { + beforeAll(() => { + i18n.init({ translations: {} }); + i18n.changeLanguage('en'); + }); + + afterEach(() => { + cleanup(); + }); + + it('should name the item in progress, which is the one after those completed', () => { + render(); + expect(screen.getByText('Instrument 2 of 3')).toBeTruthy(); + }); + + it('should report the completed count to assistive technology', () => { + render(); + const progressBar = screen.getByRole('progressbar', { name: 'Series progress' }); + expect(progressBar.getAttribute('aria-valuemin')).toBe('0'); + expect(progressBar.getAttribute('aria-valuemax')).toBe('3'); + expect(progressBar.getAttribute('aria-valuenow')).toBe('1'); + }); + + it('should not name an item past the last, in the render before the completion screen replaces it', () => { + render(); + expect(screen.getByText('Instrument 3 of 3')).toBeTruthy(); + }); + + it('should follow the completed count as it advances', () => { + const { rerender } = render(); + rerender(); + expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('1'); + expect(screen.getByText('Instrument 2 of 2')).toBeTruthy(); + }); +}); diff --git a/testing/src/pages/_app/instruments/render/$id.page.ts b/testing/src/pages/_app/instruments/render/$id.page.ts index 2d04387b8..e6bea0bd2 100644 --- a/testing/src/pages/_app/instruments/render/$id.page.ts +++ b/testing/src/pages/_app/instruments/render/$id.page.ts @@ -22,6 +22,9 @@ export class RenderInstrumentPage extends AppPage { readonly resetButton: Locator; /** Shown once every item of a series has been administered, in place of a scalar summary. */ readonly seriesCompletionHeading: Locator; + /** The bar shown above each item of a series, with its "Instrument N of M" label. */ + readonly seriesProgress: Locator; + readonly seriesProgressBar: Locator; readonly submitButton: Locator; readonly summaryHeading: Locator; @@ -33,6 +36,8 @@ export class RenderInstrumentPage extends AppPage { this.leaveDialog = page.getByTestId('blocker-dialog'); this.resetButton = page.getByRole('button', { name: 'Reset' }); this.seriesCompletionHeading = page.getByRole('heading', { name: /Thank You/i }); + this.seriesProgress = page.getByTestId('series-progress'); + this.seriesProgressBar = this.seriesProgress.getByRole('progressbar'); this.submitButton = page.getByRole('button', { name: 'Submit' }); this.summaryHeading = page.getByRole('heading', { name: /Summary of Results/i }); this.errorMessages = page.getByTestId('error-message-text'); diff --git a/testing/src/specs/series-instrument.spec.ts b/testing/src/specs/series-instrument.spec.ts index 543fa4d84..1fb4c285d 100644 --- a/testing/src/specs/series-instrument.spec.ts +++ b/testing/src/specs/series-instrument.spec.ts @@ -1,8 +1,11 @@ import type { InstrumentInfo } from '@opendatacapture/schemas/instrument'; +import type { Page } from '@playwright/test'; import { RenderInstrumentPage } from '../pages/_app/instruments/render/$id.page'; import { expect, test } from '../support/fixtures'; +import type { GetPageModel } from '../support/fixtures'; + const API = '/api/v1'; /** @@ -13,6 +16,33 @@ const SERIES_INSTRUMENT_TITLE = 'Happiness Questionnaire (Repeated)'; const ITEM_INSTRUMENT_TITLE = 'Happiness Questionnaire'; +/** Starts a session for a new subject and opens the repeated series on its overview screen. */ +async function openSeries({ + getPageModel, + page, + uniqueId +}: { + getPageModel: GetPageModel; + page: Page; + uniqueId: string; +}) { + const startSessionPage = await getPageModel('/session/start-session'); + await startSessionPage.sessionForm.waitFor({ state: 'visible' }); + await startSessionPage.selectIdentificationMethod('PERSONAL_INFO'); + await startSessionPage.fillSessionForm(`Series${uniqueId}`, `Subject${uniqueId}`, 'Female'); + await startSessionPage.submitForm(); + await expect(startSessionPage.successMessage).toBeVisible(); + + await page.getByTestId('nav-button-/instruments/accessible-instruments').click(); + await page.waitForURL('**/instruments/accessible-instruments'); + + const card = page.locator('[data-testid^="instrument-card-"]').filter({ hasText: SERIES_INSTRUMENT_TITLE }).first(); + await expect(card).toBeVisible(); + await card.click(); + + return new RenderInstrumentPage(page); +} + test.describe('series instrument', () => { /** * Assembling a series out of instruments the group has not been granted. @@ -64,21 +94,7 @@ test.describe('series instrument', () => { page, uniqueId }) => { - const startSessionPage = await getPageModel('/session/start-session'); - await startSessionPage.sessionForm.waitFor({ state: 'visible' }); - await startSessionPage.selectIdentificationMethod('PERSONAL_INFO'); - await startSessionPage.fillSessionForm(`Series${uniqueId}`, `Subject${uniqueId}`, 'Female'); - await startSessionPage.submitForm(); - await expect(startSessionPage.successMessage).toBeVisible(); - - await page.getByTestId('nav-button-/instruments/accessible-instruments').click(); - await page.waitForURL('**/instruments/accessible-instruments'); - - const card = page.locator('[data-testid^="instrument-card-"]').filter({ hasText: SERIES_INSTRUMENT_TITLE }).first(); - await expect(card).toBeVisible(); - await card.click(); - - const instrumentPage = new RenderInstrumentPage(page); + const instrumentPage = await openSeries({ getPageModel, page, uniqueId }); await instrumentPage.begin(); await instrumentPage.completeHappinessQuestionnaire(); await expect(instrumentPage.happinessSatisfiedRadio).toBeChecked(); @@ -103,4 +119,29 @@ test.describe('series instrument', () => { // records rather than one. await expect(page.getByTestId('data-table-body').getByTestId('data-table-row')).toHaveCount(2); }); + + // A skipProgress series has no interstitial screen, so the bar on each item's form is the only + // place the subject can see how far through the series they are. + test('should show the position in a skipProgress series on each item it administers', async ({ + getPageModel, + page, + uniqueId + }) => { + const instrumentPage = await openSeries({ getPageModel, page, uniqueId }); + await expect(instrumentPage.seriesProgress).toBeHidden(); + await instrumentPage.begin(); + + await expect(instrumentPage.seriesProgress).toContainText('Instrument 1 of 2'); + await expect(instrumentPage.seriesProgressBar).toHaveAttribute('aria-valuenow', '0'); + await instrumentPage.completeHappinessQuestionnaire(); + await instrumentPage.submit(); + + await expect(instrumentPage.seriesProgress).toContainText('Instrument 2 of 2'); + await expect(instrumentPage.seriesProgressBar).toHaveAttribute('aria-valuenow', '1'); + await instrumentPage.completeHappinessQuestionnaire(); + await instrumentPage.submit(); + + await expect(instrumentPage.seriesCompletionHeading).toBeVisible(); + await expect(instrumentPage.seriesProgress).toBeHidden(); + }); }); From 9c74c08d2b61f6513e08695b355f72a8c5a93891 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 4 Nov 2026 15:38:40 -0500 Subject: [PATCH 2/4] build(gateway): apply the database schema at startup run.sh copied a template database only onto an empty volume, so a deployed gateway never received a schema change made after it was first started. Install the Prisma CLI the lockfile resolved into the runner image and have run.sh run `prisma db push` before the server: it creates the database on an empty volume, brings an existing one up to date, and stops the container rather than drop data. Co-Authored-By: Claude Opus 5.5 --- apps/gateway/AGENTS.md | 7 +++++++ apps/gateway/Dockerfile | 9 +++++++-- apps/gateway/scripts/run.sh | 9 +++------ docs/en/5-reference/5.5-gateway.md | 2 ++ 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/gateway/AGENTS.md b/apps/gateway/AGENTS.md index c2a8ecc89..f68a157a0 100644 --- a/apps/gateway/AGENTS.md +++ b/apps/gateway/AGENTS.md @@ -82,6 +82,13 @@ state `apps/api` owns — so persisting either would only add a second copy that `db:push` before `dev`, `lint`, `test:e2e` and — via the `@opendatacapture/gateway#build` key in `turbo.json` — before `build`, so a gateway build needs `GATEWAY_DATABASE_URL` set. +**A deployed gateway applies this schema on every start.** `scripts/run.sh` runs `prisma db push` +before the server, with a Prisma CLI the `Dockerfile` installs at the version the lockfile resolved. +That creates the database on an empty volume and brings an existing one up to the image's schema, +so a new column must be optional or have a default. `db push` refuses a change that would lose +data, and the container then fails to start: removing or retyping a column needs a plan for the +rows already on deployed volumes. + ## Build and dev `pnpm build` is three passes into `dist/`: Vite client build (`dist/client`), Vite SSR build diff --git a/apps/gateway/Dockerfile b/apps/gateway/Dockerfile index 60a2953e9..e8590b023 100644 --- a/apps/gateway/Dockerfile +++ b/apps/gateway/Dockerfile @@ -21,12 +21,17 @@ COPY tsconfig.json vitest.config.ts ./ COPY --from=pruner /app/out/ . RUN pnpm install --frozen-lockfile RUN turbo build --filter=@opendatacapture/gateway -RUN turbo run db:push --filter=@opendatacapture/gateway +RUN node -p "require('./apps/gateway/node_modules/prisma/package.json').version" > prisma-version # RUN SERVER FROM base AS runner +ENV CHECKPOINT_DISABLE=1 +# run.sh applies the schema on every start, so the runner needs the Prisma CLI the lockfile resolved. +# npm gives it a flat node_modules, which copying pnpm's symlinked one out of the installer would not. +COPY --from=installer /app/prisma-version prisma-version +RUN npm install --prefix /app/prisma-cli --no-audit --no-fund "prisma@$(cat prisma-version)" +COPY apps/gateway/prisma/schema.prisma /app/prisma/schema.prisma COPY apps/gateway/scripts/run.sh run.sh -COPY --from=installer /app/sqlite/gateway.db /app/gateway.tmpl.db COPY --from=installer /app/apps/gateway/dist/ /app/dist/ COPY --from=installer /app/apps/gateway/public/ /app/public/ COPY --from=installer /app/apps/gateway/dist/runtime/ /runtime/ diff --git a/apps/gateway/scripts/run.sh b/apps/gateway/scripts/run.sh index a025e5706..2ecfa06ac 100755 --- a/apps/gateway/scripts/run.sh +++ b/apps/gateway/scripts/run.sh @@ -2,11 +2,8 @@ set -eu -DATABASE_FILE=${GATEWAY_DATABASE_URL#file:} -EMPTY_DATABASE_FILE="/app/gateway.tmpl.db" - -if [ ! -s "$DATABASE_FILE" ]; then - cp "$EMPTY_DATABASE_FILE" "$DATABASE_FILE" -fi +# Creates the database on first start and brings an existing one up to the schema this image was +# built with. `db push` refuses a change that would lose data, which stops the container here. +/app/prisma-cli/node_modules/.bin/prisma db push --skip-generate --schema /app/prisma/schema.prisma exec node ./dist/main.js diff --git a/docs/en/5-reference/5.5-gateway.md b/docs/en/5-reference/5.5-gateway.md index 29a642ea2..b6eb25bed 100644 --- a/docs/en/5-reference/5.5-gateway.md +++ b/docs/en/5-reference/5.5-gateway.md @@ -8,3 +8,5 @@ sidebar: In addition to the core platform, an additional service (the "Gateway") is available for external data collection. The architecture of this platform is engineered for compliance purposes. In our implementation, the core platform is deployed within the firewall of the Douglas. The Gateway, on the other hand, is deployed on a droplet accessible on the open internet. When a clinical assigns an instrument to a subject, the Gateway receives a blob of data, including the subject ID, instrument structure, and a public key. The Gateway uses this data to generate a one-time link for the subject to complete the instrument. Once completed, this data is be encrypted using the public key and stored in the Gateway Database; the data cannot be encrypted without the private key, which remains within the Douglas firewall. Periodically, the Core API requests data from the Gateway, at which point it is decrypted and inserted into the Core Database and removed from the Gateway Database. + +The Gateway Database is a SQLite file kept in the `sqlite` volume. Each time the Gateway container starts, it brings that file up to date with the version being started, so upgrading the Gateway needs no manual database step. From d838ed87c950aa78bd0333f55c49f9d11f5e3478 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 4 Nov 2026 15:50:14 -0500 Subject: [PATCH 3/4] feat: redirect the subject to a configured URL after a remote assignment is completed A remote assignment ended on a static completion screen, leaving a subject who arrived from a study portal or clinic page no way back. An assignment can now carry an optional https redirect URL, set when it is created in the single-assignment dialog, the bulk wizard or the API (one URL per bulk request). The gateway stores it and, once the completing submission is recorded, shows a five-second countdown under the completion screen before replacing the page with that URL. The URL is validated once, when the assignment is created, and is normalized to ASCII so it survives the gateway's latin1-only page props. Closes #1862 Co-Authored-By: Claude Opus 5.5 --- apps/api/prisma/schema.prisma | 1 + .../__tests__/assignments.service.test.ts | 18 +++++ .../src/assignments/assignments.service.ts | 19 +++++- .../suites/05-assignment-redirect.suite.ts | 49 +++++++++++++ apps/gateway/prisma/schema.prisma | 1 + apps/gateway/src/Root.tsx | 29 +++++--- apps/gateway/src/__tests__/Root.test.tsx | 41 ++++++++++- .../src/components/CompletionRedirect.tsx | 51 ++++++++++++++ .../__tests__/CompletionRedirect.test.tsx | 68 +++++++++++++++++++ .../src/routers/__tests__/api.router.test.ts | 24 +++++++ .../src/routers/__tests__/root.router.test.ts | 15 +++- apps/gateway/src/routers/root.router.ts | 1 + .../BulkRemoteAssignmentWizard.tsx | 3 + .../ReviewStep.stories.tsx | 1 + .../BulkRemoteAssignmentWizard/ReviewStep.tsx | 16 +++++ .../TimepointsStep.stories.tsx | 2 + .../TimepointsStep.tsx | 43 +++++++++++- .../BulkRemoteAssignmentWizard.test.tsx | 2 + .../__tests__/ReviewStep.test.tsx | 11 +++ .../__tests__/TimepointsStep.test.tsx | 33 +++++++++ .../__tests__/useBulkAssignmentWizard.test.ts | 28 ++++++++ apps/web/src/hooks/useBulkAssignmentWizard.ts | 7 ++ .../__tests__/remote-assignment.test.tsx | 30 ++++++++ .../routes/_app/session/remote-assignment.tsx | 23 ++++++- .../src/utils/__tests__/validation.test.ts | 22 ++++++ apps/web/src/utils/validation.ts | 15 ++++ docs/en/5-reference/5.5-gateway.md | 2 + .../assignment/__tests__/assignment.test.ts | 59 ++++++++++++++++ packages/schemas/src/assignment/assignment.ts | 17 +++++ .../_app/group/remote-assignments.page.ts | 14 +++- .../_app/session/remote-assignment.page.ts | 6 ++ .../src/specs/bulk-remote-assignments.spec.ts | 25 +++++++ testing/src/specs/gateway-assignment.spec.ts | 44 ++++++++++++ testing/src/specs/remote-assignment.spec.ts | 30 ++++++++ .../specs/remote-assignments-wizard.spec.ts | 27 ++++++++ 35 files changed, 757 insertions(+), 20 deletions(-) create mode 100644 apps/api/test/suites/05-assignment-redirect.suite.ts create mode 100644 apps/gateway/src/components/CompletionRedirect.tsx create mode 100644 apps/gateway/src/components/__tests__/CompletionRedirect.test.tsx diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 082856166..263f7c82d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -77,6 +77,7 @@ model Assignment { instrument Instrument @relation(fields: [instrumentId], references: [id]) instrumentId String instrumentRecord InstrumentRecord? + redirectUrl String? status AssignmentStatus subject Subject @relation(fields: [subjectId], references: [id]) subjectId String diff --git a/apps/api/src/assignments/__tests__/assignments.service.test.ts b/apps/api/src/assignments/__tests__/assignments.service.test.ts index 64c8153d3..8bd4c1f27 100644 --- a/apps/api/src/assignments/__tests__/assignments.service.test.ts +++ b/apps/api/src/assignments/__tests__/assignments.service.test.ts @@ -298,6 +298,16 @@ describe('AssignmentsService', () => { expect(assignmentModel.create.mock.lastCall?.[0].data.group).toStrictEqual({ connect: { id: GROUP_ID } }); }); + it('should store the redirect URL, so the gateway can send the subject there once they finish', async () => { + await assignmentsService.create({ ...data(), redirectUrl: 'https://example.com/done' }, permissiveUser()); + expect(assignmentModel.create.mock.lastCall?.[0].data.redirectUrl).toBe('https://example.com/done'); + }); + + it('should store no redirect URL when none is given, leaving the subject on the completion screen', async () => { + await assignmentsService.create(data(), permissiveUser()); + expect(assignmentModel.create.mock.lastCall?.[0].data.redirectUrl).toBeUndefined(); + }); + it('should allow a grouped assignment to a user granted only assignment creation in that group, which is less than managing it', async () => { const grantee = userAt('STANDARD', GROUP_ID, [{ action: 'create', groupId: GROUP_ID, subject: 'Assignment' }]); await assignmentsService.create(data(), grantee); @@ -363,6 +373,14 @@ describe('AssignmentsService', () => { expect(assignmentModel.create).toHaveBeenCalledTimes(4); }); + it('should store the redirect URL on every assignment the batch creates', async () => { + await assignmentsService.createBulk(request({ redirectUrl: 'https://example.com/done' }), permissiveUser()); + expect(assignmentModel.create.mock.calls.map(([args]) => args.data.redirectUrl)).toEqual([ + 'https://example.com/done', + 'https://example.com/done' + ]); + }); + it('should send the whole batch to the gateway in a single call, so one bundle is fetched per instrument', async () => { await assignmentsService.createBulk(request(), permissiveUser()); expect(gatewayService.createRemoteAssignments).toHaveBeenCalledTimes(1); diff --git a/apps/api/src/assignments/assignments.service.ts b/apps/api/src/assignments/assignments.service.ts index d661eb27b..a6e6e491b 100644 --- a/apps/api/src/assignments/assignments.service.ts +++ b/apps/api/src/assignments/assignments.service.ts @@ -75,7 +75,7 @@ export class AssignmentsService { } async create( - { expiresAt, groupId, instrumentId, subjectId }: $CreateAssignmentData, + { expiresAt, groupId, instrumentId, redirectUrl, subjectId }: $CreateAssignmentData, currentUser: RequestUser ): Promise { if (groupId) { @@ -91,7 +91,13 @@ export class AssignmentsService { issues: [{ instrumentIds: [instrumentId], kind: 'INSTRUMENT_UNAVAILABLE' }] } satisfies BulkAssignmentFailure); } - const { assignment, publicKey } = await this.stageAssignment({ expiresAt, groupId, instrumentId, subjectId }); + const { assignment, publicKey } = await this.stageAssignment({ + expiresAt, + groupId, + instrumentId, + redirectUrl, + subjectId + }); try { await this.gatewayService.createRemoteAssignment(assignment, publicKey); } catch (err) { @@ -123,7 +129,11 @@ export class AssignmentsService { }), BULK_KEYPAIR_CONCURRENCY )) { - staged.push(...(await Promise.all(batch.map((row) => this.stageAssignment({ ...row, groupId }))))); + staged.push( + ...(await Promise.all( + batch.map((row) => this.stageAssignment({ ...row, groupId, redirectUrl: data.redirectUrl })) + )) + ); } await this.gatewayService.createRemoteAssignments(staged); } catch (err) { @@ -347,11 +357,13 @@ export class AssignmentsService { expiresAt, groupId, instrumentId, + redirectUrl, subjectId }: { expiresAt: Date; groupId?: null | string; instrumentId: string; + redirectUrl?: string; subjectId: string; }): Promise<{ assignment: Assignment; publicKey: webcrypto.CryptoKey }> { const { privateKey, publicKey } = await HybridCrypto.generateKeyPair(); @@ -366,6 +378,7 @@ export class AssignmentsService { group: groupId ? { connect: { id: groupId } } : undefined, id, instrument: { connect: { id: instrumentId } }, + redirectUrl, status: 'OUTSTANDING', subject: { connect: { id: subjectId } }, url: `${this.assignmentBaseUrl}/assignments/${id}` diff --git a/apps/api/test/suites/05-assignment-redirect.suite.ts b/apps/api/test/suites/05-assignment-redirect.suite.ts new file mode 100644 index 000000000..13cce1746 --- /dev/null +++ b/apps/api/test/suites/05-assignment-redirect.suite.ts @@ -0,0 +1,49 @@ +import { beforeAll, expect, it } from 'vitest'; + +import { defineSuite } from '../helpers'; + +export default defineSuite('assignment redirect URL', function () { + let adminHeaders: { authorization: string }; + + beforeAll(async () => { + const login = await this.app.inject({ + method: 'POST', + payload: { password: 'DataCapture2025_Test', username: 'admin' }, + url: '/v1/auth/login' + }); + expect(login.statusCode).toBe(200); + adminHeaders = { authorization: `Bearer ${login.json<{ accessToken: string }>().accessToken}` }; + }); + + it('should refuse an assignment that would send its subject on over plain http', async () => { + const response = await this.app.inject({ + headers: adminHeaders, + method: 'POST', + payload: { + expiresAt: new Date(Date.now() + 86_400_000), + instrumentId: 'instrument-1', + redirectUrl: 'http://example.com/done', + subjectId: 'subject-1' + }, + url: '/v1/assignments' + }); + expect(response.statusCode).toBe(400); + expect(response.body).toContain('redirectUrl'); + }); + + it('should refuse a bulk operation that would send its subjects on over plain http', async () => { + const response = await this.app.inject({ + headers: adminHeaders, + method: 'POST', + payload: { + groupId: 'group-1', + redirectUrl: 'http://example.com/done', + subjectIds: ['subject-1'], + timepoints: [{ expiresAt: new Date(Date.now() + 86_400_000), instrumentId: 'instrument-1' }] + }, + url: '/v1/assignments/bulk' + }); + expect(response.statusCode).toBe(400); + expect(response.body).toContain('redirectUrl'); + }); +}); diff --git a/apps/gateway/prisma/schema.prisma b/apps/gateway/prisma/schema.prisma index 8ebf4bcb1..b28b1aa3d 100644 --- a/apps/gateway/prisma/schema.prisma +++ b/apps/gateway/prisma/schema.prisma @@ -21,6 +21,7 @@ model RemoteAssignmentModel { rawPublicKey Bytes symmetricKey String? encryptedData String? + redirectUrl String? targetStringified String status String subjectId String diff --git a/apps/gateway/src/Root.tsx b/apps/gateway/src/Root.tsx index 8154e01d0..11b072204 100644 --- a/apps/gateway/src/Root.tsx +++ b/apps/gateway/src/Root.tsx @@ -11,6 +11,7 @@ import type { InstrumentBundleContainer } from '@opendatacapture/schemas/instrum import axios from 'axios'; import CapWidget from './components/Cap'; +import CompletionRedirect from './components/CompletionRedirect'; import Landing from './components/Landing'; import './services/axios'; @@ -24,6 +25,7 @@ type BaseRootProps = { type AssignmentProps = { id: string; initialSeriesIndex?: number; + redirectUrl?: string; target: InstrumentBundleContainer; token: string; }; @@ -34,10 +36,11 @@ type LandingRootProps = BaseRootProps & { kind: 'landing' }; type RootProps = AssignmentRootProps | LandingRootProps; -const Assignment = ({ id, initialSeriesIndex, target, token }: AssignmentProps) => { +const Assignment = ({ id, initialSeriesIndex, redirectUrl, target, token }: AssignmentProps) => { const notifications = useNotificationsStore(); const [verified, setVerified] = useState(false); + const [isComplete, setIsComplete] = useState(false); // Solving the Cap challenge redeems a short-lived token; exchange it immediately for a // server-side verification flag so the form is not bound by the token's 20-minute lifetime. @@ -77,18 +80,24 @@ const Assignment = ({ id, initialSeriesIndex, target, token }: AssignmentProps) } }); notifications.addNotification({ type: 'success' }); + if (updateData.status === 'COMPLETE') { + setIsComplete(true); + } }; return ( - void handleSolve(token)} />} - className="min-h-full w-full" - disableBegin={!verified} - initialSeriesIndex={initialSeriesIndex} - target={target} - onSubmit={handleSubmit} - /> + <> + void handleSolve(token)} />} + className="min-h-full w-full" + disableBegin={!verified} + initialSeriesIndex={initialSeriesIndex} + target={target} + onSubmit={handleSubmit} + /> + {isComplete && redirectUrl && } + ); }; diff --git a/apps/gateway/src/__tests__/Root.test.tsx b/apps/gateway/src/__tests__/Root.test.tsx index 9d3a8942a..dd4c0e602 100644 --- a/apps/gateway/src/__tests__/Root.test.tsx +++ b/apps/gateway/src/__tests__/Root.test.tsx @@ -52,19 +52,22 @@ function seriesResult(complete: boolean): InstrumentSubmitHandler.AnyContext { }; } -function renderAssignment(target: InstrumentBundleContainer = formTarget) { +function renderAssignment(target: InstrumentBundleContainer = formTarget, redirectUrl?: string) { return render( ); } +const REDIRECT_URL = 'https://example.com/done'; + async function solveChallenge() { await act(async () => { document.querySelector('cap-widget')!.dispatchEvent(new CustomEvent('solve', { detail: { token: 'cap-token' } })); @@ -180,4 +183,40 @@ describe('Root', () => { expect(patch).not.toHaveBeenCalled(); expect(notifications()).toMatchObject([{ message: 'Internal Server Error', type: 'error' }]); }); + + it('should offer to send the subject on to the redirect URL once the assignment is complete', async () => { + renderAssignment(formTarget, REDIRECT_URL); + await solveChallenge(); + await submit(formResult); + expect(screen.getByTestId('completion-redirect-link').getAttribute('href')).toBe(REDIRECT_URL); + }); + + it('should not send the subject on while items of a series remain', async () => { + renderAssignment(seriesTarget, REDIRECT_URL); + await solveChallenge(); + await submit(seriesResult(false)); + expect(screen.queryByTestId('completion-redirect')).toBeNull(); + }); + + it('should send the subject on once the last item of a series is submitted', async () => { + renderAssignment(seriesTarget, REDIRECT_URL); + await solveChallenge(); + await submit(seriesResult(true)); + expect(screen.getByTestId('completion-redirect')).toBeTruthy(); + }); + + it('should leave the subject on the completion screen when the assignment has no redirect URL', async () => { + renderAssignment(formTarget); + await solveChallenge(); + await submit(formResult); + expect(screen.queryByTestId('completion-redirect')).toBeNull(); + }); + + it('should not send the subject on when the gateway did not record the submission', async () => { + patch.mockRejectedValue(new Error('Network Error')); + renderAssignment(formTarget, REDIRECT_URL); + await solveChallenge(); + await expect(submit(formResult)).rejects.toThrow('Network Error'); + expect(screen.queryByTestId('completion-redirect')).toBeNull(); + }); }); diff --git a/apps/gateway/src/components/CompletionRedirect.tsx b/apps/gateway/src/components/CompletionRedirect.tsx new file mode 100644 index 000000000..706d4f731 --- /dev/null +++ b/apps/gateway/src/components/CompletionRedirect.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; + +/** Long enough to read the completion screen, short enough not to be mistaken for a dead end. */ +const REDIRECT_DELAY_SECONDS = 5; + +type CompletionRedirectProps = { + url: string; +}; + +/** + * Sends the subject on to `url` after a short countdown. `replace` rather than `assign`, so going + * back does not return to the completed assignment, which the gateway no longer serves. + */ +const CompletionRedirect = ({ url }: CompletionRedirectProps) => { + const { t } = useTranslation(); + const [secondsLeft, setSecondsLeft] = useState(REDIRECT_DELAY_SECONDS); + + useEffect(() => { + if (secondsLeft === 0) { + window.location.replace(url); + return; + } + const timeout = setTimeout(() => setSecondsLeft(secondsLeft - 1), 1000); + return () => clearTimeout(timeout); + }, [secondsLeft, url]); + + return ( +

+ {t({ + en: `Redirecting in ${secondsLeft}…`, + es: `Redirigiendo en ${secondsLeft}…`, + fr: `Redirection dans ${secondsLeft}…` + })}{' '} + { + event.preventDefault(); + window.location.replace(url); + }} + > + {t({ en: 'Continue now', es: 'Continuar ahora', fr: 'Continuer maintenant' })} + +

+ ); +}; + +export default CompletionRedirect; diff --git a/apps/gateway/src/components/__tests__/CompletionRedirect.test.tsx b/apps/gateway/src/components/__tests__/CompletionRedirect.test.tsx new file mode 100644 index 000000000..582560cef --- /dev/null +++ b/apps/gateway/src/components/__tests__/CompletionRedirect.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import type { MockInstance } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import CompletionRedirect from '../CompletionRedirect'; + +import '@/services/i18n'; + +const URL = 'https://example.com/done'; + +describe('CompletionRedirect', () => { + let replace: MockInstance; + + beforeEach(() => { + vi.useFakeTimers(); + replace = vi.spyOn(window.location, 'replace').mockImplementation(() => undefined); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should count down the seconds before the subject is sent on', () => { + render(); + expect(screen.getByTestId('completion-redirect').textContent).toContain('Redirecting in 5…'); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(screen.getByTestId('completion-redirect').textContent).toContain('Redirecting in 4…'); + }); + + it('should send the subject on once the countdown ends, replacing the assignment in their history', () => { + render(); + for (let second = 0; second < 5; second++) { + act(() => { + vi.advanceTimersByTime(1000); + }); + } + expect(replace).toHaveBeenCalledExactlyOnceWith(URL); + }); + + it('should not send the subject on before the countdown ends', () => { + render(); + act(() => { + vi.advanceTimersByTime(4000); + }); + expect(replace).not.toHaveBeenCalled(); + }); + + it('should send the subject on at once when they choose to continue', () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Continue now' })); + expect(replace).toHaveBeenCalledExactlyOnceWith(URL); + }); + + it('should stop counting once it is gone, so a subject who left is not navigated later', () => { + const { unmount } = render(); + unmount(); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/routers/__tests__/api.router.test.ts b/apps/gateway/src/routers/__tests__/api.router.test.ts index 087f2bfdc..e5112964e 100644 --- a/apps/gateway/src/routers/__tests__/api.router.test.ts +++ b/apps/gateway/src/routers/__tests__/api.router.test.ts @@ -134,6 +134,30 @@ describe('apiRouter', () => { expect(data).toMatchObject({ id: 'assignment-1', rawPublicKey: Buffer.from([1, 2, 3]) }); expect(JSON.parse(data.targetStringified)).toEqual(instrumentContainer); }); + + it('should store the redirect URL, so the page served for the assignment can send the subject on', async () => { + const { instrumentId: _instrumentId, ...remoteAssignment } = assignment; + const response = await request('POST', '/assignments', { + ...remoteAssignment, + instrumentContainer, + redirectUrl: 'https://example.com/done' + }); + expect(response.status).toBe(201); + expect(prisma.remoteAssignmentModel.create.mock.lastCall![0].data).toMatchObject({ + redirectUrl: 'https://example.com/done' + }); + }); + + it('should refuse a redirect URL that is not https, whatever sent it', async () => { + const { instrumentId: _instrumentId, ...remoteAssignment } = assignment; + const response = await request('POST', '/assignments', { + ...remoteAssignment, + instrumentContainer, + redirectUrl: 'javascript:alert(1)' + }); + expect(response.status).toBe(400); + expect(prisma.remoteAssignmentModel.create).not.toHaveBeenCalled(); + }); }); describe('POST /assignments/bulk', () => { diff --git a/apps/gateway/src/routers/__tests__/root.router.test.ts b/apps/gateway/src/routers/__tests__/root.router.test.ts index 897c5d63d..2894297f1 100644 --- a/apps/gateway/src/routers/__tests__/root.router.test.ts +++ b/apps/gateway/src/routers/__tests__/root.router.test.ts @@ -24,11 +24,17 @@ const target = { bundle: 'export default {}', id: 'instrument-1', kind: 'FORM' } let baseUrl: string; let server: Server; -function storeAssignment(stored: { completedAt?: Date; encryptedData?: null | string; targetStringified?: string }) { +function storeAssignment(stored: { + completedAt?: Date; + encryptedData?: null | string; + redirectUrl?: null | string; + targetStringified?: string; +}) { prisma.remoteAssignmentModel.findFirst.mockResolvedValueOnce({ completedAt: null, encryptedData: null, id: 'assignment-1', + redirectUrl: null, targetStringified: JSON.stringify(target), ...stored }); @@ -105,6 +111,13 @@ describe('rootRouter', () => { }); }); + it('should hand the page the redirect URL the assignment was created with, so it can send the subject on', async () => { + storeAssignment({ redirectUrl: 'https://example.com/done' }); + expect(await getRootProps('/assignments/assignment-1')).toMatchObject({ + redirectUrl: 'https://example.com/done' + }); + }); + it('should resume a series after the items already submitted, so a returning patient does not repeat them', async () => { storeAssignment({ encryptedData: '$item-1$item-2' }); expect(await getRootProps('/assignments/assignment-1')).toMatchObject({ initialSeriesIndex: 2 }); diff --git a/apps/gateway/src/routers/root.router.ts b/apps/gateway/src/routers/root.router.ts index 2c5ff3d6e..10d38e56c 100644 --- a/apps/gateway/src/routers/root.router.ts +++ b/apps/gateway/src/routers/root.router.ts @@ -51,6 +51,7 @@ router.get( initialSeriesIndex, kind: 'assignment', language: resolveLanguage(req.query.lang), + redirectUrl: assignment.redirectUrl ?? undefined, target: targetParseResult.data, token } satisfies RootProps); diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx index 87f77ca66..cdd076935 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx @@ -171,11 +171,13 @@ export const BulkRemoteAssignmentWizard = ({ defaultExpiresAt={defaultExpiresAt} instruments={instruments} isLoading={wizard.isPreflighting} + redirectUrl={wizard.redirectUrl} subjectCount={wizard.subjectIds.length} timepoints={wizard.timepoints} onBack={() => wizard.goTo('SOURCE')} onChange={wizard.setTimepoints} onConfirm={wizard.confirmTimepoints} + onRedirectUrlChange={wizard.setRedirectUrl} onStepChange={wizard.goTo} /> )} @@ -185,6 +187,7 @@ export const BulkRemoteAssignmentWizard = ({ describeSubject={wizard.describeSubject} failure={wizard.failure} isSubmitting={wizard.isSubmitting} + redirectUrl={wizard.redirectUrl} subjectCount={wizard.subjectIds.length} timepoints={wizard.timepoints} transportError={wizard.transportError} diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx index 5b47c7870..6b9455da7 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx @@ -18,6 +18,7 @@ export default { onBack: noop, onStepChange: noop, onSubmit: noop, + redirectUrl: '', subjectCount: 3, timepoints: [ { expiresAt: inThirtyDays, instrumentId: 'instrument-1', instrumentTitle: 'Happiness Questionnaire' }, diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx index 363a8583a..f018e7551 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx @@ -21,6 +21,8 @@ type ReviewStepProps = { onBack: () => void; onStepChange: (step: WizardStep) => void; onSubmit: (options: { allowDuplicates: boolean; skipUnavailable: boolean }) => void; + /** Applied to every assignment in the batch; blank for none. */ + redirectUrl: string; subjectCount: number; timepoints: DraftTimepoint[]; transportError: boolean; @@ -75,6 +77,7 @@ export const ReviewStep = ({ onBack, onStepChange, onSubmit, + redirectUrl, subjectCount, timepoints, transportError @@ -131,6 +134,19 @@ export const ReviewStep = ({
+ {redirectUrl && ( +

+ {t( + { + en: 'Once they finish, subjects are sent to {}', + es: 'Al terminar, los sujetos se envían a {}', + fr: 'Une fois terminé, les sujets sont redirigés vers {}' + }, + { args: [redirectUrl] } + )} +

+ )} + {hasConflict && ( diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.stories.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.stories.tsx index 498ab216c..bf78f2ac3 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.stories.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.stories.tsx @@ -21,7 +21,9 @@ export default { onBack: noop, onChange: noop, onConfirm: noop, + onRedirectUrlChange: noop, onStepChange: noop, + redirectUrl: '', subjectCount: 3, timepoints: [] }, diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx index 6dbc55951..ea1a973e0 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx @@ -5,6 +5,8 @@ import { Button, Input, Select } from '@douglasneuroinformatics/libui/components import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { PlusIcon } from 'lucide-react'; +import { findRedirectUrlError } from '@/utils/validation'; + import { StepLayout } from './StepLayout'; import { TimepointTable } from './TimepointTable'; @@ -20,7 +22,10 @@ type TimepointsStepProps = { onBack: () => void; onChange: (timepoints: DraftTimepoint[]) => void; onConfirm: () => void; + onRedirectUrlChange: (redirectUrl: string) => void; onStepChange: (step: WizardStep) => void; + /** Applied to every assignment in the batch; blank for none. */ + redirectUrl: string; subjectCount: number; /** Held by the wizard, so returning from review does not discard the list. */ timepoints: DraftTimepoint[]; @@ -37,13 +42,18 @@ export const TimepointsStep = ({ onBack, onChange, onConfirm, + onRedirectUrlChange, onStepChange, + redirectUrl, subjectCount, timepoints }: TimepointsStepProps) => { const { t } = useTranslation(); const [instrumentId, setInstrumentId] = useState(''); const [expiresAt, setExpiresAt] = useState(defaultExpiresAt); + // Shown once the user leaves the field, so a URL is not called invalid while it is still being typed. + const [isRedirectUrlTouched, setIsRedirectUrlTouched] = useState(false); + const redirectUrlError = redirectUrl === '' ? null : findRedirectUrlError(redirectUrl); // An instrument already in the list cannot be added again: two live assignments for the same // subject and instrument is exactly the conflict the API refuses. @@ -77,7 +87,7 @@ export const TimepointsStep = ({
+ +
+ +

+ {t({ + en: 'Where to send each subject once they finish. Must start with https://.', + es: 'Adónde enviar a cada sujeto cuando termine. Debe comenzar con https://.', + fr: 'Où envoyer chaque sujet une fois terminé. Doit commencer par https://.' + })} +

+ setIsRedirectUrlTouched(true)} + onChange={(event) => onRedirectUrlChange(event.target.value)} + /> + {isRedirectUrlTouched && redirectUrlError && ( +

+ {redirectUrlError} +

+ )} +
); diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx index 510beb806..9c11ca8e1 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx @@ -93,11 +93,13 @@ const mockWizard = (step: WizardStep, overrides: Partial = {}): Wizard = isSubmitting: false, parsed: null, pastedText: '', + redirectUrl: '', resolveMapping: vi.fn(), resultRows: vi.fn(() => []), selectSubjects: vi.fn(), setEmails: vi.fn(), setPastedText: vi.fn(), + setRedirectUrl: vi.fn(), setSelectedSubjectIds: vi.fn(), setSourceMode: vi.fn(), setTimepoints: vi.fn(), diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/ReviewStep.test.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/ReviewStep.test.tsx index 91a0d10ec..78a0ee33a 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/ReviewStep.test.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/ReviewStep.test.tsx @@ -36,6 +36,7 @@ const renderReviewStep = (props: Partial describeSubject={(subjectId) => `described ${subjectId}`} failure={null} isSubmitting={false} + redirectUrl="" subjectCount={3} timepoints={timepoints} transportError={false} @@ -175,4 +176,14 @@ describe('ReviewStep', () => { fireEvent.click(screen.getByTestId('bulk-breadcrumb-SUBJECTS')); expect(onStepChange).toHaveBeenCalledWith('SOURCE'); }); + + it('should show where the batch sends its subjects once they finish', () => { + renderReviewStep({ redirectUrl: 'https://example.com/done' }); + expect(screen.getByTestId('bulk-review-redirect-url').textContent).toContain('https://example.com/done'); + }); + + it('should say nothing about a redirect when the batch has none', () => { + renderReviewStep(); + expect(screen.queryByTestId('bulk-review-redirect-url')).toBeNull(); + }); }); diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/TimepointsStep.test.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/TimepointsStep.test.tsx index e9cdb8893..1f0b6d7aa 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/TimepointsStep.test.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/TimepointsStep.test.tsx @@ -28,12 +28,14 @@ const renderStep = (props: Partial> onBack: vi.fn(), onChange: vi.fn(), onConfirm: vi.fn(), + onRedirectUrlChange: vi.fn(), onStepChange: vi.fn() }; const result = render( { ); @@ -190,4 +194,33 @@ describe('TimepointsStep', () => { fireEvent.click(screen.getByTestId('bulk-breadcrumb-SUBJECTS')); expect(onStepChange).toHaveBeenCalledWith('SOURCE'); }); + + it('should report each edit of the redirect URL to the wizard, which holds it for the whole batch', () => { + const { onRedirectUrlChange } = renderStep(); + fireEvent.change(screen.getByTestId('bulk-redirect-url-input'), { target: { value: 'https://example.com' } }); + expect(onRedirectUrlChange).toHaveBeenCalledWith('https://example.com'); + }); + + it('should hold back review while the redirect URL is not https', () => { + renderStep({ redirectUrl: 'http://example.com', timepoints: [happinessTimepoint] }); + expect(reviewButton().disabled).toBe(true); + }); + + it('should allow review with an https redirect URL', () => { + renderStep({ redirectUrl: 'https://example.com', timepoints: [happinessTimepoint] }); + expect(reviewButton().disabled).toBe(false); + }); + + it('should explain what is wrong with the redirect URL once the user leaves the field, not while they type', () => { + renderStep({ redirectUrl: 'http://example.com' }); + expect(screen.queryByTestId('bulk-redirect-url-error')).toBeNull(); + fireEvent.blur(screen.getByTestId('bulk-redirect-url-input')); + expect(screen.getByTestId('bulk-redirect-url-error').textContent).toContain('https://'); + }); + + it('should not call a blank redirect URL invalid, since the batch need not redirect', () => { + renderStep(); + fireEvent.blur(screen.getByTestId('bulk-redirect-url-input')); + expect(screen.queryByTestId('bulk-redirect-url-error')).toBeNull(); + }); }); diff --git a/apps/web/src/hooks/__tests__/useBulkAssignmentWizard.test.ts b/apps/web/src/hooks/__tests__/useBulkAssignmentWizard.test.ts index 60db6618d..544ca6875 100644 --- a/apps/web/src/hooks/__tests__/useBulkAssignmentWizard.test.ts +++ b/apps/web/src/hooks/__tests__/useBulkAssignmentWizard.test.ts @@ -100,6 +100,13 @@ describe('useBulkAssignmentWizard', () => { expect(result.current.parsed).toBeNull(); }); + it('should clear the redirect URL when starting a new batch', () => { + const { result } = renderWizard(); + act(() => result.current.setRedirectUrl('https://example.com/done')); + act(() => result.current.startOver()); + expect(result.current.redirectUrl).toBe(''); + }); + it('should keep the source tab while stepping back, so Back from the mapping step returns to the upload or paste', () => { const { result } = renderWizard(); expect(result.current.sourceMode).toBe('SELECT'); @@ -248,6 +255,27 @@ describe('useBulkAssignmentWizard', () => { expect(result.current.step).not.toBe('DONE'); }); + it('should send the redirect URL with the batch, for both preflight and create', () => { + mocks.createMutate.mockImplementation(() => undefined); + const { result } = renderWizard(); + act(() => result.current.selectSubjects(['alice'], {})); + act(() => result.current.setTimepoints([TIMEPOINT])); + act(() => result.current.setRedirectUrl('https://example.com/done')); + act(() => result.current.confirmTimepoints()); + act(() => result.current.submit({ allowDuplicates: false, skipUnavailable: false })); + expect(mocks.preflightMutate.mock.lastCall?.[0]).toMatchObject({ redirectUrl: 'https://example.com/done' }); + expect(mocks.createMutate.mock.lastCall?.[0]).toMatchObject({ redirectUrl: 'https://example.com/done' }); + }); + + it('should send no redirect URL when the field is blank', () => { + mocks.createMutate.mockImplementation(() => undefined); + const { result } = renderWizard(); + act(() => result.current.selectSubjects(['alice'], {})); + act(() => result.current.setTimepoints([TIMEPOINT])); + act(() => result.current.submit({ allowDuplicates: false, skipUnavailable: false })); + expect(mocks.createMutate.mock.lastCall?.[0].redirectUrl).toBeUndefined(); + }); + it('should expire each timepoint at the end of its chosen day, not at midnight before it', () => { mocks.createMutate.mockImplementation(() => undefined); const { result } = renderWizard(); diff --git a/apps/web/src/hooks/useBulkAssignmentWizard.ts b/apps/web/src/hooks/useBulkAssignmentWizard.ts index c8c66d844..28be169cc 100644 --- a/apps/web/src/hooks/useBulkAssignmentWizard.ts +++ b/apps/web/src/hooks/useBulkAssignmentWizard.ts @@ -17,6 +17,7 @@ import { } from '@/hooks/useBulkAssignments'; import type { BulkParseResult, CanonicalField, SubjectEmails } from '@/utils/bulk-assignments'; import { buildResultRows, toResultTsv, unavailableSubjectIds } from '@/utils/bulk-assignments'; +import { omittedIfBlank } from '@/utils/validation'; type InstrumentOption = { id: string; title: string }; @@ -43,6 +44,7 @@ export function useBulkAssignmentWizard({ const [parsed, setParsed] = useState(null); const [subjectIds, setSubjectIds] = useState([]); const [timepoints, setTimepoints] = useState([]); + const [redirectUrl, setRedirectUrl] = useState(''); const [assignments, setAssignments] = useState([]); const [sourceRows, setSourceRows] = useState(); const [columnMapping, setColumnMapping] = useState>({}); @@ -65,6 +67,7 @@ export function useBulkAssignmentWizard({ const toPayload = (allowDuplicates: boolean, ids: string[] = subjectIds) => ({ allowDuplicates, groupId, + redirectUrl: omittedIfBlank(redirectUrl), subjectIds: ids, timepoints: timepoints.map(({ expiresAt, instrumentId }) => ({ expiresAt: new Date(`${expiresAt}T23:59:59.999Z`), @@ -153,6 +156,8 @@ export function useBulkAssignmentWizard({ isSubmitting: createMutation.isPending, parsed, pastedText, + /** Where every assignment in the batch sends its subject once they finish; blank for nowhere. */ + redirectUrl, /** Subject ids resolved from the mapped columns, in the row order they were supplied. */ resolveMapping: (ids: string[], rows: BulkParseResult['rows'], mapping: BulkParseResult['mapping']) => { setSubjectIds(ids); @@ -184,6 +189,7 @@ export function useBulkAssignmentWizard({ }, setEmails, setPastedText, + setRedirectUrl, setSelectedSubjectIds: setSubjectIds, setSourceMode, setTimepoints, @@ -197,6 +203,7 @@ export function useBulkAssignmentWizard({ setParsed(null); setSubjectIds([]); setTimepoints([]); + setRedirectUrl(''); setAssignments([]); setSourceRows(undefined); setColumnMapping({}); diff --git a/apps/web/src/routes/_app/session/__tests__/remote-assignment.test.tsx b/apps/web/src/routes/_app/session/__tests__/remote-assignment.test.tsx index 944030c3a..e0585e011 100644 --- a/apps/web/src/routes/_app/session/__tests__/remote-assignment.test.tsx +++ b/apps/web/src/routes/_app/session/__tests__/remote-assignment.test.tsx @@ -205,6 +205,36 @@ describe('Route', () => { }); }); + it('should refuse a redirect URL that is not https, without creating the assignment', async () => { + selectInstrument(); + fireEvent.change(screen.getByLabelText('Redirect URL (Optional)'), { target: { value: 'http://example.com' } }); + submitForm(); + await waitFor(() => expect(screen.getByTestId('error-message-text')).toBeTruthy()); + expect(mocks.mutateAsync).not.toHaveBeenCalled(); + }); + + it('should create the assignment with the redirect URL given', async () => { + selectInstrument(); + fireEvent.change(screen.getByLabelText('Redirect URL (Optional)'), { + target: { value: 'https://example.com/done' } + }); + submitForm(); + await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalled()); + expect(mocks.mutateAsync).toHaveBeenCalledWith({ + data: expect.objectContaining({ redirectUrl: 'https://example.com/done' }) + }); + }); + + it('should create the assignment with no redirect URL when the field is cleared', async () => { + selectInstrument(); + const input = screen.getByLabelText('Redirect URL (Optional)'); + fireEvent.change(input, { target: { value: 'https://example.com/done' } }); + fireEvent.change(input, { target: { value: '' } }); + submitForm(); + await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalled()); + expect(mocks.mutateAsync.mock.lastCall?.[0].data.redirectUrl).toBeUndefined(); + }); + it('should create the assignment without a group when none is selected', async () => { mocks.store.currentGroup = null; await createAssignment(); diff --git a/apps/web/src/routes/_app/session/remote-assignment.tsx b/apps/web/src/routes/_app/session/remote-assignment.tsx index 4db7d4b32..4e738d54b 100644 --- a/apps/web/src/routes/_app/session/remote-assignment.tsx +++ b/apps/web/src/routes/_app/session/remote-assignment.tsx @@ -20,6 +20,7 @@ import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useAppStore } from '@/store'; import { selectAdministrableInstruments } from '@/utils/administrable-instruments'; import { getDefaultAssignmentExpiry } from '@/utils/assignment-duration'; +import { $RedirectUrl, omittedIfBlank } from '@/utils/validation'; /** Slide-over panel shown after an assignment is created, displaying the URL, copy button, and QR code */ const AssignmentResultSlider: React.FC<{ @@ -172,6 +173,20 @@ const RouteComponent = () => { es: 'Vence el', fr: "Date d'expiration" }) + }, + redirectUrl: { + description: t({ + en: 'Where to send the subject once they finish. Must start with https://.', + es: 'Adónde enviar al sujeto cuando termine. Debe comenzar con https://.', + fr: 'Où envoyer le client une fois terminé. Doit commencer par https://.' + }), + kind: 'string', + label: t({ + en: 'Redirect URL (Optional)', + es: 'URL de redirección (opcional)', + fr: 'URL de redirection (facultatif)' + }), + variant: 'input' } }} initialValues={{ @@ -185,15 +200,17 @@ const RouteComponent = () => { es: 'La fecha de vencimiento debe ser futura', fr: "La date d'expiration doit être dans le futur" }) - }) - }) satisfies z.ZodType> + }), + redirectUrl: $RedirectUrl.optional() + }) satisfies z.ZodType> } - onSubmit={async ({ expiresAt }) => { + onSubmit={async ({ expiresAt, redirectUrl }) => { const created = await createAssignmentMutation.mutateAsync({ data: { expiresAt, groupId: currentGroup?.id, instrumentId: selectedInstrument!.id, + redirectUrl: omittedIfBlank(redirectUrl), subjectId: currentSession.subjectId } }); diff --git a/apps/web/src/utils/__tests__/validation.test.ts b/apps/web/src/utils/__tests__/validation.test.ts index 40ff2b081..6ffbdf81b 100644 --- a/apps/web/src/utils/__tests__/validation.test.ts +++ b/apps/web/src/utils/__tests__/validation.test.ts @@ -7,7 +7,9 @@ import { $Email, $OptionalPassword, $PhoneNumber, + $RedirectUrl, clearedIfBlank, + findRedirectUrlError, omittedIfBlank, omittedIfUnchanged, requiresGroup, @@ -136,6 +138,26 @@ describe('$Email', () => { }); }); +describe('findRedirectUrlError', () => { + it('should find nothing wrong with an https URL', () => { + expect(findRedirectUrlError('https://example.com/done')).toBeNull(); + }); + + it('should explain that a redirect URL must be https', () => { + expect(findRedirectUrlError('http://example.com/done')).toContain('https://'); + }); +}); + +describe('$RedirectUrl', () => { + it('should accept a blank field, since an assignment need not redirect', () => { + expect($RedirectUrl.safeParse('').success).toBe(true); + }); + + it('should reject a filled field that is not an https URL', () => { + expect($RedirectUrl.safeParse('javascript:alert(1)').success).toBe(false); + }); +}); + describe('requiresGroup', () => { it('should not require a group for an admin, who is never scoped to one', () => { expect(requiresGroup({ basePermissionLevel: 'ADMIN' })).toBe(false); diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts index 7145a9d2e..75184a5fe 100644 --- a/apps/web/src/utils/validation.ts +++ b/apps/web/src/utils/validation.ts @@ -1,5 +1,6 @@ import type { ZodErrorLike } from '@douglasneuroinformatics/libjs'; import type { TranslateFunction, TranslationKey } from '@douglasneuroinformatics/libui/i18n'; +import { $AssignmentRedirectUrl } from '@opendatacapture/schemas/assignment'; import { findPhoneNumberError, MIN_PHONE_DIGITS } from '@opendatacapture/schemas/user'; import type { BasePermissionLevel, PhoneNumberErrorCode } from '@opendatacapture/schemas/user'; import { z } from 'zod/v4'; @@ -81,6 +82,18 @@ function $PhoneNumber(t: TranslateFunction, storedValue?: null | }); } +/** + * Why `value` cannot be where an assignment sends its subject once they finish, or null if it can. + * The message is the shared schema's own, which the app's zod error map localizes as it parses. + */ +function findRedirectUrlError(value: string) { + const result = $AssignmentRedirectUrl.safeParse(value); + return result.success ? null : result.error.issues[0]!.message; +} + +/** An assignment's optional redirect URL, as a form field that may be left blank. */ +const $RedirectUrl = blankOr(findRedirectUrlError); + /** * Whether this user must belong to at least one group, shared by the create and update forms so the * two cannot drift apart again. @@ -113,7 +126,9 @@ export { $Email, $OptionalPassword, $PhoneNumber, + $RedirectUrl, clearedIfBlank, + findRedirectUrlError, omittedIfBlank, omittedIfUnchanged, requiresGroup, diff --git a/docs/en/5-reference/5.5-gateway.md b/docs/en/5-reference/5.5-gateway.md index b6eb25bed..4b906da86 100644 --- a/docs/en/5-reference/5.5-gateway.md +++ b/docs/en/5-reference/5.5-gateway.md @@ -9,4 +9,6 @@ In addition to the core platform, an additional service (the "Gateway") is avail When a clinical assigns an instrument to a subject, the Gateway receives a blob of data, including the subject ID, instrument structure, and a public key. The Gateway uses this data to generate a one-time link for the subject to complete the instrument. Once completed, this data is be encrypted using the public key and stored in the Gateway Database; the data cannot be encrypted without the private key, which remains within the Douglas firewall. Periodically, the Core API requests data from the Gateway, at which point it is decrypted and inserted into the Core Database and removed from the Gateway Database. +An assignment can be given a redirect URL when it is created, which must start with `https://`. Once the subject completes the assignment, the Gateway shows its completion screen for a few seconds and then sends them to that URL, for example back to the study portal they came from; a link on the completion screen lets them go straight away. A bulk operation applies one redirect URL to every assignment it creates. Without one, the subject stays on the completion screen. + The Gateway Database is a SQLite file kept in the `sqlite` volume. Each time the Gateway container starts, it brings that file up to date with the version being started, so upgrading the Gateway needs no manual database step. diff --git a/packages/schemas/src/assignment/__tests__/assignment.test.ts b/packages/schemas/src/assignment/__tests__/assignment.test.ts index bf1f34039..5ac73c710 100644 --- a/packages/schemas/src/assignment/__tests__/assignment.test.ts +++ b/packages/schemas/src/assignment/__tests__/assignment.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + $AssignmentRedirectUrl, $BulkAssignmentFailure, $BulkAssignmentIssue, $BulkAssignmentPreflightData, @@ -39,11 +40,52 @@ const remoteAssignment = { url: 'http://localhost:3500/assignments/assignment-1' }; +describe('$AssignmentRedirectUrl', () => { + it('should accept an https URL', () => { + expect($AssignmentRedirectUrl.safeParse('https://example.com/study?id=1').data).toBe( + 'https://example.com/study?id=1' + ); + }); + + it('should normalize a URL to ASCII, so the gateway can embed it in its latin1-only page props', () => { + expect($AssignmentRedirectUrl.parse('https://exämple.com/päth')).toBe('https://xn--exmple-cua.com/p%C3%A4th'); + }); + + it('should reject a plain http URL, which would hand the subject over unencrypted', () => { + expect($AssignmentRedirectUrl.safeParse('http://example.com').success).toBe(false); + }); + + it('should reject a URL that runs script or renders a document of its own', () => { + expect($AssignmentRedirectUrl.safeParse('javascript:alert(1)').success).toBe(false); + expect($AssignmentRedirectUrl.safeParse('data:text/html,').success).toBe(false); + }); + + it('should reject a value that is not a URL', () => { + expect($AssignmentRedirectUrl.safeParse('https://').success).toBe(false); + expect($AssignmentRedirectUrl.safeParse('example.com').success).toBe(false); + }); + + it('should reject a URL over 2000 characters', () => { + expect($AssignmentRedirectUrl.safeParse(`https://example.com/${'a'.repeat(2000)}`).success).toBe(false); + }); +}); + describe('$CreateAssignmentData', () => { afterEach(() => { vi.useRealTimers(); }); + it('should accept an assignment with no redirect URL, so the subject stays on the completion screen', () => { + const result = $CreateAssignmentData.safeParse({ expiresAt: futureDate(), instrumentId: 'i', subjectId: 's' }); + expect(result.data).not.toHaveProperty('redirectUrl'); + }); + + it('should hold the redirect URL to the https rule', () => { + const data = { expiresAt: futureDate(), instrumentId: 'i', subjectId: 's' }; + expect($CreateAssignmentData.safeParse({ ...data, redirectUrl: 'https://example.com' }).success).toBe(true); + expect($CreateAssignmentData.safeParse({ ...data, redirectUrl: 'http://example.com' }).success).toBe(false); + }); + it('should reject an expiry in the past', () => { expect( $CreateAssignmentData.safeParse({ @@ -118,6 +160,15 @@ describe('$BulkAssignmentPreflightData', () => { }); describe('$CreateBulkAssignmentsData', () => { + it('should hold the redirect URL applied to the whole batch to the https rule', () => { + expect($CreateBulkAssignmentsData.safeParse({ ...baseRequest, redirectUrl: 'https://example.com' }).success).toBe( + true + ); + expect($CreateBulkAssignmentsData.safeParse({ ...baseRequest, redirectUrl: 'http://example.com' }).success).toBe( + false + ); + }); + it('should reject a timepoint whose expiry is in the past', () => { const result = $CreateBulkAssignmentsData.safeParse({ ...baseRequest, @@ -178,6 +229,14 @@ describe('$CreateRemoteAssignmentsData', () => { expect(result.data?.assignments).toHaveLength(3); }); + it('should accept an assignment stored without a redirect URL, which the database holds as null', () => { + const result = $CreateRemoteAssignmentsData.safeParse({ + assignments: [{ ...remoteAssignment, redirectUrl: null }], + instruments: [{ instrumentContainer, instrumentId: 'instrument-1' }] + }); + expect(result.success).toBe(true); + }); + it('should reject a batch with no assignments, so an empty request is not a silent no-op', () => { const result = $CreateRemoteAssignmentsData.safeParse({ assignments: [], diff --git a/packages/schemas/src/assignment/assignment.ts b/packages/schemas/src/assignment/assignment.ts index 087af657d..815eb965b 100644 --- a/packages/schemas/src/assignment/assignment.ts +++ b/packages/schemas/src/assignment/assignment.ts @@ -10,6 +10,18 @@ type AssignmentStatus = z.infer; /** Fallback validity period (in days) for a new remote assignment when the instance has not configured one. */ const DEFAULT_ASSIGNMENT_DURATION_DAYS = 365; +/** + * Where the gateway sends a subject once they complete the assignment. It is stored and replayed to + * whoever finishes, so only https is accepted. `href` punycodes the host and percent-encodes the + * rest, which keeps the value latin1-safe for the gateway's `btoa`-encoded page props. The URL + * check aborts, because zod otherwise still runs the transform, and `new URL` throws on a non-URL. + */ +const $AssignmentRedirectUrl = z + .url({ abort: true }) + .startsWith('https://') + .max(2000) + .transform((value) => new URL(value).href); + /** * An self-contained object representing an assignment. */ @@ -19,6 +31,7 @@ const $Assignment = $BaseModel.extend({ expiresAt: $DateLike, groupId: z.string().min(1).nullish(), instrumentId: z.string().min(1), + redirectUrl: $AssignmentRedirectUrl.nullish(), status: $AssignmentStatus, subjectId: z.string().min(1), url: z.string().url() @@ -55,6 +68,7 @@ const $CreateAssignmentData = z.object({ expiresAt: $FutureDate, groupId: z.string().nullish(), instrumentId: z.string(), + redirectUrl: $AssignmentRedirectUrl.optional(), subjectId: z.string() }); @@ -76,6 +90,8 @@ const $BulkAssignmentTimepoint = z.object({ const $BulkAssignmentRequestBase = z.object({ allowDuplicates: z.boolean().default(false), groupId: z.string().min(1), + /** Applied to every assignment the operation creates. */ + redirectUrl: $AssignmentRedirectUrl.optional(), subjectIds: $UniqueStrings.max(BULK_ASSIGNMENT_MAX_SUBJECTS), timepoints: z .array($BulkAssignmentTimepoint) @@ -221,6 +237,7 @@ export type { export { $Assignment, + $AssignmentRedirectUrl, $BulkAssignmentFailure, $BulkAssignmentIssue, $BulkAssignmentPreflightData, diff --git a/testing/src/pages/_app/group/remote-assignments.page.ts b/testing/src/pages/_app/group/remote-assignments.page.ts index 080079605..beffaad3c 100644 --- a/testing/src/pages/_app/group/remote-assignments.page.ts +++ b/testing/src/pages/_app/group/remote-assignments.page.ts @@ -26,6 +26,10 @@ export class RemoteAssignmentsPage extends AppPage { readonly mapStep: Locator; readonly parsePasted: Locator; readonly pasteInput: Locator; + readonly redirectUrlError: Locator; + readonly redirectUrlInput: Locator; + /** The review step's note of where the batch sends its subjects once they finish. */ + readonly reviewRedirectUrl: Locator; readonly selectAllSubjects: Locator; readonly skipUnplaceable: Locator; readonly submitBatch: Locator; @@ -49,6 +53,9 @@ export class RemoteAssignmentsPage extends AppPage { this.errorList = page.getByTestId('bulk-error-list'); this.parsePasted = page.getByTestId('bulk-parse-pasted'); this.pasteInput = page.getByTestId('bulk-paste-input'); + this.redirectUrlError = page.getByTestId('bulk-redirect-url-error'); + this.redirectUrlInput = page.getByTestId('bulk-redirect-url-input'); + this.reviewRedirectUrl = page.getByTestId('bulk-review-redirect-url'); this.skipUnplaceable = page.getByTestId('bulk-skip-unplaceable'); this.addTimepoint = page.getByTestId('bulk-add-timepoint'); this.confirmTimepoints = page.getByTestId('bulk-confirm-timepoints'); @@ -68,10 +75,15 @@ export class RemoteAssignmentsPage extends AppPage { /** Add the first offered instrument as a timepoint and continue to review. */ async addFirstInstrument() { + await this.addFirstTimepoint(); + await this.confirmTimepoints.click(); + } + + /** Add the first offered instrument as a timepoint, staying on the step. */ + async addFirstTimepoint() { await this.instrumentSelect.click(); await this.$ref.getByRole('option').first().click(); await this.addTimepoint.click(); - await this.confirmTimepoints.click(); } breadcrumb(step: WizardStepName): Locator { diff --git a/testing/src/pages/_app/session/remote-assignment.page.ts b/testing/src/pages/_app/session/remote-assignment.page.ts index 7e2fbb863..58485023f 100644 --- a/testing/src/pages/_app/session/remote-assignment.page.ts +++ b/testing/src/pages/_app/session/remote-assignment.page.ts @@ -9,13 +9,19 @@ export class RemoteAssignmentPage extends AppPage { readonly instrumentShowcase: Locator; readonly pageHeader: Locator; readonly qrCode: Locator; + /** The dialog's optional field for where the subject is sent once they finish. */ + readonly redirectUrlInput: Locator; readonly urlInput: Locator; + /** The message libui shows under a field that failed validation. */ + readonly validationError: Locator; constructor(page: Page) { super(page); this.pageHeader = page.getByTestId('page-header'); this.instrumentShowcase = page.getByTestId('instrument-showcase'); this.createDialog = page.getByRole('dialog'); + this.redirectUrlInput = this.createDialog.getByLabel('Redirect URL (Optional)'); + this.validationError = this.createDialog.getByTestId('error-message-text'); this.urlInput = page.locator('input[readonly]'); // The copy-to-clipboard button carries no accessible name or testid; it is the only button // rendered immediately next to the readonly URL input. diff --git a/testing/src/specs/bulk-remote-assignments.spec.ts b/testing/src/specs/bulk-remote-assignments.spec.ts index 3972b7688..6a33e76ed 100644 --- a/testing/src/specs/bulk-remote-assignments.spec.ts +++ b/testing/src/specs/bulk-remote-assignments.spec.ts @@ -58,6 +58,31 @@ test.describe('bulk remote assignments', () => { return apiRequestContext.post(`${API}${path}`, { data, headers: { Authorization: `Bearer ${adminToken}` } }); }; + test('should send every subject of a batch on to its redirect URL, and refuse one that is not https', async ({ + adminToken, + api, + apiRequestContext, + uniqueId + }) => { + const { group, subjectIds } = await seedGroup({ adminToken, api, apiRequestContext }, uniqueId, 2); + const [instrumentId] = await instrumentIdsFor(apiRequestContext, adminToken, 1); + const request = { groupId: group.id, subjectIds, timepoints: [{ expiresAt: futureIso(30), instrumentId }] }; + + const refused = await post(apiRequestContext, adminToken, '/assignments/bulk', { + ...request, + redirectUrl: 'http://example.com/done' + }); + expect(refused.status()).toBe(400); + + const redirectUrl = `https://example.com/done/${uniqueId}`; + const created = await post(apiRequestContext, adminToken, '/assignments/bulk', { ...request, redirectUrl }); + expect(created.status(), await created.text()).toBe(201); + expect(((await created.json()) as Assignment[]).map((assignment) => assignment.redirectUrl)).toEqual([ + redirectUrl, + redirectUrl + ]); + }); + test('should create one assignment per subject per timepoint, and refuse a repeat as a conflict', async ({ adminToken, api, diff --git a/testing/src/specs/gateway-assignment.spec.ts b/testing/src/specs/gateway-assignment.spec.ts index 3e5b39bfb..2f6e65d70 100644 --- a/testing/src/specs/gateway-assignment.spec.ts +++ b/testing/src/specs/gateway-assignment.spec.ts @@ -120,6 +120,8 @@ test.describe('gateway remote assignment', () => { await gatewayInstrument.submit(); await expect(gatewayInstrument.summaryHeading).toBeVisible(); await expect(gatewayInstrument.errorSummary).toHaveCount(0); + // Created without a redirect URL, so the subject is left on the completion screen. + await expect(gatewayPage.getByTestId('completion-redirect')).toHaveCount(0); // Revisiting the same link after completion must not serve the instrument again. Reuses this // now-completed assignment rather than soloing a second, expensive Cap proof-of-work just to @@ -145,6 +147,48 @@ test.describe('gateway remote assignment', () => { }); }); +test.describe('gateway completion redirect', () => { + test.describe.configure({ timeout: 180_000 }); + + test('should send the subject on to the redirect URL the assignment was created with once they complete it', async ({ + api, + context, + uniqueId + }) => { + const redirectUrl = `https://redirect.example.test/done/${uniqueId}`; + const group = await api.createGroup(); + const assignment = await api.createAssignment({ + expiresAt: new Date(Date.now() + 86_400_000), + groupId: group.id, + instrumentId: await api.findInstrumentIdByName('DNP_HAPPINESS_QUESTIONNAIRE'), + redirectUrl, + subjectId: await api.createSubject(group.id) + }); + + const gatewayPage = await context.newPage(); + // The site the subject is sent back to is not part of the stack, so answer for it here. + await gatewayPage.route('https://redirect.example.test/**', (route) => { + return route.fulfill({ body: '

Study portal

', contentType: 'text/html' }); + }); + await gatewayPage.goto(assignment.url); + await gatewayPage.locator('cap-widget').click(); + + const gatewayInstrument = new RenderInstrumentPage(gatewayPage); + await expect(gatewayInstrument.beginButton).toBeEnabled({ timeout: 120_000 }); + await gatewayInstrument.begin(); + await gatewayInstrument.completeHappinessQuestionnaire(); + await gatewayInstrument.submit(); + + // The completion screen stays up through the countdown, so the subject sees it before leaving. + await expect(gatewayInstrument.summaryHeading).toBeVisible(); + await expect(gatewayPage.getByTestId('completion-redirect')).toBeVisible(); + await gatewayPage.waitForURL(redirectUrl); + await expect(gatewayPage.getByRole('heading', { name: 'Study portal' })).toBeVisible(); + + await gatewayPage.close(); + }); +}); + test.describe('gateway malformed submission', () => { test.describe.configure({ timeout: 180_000 }); diff --git a/testing/src/specs/remote-assignment.spec.ts b/testing/src/specs/remote-assignment.spec.ts index d281652d9..f4f8af20f 100644 --- a/testing/src/specs/remote-assignment.spec.ts +++ b/testing/src/specs/remote-assignment.spec.ts @@ -82,6 +82,36 @@ test.describe('remote assignment', () => { expect(await urlInput.inputValue()).toMatch(/^https?:\/\/.+/); }); + test('should create a remote assignment that sends the subject on to the https redirect URL given', async ({ + api, + getPageModel, + page, + uniqueId + }) => { + await startSession(getPageModel, `Redirect${uniqueId}`, `User${uniqueId}`, 'Female'); + + await page.getByTestId('nav-button-/session/remote-assignment').click(); + await page.waitForURL('**/session/remote-assignment'); + + const remoteAssignmentPage = new RemoteAssignmentPage(page); + await remoteAssignmentPage.selectInstrument('Happiness Questionnaire'); + await remoteAssignmentPage.redirectUrlInput.fill('http://example.com/done'); + await remoteAssignmentPage.submitAssignmentForm(); + await expect(remoteAssignmentPage.validationError).toHaveText('Must start with "https://"'); + await expect(remoteAssignmentPage.createDialog).toBeVisible(); + + const redirectUrl = `https://example.com/done/${uniqueId}`; + await remoteAssignmentPage.redirectUrlInput.fill(redirectUrl); + await remoteAssignmentPage.submitAssignmentForm(); + await expect(remoteAssignmentPage.urlInput).toBeVisible(); + await remoteAssignmentPage.closeResultButton.click(); + + await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click(); + await page.waitForURL('**/datahub/subjects/**/table'); + const subjectId = page.url().split('/datahub/subjects/')[1]!.split('/')[0]!; + expect(await api.findAssignments(subjectId)).toMatchObject([{ redirectUrl }]); + }); + test('should offer a copy-to-clipboard control and a QR code alongside the assignment link', async ({ context, getPageModel, diff --git a/testing/src/specs/remote-assignments-wizard.spec.ts b/testing/src/specs/remote-assignments-wizard.spec.ts index 34c1a6f87..f231f15f8 100644 --- a/testing/src/specs/remote-assignments-wizard.spec.ts +++ b/testing/src/specs/remote-assignments-wizard.spec.ts @@ -158,6 +158,33 @@ test.describe('remote assignments wizard refusals', () => { // A stray row used to stop the whole batch, and only three steps later, as an identifier the user // never typed: it is named here by its row, and can be left out without redoing the file. + test('should hold back review until the redirect URL is https, then show it for review', async ({ + api, + isolatedGroupManager, + page + }) => { + const group = await isolatedGroupManager(); + await api.createSubject(group.id); + await page.goto('/group/remote-assignments?mode=create'); + const wizardPage = new RemoteAssignmentsPage(page); + await expect(wizardPage.wizard).toBeVisible(); + + await wizardPage.selectAllSubjects.click(); + await wizardPage.useSelectedSubjects.click(); + await expect(wizardPage.timepointsStep).toBeVisible(); + await wizardPage.addFirstTimepoint(); + + await wizardPage.redirectUrlInput.fill('http://example.com/done'); + await wizardPage.redirectUrlInput.blur(); + await expect(wizardPage.redirectUrlError).toHaveText('Must start with "https://"'); + await expect(wizardPage.confirmTimepoints).toBeDisabled(); + + await wizardPage.redirectUrlInput.fill('https://example.com/done'); + await expect(wizardPage.redirectUrlError).toBeHidden(); + await wizardPage.confirmTimepoints.click(); + await expect(wizardPage.reviewRedirectUrl).toContainText('https://example.com/done'); + }); + test('should warn about a row this group cannot place and continue once it is left out', async ({ api, isolatedGroupManager, From bd6451af474d55d425077b2aee07698162bec7f0 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 4 Nov 2026 16:07:38 -0500 Subject: [PATCH 4/4] test(web): match the walkthrough's retitled dismiss button 5fa1391a2 retitled "Don't show again" to "Don't Show Again" without updating its tests. One then failed to find the button, and the one asserting the button is hidden on the last step passed without checking anything, since no button ever had the old name. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx b/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx index 5aedcffe5..8eac32b00 100644 --- a/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx +++ b/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx @@ -303,7 +303,7 @@ describe('WalkthroughProvider', () => { it('should mark the walkthrough complete and close when the user asks not to see it again', () => { renderProvider(); - fireEvent.click(screen.getByRole('button', { name: "Don't show again" })); + fireEvent.click(screen.getByRole('button', { name: "Don't Show Again" })); expect(mocks.store.setIsWalkthroughComplete).toHaveBeenCalledWith(true); expect(mocks.store.setIsWalkthroughOpen).toHaveBeenCalledWith(false); }); @@ -326,7 +326,7 @@ describe('WalkthroughProvider', () => { it('should hide the option not to show again on the last step, where done already completes it', () => { renderProvider(); clickNext(STEP_COUNT_WITHOUT_GATEWAY - 1); - expect(screen.queryByRole('button', { name: "Don't show again" })).toBeNull(); + expect(screen.queryByRole('button', { name: "Don't Show Again" })).toBeNull(); }); it('should mark the walkthrough complete and close when the user finishes the last step', () => {