Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/assignments/__tests__/assignments.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 16 additions & 3 deletions apps/api/src/assignments/assignments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class AssignmentsService {
}

async create(
{ expiresAt, groupId, instrumentId, subjectId }: $CreateAssignmentData,
{ expiresAt, groupId, instrumentId, redirectUrl, subjectId }: $CreateAssignmentData,
currentUser: RequestUser
): Promise<Assignment> {
if (groupId) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand All @@ -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}`
Expand Down
49 changes: 49 additions & 0 deletions apps/api/test/suites/05-assignment-redirect.suite.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
7 changes: 7 additions & 0 deletions apps/gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions apps/gateway/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
1 change: 1 addition & 0 deletions apps/gateway/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ model RemoteAssignmentModel {
rawPublicKey Bytes
symmetricKey String?
encryptedData String?
redirectUrl String?
targetStringified String
status String
subjectId String
Expand Down
9 changes: 3 additions & 6 deletions apps/gateway/scripts/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 19 additions & 10 deletions apps/gateway/src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +25,7 @@ type BaseRootProps = {
type AssignmentProps = {
id: string;
initialSeriesIndex?: number;
redirectUrl?: string;
target: InstrumentBundleContainer;
token: string;
};
Expand All @@ -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.
Expand Down Expand Up @@ -77,18 +80,24 @@ const Assignment = ({ id, initialSeriesIndex, target, token }: AssignmentProps)
}
});
notifications.addNotification({ type: 'success' });
if (updateData.status === 'COMPLETE') {
setIsComplete(true);
}
};

return (
<InstrumentRenderer
disableSummaryActions
beforeBegin={<CapWidget onSolve={(token) => void handleSolve(token)} />}
className="min-h-full w-full"
disableBegin={!verified}
initialSeriesIndex={initialSeriesIndex}
target={target}
onSubmit={handleSubmit}
/>
<>
<InstrumentRenderer
disableSummaryActions
beforeBegin={<CapWidget onSolve={(token) => void handleSolve(token)} />}
className="min-h-full w-full"
disableBegin={!verified}
initialSeriesIndex={initialSeriesIndex}
target={target}
onSubmit={handleSubmit}
/>
{isComplete && redirectUrl && <CompletionRedirect url={redirectUrl} />}
</>
);
};

Expand Down
41 changes: 40 additions & 1 deletion apps/gateway/src/__tests__/Root.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,22 @@ function seriesResult(complete: boolean): InstrumentSubmitHandler.AnyContext {
};
}

function renderAssignment(target: InstrumentBundleContainer = formTarget) {
function renderAssignment(target: InstrumentBundleContainer = formTarget, redirectUrl?: string) {
return render(
<Root
activeLanguages={['en', 'fr']}
id="assignment-1"
kind="assignment"
language="en"
redirectUrl={redirectUrl}
target={target}
token="assignment-token"
/>
);
}

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' } }));
Expand Down Expand Up @@ -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();
});
});
51 changes: 51 additions & 0 deletions apps/gateway/src/components/CompletionRedirect.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-muted-foreground pb-8 text-center text-sm" data-testid="completion-redirect">
{t({
en: `Redirecting in ${secondsLeft}…`,
es: `Redirigiendo en ${secondsLeft}…`,
fr: `Redirection dans ${secondsLeft}…`
})}{' '}
<a
className="text-primary font-medium underline underline-offset-4"
data-testid="completion-redirect-link"
href={url}
onClick={(event) => {
event.preventDefault();
window.location.replace(url);
}}
>
{t({ en: 'Continue now', es: 'Continuar ahora', fr: 'Continuer maintenant' })}
</a>
</p>
);
};

export default CompletionRedirect;
Loading
Loading