Skip to content
Merged
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
5 changes: 5 additions & 0 deletions apps/api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ without external Mongo. Local development does need a real replica set —
Every model has `@@map("<Name>Model")`. libnest's Prisma extension adds `model.exists(where)` and a
computed `__modelName` field, which is what CASL subject detection reads.

`enum Language` is a third hand-maintained list that must agree with `packages/schemas`, alongside
`AppSubject` above: its values are the language codes themselves, so `LANGUAGES` in
`packages/schemas/src/core/core.ts` and this enum have to stay in step. Nothing checks it — a code
in one and not the other type-checks on both sides and fails when a document is written.

## Configuration

All environment variables are declared in `$Env` (`src/core/schemas/env.schema.ts`) and read through
Expand Down
16 changes: 11 additions & 5 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,11 @@ enum SubjectIdentificationMethod {
PERSONAL_INFO
}

type ErrorMessage {
en String?
fr String?
}

type GroupSettings {
defaultIdentificationMethod SubjectIdentificationMethod
idValidationRegex String?
idValidationRegexErrorMessage ErrorMessage?
idValidationRegexErrorMessage LocalizedString?
subjectIdDisplayLength Int?
minimumAge Int?
}
Expand All @@ -110,6 +106,7 @@ type GroupSettings {
// content can be authored in a single language without requiring all of them.
type LocalizedString {
en String?
es String?
fr String?
}

Expand Down Expand Up @@ -358,6 +355,14 @@ model Session {

// Setup

// Mirrors LANGUAGES in @opendatacapture/schemas/core. Values are the language codes themselves,
// so a document read back through Prisma is already a `Language` with nothing to narrow.
enum Language {
en
es
fr
}

type BrandingText {
en String?
fr String?
Expand Down Expand Up @@ -429,6 +434,7 @@ model SetupState {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
activeLanguages Language[] @default([en, fr])
branding BrandingConfig?
defaultAssignmentDurationDays Int?
isDemo Boolean
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/demo/demo.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import generalConsentForm from '@opendatacapture/instrument-library/forms/DNP_GE
import happinessQuestionnaire from '@opendatacapture/instrument-library/forms/DNP_HAPPINESS_QUESTIONNAIRE.js';
import breakoutTask from '@opendatacapture/instrument-library/interactive/DNP_BREAKOUT_TASK.js';
import happinessQuestionnaireWithConsent from '@opendatacapture/instrument-library/series/DNP_HAPPINESS_QUESTIONNAIRE_WITH_CONSENT.js';
import type { FormInstrument } from '@opendatacapture/runtime-core';
import type { Language, WithID } from '@opendatacapture/schemas/core';
import type { FormInstrument, Language } from '@opendatacapture/runtime-core';
import type { WithID } from '@opendatacapture/schemas/core';
import type { Group } from '@opendatacapture/schemas/group';
import { encodeScopedSubjectId, generateSubjectHash } from '@opendatacapture/subject-utils';

Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/gateway/gateway.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,28 @@ import { $GatewayHealthcheckSuccessResult } from '@opendatacapture/schemas/gatew
import type { GatewayHealthcheckFailureResult, GatewayHealthcheckResult } from '@opendatacapture/schemas/gateway';

import { InstrumentsService } from '@/instruments/instruments.service';
import { SetupService } from '@/setup/setup.service';

@Injectable()
export class GatewayService {
constructor(
private readonly httpService: HttpService,
private readonly instrumentsService: InstrumentsService,
private readonly loggingService: LoggingService
private readonly loggingService: LoggingService,
private readonly setupService: SetupService
) {}

async createRemoteAssignment(
assignment: Assignment,
publicKey: webcrypto.CryptoKey
): Promise<MutateAssignmentResponseBody> {
const instrument = await this.instrumentsService.findBundleById(assignment.instrumentId);
// The gateway cannot read this instance's setup state, so the languages it may offer this
// patient are sent with the assignment.
const { activeLanguages } = await this.setupService.getState();
const response = await this.httpService.axiosRef.post(`/api/assignments`, {
...assignment,
activeLanguages,
instrumentContainer: instrument,
publicKey: Array.from(await HybridCrypto.serializePublicKey(publicKey))
} satisfies CreateRemoteAssignmentInputData);
Expand Down
26 changes: 26 additions & 0 deletions apps/api/src/setup/__tests__/setup.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,30 @@ describe('SetupService', () => {
await expect(setupService.getState()).resolves.toMatchObject({ defaultAssignmentDurationDays: null });
});
});

describe('activeLanguages', () => {
it('should persist the languages an admin selected', async () => {
setupStateModel.findFirst.mockResolvedValue({ id: 'setup-1', isSetup: true });
await setupService.updateState({ activeLanguages: ['en', 'es'] });
expect(setupStateModel.update.mock.lastCall?.[0]).toMatchObject({
data: { activeLanguages: ['en', 'es'] },
where: { id: 'setup-1' }
});
});

it('should return the saved languages', async () => {
setupStateModel.findFirst.mockResolvedValue({ activeLanguages: ['es'], isDemo: false, isSetup: true });
await expect(setupService.getState()).resolves.toMatchObject({ activeLanguages: ['es'] });
});

it('should fall back to the default for a document saved before the setting existed', async () => {
setupStateModel.findFirst.mockResolvedValue({ activeLanguages: [], isDemo: false, isSetup: true });
await expect(setupService.getState()).resolves.toMatchObject({ activeLanguages: ['en', 'fr'] });
});

it('should fall back to the default for an instance with no setup document at all', async () => {
setupStateModel.findFirst.mockResolvedValue(null);
await expect(setupService.getState()).resolves.toMatchObject({ activeLanguages: ['en', 'fr'] });
});
});
});
4 changes: 4 additions & 0 deletions apps/api/src/setup/dto/update-setup-state.dto.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { ValidationSchema } from '@douglasneuroinformatics/libnest';
import { ApiProperty } from '@nestjs/swagger';
import type { ActiveLanguages } from '@opendatacapture/schemas/core';
import { $UpdateSetupStateData } from '@opendatacapture/schemas/setup';
import type { BrandingConfig, UpdateSetupStateData } from '@opendatacapture/schemas/setup';

@ValidationSchema($UpdateSetupStateData)
export class UpdateSetupStateDto implements UpdateSetupStateData {
@ApiProperty({ required: false })
activeLanguages?: ActiveLanguages;

@ApiProperty({ required: false })
branding?: BrandingConfig | null;

Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/setup/setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
InternalServerErrorException,
ServiceUnavailableException
} from '@nestjs/common';
import { DEFAULT_ACTIVE_LANGUAGES } from '@opendatacapture/schemas/core';
import { isMailEnabled } from '@opendatacapture/schemas/mail';
import { $BrandingConfig } from '@opendatacapture/schemas/setup';
import type { CreateAdminData, InitAppOptions, SetupState, UpdateSetupStateData } from '@opendatacapture/schemas/setup';
Expand Down Expand Up @@ -51,7 +52,13 @@ export class SetupService {
// Note: unknown keys are stripped here, so a stale dev server running an
// older $BrandingConfig will silently drop newer branding fields on read.
const branding = $BrandingConfig.nullable().safeParse(savedOptions?.branding ?? null);
// The column is `Language[]`, so an unknown code cannot be stored and nothing needs parsing.
// The destructure is only what carries the non-empty guarantee into the type: the default
// covers a document written before this setting existed, and this covers an instance with no
// setup document at all.
const [fallbackLanguage, ...otherLanguages] = savedOptions?.activeLanguages ?? [];
return {
activeLanguages: fallbackLanguage ? [fallbackLanguage, ...otherLanguages] : DEFAULT_ACTIVE_LANGUAGES,
branding: branding.success ? branding.data : null,
defaultAssignmentDurationDays: savedOptions?.defaultAssignmentDurationDays ?? null,
isDemo: Boolean(savedOptions?.isDemo),
Expand Down
11 changes: 9 additions & 2 deletions apps/gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ Its own SQLite database, entirely separate from the API's MongoDB: `prisma/schem
(`RemoteAssignmentModel`), generated to `node_modules/@prisma/generated-client` and wrapped in
`src/lib/prisma.ts` with a computed `getPublicKey()`.

A `Json` column is typed by `prisma-json-types-generator`, the same generator `apps/api` uses: a
`/// [TypeName]` docstring above the field names a type from the `PrismaJson` namespace declared in
`src/typings/prisma-json-types-generator.d.ts`, and the generated client uses it on both the read
and the write side. **The name must exist in that namespace** — the generator emits the reference
either way, so a typo surfaces as an unresolved type in the generated client rather than an error
from `prisma generate`.

`GATEWAY_DATABASE_URL` is an absolute `file:` URL written by `pnpm generate:env`. Turbo runs
`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.
Expand All @@ -83,8 +90,8 @@ hydrated tree can disagree with the SSR'd HTML until you do.
## Conventions specific to here

- `@/` aliases `src/`, declared in both `vite.config.ts` and `tsconfig.json`.
- Ambient declarations live in `src/typings/` and `src/vite-env.d.ts`: `res.locals.loadRoot`,
`window.__ROOT_PROPS__`, the `cap-widget` JSX element, `__RELEASE__`.
- Ambient declarations live in `src/typings/` and `src/vite-env.d.ts`: the `PrismaJson` namespace,
`res.locals.loadRoot`, `window.__ROOT_PROPS__`, the `cap-widget` JSX element, `__RELEASE__`.
- The eslint blocks for `apps/web` and `packages/react-core` (no default exports, no bare `clsx`,
`jsx-no-literals`) **do not cover this app**, and default exports are in use. Translation is still
required, and there are no translation resource files — `src/services/i18n.ts` initializes with
Expand Down
1 change: 1 addition & 0 deletions apps/gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"esbuild": "catalog:",
"nodemon": "catalog:",
"prisma": "catalog:",
"prisma-json-types-generator": "^3.2.2",
"tailwindcss": "catalog:",
"tsx": "catalog:",
"type-fest": "workspace:type-fest__4.x@*",
Expand Down
7 changes: 7 additions & 0 deletions apps/gateway/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ generator client {
binaryTargets = ["native", "debian-openssl-1.1.x"]
}

generator json {
provider = "prisma-json-types-generator"
}

datasource db {
provider = "sqlite"
url = env("GATEWAY_DATABASE_URL")
}

model RemoteAssignmentModel {
id String @id
/// [ActiveLanguages]
// The default covers assignments created before an instance could choose which languages to offer.
activeLanguages Json @default("[\"en\",\"fr\"]")
createdAt DateTime @default(now())
completedAt DateTime?
expiresAt DateTime
Expand Down
19 changes: 9 additions & 10 deletions apps/gateway/src/Root.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from 'react';

import { LanguageToggle, ThemeToggle } from '@douglasneuroinformatics/libui/components';
import { ThemeToggle } from '@douglasneuroinformatics/libui/components';
import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks';
import { CoreProvider } from '@douglasneuroinformatics/libui/providers';
import { Branding, InstrumentRenderer } from '@opendatacapture/react-core';
import { Branding, InstrumentRenderer, LanguageToggle } from '@opendatacapture/react-core';
import type { InstrumentSubmitHandler } from '@opendatacapture/react-core';
import type { UpdateRemoteAssignmentData } from '@opendatacapture/schemas/assignment';
import type { ActiveLanguages, Language } from '@opendatacapture/schemas/core';
import type { InstrumentBundleContainer } from '@opendatacapture/schemas/instrument';
import axios from 'axios';

Expand All @@ -15,13 +16,17 @@ import './services/axios';
import './services/i18n';

export type RootProps = {
activeLanguages: ActiveLanguages;
id: string;
initialSeriesIndex?: number;
language: Language;
target: InstrumentBundleContainer;
token: string;
};

export const Root = ({ id, initialSeriesIndex, target, token }: RootProps) => {
// `language` is not read here: the entry points apply it to the translator before render, so that
// the server and the client start from the same resolved language.
export const Root = ({ activeLanguages, id, initialSeriesIndex, target, token }: RootProps) => {
const ref = useRef<HTMLDivElement>(null);
const notifications = useNotificationsStore();

Expand Down Expand Up @@ -79,13 +84,7 @@ export const Root = ({ id, initialSeriesIndex, target, token }: RootProps) => {
<Branding className="[&>span]:hidden sm:[&>span]:block" fontSize="md" />
<div className="flex gap-3">
<ThemeToggle className="h-9 w-9" />
<LanguageToggle
options={{
en: 'English',
fr: 'Français'
}}
triggerClassName="h-9 w-9"
/>
<LanguageToggle activeLanguages={activeLanguages} triggerClassName="h-9 w-9" />
</div>
</div>
</header>
Expand Down
4 changes: 4 additions & 0 deletions apps/gateway/src/entry-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import React from 'react';
import ReactDOM from 'react-dom/client';

import { Root } from './Root';
import { i18n } from './services/i18n';

import '@opendatacapture/react-core/globals.css';
import './globals.css';

const ROOT_PROPS = window.__ROOT_PROPS__;

// Set before hydrating so the client resolves the same language the server rendered.
i18n.changeLanguage(ROOT_PROPS.language);

ReactDOM.hydrateRoot(
document.getElementById('root')!,
<React.StrictMode>
Expand Down
2 changes: 2 additions & 0 deletions apps/gateway/src/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import React from 'react';
import ReactDOMServer from 'react-dom/server';

import { Root } from './Root';
import { i18n } from './services/i18n';

import type { RootProps } from './Root';

export type RenderFunction = (props: RootProps) => { html: string };

export const render: RenderFunction = (props) => {
i18n.changeLanguage(props.language);
const html = ReactDOMServer.renderToString(
<React.StrictMode>
<Root {...props} />
Expand Down
3 changes: 2 additions & 1 deletion apps/gateway/src/routers/api.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ router.post(
logger.error(result.error.issues);
throw new HttpException(400, 'Bad Request');
}
const { instrumentContainer, publicKey, ...assignment } = result.data;
const { activeLanguages, instrumentContainer, publicKey, ...assignment } = result.data;

await prisma.remoteAssignmentModel.create({
data: {
...assignment,
activeLanguages,
rawPublicKey: Buffer.from(publicKey),
targetStringified: JSON.stringify(instrumentContainer)
}
Expand Down
12 changes: 12 additions & 0 deletions apps/gateway/src/routers/root.router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { $Language } from '@opendatacapture/schemas/core';
import { $InstrumentBundleContainer } from '@opendatacapture/schemas/instrument';
import { Router } from 'express';

Expand Down Expand Up @@ -40,10 +41,21 @@ router.get(
initialSeriesIndex = assignment.encryptedData.slice(1).split('$').length;
}

// Read server-side rather than from `window.location` so the SSR pass and the hydration pass
// resolve the same language; anything else renders the page in English and then swaps it.
const { activeLanguages } = assignment;
const requestedLanguage = $Language.safeParse(req.query.lang);
const language =
requestedLanguage.success && activeLanguages.includes(requestedLanguage.data)
? requestedLanguage.data
: activeLanguages[0];

const token = generateToken(assignment.id);
const html = res.locals.loadRoot({
activeLanguages,
id,
initialSeriesIndex,
language,
target: targetParseResult.data,
token
} satisfies RootProps);
Expand Down
5 changes: 5 additions & 0 deletions apps/gateway/src/services/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { i18n } from '@douglasneuroinformatics/libui/i18n';

// `defaultLanguage` is deliberately left unset. It is what `t()` falls back to when a string has no
// entry in the resolved language, so pointing it at the patient's language would make every string
// missing a translation render as nothing. The session's language is set with `changeLanguage`.
i18n.init({
translations: {}
});

export { i18n };
7 changes: 7 additions & 0 deletions apps/gateway/src/typings/prisma-json-types-generator.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { ActiveLanguages as SchemaActiveLanguages } from '@opendatacapture/schemas/core';

declare global {
namespace PrismaJson {
type ActiveLanguages = SchemaActiveLanguages;
}
}
Loading
Loading