Skip to content

Commit ac4be54

Browse files
Merge pull request #1083 from DouglasNeuroInformatics/main
merge
2 parents d578312 + 7ea5bc2 commit ac4be54

95 files changed

Lines changed: 4248 additions & 2888 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.template

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ PLAYGROUND_DEV_SERVER_PORT=3750
7676
GATEWAY_DEV_SERVER_PORT=3500
7777
# The port to use for the Vite (full web app) development server
7878
WEB_DEV_SERVER_PORT=3000
79-
79+
# Set an arbitrary delay (in milliseconds) for all responses (useful for testing suspense)
80+
API_RESPONSE_DELAY=
8081
# If set to 'true' and NODE_ENV === 'development', then login is automated
8182
VITE_DEV_BYPASS_AUTH=false
8283
# The username to use if VITE_DEV_BYPASS_AUTH is set to true
@@ -87,6 +88,8 @@ VITE_DEV_PASSWORD=Password12345678
8788
API_BASE_URL=http://localhost:5500
8889
# The number of miliseconds to delay the result of HTTP requests in development
8990
VITE_DEV_NETWORK_LATENCY=0
91+
# Whether to force clear queries when the pathname changes
92+
VITE_DEV_FORCE_CLEAR_QUERY_CACHE=false
9093
# Plausable analytics config (optional, set both to an empty string to disable)
9194
PLAUSIBLE_BASE_URL=
9295
PLAUSIBLE_WEB_DATA_DOMAIN=

apps/api/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM node:iron-alpine AS base
1+
FROM node:iron AS base
22
WORKDIR /app
33
ARG RELEASE_VERSION
44
ENV GATEWAY_DATABASE_URL="file:/dev/null"

apps/api/package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,17 @@
2626
"@douglasneuroinformatics/libnest": "catalog:",
2727
"@douglasneuroinformatics/libpasswd": "catalog:",
2828
"@douglasneuroinformatics/libstats": "catalog:",
29-
"@faker-js/faker": "^9.0.0",
29+
"@faker-js/faker": "^9.4.0",
3030
"@nestjs/axios": "^3.0.3",
3131
"@nestjs/common": "^10.4.1",
3232
"@nestjs/config": "^3.2.3",
3333
"@nestjs/core": "^10.4.1",
3434
"@nestjs/jwt": "^10.2.0",
35-
"@nestjs/mapped-types": "2.0.5",
35+
"@nestjs/mapped-types": "2.1.0",
3636
"@nestjs/passport": "^10.0.3",
3737
"@nestjs/platform-express": "^10.4.1",
3838
"@nestjs/swagger": "^7.4.0",
39-
"@nestjs/throttler": "^6.2.1",
39+
"@nestjs/throttler": "^6.3.0",
4040
"@opendatacapture/demo": "workspace:*",
4141
"@opendatacapture/instrument-library": "workspace:*",
4242
"@opendatacapture/instrument-utils": "workspace:*",
@@ -47,9 +47,9 @@
4747
"@opendatacapture/subject-utils": "workspace:*",
4848
"@prisma/client": "catalog:",
4949
"axios": "catalog:",
50-
"express": "^4.19.2",
50+
"express": "^4.21.2",
5151
"lodash-es": "workspace:lodash-es__4.x@*",
52-
"mongodb": "^6.8.1",
52+
"mongodb": "^6.12.0",
5353
"passport": "^0.7.0",
5454
"passport-jwt": "4.0.1",
5555
"reflect-metadata": "^0.1.14",
@@ -62,15 +62,15 @@
6262
"@nestjs/testing": "^10.4.1",
6363
"@opendatacapture/instrument-stubs": "workspace:*",
6464
"@types/express": "^4.17.21",
65-
"@types/passport": "^1.0.16",
65+
"@types/passport": "^1.0.17",
6666
"@types/passport-jwt": "^4.0.1",
6767
"@types/supertest": "^6.0.2",
68-
"concurrently": "^9.0.0",
68+
"concurrently": "^9.1.2",
6969
"esbuild": "catalog:",
7070
"esbuild-plugin-tsc": "^0.4.0",
7171
"nodemon": "catalog:",
7272
"prisma": "catalog:",
73-
"prisma-json-types-generator": "^3.0.4",
73+
"prisma-json-types-generator": "^3.2.2",
7474
"supertest": "^7.0.0",
7575
"type-fest": "workspace:type-fest__4.x@*"
7676
},

apps/api/src/app.module.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CryptoModule } from '@douglasneuroinformatics/libnest/crypto';
22
import { LoggingModule } from '@douglasneuroinformatics/libnest/logging';
33
import { Module } from '@nestjs/common';
4+
import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
45
import { APP_GUARD } from '@nestjs/core';
56
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
67

@@ -10,6 +11,7 @@ import { AuthenticationGuard } from './auth/guards/authentication.guard';
1011
import { AuthorizationGuard } from './auth/guards/authorization.guard';
1112
import { ConfigurationModule } from './configuration/configuration.module';
1213
import { ConfigurationService } from './configuration/configuration.service';
14+
import { DelayMiddleware } from './core/middleware/delay.middleware';
1315
import { GatewayModule } from './gateway/gateway.module';
1416
import { GroupsModule } from './groups/groups.module';
1517
import { InstrumentsModule } from './instruments/instruments.module';
@@ -93,4 +95,13 @@ import { UsersModule } from './users/users.module';
9395
}
9496
]
9597
})
96-
export class AppModule {}
98+
export class AppModule implements NestModule {
99+
constructor(private readonly configurationService: ConfigurationService) {}
100+
101+
configure(consumer: MiddlewareConsumer) {
102+
const isDev = this.configurationService.get('NODE_ENV') === 'development';
103+
if (isDev) {
104+
consumer.apply(DelayMiddleware).forRoutes('*');
105+
}
106+
}
107+
}

apps/api/src/configuration/configuration.schema.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isNumberLike, parseNumber } from '@douglasneuroinformatics/libjs';
12
import { $BooleanString } from '@opendatacapture/schemas/core';
23
import { z } from 'zod';
34

@@ -10,17 +11,27 @@ const $OptionalURL = z.preprocess(
1011
.transform((arg) => (arg ? new URL(arg) : undefined))
1112
);
1213

14+
const $ParsedNumber = <TSchema extends z.ZodTypeAny>(schema: TSchema) => {
15+
return z.preprocess((arg) => {
16+
if (!isNumberLike(arg)) {
17+
return undefined;
18+
}
19+
return parseNumber(arg);
20+
}, schema);
21+
};
22+
1323
export const $Configuration = z
1424
.object({
15-
API_DEV_SERVER_PORT: z.coerce.number().positive().int().optional(),
16-
API_PROD_SERVER_PORT: z.coerce.number().positive().int().default(80),
25+
API_DEV_SERVER_PORT: $ParsedNumber(z.number().positive().int().optional()),
26+
API_PROD_SERVER_PORT: $ParsedNumber(z.number().positive().int().default(80)),
27+
API_RESPONSE_DELAY: $ParsedNumber(z.number().positive().int().optional()),
1728
DANGEROUSLY_DISABLE_PBKDF2_ITERATION: $BooleanString.default(false),
1829
DEBUG: $BooleanString,
1930
GATEWAY_API_KEY: z.string().min(32),
20-
GATEWAY_DEV_SERVER_PORT: z.coerce.number().positive().int().optional(),
31+
GATEWAY_DEV_SERVER_PORT: $ParsedNumber(z.number().positive().int().optional()),
2132
GATEWAY_ENABLED: $BooleanString,
2233
GATEWAY_INTERNAL_NETWORK_URL: $OptionalURL,
23-
GATEWAY_REFRESH_INTERVAL: z.coerce.number().positive().int(),
34+
GATEWAY_REFRESH_INTERVAL: $ParsedNumber(z.number().positive().int()),
2435
GATEWAY_SITE_ADDRESS: $OptionalURL,
2536
MONGO_DIRECT_CONNECTION: z.string().optional(),
2637
MONGO_REPLICA_SET: z.string().optional(),
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Injectable, type NestMiddleware } from '@nestjs/common';
2+
3+
import { ConfigurationService } from '@/configuration/configuration.service';
4+
5+
@Injectable()
6+
export class DelayMiddleware implements NestMiddleware {
7+
constructor(private readonly configurationService: ConfigurationService) {}
8+
9+
use(_req: any, _res: any, next: (error?: any) => void) {
10+
const responseDelay = this.configurationService.get('API_RESPONSE_DELAY');
11+
if (!responseDelay) {
12+
return next();
13+
}
14+
setTimeout(() => {
15+
next();
16+
}, responseDelay);
17+
}
18+
}

apps/api/src/instrument-records/instrument-records.service.ts

Lines changed: 54 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
LinearRegressionResults,
1212
UploadInstrumentRecordsData
1313
} from '@opendatacapture/schemas/instrument-records';
14-
import type { InstrumentRecordModel, Prisma, SessionModel } from '@prisma/generated-client';
14+
import { type InstrumentRecordModel, Prisma, type SessionModel } from '@prisma/generated-client';
1515
import { isNumber, pickBy } from 'lodash-es';
1616

1717
import { accessibleQuery } from '@/ability/ability.utils';
@@ -274,91 +274,84 @@ export class InstrumentRecordsService {
274274
);
275275
}
276276

277-
const createdRecordsArray: InstrumentRecordModel[] = [];
278277
const createdSessionsArray: SessionModel[] = [];
279278

280279
try {
281-
for (let i = 0; i < records.length; i++) {
282-
const { data: rawData, date, subjectId } = records[i]!;
283-
await this.createSubjectIfNotFound(subjectId);
284-
285-
const session = await this.sessionsService.create({
286-
date: date,
287-
groupId: groupId ? groupId : null,
288-
subjectData: {
289-
id: subjectId
290-
},
291-
type: 'RETROSPECTIVE'
292-
});
280+
const preProcessedRecords = await Promise.all(
281+
records.map(async (record) => {
282+
const { data: rawData, date, subjectId } = record;
283+
284+
// Validate data
285+
const parseResult = instrument.validationSchema.safeParse(this.parseJson(rawData));
286+
if (!parseResult.success) {
287+
console.error(parseResult.error.issues);
288+
throw new UnprocessableEntityException(
289+
`Data received for record does not pass validation schema of instrument '${instrument.id}'`
290+
);
291+
}
293292

294-
createdSessionsArray.push(session);
293+
// Ensure subject exists
294+
await this.createSubjectIfNotFound(subjectId);
295295

296-
const sessionId = session.id;
296+
const session = await this.sessionsService.create({
297+
date: date,
298+
groupId: groupId ?? null,
299+
subjectData: { id: subjectId },
300+
type: 'RETROSPECTIVE'
301+
});
297302

298-
const parseResult = instrument.validationSchema.safeParse(this.parseJson(rawData));
299-
if (!parseResult.success) {
300-
console.error(parseResult.error.issues);
301-
throw new UnprocessableEntityException(
302-
`Data received for record at index '${i}' does not pass validation schema of instrument '${instrument.id}'`
303-
);
304-
}
303+
createdSessionsArray.push(session);
305304

306-
const createdRecord = await this.instrumentRecordModel.create({
307-
data: {
308-
computedMeasures: instrument.measures
309-
? this.instrumentMeasuresService.computeMeasures(instrument.measures, parseResult.data)
310-
: null,
305+
const computedMeasures = instrument.measures
306+
? this.instrumentMeasuresService.computeMeasures(instrument.measures, parseResult.data)
307+
: null;
308+
309+
return {
310+
computedMeasures,
311311
data: this.serializeData(parseResult.data),
312312
date,
313-
group: groupId
314-
? {
315-
connect: { id: groupId }
316-
}
317-
: undefined,
318-
instrument: {
319-
connect: {
320-
id: instrumentId
321-
}
322-
},
323-
session: {
324-
connect: {
325-
id: sessionId
326-
}
327-
},
328-
subject: {
329-
connect: {
330-
id: subjectId
331-
}
332-
}
333-
}
334-
});
313+
groupId,
314+
instrumentId,
315+
sessionId: session.id,
316+
subjectId
317+
};
318+
})
319+
);
335320

336-
createdRecordsArray.push(createdRecord);
337-
}
338-
} catch (err) {
339-
await this.instrumentRecordModel.deleteMany({
321+
await this.instrumentRecordModel.createMany({
322+
data: preProcessedRecords
323+
});
324+
325+
return this.instrumentRecordModel.findMany({
340326
where: {
341-
id: {
342-
in: createdRecordsArray.map((record) => record.id)
343-
}
327+
groupId,
328+
instrumentId
344329
}
345330
});
331+
} catch (err) {
346332
await this.sessionsService.deleteByIds(createdSessionsArray.map((session) => session.id));
347333
throw err;
348334
}
349-
350-
return createdRecordsArray;
351335
}
352336

353337
private async createSubjectIfNotFound(subjectId: string) {
354338
try {
355-
await this.subjectsService.findById(subjectId);
339+
return await this.subjectsService.findById(subjectId);
356340
} catch (exception) {
357341
if (exception instanceof NotFoundException) {
358342
const addedSubject: CreateSubjectDto = {
359343
id: subjectId
360344
};
361-
await this.subjectsService.create(addedSubject);
345+
try {
346+
return await this.subjectsService.create(addedSubject);
347+
} catch (prismaError) {
348+
if (prismaError instanceof Prisma.PrismaClientKnownRequestError && prismaError.code === 'P2002') {
349+
console.error(prismaError);
350+
return await this.subjectsService.findById(subjectId);
351+
} else {
352+
throw prismaError;
353+
}
354+
}
362355
} else {
363356
throw exception;
364357
}

apps/api/src/users/users.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,14 @@ export class UsersService {
107107
return user;
108108
}
109109

110-
async updateById(id: string, data: UpdateUserDto, { ability }: EntityOperationOptions = {}) {
110+
async updateById(id: string, { groupIds, ...data }: UpdateUserDto, { ability }: EntityOperationOptions = {}) {
111111
return this.userModel.update({
112-
data,
112+
data: {
113+
...data,
114+
groups: {
115+
connect: groupIds?.map((id) => ({ id }))
116+
}
117+
},
113118
where: { AND: [accessibleQuery(ability, 'update', 'User')], id }
114119
});
115120
}

apps/gateway/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM node:iron-alpine AS base
1+
FROM node:iron AS base
22
WORKDIR /app
33
ARG RELEASE_VERSION
44
ENV GATEWAY_DATABASE_URL=file:/app/sqlite/gateway.db

apps/gateway/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@
2525
"@opendatacapture/schemas": "workspace:*",
2626
"@prisma/client": "catalog:",
2727
"axios": "catalog:",
28-
"compression": "^1.7.4",
29-
"express": "^4.19.2",
28+
"compression": "^1.7.5",
29+
"express": "^4.21.2",
3030
"lodash-es": "workspace:lodash-es__4.x@*",
31-
"pino-http": "^10.3.0",
31+
"pino-http": "^10.4.0",
3232
"pino-pretty": "^11.2.2",
3333
"react": "workspace:react__18.x@*",
3434
"react-dom": "workspace:react-dom__18.x@*",
@@ -42,15 +42,15 @@
4242
"@opendatacapture/vite-plugin-runtime": "workspace:*",
4343
"@types/compression": "^1.7.5",
4444
"@types/express": "^4.17.21",
45-
"@vitejs/plugin-react-swc": "^3.7.0",
45+
"@vitejs/plugin-react-swc": "^3.7.2",
4646
"autoprefixer": "^10.4.20",
4747
"esbuild": "catalog:",
4848
"nodemon": "catalog:",
49-
"postcss": "^8.4.45",
49+
"postcss": "^8.5.1",
5050
"prisma": "catalog:",
51-
"tailwindcss": "^3.4.10",
51+
"tailwindcss": "^3.4.17",
5252
"type-fest": "workspace:type-fest__4.x@*",
53-
"vite": "^5.4.3"
53+
"vite": "catalog:"
5454
},
5555
"trustedDependencies": [
5656
"prisma",

0 commit comments

Comments
 (0)