diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2362a34e2..dd1acfa16 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,10 +18,6 @@ jobs: node-version-file: '.nvmrc' - name: Generate Environment run: ./scripts/generate-env.sh - - name: Setup Mongo - run: | - docker run -d --name mongo -p 27017:27017 mongo:7 --replSet rs0 - docker exec mongo mongosh --eval "rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'localhost:27017'}]});" - name: Configure Git run: | # necessary for the git commands used for release info diff --git a/apps/api/libnest.config.ts b/apps/api/libnest.config.ts index d518e1f4b..89d071dc7 100644 --- a/apps/api/libnest.config.ts +++ b/apps/api/libnest.config.ts @@ -1,23 +1,25 @@ -/* eslint-disable @typescript-eslint/no-namespace */ /* eslint-disable @typescript-eslint/no-empty-object-type */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +/* eslint-disable @typescript-eslint/no-namespace */ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as url from 'node:url'; import { defineUserConfig } from '@douglasneuroinformatics/libnest/user-config'; -import type { InferUserConfig } from '@douglasneuroinformatics/libnest/user-config'; import { getReleaseInfo } from '@opendatacapture/release-info'; import type { TokenPayload } from '@opendatacapture/schemas/auth'; -import type { Permissions } from '@opendatacapture/schemas/core'; + +import type { AppAbility } from '@/auth/auth.types.js'; +import type { RuntimePrismaClient } from '@/core/prisma.js'; +import type { $Env } from '@/core/schemas/env.schema.js'; declare module '@douglasneuroinformatics/libnest/user-config' { - export interface UserConfig extends InferUserConfig {} export namespace UserTypes { - export interface JwtPayload extends TokenPayload {} - export interface UserQueryMetadata { - additionalPermissions?: Permissions; + export interface Env extends $Env {} + export interface PrismaClient extends RuntimePrismaClient {} + export interface RequestUser extends TokenPayload { + ability: AppAbility; } } } diff --git a/apps/api/package.json b/apps/api/package.json index ce640df97..19bfb7ab2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,15 +15,19 @@ "test": "env-cmd -f ../../.env vitest" }, "dependencies": { + "@casl/ability": "^6.7.3", + "@casl/prisma": "^1.5.1", "@douglasneuroinformatics/libcrypto": "catalog:", "@douglasneuroinformatics/libjs": "catalog:", - "@douglasneuroinformatics/libnest": "^7.3.3", + "@douglasneuroinformatics/libnest": "^8.0.1", "@douglasneuroinformatics/libpasswd": "catalog:", "@douglasneuroinformatics/libstats": "catalog:", "@faker-js/faker": "^9.4.0", "@nestjs/axios": "^4.0.0", "@nestjs/common": "^11.0.11", "@nestjs/core": "^11.0.11", + "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.11", "@nestjs/swagger": "^11.0.6", "@opendatacapture/demo": "workspace:*", @@ -39,7 +43,9 @@ "express": "^5.0.1", "lodash-es": "workspace:lodash-es__4.x@*", "mongodb": "^6.15.0", - "neverthrow": "^8.2.0", + "neverthrow": "catalog:", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.2", "ts-pattern": "workspace:ts-pattern__5.x@*", @@ -48,6 +54,9 @@ "devDependencies": { "@nestjs/testing": "^11.0.11", "@types/express": "^5.0.0", + "@types/passport": "^1.0.17", + "@types/passport-jwt": "^4.0.1", + "mongodb-memory-server": "^10.3.0", "prisma": "catalog:", "prisma-json-types-generator": "^3.2.2" }, diff --git a/apps/api/src/assignments/assignments.controller.ts b/apps/api/src/assignments/assignments.controller.ts index 8058a84a8..afbb0df0d 100644 --- a/apps/api/src/assignments/assignments.controller.ts +++ b/apps/api/src/assignments/assignments.controller.ts @@ -1,9 +1,11 @@ -import { CurrentUser, RouteAccess } from '@douglasneuroinformatics/libnest'; -import type { AppAbility } from '@douglasneuroinformatics/libnest'; -import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common/decorators'; +import { CurrentUser } from '@douglasneuroinformatics/libnest'; +import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiOperation } from '@nestjs/swagger'; import type { Assignment } from '@opendatacapture/schemas/assignment'; +import type { AppAbility } from '@/auth/auth.types'; +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + import { AssignmentsService } from './assignments.service'; import { CreateAssignmentDto } from './dto/create-assignment.dto'; import { UpdateAssignmentDto } from './dto/update-assignment.dto'; diff --git a/apps/api/src/assignments/assignments.service.ts b/apps/api/src/assignments/assignments.service.ts index a9819e144..a793df34c 100644 --- a/apps/api/src/assignments/assignments.service.ts +++ b/apps/api/src/assignments/assignments.service.ts @@ -1,11 +1,12 @@ import crypto from 'node:crypto'; import { HybridCrypto } from '@douglasneuroinformatics/libcrypto'; -import { accessibleQuery, ConfigService, InjectModel } from '@douglasneuroinformatics/libnest'; +import { ConfigService, InjectModel } from '@douglasneuroinformatics/libnest'; import type { Model } from '@douglasneuroinformatics/libnest'; import { Injectable, NotFoundException } from '@nestjs/common'; import type { Assignment, UpdateAssignmentData } from '@opendatacapture/schemas/assignment'; +import { accessibleQuery } from '@/auth/ability.utils'; import type { EntityOperationOptions } from '@/core/types'; import { GatewayService } from '@/gateway/gateway.service'; diff --git a/apps/api/src/auth/__tests__/ability.utils.test.ts b/apps/api/src/auth/__tests__/ability.utils.test.ts new file mode 100644 index 000000000..340aa3bfe --- /dev/null +++ b/apps/api/src/auth/__tests__/ability.utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { accessibleQuery } from '../ability.utils'; + +const accessibleBy = vi.hoisted(() => vi.fn()); + +vi.mock('@casl/prisma/runtime', () => ({ + createAccessibleByFactory: () => accessibleBy +})); + +describe('accessibleQuery', () => { + it('should return an empty object if ability is undefined', () => { + expect(accessibleQuery(undefined, 'manage', 'User')).toStrictEqual({}); + expect(accessibleBy).not.toHaveBeenCalled(); + }); + it('should call accessibleBy with the correct parameters and return the result of accessibleBy for the model', () => { + accessibleBy.mockReturnValueOnce({ + User: 'QUERY' + }); + const ability = vi.fn(); + expect(accessibleQuery(ability as any, 'manage', 'User')).toStrictEqual('QUERY'); + expect(accessibleBy).toHaveBeenCalledExactlyOnceWith(ability, 'manage'); + }); +}); diff --git a/apps/api/src/auth/ability.factory.ts b/apps/api/src/auth/ability.factory.ts new file mode 100644 index 000000000..5c8522d61 --- /dev/null +++ b/apps/api/src/auth/ability.factory.ts @@ -0,0 +1,63 @@ +import { AbilityBuilder } from '@casl/ability'; +import { createPrismaAbility } from '@casl/prisma'; +import { LoggingService } from '@douglasneuroinformatics/libnest'; +import { Injectable } from '@nestjs/common'; +import type { TokenPayload } from '@opendatacapture/schemas/auth'; + +import { createAppAbility, detectAppSubject } from './ability.utils'; + +import type { AppAbility, Permission } from './auth.types'; + +@Injectable() +export class AbilityFactory { + constructor(private readonly loggingService: LoggingService) {} + + createForPayload(payload: Omit): AppAbility { + this.loggingService.verbose({ + message: 'Creating Ability From Payload', + payload + }); + const ability = new AbilityBuilder(createPrismaAbility); + const groupIds = payload.groups.map((group) => group.id); + switch (payload.basePermissionLevel) { + case 'ADMIN': + ability.can('manage', 'all'); + break; + case 'GROUP_MANAGER': + ability.can('manage', 'Assignment', { groupId: { in: groupIds } }); + ability.can('manage', 'Group', { id: { in: groupIds } }); + ability.can('read', 'Instrument'); + ability.can('create', 'InstrumentRecord'); + ability.can('read', 'InstrumentRecord', { groupId: { in: groupIds } }); + ability.can('create', 'Session'); + ability.can('read', 'Session', { groupId: { in: groupIds } }); + ability.can('create', 'Subject'); + ability.can('read', 'Subject', { groupIds: { hasSome: groupIds } }); + ability.can('read', 'User', { groupIds: { hasSome: groupIds } }); + break; + case 'STANDARD': + ability.can('read', 'Group', { id: { in: groupIds } }); + ability.can('read', 'Instrument'); + ability.can('create', 'InstrumentRecord'); + ability.can('read', 'Session', { groupId: { in: groupIds } }); + ability.can('create', 'Session'); + ability.can('create', 'Subject'); + ability.can('read', 'Subject', { groupIds: { hasSome: groupIds } }); + break; + } + payload.additionalPermissions?.forEach(({ action, subject }) => { + ability.can(action, subject); + }); + return ability.build({ + detectSubjectType: detectAppSubject + }); + } + + createForPermissions(permissions: Permission[]): AppAbility { + this.loggingService.verbose({ + message: 'Creating Ability From Permissions', + permissions + }); + return createAppAbility(permissions); + } +} diff --git a/apps/api/src/auth/ability.utils.ts b/apps/api/src/auth/ability.utils.ts new file mode 100644 index 000000000..d3b061a5c --- /dev/null +++ b/apps/api/src/auth/ability.utils.ts @@ -0,0 +1,35 @@ +import { detectSubjectType } from '@casl/ability'; +import { createPrismaAbility } from '@casl/prisma'; +import type { PrismaQuery } from '@casl/prisma'; +import { createAccessibleByFactory } from '@casl/prisma/runtime'; +import type { AppSubject, Prisma } from '@prisma/client'; + +import type { PrismaModelWhereInputMap } from '@/core/prisma'; + +import type { AppAbilities, AppAbility, AppAction, Permission } from './auth.types'; + +const accessibleBy = createAccessibleByFactory(); + +export function detectAppSubject(obj: { [key: string]: any }) { + if (typeof obj.__modelName === 'string') { + return obj.__modelName as AppSubject; + } + return detectSubjectType(obj) as AppSubject; +} + +export function createAppAbility(permissions: Permission[]): AppAbility { + return createPrismaAbility(permissions, { + detectSubjectType: detectAppSubject + }); +} + +export function accessibleQuery( + ability: AppAbility | undefined, + action: AppAction, + modelName: T +): NonNullable { + if (!ability) { + return {}; + } + return accessibleBy(ability, action)[modelName]!; +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 000000000..f7e206055 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,29 @@ +import { CurrentUser } from '@douglasneuroinformatics/libnest'; +import type { RequestUser } from '@douglasneuroinformatics/libnest'; +import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; +import { $LoginCredentials } from '@opendatacapture/schemas/auth'; + +import { RouteAccess } from '@/core/decorators/route-access.decorator.js'; + +import { AuthService } from './auth.service.js'; + +@Controller({ path: 'auth' }) +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Get('create-instrument-token') + @HttpCode(HttpStatus.OK) + @RouteAccess({ action: 'create', subject: 'Instrument' }) + async getCreateInstrumentToken(@CurrentUser() currentUser: RequestUser): Promise<{ accessToken: string }> { + return this.authService.getCreateInstrumentToken(currentUser); + } + + @ApiOperation({ summary: 'Login' }) + @HttpCode(HttpStatus.OK) + @Post('login') + @RouteAccess('public') + async login(@Body() credentials: $LoginCredentials): Promise<{ accessToken: string }> { + return this.authService.login(credentials); + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 000000000..b37cd29b3 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,35 @@ +import { ConfigService } from '@douglasneuroinformatics/libnest'; +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { JwtModule } from '@nestjs/jwt'; + +import { UsersModule } from '@/users/users.module'; + +import { AbilityFactory } from './ability.factory'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +@Module({ + controllers: [AuthController], + imports: [ + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('SECRET_KEY') + }) + }), + UsersModule + ], + providers: [ + AbilityFactory, + AuthService, + JwtStrategy, + { + provide: APP_GUARD, + useClass: JwtAuthGuard + } + ] +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 000000000..380bd295a --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,73 @@ +import { CryptoService } from '@douglasneuroinformatics/libnest'; +import type { RequestUser } from '@douglasneuroinformatics/libnest'; +import { ForbiddenException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import type { $LoginCredentials, TokenPayload } from '@opendatacapture/schemas/auth'; +import type { Group, User } from '@prisma/client'; + +import { UsersService } from '@/users/users.service'; + +import { AbilityFactory } from './ability.factory'; + +@Injectable() +export class AuthService { + constructor( + private readonly abilityFactory: AbilityFactory, + private readonly cryptoService: CryptoService, + private readonly jwtService: JwtService, + private readonly usersService: UsersService + ) {} + + async getCreateInstrumentToken(currentUser: RequestUser) { + if (!currentUser.ability.can('create', 'Instrument')) { + throw new ForbiddenException(); + } + + const limitedAbility = this.abilityFactory.createForPermissions([{ action: 'create', subject: 'Instrument' }]); + + return { + accessToken: await this.jwtService.signAsync({ permissions: limitedAbility.rules }, { expiresIn: '1h' }) + }; + } + + async login(credentials: $LoginCredentials): Promise<{ accessToken: string }> { + let user: User & { + groups: Group[]; + }; + try { + user = await this.usersService.findByUsername(credentials.username, { includeHashedPassword: true }); + } catch (err) { + if (err instanceof NotFoundException) { + throw new UnauthorizedException('Invalid Credentials'); + } + throw err; + } + const isCorrectPassword = await this.cryptoService.comparePassword(credentials.password, user.hashedPassword); + if (isCorrectPassword !== true) { + throw new UnauthorizedException('Invalid Credentials'); + } + + const tokenPayload: Omit = { + additionalPermissions: user.additionalPermissions, + basePermissionLevel: user.basePermissionLevel, + firstName: user.firstName, + groups: user.groups, + lastName: user.lastName, + username: user.username + }; + + const ability = this.abilityFactory.createForPayload(tokenPayload); + + return { + accessToken: await this.jwtService.signAsync( + { + ...tokenPayload, + permissions: ability.rules + }, + { + expiresIn: '1h' + } + ) + }; + } +} diff --git a/apps/api/src/auth/auth.types.ts b/apps/api/src/auth/auth.types.ts new file mode 100644 index 000000000..f46a80b82 --- /dev/null +++ b/apps/api/src/auth/auth.types.ts @@ -0,0 +1,23 @@ +import { PureAbility } from '@casl/ability'; +import type { RawRuleOf } from '@casl/ability'; +import type { PrismaQuery, Subjects } from '@casl/prisma'; +import { Prisma } from '@prisma/client'; +import type { DefaultSelection } from '@prisma/client/runtime/library'; + +type AppAction = 'create' | 'delete' | 'manage' | 'read' | 'update'; + +type AppSubjects = + | 'all' + | Subjects<{ + [K in keyof Prisma.TypeMap['model']]: DefaultSelection; + }>; + +type AppSubjectName = Extract; + +type AppAbilities = [AppAction, AppSubjects]; + +type AppAbility = PureAbility; + +type Permission = RawRuleOf>; + +export type { AppAbilities, AppAbility, AppAction, AppSubjectName, Permission }; diff --git a/apps/api/src/auth/guards/__tests__/jwt-auth.guard.spec.ts b/apps/api/src/auth/guards/__tests__/jwt-auth.guard.spec.ts new file mode 100644 index 000000000..14d16d874 --- /dev/null +++ b/apps/api/src/auth/guards/__tests__/jwt-auth.guard.spec.ts @@ -0,0 +1,131 @@ +import { LoggingService } from '@douglasneuroinformatics/libnest'; +import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; +import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; +import { InternalServerErrorException } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Request } from 'express'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; + +import type { RouteAccessType } from '@/core/decorators/route-access.decorator.js'; + +import { AbilityFactory } from '../../ability.factory.js'; +import { JwtAuthGuard } from '../jwt-auth.guard.js'; + +const BaseConstructor = vi.hoisted(() => { + const constructor = vi.fn(); + constructor.prototype.canActivate = vi.fn(); + return constructor; +}); + +vi.mock('@nestjs/passport', () => ({ AuthGuard: () => BaseConstructor })); + +describe('JwtAuthGuard', () => { + let guard: JwtAuthGuard; + + let abilityFactory: AbilityFactory; + let loggingService: MockedInstance; + let reflector: MockedInstance; + + let context: MockedInstance; + let getRequest: Mock<() => Partial>; + + beforeEach(() => { + getRequest = vi.fn().mockReturnValue({ url: 'http://localhost:5500' }); + context = { + getHandler: vi.fn(), + switchToHttp: vi.fn(() => ({ getRequest, getResponse: vi.fn() })) + } satisfies Partial> as MockedInstance; + loggingService = MockFactory.createMock(LoggingService); + reflector = MockFactory.createMock(Reflector); + guard = new JwtAuthGuard(loggingService as any, reflector); + abilityFactory = new AbilityFactory(loggingService as unknown as LoggingService); + }); + + it('should extend BaseConstructor', () => { + expect(BaseConstructor).toHaveBeenCalled(); + }); + + it('should return true for a public route', async () => { + reflector.get.mockReturnValueOnce('public' satisfies RouteAccessType); + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(loggingService.verbose).toHaveBeenCalledTimes(2); + expect(loggingService.verbose).toHaveBeenLastCalledWith('Granting access for public route: http://localhost:5500'); + }); + + it('should throw an InternalServerError if RouteAccess is not defined for a route', async () => { + reflector.get.mockReturnValueOnce(undefined); + await expect(guard.canActivate(context)).rejects.toThrowError(InternalServerErrorException); + expect(loggingService.error).toHaveBeenLastCalledWith(`Route access is not defined for url: http://localhost:5500`); + }); + + it('should return false for a protected route, if AuthGuard.canActivate returns false', async () => { + reflector.get.mockReturnValueOnce({ + action: 'manage', + subject: 'all' + } satisfies RouteAccessType); + BaseConstructor.prototype.canActivate.mockResolvedValueOnce(false); + await expect(guard.canActivate(context)).resolves.toBe(false); + }); + + it('should return false for a protected route, if AuthGuard.canActivate returns a truthy, non-boolean value', async () => { + reflector.get.mockReturnValueOnce({ + action: 'manage', + subject: 'all' + } satisfies RouteAccessType); + BaseConstructor.prototype.canActivate.mockResolvedValueOnce({}); + await expect(guard.canActivate(context)).resolves.toBe(false); + }); + + it('should throw an InternalServerError if, for some reason, there is no user ability for the request', async () => { + reflector.get.mockReturnValueOnce({ + action: 'manage', + subject: 'all' + } satisfies RouteAccessType); + BaseConstructor.prototype.canActivate.mockResolvedValueOnce(true); + await expect(guard.canActivate(context)).rejects.toThrowError(InternalServerErrorException); + expect(loggingService.error).toHaveBeenLastCalledWith( + 'User property of request does not include expected AppAbility' + ); + }); + + it('should return false for a protected route, if the user does not have the right permissions', async () => { + reflector.get.mockReturnValueOnce({ + action: 'manage', + subject: 'all' + } satisfies RouteAccessType); + BaseConstructor.prototype.canActivate.mockResolvedValueOnce(true); + const ability = abilityFactory.createForPermissions([{ action: 'manage', subject: 'Group' }]); + getRequest.mockReturnValueOnce({ url: 'http://localhost:5500', user: { ability } as any }); + await expect(guard.canActivate(context)).resolves.toBe(false); + }); + + it('should return false for a protected route, if the user has only some of the right permissions', async () => { + reflector.get.mockReturnValueOnce([ + { + action: 'read', + subject: 'Assignment' + }, + { + action: 'update', + subject: 'Group' + } + ] satisfies RouteAccessType); + BaseConstructor.prototype.canActivate.mockResolvedValueOnce(true); + const ability = abilityFactory.createForPermissions([{ action: 'read', subject: 'Instrument' }]); + getRequest.mockReturnValueOnce({ url: 'http://localhost:5500', user: { ability } as any }); + await expect(guard.canActivate(context)).resolves.toBe(false); + }); + + it('should return true for a protected route, if the user has the right permissions', async () => { + reflector.get.mockReturnValueOnce({ + action: 'manage', + subject: 'all' + } satisfies RouteAccessType); + BaseConstructor.prototype.canActivate.mockResolvedValueOnce(true); + const ability = abilityFactory.createForPermissions([{ action: 'manage', subject: 'all' }]); + getRequest.mockReturnValueOnce({ url: 'http://localhost:5500', user: { ability } as any }); + await expect(guard.canActivate(context)).resolves.toBe(true); + }); +}); diff --git a/apps/api/src/auth/guards/jwt-auth.guard.ts b/apps/api/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 000000000..4ef5d2b50 --- /dev/null +++ b/apps/api/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,56 @@ +import { PureAbility } from '@casl/ability'; +import { LoggingService } from '@douglasneuroinformatics/libnest'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import type { Request } from 'express'; + +import { ROUTE_ACCESS_METADATA_KEY } from '@/core/decorators/route-access.decorator.js'; +import type { RouteAccessType } from '@/core/decorators/route-access.decorator.js'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor( + private readonly loggingService: LoggingService, + private readonly reflector: Reflector + ) { + super(); + } + + override async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + this.loggingService.verbose(`Checking auth for request url: ${request.url}`); + + const routeAccess = this.reflector.get( + ROUTE_ACCESS_METADATA_KEY, + context.getHandler() + ); + + if (!routeAccess) { + this.loggingService.error(`Route access is not defined for url: ${request.url}`); + throw new InternalServerErrorException(); + } + + if (routeAccess === 'public') { + this.loggingService.verbose(`Granting access for public route: ${request.url}`); + return true; + } + + const isAuthenticated = await super.canActivate(context); + if (isAuthenticated !== true) { + return false; + } + + const ability = request.user?.ability; + + if (!(ability instanceof PureAbility)) { + this.loggingService.error('User property of request does not include expected AppAbility'); + throw new InternalServerErrorException(); + } + + return Array.isArray(routeAccess) + ? routeAccess.every(({ action, subject }) => ability.can(action, subject)) + : ability.can(routeAccess.action, routeAccess.subject); + } +} diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 000000000..704705940 --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,28 @@ +import { ConfigService } from '@douglasneuroinformatics/libnest'; +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import type { TokenPayload } from '@opendatacapture/schemas/auth'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +import type { AppAbility } from '@/auth/auth.types'; + +import { AbilityFactory } from '../ability.factory.js'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor( + configService: ConfigService, + private readonly abilityFactory: AbilityFactory + ) { + super({ + ignoreExpiration: configService.getOrThrow('NODE_ENV') === 'development', + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: configService.getOrThrow('SECRET_KEY') + }); + } + + validate(payload: TokenPayload): TokenPayload & { ability: AppAbility } { + const ability = this.abilityFactory.createForPermissions(payload.permissions); + return { ability, ...payload }; + } +} diff --git a/apps/api/src/core/decorators/route-access.decorator.ts b/apps/api/src/core/decorators/route-access.decorator.ts new file mode 100644 index 000000000..9772eb47c --- /dev/null +++ b/apps/api/src/core/decorators/route-access.decorator.ts @@ -0,0 +1,22 @@ +import { SetMetadata } from '@nestjs/common'; + +import type { AppAction, AppSubjectName } from '../../auth/auth.types.js'; + +const ROUTE_ACCESS_METADATA_KEY = 'ODC_ROUTE_ACCESS_TOKEN'; + +export type PublicRouteAccess = 'public'; + +export type ProtectedRoutePermissionSet = { + action: AppAction; + subject: AppSubjectName; +}; + +export type ProtectedRouteAccess = ProtectedRoutePermissionSet | ProtectedRoutePermissionSet[]; + +export type RouteAccessType = ProtectedRouteAccess | PublicRouteAccess; + +export function RouteAccess(value: RouteAccessType): MethodDecorator { + return SetMetadata(ROUTE_ACCESS_METADATA_KEY, value); +} + +export { ROUTE_ACCESS_METADATA_KEY }; diff --git a/apps/api/src/core/prisma.ts b/apps/api/src/core/prisma.ts new file mode 100644 index 000000000..669de3973 --- /dev/null +++ b/apps/api/src/core/prisma.ts @@ -0,0 +1,77 @@ +import { ConfigService, LibnestPrismaExtension } from '@douglasneuroinformatics/libnest'; +import type { PrismaModelKey, PrismaModelName, PrismaModuleOptions } from '@douglasneuroinformatics/libnest'; +import { Injectable } from '@nestjs/common'; +import type { OnApplicationShutdown } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import type { MongoMemoryReplSet } from 'mongodb-memory-server'; + +@Injectable() +export class PrismaModuleOptionsFactory implements OnApplicationShutdown { + private memoryReplSet: MongoMemoryReplSet | null; + + constructor(private readonly configService: ConfigService) { + this.memoryReplSet = null; + } + + async create() { + let datasourceUrl: string; + if (this.configService.get('NODE_ENV') === 'test') { + datasourceUrl = await this.createMemoryConnection(); + } else { + datasourceUrl = this.getExternalConnection(); + } + const client = new PrismaClient({ + datasourceUrl, + omit: { + user: { + hashedPassword: true + } + } + }).$extends(LibnestPrismaExtension); + await client.$connect(); + return { client } satisfies PrismaModuleOptions; + } + + async onApplicationShutdown() { + if (this.memoryReplSet) { + await this.memoryReplSet.stop(); + } + } + + private async createMemoryConnection(): Promise { + // prevent mongodb-memory-server from being included in the production bundle + const { MongoMemoryReplSet } = await import('mongodb-memory-server'); + const replSet = await MongoMemoryReplSet.create({ replSet: { count: 1, name: 'rs0' } }); + return new URL(replSet.getUri('test')).href; + } + + private getExternalConnection(): string { + const mongoUri = this.configService.get('MONGO_URI'); + const env = this.configService.get('NODE_ENV'); + const url = new URL(`${mongoUri.href}/data-capture-${env}`); + const params = { + directConnection: this.configService.get('MONGO_DIRECT_CONNECTION'), + replicaSet: this.configService.get('MONGO_REPLICA_SET'), + retryWrites: this.configService.get('MONGO_RETRY_WRITES'), + w: this.configService.get('MONGO_WRITE_CONCERN') + }; + for (const [key, value] of Object.entries(params)) { + if (value) { + url.searchParams.append(key, String(value)); + } + } + return url.href; + } +} + +export type RuntimePrismaClient = Awaited< + ReturnType<(typeof PrismaModuleOptionsFactory)['prototype']['create']> +>['client']; + +export type PrismaModelWhereInputMap = { + [K in PrismaModelName]: PrismaClient[PrismaModelKey] extends { + findFirst: (args: { where: infer TWhereInput }) => any; + } + ? TWhereInput + : never; +}; diff --git a/apps/api/src/core/env.schema.ts b/apps/api/src/core/schemas/env.schema.ts similarity index 89% rename from apps/api/src/core/env.schema.ts rename to apps/api/src/core/schemas/env.schema.ts index 9a76ac93f..5abaca180 100644 --- a/apps/api/src/core/env.schema.ts +++ b/apps/api/src/core/schemas/env.schema.ts @@ -1,9 +1,11 @@ import { $BooleanLike, $NumberLike, $UrlLike } from '@douglasneuroinformatics/libjs'; -import { $BaseEnv } from '@douglasneuroinformatics/libnest'; +import { $BaseEnv, $MongoEnv } from '@douglasneuroinformatics/libnest'; import { z } from 'zod/v4'; +export type $Env = z.infer; export const $Env = $BaseEnv .omit({ API_PORT: true }) + .extend($MongoEnv.shape) .extend({ API_DEV_SERVER_PORT: $NumberLike.pipe(z.number().int().nonnegative()).optional(), GATEWAY_API_KEY: z.string().min(32), diff --git a/apps/api/src/core/schemas/mongo-stats.schema.ts b/apps/api/src/core/schemas/mongo-stats.schema.ts new file mode 100644 index 000000000..b4d4ad829 --- /dev/null +++ b/apps/api/src/core/schemas/mongo-stats.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod/v4'; + +export type $MongoStats = z.infer; +export const $MongoStats = z.object({ + collections: z.number(), + db: z.string(), + objects: z.number() +}); diff --git a/apps/api/src/core/types.ts b/apps/api/src/core/types.ts index 6fd27f85b..0b5c5bf79 100644 --- a/apps/api/src/core/types.ts +++ b/apps/api/src/core/types.ts @@ -1,4 +1,4 @@ -import type { AppAbility } from '@douglasneuroinformatics/libnest'; +import type { AppAbility } from '@/auth/auth.types'; export type EntityOperationOptions = { ability?: AppAbility; diff --git a/apps/api/src/demo/demo.service.ts b/apps/api/src/demo/demo.service.ts index 8c97eaf18..cd9811498 100644 --- a/apps/api/src/demo/demo.service.ts +++ b/apps/api/src/demo/demo.service.ts @@ -1,12 +1,11 @@ import { randomValue, toUpperCase } from '@douglasneuroinformatics/libjs'; -import { LoggingService, PrismaService } from '@douglasneuroinformatics/libnest'; +import { InjectPrismaClient, LoggingService } from '@douglasneuroinformatics/libnest'; import { faker } from '@faker-js/faker'; -import { Injectable } from '@nestjs/common'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { DEMO_GROUPS, DEMO_USERS } from '@opendatacapture/demo'; import enhancedDemographicsQuestionnaire from '@opendatacapture/instrument-library/forms/DNP_ENHANCED_DEMOGRAPHICS_QUESTIONNAIRE.js'; import generalConsentForm from '@opendatacapture/instrument-library/forms/DNP_GENERAL_CONSENT_FORM.js'; import happinessQuestionnaire from '@opendatacapture/instrument-library/forms/DNP_HAPPINESS_QUESTIONNAIRE.js'; -import patientHealthQuestionnaire9 from '@opendatacapture/instrument-library/forms/PHQ_9.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'; @@ -14,6 +13,8 @@ import type { Json, Language, WithID } from '@opendatacapture/schemas/core'; import type { Group } from '@opendatacapture/schemas/group'; import { encodeScopedSubjectId, generateSubjectHash } from '@opendatacapture/subject-utils'; +import type { RuntimePrismaClient } from '@/core/prisma'; +import { $MongoStats } from '@/core/schemas/mongo-stats.schema'; import { GroupsService } from '@/groups/groups.service'; import { InstrumentRecordsService } from '@/instrument-records/instrument-records.service'; import { InstrumentsService } from '@/instruments/instruments.service'; @@ -33,11 +34,11 @@ faker.seed(123); @Injectable() export class DemoService { constructor( + @InjectPrismaClient() private readonly prismaClient: RuntimePrismaClient, private readonly groupsService: GroupsService, private readonly instrumentRecordsService: InstrumentRecordsService, private readonly instrumentsService: InstrumentsService, private readonly loggingService: LoggingService, - private readonly prismaService: PrismaService, private readonly sessionsService: SessionsService, private readonly subjectsService: SubjectsService, private readonly usersService: UsersService @@ -51,7 +52,7 @@ export class DemoService { recordsPerSubject: number; }): Promise { try { - const dbName = await this.prismaService.getDbName(); + const dbName = await this.getDbName(); this.loggingService.log(`Initializing demo for database: '${dbName}'`); const hq = (await this.instrumentsService.create({ bundle: happinessQuestionnaire })) as WithID< @@ -60,8 +61,7 @@ export class DemoService { await Promise.all([ this.instrumentsService.create({ bundle: enhancedDemographicsQuestionnaire }), - this.instrumentsService.create({ bundle: generalConsentForm }), - this.instrumentsService.create({ bundle: patientHealthQuestionnaire9 }) + this.instrumentsService.create({ bundle: generalConsentForm }) ]); this.loggingService.debug('Done creating forms'); @@ -152,4 +152,20 @@ export class DemoService { throw err; } } + + private async getDbName(): Promise { + const { db } = await this.getDbStats(); + return db; + } + + private async getDbStats(): Promise<$MongoStats> { + const commandOutput = await this.prismaClient.$runCommandRaw({ dbStats: 1 }); + const result = await $MongoStats.safeParseAsync(commandOutput); + if (!result.success) { + throw new InternalServerErrorException('Raw mongodb command returned unexpected value', { + cause: result.error + }); + } + return result.data; + } } diff --git a/apps/api/src/gateway/gateway.controller.ts b/apps/api/src/gateway/gateway.controller.ts index 908619efd..8df71af38 100644 --- a/apps/api/src/gateway/gateway.controller.ts +++ b/apps/api/src/gateway/gateway.controller.ts @@ -1,8 +1,9 @@ -import { RouteAccess } from '@douglasneuroinformatics/libnest'; import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import type { GatewayHealthcheckResult } from '@opendatacapture/schemas/gateway'; +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + import { GatewayService } from './gateway.service'; @ApiTags('Gateway') diff --git a/apps/api/src/groups/groups.controller.ts b/apps/api/src/groups/groups.controller.ts index 314272c7f..c654a7bab 100644 --- a/apps/api/src/groups/groups.controller.ts +++ b/apps/api/src/groups/groups.controller.ts @@ -1,8 +1,10 @@ -import { CurrentUser, RouteAccess } from '@douglasneuroinformatics/libnest'; -import type { AppAbility } from '@douglasneuroinformatics/libnest'; +import { CurrentUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { AppAbility } from '@/auth/auth.types'; +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + import { CreateGroupDto } from './dto/create-group.dto'; import { UpdateGroupDto } from './dto/update-group.dto'; import { GroupsService } from './groups.service'; diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts index d0615f45e..8031cce97 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -1,8 +1,9 @@ -import { accessibleQuery, InjectModel } from '@douglasneuroinformatics/libnest'; +import { InjectModel } from '@douglasneuroinformatics/libnest'; import type { Model } from '@douglasneuroinformatics/libnest'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import type { Prisma } from '@prisma/client'; +import { accessibleQuery } from '@/auth/ability.utils'; import type { EntityOperationOptions } from '@/core/types'; import { InstrumentsService } from '@/instruments/instruments.service'; diff --git a/apps/api/src/instrument-records/dto/create-instrument-record.dto.ts b/apps/api/src/instrument-records/dto/create-instrument-record.dto.ts deleted file mode 100644 index 5b1df4daf..000000000 --- a/apps/api/src/instrument-records/dto/create-instrument-record.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import type { Json } from '@opendatacapture/schemas/core'; -import { $CreateInstrumentRecordData } from '@opendatacapture/schemas/instrument-records'; - -@ValidationSchema($CreateInstrumentRecordData) -export class CreateInstrumentRecordDto { - data: Json; - date: Date; - groupId?: string; - instrumentId: string; - sessionId: string; - subjectId: string; -} diff --git a/apps/api/src/instrument-records/dto/update-instrument-record.dto.ts b/apps/api/src/instrument-records/dto/update-instrument-record.dto.ts deleted file mode 100644 index cd40ee4ea..000000000 --- a/apps/api/src/instrument-records/dto/update-instrument-record.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { DataTransferObject } from '@douglasneuroinformatics/libnest'; -import { z } from 'zod/v4'; - -export class UpdateInstrumentRecordDto extends DataTransferObject({ - data: z.union([z.record(z.string(), z.any()), z.array(z.any())]) -}) {} diff --git a/apps/api/src/instrument-records/dto/upload-instrument-record.dto.ts b/apps/api/src/instrument-records/dto/upload-instrument-record.dto.ts deleted file mode 100644 index c2d818210..000000000 --- a/apps/api/src/instrument-records/dto/upload-instrument-record.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import type { Json } from '@opendatacapture/schemas/core'; -import { $UploadInstrumentRecordsData } from '@opendatacapture/schemas/instrument-records'; - -@ValidationSchema($UploadInstrumentRecordsData) -export class UploadInstrumentRecordsDto { - groupId?: string; - instrumentId: string; - records: { - data: Json; - date: Date; - subjectId: string; - }[]; -} diff --git a/apps/api/src/instrument-records/instrument-records.controller.ts b/apps/api/src/instrument-records/instrument-records.controller.ts index e5368ac25..f15c11aac 100644 --- a/apps/api/src/instrument-records/instrument-records.controller.ts +++ b/apps/api/src/instrument-records/instrument-records.controller.ts @@ -1,15 +1,19 @@ /* eslint-disable perfectionist/sort-classes */ -import { CurrentUser, ParseSchemaPipe, RouteAccess, ValidObjectIdPipe } from '@douglasneuroinformatics/libnest'; -import type { AppAbility } from '@douglasneuroinformatics/libnest'; +import { CurrentUser, ParseSchemaPipe, ValidObjectIdPipe } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import type { InstrumentKind } from '@opendatacapture/runtime-core'; +import { + $CreateInstrumentRecordData, + $UpdateInstrumentRecordData, + $UploadInstrumentRecordsData +} from '@opendatacapture/schemas/instrument-records'; import { z } from 'zod/v4'; -import { CreateInstrumentRecordDto } from './dto/create-instrument-record.dto'; -import { UpdateInstrumentRecordDto } from './dto/update-instrument-record.dto'; -import { UploadInstrumentRecordsDto } from './dto/upload-instrument-record.dto'; +import type { AppAbility } from '@/auth/auth.types'; +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + import { InstrumentRecordsService } from './instrument-records.service'; @ApiTags('Instrument Records') @@ -20,14 +24,14 @@ export class InstrumentRecordsController { @ApiOperation({ summary: 'Create Instrument Record' }) @Post() @RouteAccess({ action: 'create', subject: 'InstrumentRecord' }) - create(@Body() data: CreateInstrumentRecordDto, @CurrentUser('ability') ability: AppAbility) { + create(@Body() data: $CreateInstrumentRecordData, @CurrentUser('ability') ability: AppAbility) { return this.instrumentRecordsService.create(data, { ability }); } @ApiOperation({ summary: 'Upload Multiple Instrument Records' }) @Post('upload') @RouteAccess({ action: 'create', subject: 'InstrumentRecord' }) - upload(@Body() data: UploadInstrumentRecordsDto, @CurrentUser('ability') ability: AppAbility) { + upload(@Body() data: $UploadInstrumentRecordsData, @CurrentUser('ability') ability: AppAbility) { return this.instrumentRecordsService.upload(data, { ability }); } @@ -80,10 +84,10 @@ export class InstrumentRecordsController { @ApiOperation({ summary: 'Update Instrument Record' }) @Patch(':id') - @RouteAccess({ action: 'delete', subject: 'InstrumentRecord' }) + @RouteAccess({ action: 'update', subject: 'InstrumentRecord' }) updateById( @Param('id', ValidObjectIdPipe) id: string, - @Body() { data }: UpdateInstrumentRecordDto, + @Body() { data }: $UpdateInstrumentRecordData, @CurrentUser('ability') ability: AppAbility ) { return this.instrumentRecordsService.updateById(id, data, { ability }); diff --git a/apps/api/src/instrument-records/instrument-records.service.ts b/apps/api/src/instrument-records/instrument-records.service.ts index 3c6a3170b..0830cd7e3 100644 --- a/apps/api/src/instrument-records/instrument-records.service.ts +++ b/apps/api/src/instrument-records/instrument-records.service.ts @@ -1,5 +1,5 @@ import { replacer, reviver, yearsPassed } from '@douglasneuroinformatics/libjs'; -import { accessibleQuery, InjectModel } from '@douglasneuroinformatics/libnest'; +import { InjectModel } from '@douglasneuroinformatics/libnest'; import type { Model } from '@douglasneuroinformatics/libnest'; import { linearRegression } from '@douglasneuroinformatics/libstats'; import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; @@ -19,6 +19,7 @@ import { Prisma } from '@prisma/client'; import type { Session } from '@prisma/client'; import { isNumber, mergeWith, pickBy } from 'lodash-es'; +import { accessibleQuery } from '@/auth/ability.utils'; import type { EntityOperationOptions } from '@/core/types'; import { GroupsService } from '@/groups/groups.service'; import { InstrumentsService } from '@/instruments/instruments.service'; diff --git a/apps/api/src/instruments/instruments.controller.ts b/apps/api/src/instruments/instruments.controller.ts index 8715f5f83..a5309a6ac 100644 --- a/apps/api/src/instruments/instruments.controller.ts +++ b/apps/api/src/instruments/instruments.controller.ts @@ -1,10 +1,12 @@ -import { CurrentUser, RouteAccess } from '@douglasneuroinformatics/libnest'; -import type { AppAbility } from '@douglasneuroinformatics/libnest'; +import { CurrentUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import type { InstrumentKind } from '@opendatacapture/runtime-core'; import type { InstrumentBundleContainer, InstrumentInfo } from '@opendatacapture/schemas/instrument'; +import type { AppAbility } from '@/auth/auth.types'; +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + import { CreateInstrumentDto } from './dto/create-instrument.dto'; import { InstrumentsService } from './instruments.service'; diff --git a/apps/api/src/instruments/instruments.service.ts b/apps/api/src/instruments/instruments.service.ts index f78b738c1..21505642e 100644 --- a/apps/api/src/instruments/instruments.service.ts +++ b/apps/api/src/instruments/instruments.service.ts @@ -1,18 +1,12 @@ -import { - accessibleQuery, - CryptoService, - InjectModel, - LoggingService, - VirtualizationService -} from '@douglasneuroinformatics/libnest'; -import type { AppAbility, Model } from '@douglasneuroinformatics/libnest'; -import { Injectable } from '@nestjs/common'; +import { CryptoService, InjectModel, LoggingService, VirtualizationService } from '@douglasneuroinformatics/libnest'; +import type { Model } from '@douglasneuroinformatics/libnest'; import { ConflictException, + Injectable, InternalServerErrorException, NotFoundException, UnprocessableEntityException -} from '@nestjs/common/exceptions'; +} from '@nestjs/common'; import { isScalarInstrument, isSeriesInstrument } from '@opendatacapture/instrument-utils'; import type { AnyInstrument, @@ -30,6 +24,8 @@ import type { } from '@opendatacapture/schemas/instrument'; import { pick } from 'lodash-es'; +import { accessibleQuery } from '@/auth/ability.utils'; +import type { AppAbility } from '@/auth/auth.types'; import type { EntityOperationOptions } from '@/core/types'; import { CreateInstrumentDto } from './dto/create-instrument.dto'; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index fe965eb75..722c0273e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,8 +1,9 @@ -import { AppFactory } from '@douglasneuroinformatics/libnest'; -import { PrismaClient } from '@prisma/client'; +import { AppFactory, PrismaModule } from '@douglasneuroinformatics/libnest'; import { AssignmentsModule } from './assignments/assignments.module'; -import { $Env } from './core/env.schema'; +import { AuthModule } from './auth/auth.module'; +import { PrismaModuleOptionsFactory } from './core/prisma'; +import { $Env } from './core/schemas/env.schema'; import { GatewayModule } from './gateway/gateway.module'; import { GroupsModule } from './groups/groups.module'; import { InstrumentRecordsModule } from './instrument-records/instrument-records.module'; @@ -12,7 +13,6 @@ import { SetupModule } from './setup/setup.module'; import { SubjectsModule } from './subjects/subjects.module'; import { SummaryModule } from './summary/summary.module'; import { UsersModule } from './users/users.module'; -import { ConfiguredAuthModule } from './vendor/configured.auth.module'; export default AppFactory.create({ docs: { @@ -36,10 +36,13 @@ export default AppFactory.create({ }, envSchema: $Env, imports: [ - ConfiguredAuthModule, + AuthModule, GroupsModule, InstrumentRecordsModule, InstrumentsModule, + PrismaModule.forRootAsync({ + useClass: PrismaModuleOptionsFactory + }), SessionsModule, SetupModule, SubjectsModule, @@ -54,11 +57,5 @@ export default AppFactory.create({ when: 'GATEWAY_ENABLED' } ], - prisma: { - client: { - constructor: PrismaClient - }, - dbPrefix: 'data-capture' - }, version: '1' }); diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index fa15a9fcc..47791d576 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -1,9 +1,11 @@ -import { CurrentUser, RouteAccess } from '@douglasneuroinformatics/libnest'; -import type { AppAbility } from '@douglasneuroinformatics/libnest'; +import { CurrentUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { ApiOperation } from '@nestjs/swagger'; import type { Session } from '@prisma/client'; +import type { AppAbility } from '@/auth/auth.types'; +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + import { CreateSessionDto } from './dto/create-session.dto'; import { SessionsService } from './sessions.service'; diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index 4b0e7b00b..3d01b0fe1 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -1,12 +1,13 @@ -import { accessibleQuery, InjectModel, InjectPrismaClient, LoggingService } from '@douglasneuroinformatics/libnest'; -import type { ExtendedPrismaClient, Model } from '@douglasneuroinformatics/libnest'; -import { Injectable } from '@nestjs/common'; -import { InternalServerErrorException, NotFoundException } from '@nestjs/common/exceptions'; +import { InjectModel, InjectPrismaClient, LoggingService } from '@douglasneuroinformatics/libnest'; +import type { Model } from '@douglasneuroinformatics/libnest'; +import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import type { Group } from '@opendatacapture/schemas/group'; import type { CreateSessionData } from '@opendatacapture/schemas/session'; import type { CreateSubjectData } from '@opendatacapture/schemas/subject'; import type { Prisma, Session, Subject, User } from '@prisma/client'; +import { accessibleQuery } from '@/auth/ability.utils'; +import type { RuntimePrismaClient } from '@/core/prisma'; import type { EntityOperationOptions } from '@/core/types'; import { GroupsService } from '@/groups/groups.service'; import { SubjectsService } from '@/subjects/subjects.service'; @@ -14,7 +15,7 @@ import { SubjectsService } from '@/subjects/subjects.service'; @Injectable() export class SessionsService { constructor( - @InjectPrismaClient() private readonly prismaClient: ExtendedPrismaClient, + @InjectPrismaClient() private readonly prismaClient: RuntimePrismaClient, @InjectModel('Session') private readonly sessionModel: Model<'Session'>, private readonly groupsService: GroupsService, private readonly loggingService: LoggingService, diff --git a/apps/api/src/setup/setup.controller.ts b/apps/api/src/setup/setup.controller.ts index c9e5d1ad0..ed1a959d6 100644 --- a/apps/api/src/setup/setup.controller.ts +++ b/apps/api/src/setup/setup.controller.ts @@ -1,8 +1,9 @@ -import { RouteAccess } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Delete, Get, Patch, Post } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import type { SetupState } from '@opendatacapture/schemas/setup'; +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + import { InitAppDto } from './dto/init-app.dto'; import { UpdateSetupStateDto } from './dto/update-setup-state.dto'; import { SetupService } from './setup.service'; diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index 2eed7800c..ac8cb7a0e 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -1,19 +1,26 @@ -import { ConfigService, InjectModel, PrismaService } from '@douglasneuroinformatics/libnest'; +import { isPlainObject } from '@douglasneuroinformatics/libjs'; +import { ConfigService, InjectModel, InjectPrismaClient } from '@douglasneuroinformatics/libnest'; import type { Model } from '@douglasneuroinformatics/libnest'; -import { ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + InternalServerErrorException, + ServiceUnavailableException +} from '@nestjs/common'; import type { CreateAdminData, InitAppOptions, SetupState, UpdateSetupStateData } from '@opendatacapture/schemas/setup'; +import type { RuntimePrismaClient } from '@/core/prisma'; import { DemoService } from '@/demo/demo.service'; import { UsersService } from '@/users/users.service'; @Injectable() export class SetupService { constructor( + @InjectPrismaClient() private readonly prismaClient: RuntimePrismaClient, @InjectModel('SetupState') private readonly setupStateModel: Model<'SetupState'>, private readonly configService: ConfigService, private readonly demoService: DemoService, - private readonly usersService: UsersService, - private readonly prismaService: PrismaService + private readonly usersService: UsersService ) {} async createAdmin(admin: CreateAdminData) { @@ -25,7 +32,7 @@ export class SetupService { if (!isTest) { throw new ForbiddenException('Cannot access outside of test'); } - await this.prismaService.dropDatabase(); + await this.dropDatabase(); } async getState() { @@ -46,7 +53,7 @@ export class SetupService { if (savedOptions?.isSetup && !isDev) { throw new ForbiddenException(); } - await this.prismaService.dropDatabase(); + await this.dropDatabase(); await this.createAdmin(admin); if (initDemo) { await this.demoService.init({ @@ -73,6 +80,15 @@ export class SetupService { }); } + private async dropDatabase(): Promise { + const result = await this.prismaClient.$runCommandRaw({ dropDatabase: 1 }); + if (!isPlainObject(result) || result.ok !== 1) { + throw new InternalServerErrorException('Failed to drop database: raw mongodb command returned unexpected value', { + cause: result + }); + } + } + private async getSavedOptions() { return await this.setupStateModel.findFirst(); } diff --git a/apps/api/src/subjects/__tests__/subjects.service.spec.ts b/apps/api/src/subjects/__tests__/subjects.service.spec.ts index 54f10197f..957cf7083 100644 --- a/apps/api/src/subjects/__tests__/subjects.service.spec.ts +++ b/apps/api/src/subjects/__tests__/subjects.service.spec.ts @@ -1,5 +1,5 @@ import { CryptoService, getModelToken, PRISMA_CLIENT_TOKEN } from '@douglasneuroinformatics/libnest'; -import type { ExtendedPrismaClient, Model } from '@douglasneuroinformatics/libnest'; +import type { Model } from '@douglasneuroinformatics/libnest'; import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; import { ConflictException, NotFoundException } from '@nestjs/common'; @@ -7,12 +7,14 @@ import { Test } from '@nestjs/testing'; import { pick } from 'lodash-es'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RuntimePrismaClient } from '@/core/prisma'; + import { SubjectsService } from '../subjects.service'; describe('SubjectsService', () => { let subjectsService: SubjectsService; let subjectModel: MockedInstance>; - let prismaClient: MockedInstance & { + let prismaClient: MockedInstance & { [key: string]: any; }; diff --git a/apps/api/src/subjects/subjects.controller.ts b/apps/api/src/subjects/subjects.controller.ts index 3a7122ad3..871127f36 100644 --- a/apps/api/src/subjects/subjects.controller.ts +++ b/apps/api/src/subjects/subjects.controller.ts @@ -1,10 +1,12 @@ import { $BooleanLike } from '@douglasneuroinformatics/libjs'; -import { CurrentUser, ParseSchemaPipe, RouteAccess } from '@douglasneuroinformatics/libnest'; -import type { AppAbility } from '@douglasneuroinformatics/libnest'; +import { CurrentUser, ParseSchemaPipe } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import z from 'zod/v4'; +import type { AppAbility } from '@/auth/auth.types'; +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + import { CreateSubjectDto } from './dto/create-subject.dto'; import { SubjectsService } from './subjects.service'; diff --git a/apps/api/src/subjects/subjects.service.ts b/apps/api/src/subjects/subjects.service.ts index bcb764dba..4be993da9 100644 --- a/apps/api/src/subjects/subjects.service.ts +++ b/apps/api/src/subjects/subjects.service.ts @@ -1,8 +1,10 @@ -import { accessibleQuery, InjectModel, InjectPrismaClient } from '@douglasneuroinformatics/libnest'; -import type { ExtendedPrismaClient, Model } from '@douglasneuroinformatics/libnest'; +import { InjectModel, InjectPrismaClient } from '@douglasneuroinformatics/libnest'; +import type { Model } from '@douglasneuroinformatics/libnest'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import type { Prisma } from '@prisma/client'; +import { accessibleQuery } from '@/auth/ability.utils'; +import type { RuntimePrismaClient } from '@/core/prisma'; import type { EntityOperationOptions } from '@/core/types'; import { CreateSubjectDto } from './dto/create-subject.dto'; @@ -10,7 +12,7 @@ import { CreateSubjectDto } from './dto/create-subject.dto'; @Injectable() export class SubjectsService { constructor( - @InjectPrismaClient() private readonly prismaClient: ExtendedPrismaClient, + @InjectPrismaClient() private readonly prismaClient: RuntimePrismaClient, @InjectModel('Subject') private readonly subjectModel: Model<'Subject'> ) {} diff --git a/apps/api/src/summary/summary.controller.ts b/apps/api/src/summary/summary.controller.ts index 50ea42de6..6804c6c05 100644 --- a/apps/api/src/summary/summary.controller.ts +++ b/apps/api/src/summary/summary.controller.ts @@ -1,8 +1,10 @@ -import { CurrentUser, RouteAccess } from '@douglasneuroinformatics/libnest'; -import type { AppAbility } from '@douglasneuroinformatics/libnest'; +import { CurrentUser } from '@douglasneuroinformatics/libnest'; import { Controller, Get, Query } from '@nestjs/common'; import type { Summary } from '@opendatacapture/schemas/summary'; +import type { AppAbility } from '@/auth/auth.types'; +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + import { SummaryService } from './summary.service'; @Controller('summary') diff --git a/apps/api/src/typings/global.d.ts b/apps/api/src/typings/global.d.ts index 5d0c27055..e9b89addf 100644 --- a/apps/api/src/typings/global.d.ts +++ b/apps/api/src/typings/global.d.ts @@ -1,5 +1,12 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import type { RequestUser } from '@douglasneuroinformatics/libnest'; import type { ReleaseInfo } from '@opendatacapture/schemas/setup'; declare global { const __RELEASE__: ReleaseInfo; + namespace Express { + interface User extends RequestUser {} + } } diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index bcf0394bd..ef995ec8b 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -1,12 +1,13 @@ -import { CurrentUser, RouteAccess } from '@douglasneuroinformatics/libnest'; -import type { AppAbility } from '@douglasneuroinformatics/libnest'; +import { CurrentUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { AppAbility } from '@/auth/auth.types'; +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UsersService } from './users.service'; - @ApiTags('Users') @Controller({ path: 'users' }) export class UsersController { diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 90fe1934d..1d4130f95 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -1,7 +1,8 @@ -import { accessibleQuery, CryptoService, InjectModel } from '@douglasneuroinformatics/libnest'; +import { CryptoService, InjectModel } from '@douglasneuroinformatics/libnest'; import type { Model } from '@douglasneuroinformatics/libnest'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { accessibleQuery } from '@/auth/ability.utils'; import type { EntityOperationOptions } from '@/core/types'; import { GroupsService } from '@/groups/groups.service'; @@ -123,11 +124,14 @@ export class UsersService { return user; } - async findByUsername(username: string, { ability }: EntityOperationOptions = {}) { + async findByUsername( + username: string, + { ability, includeHashedPassword }: EntityOperationOptions & { includeHashedPassword?: boolean } = {} + ) { const user = await this.userModel.findFirst({ include: { groups: true }, omit: { - hashedPassword: true + hashedPassword: !includeHashedPassword }, where: { AND: [accessibleQuery(ability, 'read', 'User'), { username }] } }); diff --git a/apps/api/src/vendor/configured.auth.module.ts b/apps/api/src/vendor/configured.auth.module.ts deleted file mode 100644 index a84b11696..000000000 --- a/apps/api/src/vendor/configured.auth.module.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { AuthModule, getModelToken } from '@douglasneuroinformatics/libnest'; -import type { Model } from '@douglasneuroinformatics/libnest'; -import { Module } from '@nestjs/common'; -import { $LoginCredentials } from '@opendatacapture/schemas/auth'; - -@Module({ - imports: [ - AuthModule.forRootAsync({ - inject: [getModelToken('User')], - useFactory: (userModel: Model<'User'>) => { - return { - defineAbility: (ability, payload, metadata) => { - const groupIds = payload.groups.map((group) => group.id); - switch (payload.basePermissionLevel) { - case 'ADMIN': - ability.can('manage', 'all'); - break; - case 'GROUP_MANAGER': - ability.can('manage', 'Assignment', { groupId: { in: groupIds } }); - ability.can('manage', 'Group', { id: { in: groupIds } }); - ability.can('read', 'Instrument'); - ability.can('create', 'InstrumentRecord'); - ability.can('read', 'InstrumentRecord', { groupId: { in: groupIds } }); - ability.can('create', 'Session'); - ability.can('read', 'Session', { groupId: { in: groupIds } }); - ability.can('create', 'Subject'); - ability.can('read', 'Subject', { groupIds: { hasSome: groupIds } }); - ability.can('read', 'User', { groupIds: { hasSome: groupIds } }); - break; - case 'STANDARD': - ability.can('read', 'Group', { id: { in: groupIds } }); - ability.can('read', 'Instrument'); - ability.can('create', 'InstrumentRecord'); - ability.can('read', 'Session', { groupId: { in: groupIds } }); - ability.can('create', 'Session'); - ability.can('create', 'Subject'); - ability.can('read', 'Subject', { groupIds: { hasSome: groupIds } }); - break; - } - metadata.additionalPermissions?.forEach(({ action, subject }) => { - ability.can(action, subject); - }); - }, - schemas: { - loginCredentials: $LoginCredentials - }, - userQuery: async ({ username }) => { - const user = await userModel.findFirst({ - include: { groups: true }, - where: { username } - }); - if (!user) { - return null; - } - return { - hashedPassword: user.hashedPassword, - metadata: { - additionalPermissions: user.additionalPermissions - }, - tokenPayload: { - basePermissionLevel: user.basePermissionLevel, - firstName: user.firstName, - groups: user.groups, - lastName: user.lastName, - username: user.username - } - }; - } - }; - } - }) - ] -}) -export class ConfiguredAuthModule {} diff --git a/apps/playground/package.json b/apps/playground/package.json index 75b48ba2d..e224c8a77 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -25,11 +25,13 @@ "esbuild-wasm": "catalog:", "immer": "^10.1.1", "jszip": "^3.10.1", + "jwt-decode": "^4.0.0", "lodash-es": "workspace:lodash-es__4.x@*", "lucide-react": "^0.503.0", "lz-string": "^1.5.0", "monaco-editor": "^0.52.2", "motion": "catalog:", + "neverthrow": "catalog:", "react": "workspace:react__19.x@*", "react-dom": "workspace:react-dom__19.x@*", "react-dropzone": "^14.3.8", diff --git a/apps/playground/src/components/Header/ActionsDropdown/ActionsDropdown.tsx b/apps/playground/src/components/Header/ActionsDropdown/ActionsDropdown.tsx index daaefb2bd..d32391a50 100644 --- a/apps/playground/src/components/Header/ActionsDropdown/ActionsDropdown.tsx +++ b/apps/playground/src/components/Header/ActionsDropdown/ActionsDropdown.tsx @@ -6,6 +6,7 @@ import { EllipsisVerticalIcon } from 'lucide-react'; import { useAppStore } from '@/store'; import { DeleteInstrumentDialog } from './DeleteInstrumentDialog'; +import { LoginDialog } from './LoginDialog'; import { RestoreDefaultsDialog } from './RestoreDefaultsDialog'; import { UploadBundleDialog } from './UploadBundleDialog'; import { UserSettingsDialog } from './UserSettingsDialog'; @@ -15,6 +16,7 @@ export const ActionsDropdown = () => { const [showDeleteInstrumentDialog, setShowDeleteInstrumentDialog] = useState(false); const [showRestoreDefaultsDialog, setShowRestoreDefaultsDialog] = useState(false); const [showUploadBundleDialog, setShowUploadBundleDialog] = useState(false); + const [showLoginDialog, setShowLoginDialog] = useState(false); const selectedInstrument = useAppStore((store) => store.selectedInstrument); @@ -37,6 +39,11 @@ export const ActionsDropdown = () => { GitHub + setShowLoginDialog(true)}> + + setShowUploadBundleDialog(true)}> {' '} + to upload a bundle. +

+ )} + diff --git a/apps/playground/src/store/index.ts b/apps/playground/src/store/index.ts index 03bb52ffd..323f0018c 100644 --- a/apps/playground/src/store/index.ts +++ b/apps/playground/src/store/index.ts @@ -1,4 +1,5 @@ /* eslint-disable import/exports-last */ +import { jwtDecode } from 'jwt-decode'; import { pick } from 'lodash-es'; import { create } from 'zustand'; import { createJSONStorage, devtools, persist, subscribeWithSelector } from 'zustand/middleware'; @@ -6,6 +7,7 @@ import { immer } from 'zustand/middleware/immer'; import { resolveIndexFilename } from '@/utils/file'; +import { createAuthSlice } from './slices/auth.slice'; import { createEditorSlice } from './slices/editor.slice'; import { createInstrumentSlice } from './slices/instrument.slice'; import { createSettingsSlice } from './slices/settings.slice'; @@ -19,6 +21,7 @@ export const useAppStore = create( persist( subscribeWithSelector( immer((...a) => ({ + ...createAuthSlice(...a), ...createEditorSlice(...a), ...createInstrumentSlice(...a), ...createSettingsSlice(...a), @@ -29,7 +32,7 @@ export const useAppStore = create( { merge: (_persistedState, currentState) => { const persistedState = _persistedState as - | Partial> + | (Partial> & { _accessToken?: string }) | undefined; const instruments = [ ...currentState.instruments, @@ -41,10 +44,26 @@ export const useAppStore = create( instruments.find(({ id }) => id === persistedState?.selectedInstrument?.id) ?? currentState.selectedInstrument; const settings = persistedState?.settings ?? currentState.settings; - return { ...currentState, instruments, selectedInstrument, settings }; + const state: AppStore = { ...currentState, instruments, selectedInstrument, settings }; + if (persistedState?._accessToken) { + try { + state.auth = { + accessToken: persistedState._accessToken, + payload: jwtDecode(persistedState._accessToken) + }; + } catch (_) { + // if token is expired, ignore + } + } + return state; }, name: 'app', - partialize: (state) => pick(state, ['instruments', 'selectedInstrument', 'settings']), + partialize: (state) => { + return { + ...pick(state, ['instruments', 'selectedInstrument', 'settings']), + _accessToken: state.auth?.accessToken + }; + }, storage: createJSONStorage(() => localStorage), version: 1 } diff --git a/apps/playground/src/store/slices/auth.slice.ts b/apps/playground/src/store/slices/auth.slice.ts new file mode 100644 index 000000000..84295f46c --- /dev/null +++ b/apps/playground/src/store/slices/auth.slice.ts @@ -0,0 +1,23 @@ +import { jwtDecode } from 'jwt-decode'; + +import type { AuthSlice, SliceCreator } from '../types'; + +export const createAuthSlice: SliceCreator = (set, get) => ({ + auth: null, + login: (accessToken) => { + set((state) => { + state.auth = { + accessToken, + payload: jwtDecode(accessToken) + }; + }); + }, + revalidateToken: () => { + const { auth } = get(); + if (auth?.payload.exp && Date.now() / 1000 > auth.payload.exp) { + set((state) => { + state.auth = null; + }); + } + } +}); diff --git a/apps/playground/src/store/types.ts b/apps/playground/src/store/types.ts index 80bf85178..5bc9fa6f6 100644 --- a/apps/playground/src/store/types.ts +++ b/apps/playground/src/store/types.ts @@ -1,3 +1,4 @@ +import type { JwtPayload } from 'jwt-decode'; import type { Simplify } from 'type-fest'; import type { StateCreator } from 'zustand'; @@ -34,6 +35,15 @@ export type TranspilerSlice = { transpilerState: TranspilerState; }; +export type AuthSlice = { + auth: null | { + accessToken: string; + payload: JwtPayload; + }; + login: (accessToken: string) => void; + revalidateToken: () => void; +}; + export type EditorState = { files: EditorFile[]; indexFilename: null | string; @@ -77,7 +87,7 @@ export type ViewerSlice = { }; }; -export type AppStore = EditorSlice & InstrumentSlice & SettingsSlice & TranspilerSlice & ViewerSlice; +export type AppStore = AuthSlice & EditorSlice & InstrumentSlice & SettingsSlice & TranspilerSlice & ViewerSlice; export type SliceCreator = StateCreator< AppStore, diff --git a/apps/web/src/components/DemoBanner/DemoBanner.tsx b/apps/web/src/components/DemoBanner/DemoBanner.tsx index d0e18577c..1bef3d11d 100644 --- a/apps/web/src/components/DemoBanner/DemoBanner.tsx +++ b/apps/web/src/components/DemoBanner/DemoBanner.tsx @@ -2,11 +2,11 @@ import { snakeToCamelCase } from '@douglasneuroinformatics/libjs'; import { Card, Dialog, Table, Tooltip } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { DEMO_USERS } from '@opendatacapture/demo'; -import type { LoginCredentials } from '@opendatacapture/schemas/auth'; +import type { $LoginCredentials } from '@opendatacapture/schemas/auth'; import { InfoIcon, LogInIcon } from 'lucide-react'; type DemoBannerProps = { - onLogin: (credentials: LoginCredentials) => void; + onLogin: (credentials: $LoginCredentials) => void; }; export const DemoBanner = ({ onLogin }: DemoBannerProps) => { diff --git a/apps/web/src/components/LoginForm/LoginForm.tsx b/apps/web/src/components/LoginForm/LoginForm.tsx index 28cd6f633..4e6c35067 100644 --- a/apps/web/src/components/LoginForm/LoginForm.tsx +++ b/apps/web/src/components/LoginForm/LoginForm.tsx @@ -2,11 +2,11 @@ import { Form } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; -import type { LoginCredentials } from '@opendatacapture/schemas/auth'; +import type { $LoginCredentials } from '@opendatacapture/schemas/auth'; import { z } from 'zod/v4'; type LoginFormProps = { - onSubmit: (credentials: LoginCredentials) => void; + onSubmit: (credentials: $LoginCredentials) => void; }; export const LoginForm = ({ onSubmit }: LoginFormProps) => { diff --git a/apps/web/src/routes/auth/login.tsx b/apps/web/src/routes/auth/login.tsx index 0d15a9502..45f1c07ae 100644 --- a/apps/web/src/routes/auth/login.tsx +++ b/apps/web/src/routes/auth/login.tsx @@ -1,7 +1,7 @@ import { Card, Heading, LanguageToggle, ThemeToggle } from '@douglasneuroinformatics/libui/components'; import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { Logo } from '@opendatacapture/react-core'; -import type { AuthPayload, LoginCredentials } from '@opendatacapture/schemas/auth'; +import type { $LoginCredentials, AuthPayload } from '@opendatacapture/schemas/auth'; import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'; import axios from 'axios'; @@ -12,7 +12,7 @@ import { setupStateQueryOptions, useSetupStateQuery } from '@/hooks/useSetupStat import { useAppStore } from '@/store'; const loginRequest = async ( - credentials: LoginCredentials + credentials: $LoginCredentials ): Promise<{ accessToken: string; success: true } | { success: false }> => { const response = await axios.post('/v1/auth/login', credentials, { validateStatus: (status) => status === 200 || status === 401 @@ -31,7 +31,7 @@ const RouteComponent = () => { const { t } = useTranslation('auth'); const navigate = useNavigate(); - const handleLogin = async (credentials: LoginCredentials) => { + const handleLogin = async (credentials: $LoginCredentials) => { const result = await loginRequest(credentials); if (!result.success) { notifications.addNotification({ diff --git a/package.json b/package.json index e00faf1db..872bcbcb6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opendatacapture", "type": "module", - "version": "1.11.2", + "version": "1.12.0", "private": true, "packageManager": "pnpm@10.7.0", "license": "Apache-2.0", @@ -80,6 +80,7 @@ }, "onlyBuiltDependencies": [ "@nestjs/core", + "mongodb-memory-server", "sharp" ] }, diff --git a/packages/instrument-library/src/forms/ADHD_ASRS_1.1/index.ts b/packages/instrument-library/src/forms/ADHD_ASRS_1.1/index.ts deleted file mode 100644 index d23941cfe..000000000 --- a/packages/instrument-library/src/forms/ADHD_ASRS_1.1/index.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; -import { sum } from '/runtime/v1/lodash-es@4.x'; -import { z } from '/runtime/v1/zod@3.x'; - -const likertScaleOptions = { - en: { - 0: 'Never', - 1: 'Rarely', - 2: 'Sometimes', - 3: 'Often', - 4: 'Very Often' - }, - fr: { - 0: 'Jamais', - 1: 'Rarement', - 2: 'Parfois', - 3: 'Souvent', - 4: 'Très Souvent' - } -}; - -const $LikertScaleValidation = z.number().int().min(0).max(4); - -export default defineInstrument({ - content: { - selfReportADHD: { - items: { - difficultyConcentrating: { - label: { - en: 'How often do you have difficulty concentrating on what people are saying to you even when they are speaking to you directly?', - fr: "À quelle fréquence avez-vous des difficultés à vous concentrer sur ce que les gens vous disent, même lorsqu'ils vous parlent directement?" - } - }, - restlessInappropriately: { - label: { - en: 'How often do you leave your seat in meetings or other situations in which you are expected to remain seated?', - fr: "À quelle fréquence vous levez-vous pendant des réunions ou d'autres situations dans lesquelles vous êtes censé rester assis?" - } - }, - difficultyRelaxing: { - label: { - en: 'How often do you have difficulty unwinding and relaxing when you have time to yourself?', - fr: 'À quelle fréquence avez-vous des difficultés à vous détendre et à vous relaxer pendant votre temps libre?' - } - }, - sentenceCompletion: { - label: { - en: "When you're in a conversation, how often do you find yourself finishing the sentences of the people you are talking to before they can finish them themselves?", - fr: "À quelle fréquence vous surprenez-vous terminant les phrases des autres dans une discussion avant qu'ils aient pu le faire eux-mêmes?" - } - }, - procrastination: { - label: { - en: 'How often do you put things off until the last minute?', - fr: "À quelle fréquence mettez-vous les choses de côté jusqu'à la dernière minute?" - } - }, - relyOnOthers: { - label: { - en: 'How often do you depend on others to keep your life in order and attend to details?', - fr: "À quelle fréquence dépendez-vous des autres pour garder votre vie en ordre et s'occuper des détails?" - } - } - }, - kind: 'number-record', - label: { - en: 'Check the box that best describes how you have felt and conducted yourself over the past 6 months.', - fr: 'Cochez la case qui décrit le mieux la manière dont vous vous êtes senti et comporté au cours des six derniers mois.' - }, - options: likertScaleOptions, - variant: 'likert' - } - }, - details: { - description: { - en: [ - 'The Adult ADHD Self-Report Scale (ASRS v1.1) and scoring system were developed in conjunction with', - 'the World Health Organization (WHO) and the Workgroup on Adult ADHD to help healthcare', - 'professionals to screen their patients for adult ADHD. Insights gained through this screening may suggest', - 'the need for a more in-depth clinician interview. The questions in the ASRS v1.1 are consistent with', - 'DSM-IV criteria and address the manifestations of ADHD symptoms in adults. The content of the', - 'questionnaire also reflects the importance that DSM-IV places on symptoms, impairments, and history for', - 'a correct diagnosis.' - ].join(' '), - fr: [ - "L'échelle d'auto-évaluation du TDAH de l'adulte (ASRS v1.1) et le système de notation ont été élaborés en collaboration", - "avec l'Organisation mondiale de la santé (OMS) et le groupe de travail sur le TDAH de l'adulte afin d'aider les", - "professionnels de la santé à dépister le TDAH de l'adulte chez leurs patients. Les informations obtenues grâce à", - "ce dépistage peuvent suggérer la nécessité d'un entretien plus approfondi avec le clinicien. Les questions", - "de l'ASRS v1.1 sont conformes aux critères du DSM-IV et portent sur les manifestations des symptômes du TDAH", - "chez l'adulte. Le contenu du questionnaire reflète également l'importance que le DSM-IV accorde aux", - 'symptômes, aux déficiences et faux antécédents pour un diagnostic correct.' - ].join(' ') - }, - estimatedDuration: 1, - instructions: { - en: ['This is a self-rated instrument, please answer all questions.'], - fr: ["Il s'agit d'un instrument d'auto-évaluation, veuillez répondre à toutes les questions."] - }, - license: 'CC-BY-4.0', - referenceUrl: 'http://www.hcp.med.harvard.edu/ncs/asrs.php', - title: { - en: 'Adult ADHD Self-Report Screening Scale for DSM-5 (ASRS-5) v1.1', - fr: "Échelle d'auto-évaluation du dépistage du TDAH chez l'adulte pour le DSM-5 (ASRS-5) v1.1" - } - }, - internal: { - edition: 1, - name: 'ADHD_ASRS_1.1' - }, - kind: 'FORM', - language: ['en', 'fr'], - measures: { - difficultyConcentrating: { - kind: 'computed', - label: { en: 'Result difficulty concentrating', fr: 'Résultat difficulté de concentration' }, - value: (data) => data.selfReportADHD.difficultyConcentrating - }, - difficultyRelaxing: { - kind: 'computed', - label: { en: 'Result difficulty relaxing', fr: 'Résultat difficulté de détente' }, - value: (data) => data.selfReportADHD.difficultyRelaxing - }, - procrastination: { - kind: 'computed', - label: { en: 'Result procrastination', fr: 'Résultat procrastination' }, - value: (data) => data.selfReportADHD.procrastination - }, - relyOnOthers: { - kind: 'computed', - label: { en: 'Result relying on others', fr: 'Résultat dépendant des autres' }, - value: (data) => data.selfReportADHD.relyOnOthers - }, - restlessInappropriately: { - kind: 'computed', - label: { en: 'Result inappropriate restlessness', fr: 'Résultat agitation inappropriée' }, - value: (data) => data.selfReportADHD.restlessInappropriately - }, - sentenceCompletion: { - kind: 'computed', - label: { en: 'Result inappropriate sentence completion', fr: 'Résultat achèvement inapproprié de la phrase' }, - value: (data) => data.selfReportADHD.sentenceCompletion - }, - totalScore: { - kind: 'computed', - label: { en: 'Total ADHD Score', fr: 'Score total de TDAH' }, - value: (data) => { - return sum(Object.values(data.selfReportADHD)); - } - } - }, - tags: { - en: ['ADHD', 'ADD'], - fr: ['TDAH', 'TDA'] - }, - validationSchema: z.object({ - selfReportADHD: z.object({ - difficultyConcentrating: $LikertScaleValidation, - difficultyRelaxing: $LikertScaleValidation, - procrastination: $LikertScaleValidation, - relyOnOthers: $LikertScaleValidation, - restlessInappropriately: $LikertScaleValidation, - sentenceCompletion: $LikertScaleValidation - }) - }) -}); diff --git a/packages/instrument-library/src/forms/AUDIT_C/index.ts b/packages/instrument-library/src/forms/AUDIT_C/index.ts deleted file mode 100644 index 17b0dce8e..000000000 --- a/packages/instrument-library/src/forms/AUDIT_C/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; -import { sum } from '/runtime/v1/lodash-es@4.x'; -import { z } from '/runtime/v1/zod@3.x'; - -export default defineInstrument({ - kind: 'FORM', - language: ['en', 'fr'], - internal: { - name: 'AUDIT_C', - edition: 1 - }, - tags: { - en: ['Alcohol', 'Health', 'Disorder'], - fr: ['Alcool', 'Santé', 'Troubles'] - }, - clientDetails: { - title: { - en: 'Alcohol Use (AUDIT-C)', - fr: "Consommation d'alcool (AUDIT-C)" - } - }, - details: { - description: { - en: 'The Alcohol Use Disorders Identification Test (AUDIT-C) is an alcohol screen that can help identify patients who are hazardous drinkers or have active alcohol use disorders (including alcohol abuse or dependence).', - fr: "Le test d'identification des troubles liés à la consommation d'alcool (AUDIT-C) est un test de dépistage de l'alcool qui permet d'identifier les patients qui sont des buveurs dangereux ou qui présentent des troubles liés à la consommation d'alcool (y compris l'abus d'alcool ou la dépendance)." - }, - estimatedDuration: 2, - instructions: { - en: ['Please respond to every question'], - fr: ['Veuillez répondre à toutes les questions'] - }, - license: 'PUBLIC-DOMAIN', - title: { - en: 'Alcohol Use Disorders Identification Test (AUDIT-C)', - fr: "Test d'identification des troubles liés à l'utilisation de l'alcool (AUDIT-C)" - } - }, - content: { - drinkingFrequency: { - kind: 'number', - label: { - en: '1. How often do you have a drink containing alcohol?', - fr: "1. A quelle fréquence vous arrive-t-il de consommer des boissons contenant de l'alcool?" - }, - options: { - en: { - 0: 'Never', - 1: 'Monthly or Less', - 2: '2 to 4 times a month', - 3: '2 to 3 times a week', - 4: '4 or more times a week' - }, - fr: { - 0: 'Jamais', - 1: 'Une fois par mois ou moins', - 2: '2 à 4 fois par mois', - 3: '2 à 3 fois par semaine', - 4: '4 fois ou plus par semaine' - } - }, - variant: 'radio' - }, - typicalDrinkQuantity: { - kind: 'number', - label: { - en: '2. How many drinks containing alcohol do you have on a typical day when you are drinking?', - fr: "2. Combien de verres standards buvez-vous au cours d'une journée ordinaire où vous buvez de l'alcool?" - }, - options: { - en: { - 0: '1 or 2', - 1: '3 or 4', - 2: '5 or 6', - 3: '7 to 9', - 4: '10 or more' - }, - fr: { - 0: '1 ou 2', - 1: '3 ou 4', - 2: '5 ou 6', - 3: '7 ou 9', - 4: '10 ou plus' - } - }, - variant: 'radio' - }, - bingeDrinkingFrequency: { - kind: 'number', - label: { - en: '3. How often do you have six or more drinks on one occasion?', - fr: "3. Au cours d'une même occasion, à quelle fréquence vous arrive-t-il de boire six verres standard ou plus?" - }, - options: { - en: { - 0: 'Never', - 1: 'Less than monthly', - 2: 'Monthly', - 3: 'Weekly', - 4: 'Daily or almost daily' - }, - fr: { - 0: 'Jamais', - 1: "Moins d'une fois par mois", - 2: 'Une fois par mois', - 3: 'Une fois par semaine', - 4: 'Chaque jour ou presque' - } - }, - variant: 'radio' - } - }, - measures: { - auditCScore: { - kind: 'computed', - label: { - en: 'Total Score', - fr: 'Score total' - }, - value: (data) => { - return sum(Object.values(data)); - } - } - }, - validationSchema: z.object({ - drinkingFrequency: z.number().int().min(0).max(4), - typicalDrinkQuantity: z.number().int().min(0).max(4), - bingeDrinkingFrequency: z.number().int().min(0).max(4) - }) -}); diff --git a/packages/instrument-library/src/forms/CUDIT_R/index.ts b/packages/instrument-library/src/forms/CUDIT_R/index.ts deleted file mode 100644 index 566520d20..000000000 --- a/packages/instrument-library/src/forms/CUDIT_R/index.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; -import { z } from '/runtime/v1/zod@3.x'; - -const $NumberRange = z.number().int().min(0).max(4); - -const $InstrumentData = z - .object({ - isCannabisUsed: z.boolean(), - cannabisFrequency: $NumberRange.optional(), - stonedTime: $NumberRange.optional(), - unableToStopUsage: $NumberRange.optional(), - cannabisInducedFailure: $NumberRange.optional(), - cannabisRelatedUsageTime: $NumberRange.optional(), - cannabisMemoryConcentration: $NumberRange.optional(), - cannabisHazards: $NumberRange.optional(), - cannabisReduction: $NumberRange.optional() - }) - .refine(({ isCannabisUsed, ...data }) => { - if (!isCannabisUsed) { - return true; - } - // in case in the future you can deselect options - return Object.values(data).length === 8 && Object.values(data).every((arg) => typeof arg === 'number'); - }, 'Error: Please fill out all the questions / Erreur: Veuillez répondre à toutes les questions'); - -function createDependentField(field: T) { - return { - kind: 'dynamic' as const, - deps: ['isCannabisUsed'] as const, - render: (data: { isCannabisUsed?: unknown }) => { - if (data.isCannabisUsed === true) { - return field; - } - return null; - } - }; -} - -const calculateCannabisUse = (data: { [key: string]: unknown }) => { - let sum = 0; - for (const key in data) { - const value = data[key as keyof typeof data]; - if (typeof value === 'number') { - sum += value; - } - } - return sum; -}; - -export default defineInstrument({ - kind: 'FORM', - language: ['en', 'fr'], - tags: { - en: ['Cannabis', 'Addiction', 'Substance Abuse'], - fr: ['Cannabis', 'Dépendance', 'Abus de substance'] - }, - internal: { - edition: 1, - name: 'CUDIT_R' - }, - clientDetails: { - estimatedDuration: 10, - instructions: { - en: ['Please fill out answer that best describe your cannabis usage.'], - fr: ['Veuillez remplir les réponses qui décrivent le mieux votre consommation de cannabis'] - }, - title: { - en: 'Cannabis Use Disorder Identification Test - Revised (CUDIT-R)', - fr: 'Teste de consommation du Cannabis (CUDIT-R)' - } - }, - details: { - description: { - en: 'The CUDIT-R is an 8-item screening tool used to assess problematic cannabis use and identify individuals at risk of Cannabis Use Disorder (CUD). A score of 8 or higher suggests hazardous use, warranting further assessment or intervention.', - fr: 'Le CUDIT-R est un outil de dépistage en 8 points utilisé pour évaluer la consommation problématique de cannabis et identifier les personnes présentant un risque de trouble lié à la consommation de cannabis (TCC). Un score de 8 ou plus indique une consommation dangereuse, justifiant une évaluation ou une intervention plus poussée.' - }, - license: 'PUBLIC-DOMAIN', - title: { - en: 'Cannabis Use Disorder Identification Test - Revised (CUDIT-R)', - fr: "Test d'identification des troubles liés à l'usage du cannabis - version révisée (CUDIT-R)" - } - }, - content: { - isCannabisUsed: { - kind: 'boolean', - label: { - en: 'Have you used any cannabis over the past six months?', - fr: 'Avez-vous consommé du cannabis au cours des 6 derniers mois?' - }, - options: { - en: { - true: 'Yes', - false: 'No' - }, - fr: { - true: 'Oui', - false: 'Non' - } - }, - variant: 'radio' - }, - cannabisFrequency: createDependentField({ - kind: 'number', - label: { - en: '1. How often do you use Cannabis?', - fr: '1. A quelle fréquence tu consomme du Cannabis?' - }, - options: { - en: { - 0: 'Never', - 1: 'Monthly or less', - 2: '2-4 times a month', - 3: '2-3 times a week', - 4: '4 or more times a week' - }, - fr: { - 0: 'Jamais', - 1: '≤ 1 fois/mois', - 2: '2 à 4 fois/mois', - 3: '2 à 3 fois/semaine', - 4: 'Plus que 4 fois/semaine' - } - }, - variant: 'radio' - }), - stonedTime: createDependentField({ - kind: 'number', - label: { - en: '2. How many hours were you "stoned" on a typical day when you were using cannabis?', - fr: "2. Combien d'heures êtes-vous « défoncé » un jour typique où vous consommez du cannabis??" - }, - options: { - en: { - 0: 'Less than 1', - 1: '1 or 2', - 2: '3 or 4', - 3: '5 or 6', - 4: '7 or more' - }, - fr: { - 0: 'Moins de 1h', - 1: '1 ou 2h', - 2: '3 ou 4h', - 3: '5 ou 6h', - 4: '7h ou plus' - } - }, - variant: 'radio' - }), - unableToStopUsage: createDependentField({ - kind: 'number', - label: { - en: '3. How often during the past 6 months did you find that you were not able to stop using cannabis once you had started?', - fr: "3. Au cours des 6 derniers mois, à quelle fréquence avez-vous constaté que vous n'étiez plus capable de vous arrêter de fumer du cannabis une fois que vous aviez commencé?" - }, - options: { - en: { - 0: 'Never', - 1: 'Less than monthly', - 2: 'Monthly', - 3: 'Weekly', - 4: 'Daily or almost daily' - }, - fr: { - 0: 'Jamais', - 1: 'Moins que 1 fois/mois', - 2: '2 à 4 fois/mois', - 3: '2 à 3 fois/semaine', - 4: 'Plus que 4 fois/semaine' - } - }, - variant: 'radio' - }), - cannabisInducedFailure: createDependentField({ - kind: 'number', - label: { - en: '4. How often during the past 6 months did you fail to do what was normally expected from you because of using cannabis?', - fr: '4. Au cours des 6 derniers mois, combien de fois votre consommation de cannabis vous a-t-elle empêché de faire ce qui était normalement attendu de vous?' - }, - options: { - en: { - 0: 'Never', - 1: 'Less than monthly', - 2: 'Monthly', - 3: 'Weekly', - 4: 'Daily or almost daily' - }, - fr: { - 0: 'Jamais', - 1: 'Moins que 1 fois/mois', - 2: '2 à 4 fois/mois', - 3: '2 à 3 fois/semaine', - 4: 'Plus que 4 fois/semaine' - } - }, - variant: 'radio' - }), - cannabisRelatedUsageTime: createDependentField({ - kind: 'number', - label: { - en: '5. How often in the past 6 months have you devoted a great deal of your time to getting, using, or recovering from cannabis?', - fr: '5. Au cours des 6 derniers mois, combien de fois avez-vous passé une grande partie de votre temps à chercher à vous procurer ou consommer du cannabis, ou à vous remettre des effets du cannabis ?' - }, - options: { - en: { - 0: 'Never', - 1: 'Less than monthly', - 2: 'Monthly', - 3: 'Weekly', - 4: 'Daily or almost daily' - }, - fr: { - 0: 'Jamais', - 1: 'Moins que 1 fois/mois', - 2: '2 à 4 fois/mois', - 3: '2 à 3 fois/semaine', - 4: 'Plus que 4 fois/semaine' - } - }, - variant: 'radio' - }), - cannabisMemoryConcentration: createDependentField({ - kind: 'number', - label: { - en: '6. How often in the past 6 months have you had a problem with your memory or concentration after using cannabis?', - fr: '6. Au cours des 6 derniers mois, combien de fois avez-vous éprouvé des problèmes de mémoire ou de concentration après avoir fumé du cannabis?' - }, - options: { - en: { - 0: 'Never', - 1: 'Less than monthly', - 2: 'Monthly', - 3: 'Weekly', - 4: 'Daily or almost daily' - }, - fr: { - 0: 'Jamais', - 1: 'moins que 1fois/mois', - 2: '2 à 4 fois/mois', - 3: '2 à 3 fois/semaine', - 4: 'plus que 4 fois/semaine' - } - }, - variant: 'radio' - }), - cannabisHazards: createDependentField({ - kind: 'number', - label: { - en: '7. How often do you use cannabis in situations that could be physically hazardous, such as driving, operating machinery, or caring for children:', - fr: "7. A quelle fréquence consommez-vous du cannabis dans des situations qui pourraient entrainer un danger, par exemple conduire un véhicule, utiliser une machine, ou s'occuper d'enfants?" - }, - options: { - en: { - 0: 'Never', - 1: 'Less than monthly', - 2: 'Monthly', - 3: 'Weekly', - 4: 'Daily or almost daily' - }, - fr: { - 0: 'Jamais', - 1: 'Moins que 1 fois/mois', - 2: '2 à 4 fois/mois', - 3: '2 à 3 fois/semaine', - 4: 'Plus que 4 fois/semaine' - } - }, - variant: 'radio' - }), - cannabisReduction: createDependentField({ - kind: 'number', - label: { - en: '8. Have you ever thought about cutting down, or stopping, your use of cannabis?', - fr: "8. Avez-vous déjà envisagé de réduire ou d'arrêter votre consommation de cannabis ?" - }, - options: { - en: { - 0: 'Never', - 2: 'Yes but not in the past 6 months', - 4: 'Monthly' - }, - fr: { - 0: 'Jamais', - 2: 'Oui, mais pas au cours des 6 derniers mois', - 4: 'Oui, au cours des 6 derniers mois' - } - }, - variant: 'radio' - }) - }, - validationSchema: $InstrumentData, - measures: { - cannabisFrequency: { - kind: 'const', - label: { - en: 'Cannabis Frequency', - fr: 'Frequence de cosommation du Cannabis' - }, - ref: 'cannabisFrequency' - }, - stonedTime: { - kind: 'const', - label: { - en: 'Time "Stoned"', - fr: 'Temps « défoncé »' - }, - ref: 'stonedTime' - }, - unableToStopUsage: { - kind: 'const', - label: { - en: 'Usage Reduction', - fr: 'Reduction du Cosummation' - }, - ref: 'unableToStopUsage' - }, - cannabisInducedFailure: { - kind: 'const', - label: { - en: 'Cannabis induced failure', - fr: 'Défaillance à cause du cannabis' - }, - ref: 'cannabisInducedFailure' - }, - cannabisRelatedUsageTime: { - kind: 'const', - label: { - en: 'Cannabis usage time', - fr: 'Temp de cosommation du Cannabis' - }, - ref: 'cannabisRelatedUsageTime' - }, - cannabisMemoryConcentration: { - kind: 'const', - label: { - en: 'Memory and Concentration', - fr: 'la memoire et la concentration' - }, - ref: 'cannabisMemoryConcentration' - }, - cannabisHazards: { - kind: 'const', - label: { - en: 'Cannabis Hazard Score', - fr: 'Score de dangerosité du cannabis' - }, - ref: 'cannabisHazards' - }, - cannabisReduction: { - kind: 'const', - label: { - en: 'Cannabis Reduction', - fr: 'Reduction de la consommation du cannabis' - }, - ref: 'cannabisReduction' - }, - cannabisScore: { - kind: 'computed', - hidden: true, - label: { - en: 'Cannabis use score', - fr: 'Score de consommation de cannabis' - }, - value: (data) => { - return calculateCannabisUse(data); - } - }, - cannabisScoreInterpretation: { - kind: 'computed', - hidden: true, - label: { - en: 'Cannabis use score interpretation', - fr: 'Score de consommation de interprétation' - }, - value: (data) => { - const score = calculateCannabisUse(data); - if (score >= 8 && score < 12) { - return 'Hazardous cannabis use / Consommation dangereuse de cannabis'; - } else if (score >= 12) { - return "Possible cannabis use disorder / Un trouble de l'usage du cannabis est probable"; - } - return; - } - } - } -}); diff --git "a/packages/instrument-library/src/forms/FAGERSTR\303\226M_NICOTINE_DEPENDENCE/index.ts" "b/packages/instrument-library/src/forms/FAGERSTR\303\226M_NICOTINE_DEPENDENCE/index.ts" deleted file mode 100644 index ab202776a..000000000 --- "a/packages/instrument-library/src/forms/FAGERSTR\303\226M_NICOTINE_DEPENDENCE/index.ts" +++ /dev/null @@ -1,161 +0,0 @@ -import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; -import { sum } from '/runtime/v1/lodash-es@4.x'; -import { z } from '/runtime/v1/zod@3.x'; - -const yesNoOptions = { - en: { - 1: 'Yes', - 0: 'No' - }, - fr: { - 1: 'Oui', - 0: 'Non' - } -}; - -export default defineInstrument({ - kind: 'FORM', - language: ['en', 'fr'], - internal: { - name: 'FAGERSTRÖM_NICOTINE_DEPENDENCE', - edition: 1 - }, - tags: { - en: ['smoking', 'addiction', 'nicotine'], - fr: ['fumer', 'dépendance', 'nicotine'] - }, - details: { - description: { - en: 'The Fagerström Test for Nicotine Dependence is a standard instrument for assessing the intensity of physical addiction to nicotine. The test was designed to provide an ordinal measure of nicotine dependence related to cigarette smoking. It contains six items that evaluate the quantity of cigarette consumption, the compulsion to use, and dependence.', - fr: "Le test de Fagerström pour la dépendance à la nicotine est un instrument standard pour évaluer l'intensité de la dépendance physique à la nicotine. Le test a été conçu pour fournir une mesure ordinale de la dépendance à la nicotine liée au tabagisme. Il contient six items qui évaluent la quantité de cigarettes consommées, la compulsion à consommer et la dépendance." - }, - estimatedDuration: 5, - instructions: { - en: ['Please respond to every question'], - fr: ['Veuillez répondre à toutes les questions'] - }, - license: 'PUBLIC-DOMAIN', - title: { - en: 'Fagerström Nicotine Dependence (FTND)', - fr: 'Fagerström test de dépendance à la nicotine (FTND)' - } - }, - - content: { - smokeTime: { - disableAutoPrefix: true, - kind: 'number', - label: { - en: '1. How soon after waking do you smoke your first cigarette?', - fr: '1. Combien de temps après le réveil fumez-vous votre première cigarette?' - }, - options: { - en: { - 3: 'Within 5 minutes', - 2: '6-30 minutes', - 1: '31-60 minutes', - 0: 'More than 60 minutes' - }, - fr: { - 3: 'Dans les 5 minutes', - 2: '6 à 30 minutes', - 1: '31 à 60 minutes ', - 0: 'Plus de 60 minutes' - } - }, - variant: 'radio' - }, - difficultToRefrainSmoking: { - disableAutoPrefix: true, - kind: 'number', - label: { - en: '2. Do you find it difficult to refrain from smoking in places where it is forbidden? e.g., Church, Library, etc.', - fr: "2. Trouvez-vous qu'il est difficile de vous abstenir de fumer dans les endroits où c'est interdit, tels le métro, le cinéma, l'hôpital, les restaurants?" - }, - options: yesNoOptions, - variant: 'radio' - }, - cigaretteHateToGiveup: { - disableAutoPrefix: true, - kind: 'number', - label: { - en: '3. Which cigarette would you hate to give up?', - fr: '3. À quelle cigarette renonceriez-vous le plus difficilement?' - }, - options: { - en: { - 1: 'The first in the morning', - 0: 'Any other' - }, - fr: { - 1: 'La première du matin', - 0: 'À une autre' - } - }, - variant: 'radio' - }, - cigaretteAmount: { - disableAutoPrefix: true, - kind: 'number', - label: { - en: '4. How many cigarettes a do you smoke?', - fr: '4. Combien de cigarettes fumez-vous par jour?' - }, - options: { - en: { - 0: '10 or less', - 1: '11 - 20', - 2: '21 - 30', - 3: '31 or more' - }, - fr: { - 0: '10 ou moins', - 1: '11 à 20', - 2: '21 à 30', - 3: '31 ou plus 3' - } - }, - variant: 'radio' - }, - smokeMoreInMorning: { - disableAutoPrefix: true, - kind: 'number', - label: { - en: '5. Do you smoke more frequently in the morning?', - fr: '5. Fumez-vous à intervalles plus rapprochés durant les premières heures de la matinée que durant le reste de la journée?' - }, - options: yesNoOptions, - variant: 'radio' - }, - smokeWhileSickInBed: { - disableAutoPrefix: true, - kind: 'number', - label: { - en: '6. Do you smoke even if you are sick in bed most of the day?', - fr: '6. Fumez-vous lorsque vous êtes malade au point de devoir rester au lit presque toute la journée?' - }, - options: yesNoOptions, - variant: 'radio' - } - }, - measures: { - auditCScore: { - kind: 'computed', - label: { - en: 'Total Score:', - fr: 'Score total:' - }, - value: (data) => { - return sum(Object.values(data)); - } - } - }, - validationSchema: z.object({ - smokeTime: z.number().int().min(0).max(3), - difficultToRefrainSmoking: z.number().int().min(0).max(1), - cigaretteHateToGiveup: z.number().int().min(0).max(1), - cigaretteAmount: z.number().int().min(0).max(3), - smokeMoreInMorning: z.number().int().min(0).max(1), - smokeWhileSickInBed: z.number().int().min(0).max(1) - }) -}); diff --git a/packages/instrument-library/src/forms/GAD_7/index.ts b/packages/instrument-library/src/forms/GAD_7/index.ts deleted file mode 100644 index 3bd9e5a8c..000000000 --- a/packages/instrument-library/src/forms/GAD_7/index.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; -import { z } from '/runtime/v1/zod@3.x'; - -const likertOptions = { - en: { - 0: 'Not at All', - 1: 'Several Days', - 2: 'More than half the days', - 3: 'Nearly every day' - }, - fr: { - 0: 'Jamais', - 1: 'Plusieurs jours', - 2: 'Plus de la moitié des jours', - 3: 'Presque tous les jours' - } -}; - -const calculateGAD7total = (data: { [key: string]: unknown }) => { - let sum = 0; - for (const key in data) { - const value = data[key as keyof typeof data]; - if (typeof value === 'number' && key != 'difficultyCoping') { - sum += value; - } - } - return sum; -}; - -export default defineInstrument({ - details: { - title: { - en: 'Generalized Anxiety Disorder-7 (GAD-7)', - fr: "Trouble d'anxiété générale-7 (GAD-7)" - }, - description: { - en: 'The Generalized Anxiety Disorder 7 (GAD-7) is a self-reported questionnaire for screening and severity measuring of generalized anxiety disorder (GAD). The GAD7 asks for self-reported anxiety symptoms over the past two weeks.', - fr: "Le trouble d'anxiété généralisée 7 (GAD-7) est un questionnaire autodéclaré pour le dépistage et la mesure de la gravité du trouble d'anxiété généralisée (TAG). Le GAD7 demande les symptômes d’anxiété autodéclarés au cours des deux dernières semaines." - }, - estimatedDuration: 1, - instructions: { - en: ['Please complete all questions'], - fr: ['Veuillez répondre à toutes les questions'] - }, - license: 'PUBLIC-DOMAIN' - }, - kind: 'FORM', - language: ['en', 'fr'], - tags: { - en: ['Anxiety'], - fr: ['Anxiété'] - }, - internal: { - name: 'GAD_7', - edition: 1 - }, - content: [ - { - title: { - en: 'Over the last two weeks, how often have you been bothered by the following problems?', - fr: 'Au cours des 14 derniers jours, à quelle fréquence avez-vous été dérangé(e) par les problèmes suivants?' - }, - fields: { - nervousAnxiousOnEdge: { - description: { - en: 'Over the last two weeks, how often have you been bothered by feeling nervous, anxious, or on edge?', - fr: "Au cours des 14 derniers jours, à quelle fréquence avez-vous été dérangé(e) par sentiment de nervosité, d'anxiété ou de tension?" - }, - label: { - en: 'Feeling nervous, anxious, or on edge', - fr: "Sentiment de nervosité, d'anxiété ou de tension" - }, - kind: 'number', - options: likertOptions, - variant: 'radio' - }, - noStopControlWorrying: { - description: { - en: 'Over the last two weeks, how often have you been bothered by not being able to stop or control worrying?', - fr: "Au cours des 14 derniers jours, à quelle fréquence avez-vous été dérangé(e) par incapable d'arrêter de vous inquiéter ou de contrôler vos inquiétudes?" - }, - label: { - en: 'Not being able to stop or control worrying', - fr: "Incapable d'arrêter de vous inquiéter ou de contrôler vos inquiétudes" - }, - kind: 'number', - options: likertOptions, - variant: 'radio' - }, - worryingTooMuch: { - description: { - en: 'Over the last two weeks, how often have you been bothered by worrying too much about different things?', - fr: 'Au cours des 14 derniers jours, à quelle fréquence avez-vous été dérangé(e) par inquiétudes excessives à propos de tout et de rien?' - }, - label: { - en: 'Worrying too much about different things', - fr: 'Inquiétudes excessives à propos de tout et de rien' - }, - kind: 'number', - options: likertOptions, - variant: 'radio' - }, - troubleRelaxing: { - description: { - en: 'Over the last two weeks, how often have you had trouble relaxing?', - fr: 'Au cours des deux dernières semaines, à quelle fréquence avez-vous eu du mal à vous détendre?' - }, - label: { - en: 'Have trouble relaxing', - fr: 'Difficulté à se détendre' - }, - kind: 'number', - options: likertOptions, - variant: 'radio' - }, - restless: { - description: { - en: 'Over the last two weeks, how often have you been bothered by being so restless that it is hard to sit still?', - fr: "Au cours des 14 derniers jours, à quelle fréquence avez-vous été dérangé(e) par agitation telle qu'il est difficile de rester tranquille?" - }, - label: { - en: 'Being so restless that it is hard to sit still', - fr: "Agitation telle qu'il est difficile de rester tranquille" - }, - kind: 'number', - options: likertOptions, - variant: 'radio' - }, - easilyAnnoyedIrritable: { - description: { - en: 'Over the last two weeks, how often have you been bothered by becoming easily annoyed or irritable?', - fr: 'Au cours des 14 derniers jours, à quelle fréquence avez-vous été dérangé(e) par devenir facilement contrarié(e) ou irritable?' - }, - label: { - en: 'Becoming easily annoyed or irritable', - fr: 'Devenir facilement contrarié(e) ou irritable' - }, - kind: 'number', - options: likertOptions, - variant: 'radio' - }, - afraidSomethingAwful: { - description: { - en: 'Over the last two weeks, how often have you been bothered by feeling afraid, as if something awful might happen?', - fr: "Au cours des 14 derniers jours, à quelle fréquence avez-vous été dérangé(e) par avoir peur que quelque chose d'épouvantable puisse arriver?" - }, - label: { - en: 'Feeling afraid, as if something awful might happen', - fr: "Avoir peur que quelque chose d'épouvantable puisse arriver" - }, - kind: 'number', - options: likertOptions, - variant: 'radio' - } - } - }, - { - title: { - en: ' ', - fr: 'Difficulté à faire face' - }, - fields: { - difficultyCoping: { - kind: 'dynamic', - deps: [ - 'nervousAnxiousOnEdge', - 'noStopControlWorrying', - 'worryingTooMuch', - 'troubleRelaxing', - 'restless', - 'easilyAnnoyedIrritable', - 'afraidSomethingAwful' - ], - render(data) { - if ( - !( - data?.nervousAnxiousOnEdge || - data?.noStopControlWorrying || - data?.worryingTooMuch || - data?.troubleRelaxing || - data?.restless || - data?.easilyAnnoyedIrritable || - data?.afraidSomethingAwful - ) - ) { - return null; - } - return { - description: { - en: 'Given your problems selected above, how difficult have they made it for you to do your work, take care of things at home, or get along with other people?', - fr: 'Compte tenu des problèmes sélectionnés ci-dessus, dans quelle mesure ceux-ci rendus plus difficile votre travail, vous occuper de vos affaires à la maison ou vous entendre avec les autres personnes?' - }, - label: { - en: 'Given your problems selected above, how difficult have they made it for you to do your work, take care of things at home, or get along with other people?', - fr: 'Compte tenu des problèmes sélectionnés ci-dessus, dans quelle mesure ceux-ci rendus plus difficile votre travail, vous occuper de vos affaires à la maison ou vous entendre avec les autres personnes?' - }, - kind: 'number', - options: { - en: { - 0: 'Not difficult at all', - 1: 'Somewhat difficult', - 2: 'Very difficult', - 3: 'Extremely difficult' - }, - fr: { - 0: 'Pas difficile', - 1: 'Un peu difficile', - 2: 'Très difficile', - 3: 'Extrêmement difficile' - } - }, - variant: 'radio' - }; - } - } - } - } - ], - measures: { - nervousAnxiousOnEdge: { - kind: 'const', - ref: 'nervousAnxiousOnEdge' - }, - noStopControlWorrying: { - kind: 'const', - ref: 'noStopControlWorrying' - }, - worryingTooMuch: { - kind: 'const', - ref: 'worryingTooMuch' - }, - troubleRelaxing: { - kind: 'const', - ref: 'troubleRelaxing' - }, - restless: { - kind: 'const', - ref: 'restless' - }, - easilyAnnoyedIrritable: { - kind: 'const', - ref: 'easilyAnnoyedIrritable' - }, - afraidSomethingAwful: { - kind: 'const', - ref: 'afraidSomethingAwful' - }, - difficultyCoping: { - kind: 'const', - ref: 'difficultyCoping' - }, - gad7Total: { - kind: 'computed', - label: { - en: 'Total of GAD7', - fr: '' - }, - value: (data) => { - return calculateGAD7total(data); - } - } - }, - validationSchema: z.object({ - nervousAnxiousOnEdge: z.number().int().min(0).max(3), - noStopControlWorrying: z.number().int().min(0).max(3), - worryingTooMuch: z.number().int().min(0).max(3), - troubleRelaxing: z.number().int().min(0).max(3), - restless: z.number().int().min(0).max(3), - easilyAnnoyedIrritable: z.number().int().min(0).max(3), - afraidSomethingAwful: z.number().int().min(0).max(3), - difficultyCoping: z.number().int().min(0).max(3).optional() - }) -}); diff --git a/packages/instrument-library/src/forms/OLDER_AMERICANS_RESOURCES_AND_SERVICES/index.ts b/packages/instrument-library/src/forms/OLDER_AMERICANS_RESOURCES_AND_SERVICES/index.ts deleted file mode 100644 index d4326ae83..000000000 --- a/packages/instrument-library/src/forms/OLDER_AMERICANS_RESOURCES_AND_SERVICES/index.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; -import { z } from '/runtime/v1/zod@3.x'; - -export default defineInstrument({ - kind: 'FORM', - language: ['en', 'fr'], - internal: { - name: 'OLDER_AMERICANS_RESOURCES_AND_SERVICES', - edition: 1 - }, - tags: { - en: ['social'], - fr: ['social'] - }, - content: { - peopleYouKnowCanVisitTheirHome: { - kind: 'string', - variant: 'radio', - label: { - en: 'How many people do you know well enough to visit within their homes?', - fr: 'Combien de personnes connaissez-vous assez bien pour leur rendre visite chez elles?' - }, - options: { - en: { - 'five or more': 'five or more', - 'three to four': 'three to four', - 'one to two': 'one to two', - none: 'none', - 'prefer not to answer': 'prefer not to answer' - }, - fr: { - 'five or more': 'cinq ou plus', - 'three to four': 'trois ou quatre', - 'one to two': 'une ou deux', - none: 'personne', - 'prefer not to answer': 'préfère ne pas répondre' - } - } - }, - numbersOfCallsLastWeek: { - kind: 'string', - variant: 'radio', - label: { - en: 'About how many times did you talk to someone (friends, relatives, or others) on the telephone in the past week? (either you called them or they called you) If you do not have a phone, the question still applies.', - fr: "Combien de conversations téléphoniques avez-vous eues avec des amis, des membres de votre famille ou autres pendant la dernière semaine? (leur avez-vous téléphoné ou vous ont-ils téléphoné) La question s'applique même si vous n'avez pas de téléphone." - }, - options: { - en: { - 'once a day or more': 'once a day or more', - '2 - 6 times': '2 - 6 times', - once: 'once', - 'not at all': 'not at all', - 'prefer not to answer': 'prefer not to answer' - }, - fr: { - 'once a day or more': 'une fois par jour ou plus', - '2 - 6 times': 'de deux à six', - once: 'une', - 'not at all': 'aucune', - 'prefer not to answer': 'préfère ne pas répondre' - } - } - }, - numbersOfTimeSpentWithSomeoneLastWeek: { - kind: 'string', - variant: 'radio', - label: { - en: 'How many times during the past week did you spend some time with someone who does not live with you, that is you went to see them or they came to visit you or you went out to do things together?', - fr: "Combien de fois pendant la dernière semaine avez-vous passé du temps avec une personne qui n'habite pas avec vous, c'est à dire que vous lui avez rendu visite ou qu'elle vous a rendu visite ou encore que vous êtes sortis ensemble pour faire quelque chose?" - }, - options: { - en: { - 'once a day or more': 'once a day or more', - '2 - 6 times': '2 - 6 times', - once: 'once', - 'not at all': 'not at all', - 'prefer not to answer': 'prefer not to answer' - }, - fr: { - 'once a day or more': 'une fois par jour ou plus', - '2 - 6 times': 'de deux à six', - once: 'une', - 'not at all': 'aucune', - 'prefer not to answer': 'préfère ne pas répondre' - } - } - }, - haveSomeoneYouTrust: { - kind: 'string', - variant: 'radio', - label: { - en: 'Do you have someone you trust and can confide in?', - fr: 'Y a-t-il une personne en qui vous avez confiance et à laquelle vous pouvez-vous confier?' - }, - options: { - en: { - yes: 'yes', - no: 'no', - 'prefer not to answer': 'prefer not to answer' - }, - fr: { - yes: 'oui', - no: 'non', - 'prefer not to answer': 'préfère ne pas répondre' - } - } - }, - doYouFeelLonely: { - kind: 'string', - variant: 'radio', - label: { - en: 'Do you find yourself feeling lonely quite often, sometimes or almost never?', - fr: 'Vous sentez-vous seul(e) assez souvent quelquefois ou presque jamais?' - }, - options: { - en: { - 'quite often': 'quite often', - sometimes: 'sometimes', - 'almost never': 'almost never', - 'prefer not to answer': 'prefer not to answer' - }, - fr: { - 'quite often': 'assez souvent', - sometimes: 'quelquefois', - 'almost never': 'presque jamais', - 'prefer not to answer': 'préfère ne pas répondre' - } - } - }, - seeFriendsAndRelativesAsYouWant: { - kind: 'string', - variant: 'radio', - label: { - en: 'Do you see your relatives and friends as often as you want to or are you somewhat unhappy about how little you see them?', - fr: 'Voyez-vous votre famille et vos amis aussi souvent que vous le désirez, ou êtes-vous plutôt insatisfait(e) que vous les voyez rarement?' - }, - options: { - en: { - 'as often as wants to': 'as often as wants to', - 'somewhat unhappy about how little': 'somewhat unhappy about how little', - 'prefer not to answer': 'prefer not to answer' - }, - fr: { - 'as often as wants to': 'aussi souvent que je le desire', - 'somewhat unhappy about how little': 'plutôt insatisfait(e) que je les vois rarement', - 'prefer not to answer': 'préfère ne pas répondre' - } - } - }, - someoneTakeCareOfYouWhenNeeded: { - kind: 'string', - variant: 'radio', - label: { - en: 'Do you have someone you trust and can confide in?', - fr: 'Y a-t-il une personne en qui vous avez confiance et à laquelle vous pouvez-vous confier?' - }, - options: { - en: { - yes: 'yes', - 'no one willing and able': 'no one willing and able', - 'prefer not to answer': 'prefer not to answer' - }, - fr: { - yes: 'oui', - 'no one willing and able': "il n'y a personne qui disposerait ou qui soit en mesure de m'aider", - 'prefer not to answer': 'préfère ne pas répondre' - } - } - }, - someoneTakeCareOfYouAsLongAsYouNeed: { - kind: 'dynamic', - deps: ['someoneTakeCareOfYouWhenNeeded'], - render: (data) => { - return data?.someoneTakeCareOfYouWhenNeeded === 'yes' - ? { - kind: 'string', - variant: 'radio', - label: { - en: 'Is there someone who would take care of you as long as you needed, or only for a short time, or only someone who would help you now and then (for example, taking you to the doctor or fixing lunch occasionally, etc.)?', - fr: "Y a-t-il une personne qui prendrait soin de vous aussi longtemps qu'il le faudrait une courte période, ou qui vous aiderait de temps en temps seulement, par exemple, une personne qui vous conduirait chez le médcin ou vous ferait à déjeuner occasionnellement, etc." - }, - options: { - en: { - 'Someone who would take care of you indefinitely (as long as needed)': - 'Someone who would take care of you indefinitely (as long as needed)', - 'Someone who would take care of you for a short time (a few weeks to six months)': - 'Someone who would take care of you for a short time (a few weeks to six months)', - 'Someone who would help you now and then (taking you to the doctor, fixing lunch, etc.)': - 'Someone who would help you now and then (taking you to the doctor, fixing lunch, etc.)', - 'prefer not to answer': 'prefer not to answer' - }, - fr: { - 'Someone who would take care of you indefinitely (as long as needed)': - "une personne qui prendrait soin de vous indéfiniment (aussi longtemps qu'il le faudrait)", - 'Someone who would take care of you for a short time (a few weeks to six months)': - 'une personne qui prendrait soin de vous pendant une courte période (de quelques semaines à six mois)', - 'Someone who would help you now and then (taking you to the doctor, fixing lunch, etc.)': - 'une personne qui vous aiderait de temps en temps (qui vous conduirait chez le médecin ou vous ferait à déjeuner, etc.)', - 'prefer not to answer': 'préfère ne pas répondre' - } - } - } - : null; - } - } - }, - details: { - description: { - en: 'Social Support: Now, we will ask you some questions about your family and friends. Reference: https://osf.io/94qv5/', - fr: 'Soutien social: Nous allons maintenant vous poser quelques questions concernant votre famille et vos amis. Référence: https://osf.io/94qv5/' - }, - estimatedDuration: 3, - instructions: { - en: ['Now, I would like to ask you some questions about your family and friends. Please complete all questions.'], - fr: [ - "Maintenant, j'aimerais vous poser quelques questions sur votre famille et vos amis. Veuillez répondre à toutes les questions." - ] - }, - license: 'CC-BY-4.0', - title: { - en: 'Older Americans Resources and Services Social Resource Scale', - fr: 'Réseau social' - } - }, - validationSchema: z.object({ - peopleYouKnowCanVisitTheirHome: z.enum([ - 'five or more', - 'three to four', - 'one to two', - 'none', - 'prefer not to answer' - ]), - numbersOfCallsLastWeek: z.enum(['once a day or more', '2 - 6 times', 'once', 'not at all', 'prefer not to answer']), - numbersOfTimeSpentWithSomeoneLastWeek: z.enum([ - 'once a day or more', - '2 - 6 times', - 'once', - 'not at all', - 'prefer not to answer' - ]), - haveSomeoneYouTrust: z.enum(['yes', 'no', 'prefer not to answer']), - doYouFeelLonely: z.enum(['quite often', 'sometimes', 'almost never', 'prefer not to answer']), - seeFriendsAndRelativesAsYouWant: z.enum([ - 'as often as wants to', - 'somewhat unhappy about how little', - 'prefer not to answer' - ]), - someoneTakeCareOfYouWhenNeeded: z.enum(['yes', 'no one willing and able', 'prefer not to answer']), - someoneTakeCareOfYouAsLongAsYouNeed: z - .enum([ - 'Someone who would take care of you indefinitely (as long as needed)', - 'Someone who would take care of you for a short time (a few weeks to six months)', - 'Someone who would help you now and then (taking you to the doctor, fixing lunch, etc.)', - 'prefer not to answer' - ]) - .optional() - }), - measures: { - peopleYouKnowCanVisitTheirHome: { - kind: 'const', - ref: 'peopleYouKnowCanVisitTheirHome' - }, - numbersOfCallsLastWeek: { - kind: 'const', - ref: 'numbersOfCallsLastWeek' - }, - numbersOfTimeSpentWithSomeoneLastWeek: { - kind: 'const', - ref: 'numbersOfTimeSpentWithSomeoneLastWeek' - }, - haveSomeoneYouTrust: { - kind: 'const', - ref: 'haveSomeoneYouTrust' - }, - doYouFeelLonely: { - kind: 'const', - ref: 'doYouFeelLonely' - }, - seeFriendsAndRelativesAsYouWant: { - kind: 'const', - ref: 'seeFriendsAndRelativesAsYouWant' - }, - someoneTakeCareOfYouWhenNeeded: { - kind: 'const', - ref: 'someoneTakeCareOfYouWhenNeeded' - }, - someoneTakeCareOfYouAsLongAsYouNeed: { - kind: 'computed', - label: { - en: 'Is there someone who would take care of you as long as you needed, or only for a short time, or only someone who would help you now and then (for example, taking you to the doctor or fixing lunch occasionally, etc.)?', - fr: "Y a-t-il une personne qui prendrait soin de vous aussi longtemps qu'il le faudrait une courte période, ou qui vous aiderait de temps en temps seulement, par exemple, une personne qui vous conduirait chez le médcin ou vous ferait à déjeuner occasionnellement, etc." - }, - value: (data) => { - return data.someoneTakeCareOfYouAsLongAsYouNeed ?? 'N/A'; - } - } - } -}); diff --git a/packages/instrument-library/src/forms/PHQ_9/index.ts b/packages/instrument-library/src/forms/PHQ_9/index.ts deleted file mode 100644 index 00edb9ab0..000000000 --- a/packages/instrument-library/src/forms/PHQ_9/index.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; -import { omit, sum } from '/runtime/v1/lodash-es@4.x'; -import { z } from '/runtime/v1/zod@3.x'; - -const $Response = z.number().int().min(0).max(3); - -export default defineInstrument({ - kind: 'FORM', - language: ['en', 'fr'], - tags: { - en: ['Health', 'Depression'], - fr: ['Santé', 'Dépression'] - }, - internal: { - edition: 1, - name: 'PHQ_9' - }, - content: [ - { - title: { - en: 'Summary of Instructions', - fr: 'Résumé des instructions' - }, - description: { - en: 'Over the last 2 weeks, how often have you been bothered by any of the following problems?', - fr: 'Au cours des deux dernières semaines, à quelle fréquence avez-vous été dérangé(e) par les problèmes suivants?' - }, - fields: { - questions: { - kind: 'number-record', - label: { - en: 'Questions', - fr: 'Questions' - }, - items: { - interestPleasure: { - label: { - en: '1. Little interest or pleasure in doing things', - fr: "1. Peu d'intérêt ou de plaisir à faire des choses" - } - }, - feelingDown: { - label: { - en: '2. Feeling down, depressed, or hopeless', - fr: '2. Se sentir triste, déprimé(e) ou désespéré(e)' - } - }, - sleepIssues: { - label: { - en: '3. Trouble falling or staying asleep, or sleeping too much', - fr: "3. Difficultés à s'endormir ou à rester endormi(e), ou trop dormir" - } - }, - energyLevel: { - label: { - en: '4. Feeling tired or having little energy', - fr: "4. Se sentir fatigué(e) ou avoir peu d'énergie" - } - }, - appetiteChanges: { - label: { - en: '5. Poor appetite or overeating', - fr: "5. Peu d'appétit ou trop manger" - } - }, - selfWorth: { - label: { - en: '6. Feeling bad about yourself — or that you are a failure or have let yourself or your family down', - fr: "6. Mauvaise perception de vous-même — ou vous pensez que vous êtes un perdant ou que vous n'avez pas satisfait vos propres attentes ou celles de votre famille" - } - }, - concentrationIssues: { - label: { - en: '7. Trouble concentrating on things, such as reading the newspaper or watching television', - fr: '7. Difficultés à se concentrer sur des choses telles que lire le journal ou regarder la télévision' - } - }, - psychomotorChanges: { - label: { - en: '8. Moving or speaking so slowly that other people could have noticed? Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual', - fr: "8. Vous bougez ou parlez si lentement que les autres personnes ont pu le remarquer. Ou au contraire — vous êtes si agité(e) que vous bougez beaucoup plus que d'habitude" - } - }, - suicidalThoughts: { - label: { - en: '9. Thoughts that you would be better off dead or of hurting yourself in some way', - fr: "9. Vous avez pensé que vous seriez mieux mort(e) ou pensé à vous blesser d'une façon ou d'une autre" - } - } - }, - options: { - en: { - 0: 'Not at All', - 1: 'Several Days', - 2: 'More than half the days', - 3: 'Nearly every day' - }, - fr: { - 0: 'Jamais', - 1: 'Plusieurs jours', - 2: 'Plus de la moitié des jours', - 3: 'Presque tous les jours' - } - }, - variant: 'likert' - }, - impactOnFunctioning: { - kind: 'dynamic', - deps: ['questions'], - render: (data) => { - if (!data.questions || sum(Object.values(data.questions)) === 0) { - return null; - } - return { - disableAutoPrefix: true, - kind: 'number', - label: { - en: 'How difficult have these problems made it for you to do your work, take care of things at home, or get along with other people?', - fr: 'Dans quelle mesure ce(s) problème(s) a-t-il (ont-ils) rendu difficile(s) votre travail, vos tâches à la maison ou votre capacité à bien vous entendre avec les autres?' - }, - options: { - en: { - 0: 'Not difficult at all', - 1: 'Somewhat difficult', - 2: 'Very difficult', - 3: 'Extremely difficult' - }, - fr: { - 0: 'Pas du tout difficile(s)', - 1: 'Plutôt difficile(s)', - 2: 'Très difficile(s)', - 3: 'Extrêmement difficile(s)' - } - }, - variant: 'select' - }; - } - } - } - } - ], - clientDetails: { - estimatedDuration: 1, - instructions: { - en: [ - "Before beginning this test, please ensure you are in a a quiet place where you can focus without distractions. You will answer 9 questions about how you've been feeling over the past 2 weeks. Answer each question as honestly as possible based on your feelings and experiences." - ], - fr: [ - "Avant de commencer ce test, assurez-vous d'être dans un endroit calme où vous pourrez vous concentrer sans distraction. Vous allez répondre à 9 questions sur ce que vous avez ressenti au cours des deux dernières semaines. Répondez à chaque question le plus honnêtement possible, en vous basant sur vos sentiments et vos expériences." - ] - } - }, - details: { - description: { - en: 'The Patient Health Questionnaire (PHQ) is a diagnostic tool for mental health disorders used by health care professionals that is quick and easy for patients to complete. In the mid-1990s, Robert L. Spitzer, MD, Janet B.W. Williams, DSW, and Kurt Kroenke, MD, and colleagues at Columbia University developed the Primary Care Evaluation of Mental Disorders (PRIME-MD), a diagnostic tool containing modules on 12 different mental health disorders. They worked in collaboration with researchers at the Regenstrief Institute at Indiana University and with the support of an educational grant from Pfizer Inc. During the development of PRIME-MD, Drs. Spitzer, Williams and Kroenke, created the PHQ and GAD-7 screeners. The PHQ-9, a tool specific to depression, simply scores each of the 9 DSM-IV criteria based on the mood module from the original PRIME-MD.', - fr: "Le questionnaire sur la santé du patient (PHQ) est un outil de diagnostic des troubles mentaux utilisé par les professionnels de la santé, rapide et facile à remplir par les patients. Au milieu des années 1990, Robert L. Spitzer, MD, Janet B.W. Williams, DSW, et Kurt Kroenke, MD, et leurs collègues de Columbia University ont mis au point le Primary Care Evaluation of Mental Disorders (PRIME-MD), un outil de diagnostic contenant des modules sur 12 troubles mentaux différents. Ils ont travaillé en collaboration avec des chercheurs de l'Institut Regenstrief d'Indiana University et avec le soutien d'une bourse éducative de Pfizer Inc. Au cours du développement de PRIME-MD, les docteurs Spitzer, Williams et Kroenke ont créé les screeners PHQ et GAD-7. Le PHQ-9, un outil spécifique à la dépression, évalue simplement chacun des 9 critères du DSM-IV en se basant sur le module de l'humeur de PRIME-MD." - }, - - license: 'PUBLIC-DOMAIN', - title: { - en: 'Patient Health Questionnaire (PHQ-9)', - fr: 'Questionnaire sur la santé du patient (PHQ-9)' - } - }, - measures: { - interestPleasure: { - kind: 'computed', - label: { - en: 'Little Interest/Pleasure', - fr: "Peu d'intérêt/plaisir" - }, - value: ({ questions }) => questions.interestPleasure - }, - feelingDown: { - kind: 'computed', - label: { en: 'Feeling Down/Depressed', fr: 'Se sentir déprimé/triste' }, - value: ({ questions }) => questions.feelingDown - }, - sleepIssues: { - kind: 'computed', - label: { en: 'Sleep Issues', fr: 'Problèmes de sommeil' }, - value: ({ questions }) => questions.sleepIssues - }, - energyLevel: { - kind: 'computed', - label: { en: 'Low Energy', fr: 'Faible énergie' }, - value: ({ questions }) => questions.energyLevel - }, - appetiteChanges: { - kind: 'computed', - label: { en: 'Appetite Changes', fr: "Changements d'appétit" }, - value: ({ questions }) => questions.appetiteChanges - }, - selfWorth: { - kind: 'computed', - label: { en: 'Low Self-Worth', fr: 'Faible estime de soi' }, - value: ({ questions }) => questions.selfWorth - }, - concentrationIssues: { - kind: 'computed', - label: { en: 'Concentration Issues', fr: 'Problèmes de concentration' }, - value: ({ questions }) => questions.concentrationIssues - }, - psychomotorChanges: { - kind: 'computed', - label: { en: 'Psychomotor Changes', fr: 'Changements psychomoteurs' }, - value: ({ questions }) => questions.psychomotorChanges - }, - suicidalThoughts: { - kind: 'computed', - label: { en: 'Suicidal Thoughts', fr: 'Pensées suicidaires' }, - value: ({ questions }) => questions.suicidalThoughts - }, - impactOnFunctioning: { - kind: 'computed', - label: { en: 'Impact on Functioning', fr: 'Impact sur le fonctionnement' }, - value: ({ impactOnFunctioning }) => impactOnFunctioning - }, - totalScore: { - kind: 'computed', - label: { en: 'Total Score', fr: 'Score total' }, - value: ({ questions }) => sum(Object.values(omit(questions, 'impactOnFunctioning'))) - } - }, - validationSchema: z - .object({ - questions: z.object({ - interestPleasure: $Response, - feelingDown: $Response, - sleepIssues: $Response, - energyLevel: $Response, - appetiteChanges: $Response, - selfWorth: $Response, - concentrationIssues: $Response, - psychomotorChanges: $Response, - suicidalThoughts: $Response - }), - impactOnFunctioning: $Response.optional() - }) - .superRefine(({ impactOnFunctioning, questions }, ctx) => { - const isAnyNonZero = sum(Object.values(questions)) > 0; - // If any response is not zero, then impactOnFunctioning is required - if (isAnyNonZero && impactOnFunctioning === undefined) { - ctx.addIssue({ - code: 'custom', - message: 'This question is required / Cette question est obligatoire', - path: ['impactOnFunctioning'] - }); - } - }) -}); diff --git a/packages/instrument-library/src/forms/STARKSTEIN_APATHY_SCALE/index.ts b/packages/instrument-library/src/forms/STARKSTEIN_APATHY_SCALE/index.ts deleted file mode 100644 index c0129d0fa..000000000 --- a/packages/instrument-library/src/forms/STARKSTEIN_APATHY_SCALE/index.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; -import { z } from '/runtime/v1/zod@3.x'; - -const $FieldOptionsValidation = z.number().int().gte(0).lte(3); -const fieldOptionsLikertScale = { - en: { - 0: 'Not at all', - 1: 'Slightly', - 2: 'Some', - 3: 'A lot' - }, - fr: { - 0: 'Pas du tout', - 1: 'Un peut', - 2: 'Assez', - 3: 'Beaucoup' - } -}; - -export default defineInstrument({ - kind: 'FORM', - language: ['en', 'fr'], - validationSchema: z.object({ - interestedInLearningNewThings: $FieldOptionsValidation, - anythingInterestsYou: $FieldOptionsValidation, - concernedAboutOwnCondition: $FieldOptionsValidation, - putMuchEffortIntoThings: $FieldOptionsValidation, - alwaysLookForSomethingToDo: $FieldOptionsValidation, - havePlanAndGoalsForFuture: $FieldOptionsValidation, - haveMotivation: $FieldOptionsValidation, - haveEnergyForDailyActivities: $FieldOptionsValidation, - needToTellYouWhatToDoEveryday: $FieldOptionsValidation, - indifferentToThings: $FieldOptionsValidation, - unconcernedWithManyThings: $FieldOptionsValidation, - needPushToGetStarted: $FieldOptionsValidation, - notHappyOrSadJustNeutral: $FieldOptionsValidation, - areYouApathetic: $FieldOptionsValidation - }), - details: { - description: { - en: 'For each question, choose the answer that best describes your thoughts, feelings, and behaviors in the last 4 weeks.', - fr: 'Pour chaque question, choisissez la réponse qui décrit le mieux vos pensées, sentiments et comportements au cours des 4 dernières semaines.' - }, - license: 'PUBLIC-DOMAIN', - title: { - en: 'Starkstein Apathy Scale', - fr: "Échelle d'apathie de Starkstein" - }, - referenceUrl: 'https://www.ncbi.nlm.nih.gov/pmc/articles/PMC8467636/', - estimatedDuration: 5, - instructions: { - en: [ - 'For each question, choose the answer that best describes your thoughts, feelings, and behaviors in the last 4 weeks.' - ], - fr: [ - 'Pour chaque question, choisissez la réponse qui décrit le mieux vos pensées, sentiments et comportements au cours des 4 dernières semaines.' - ] - } - }, - content: { - interestedInLearningNewThings: { - kind: 'number', - variant: 'radio', - label: { - en: 'Are you interested in learning new things?', - fr: 'Êtes-vous intéressé à apprendre de nouvelles choses?' - }, - options: fieldOptionsLikertScale - }, - anythingInterestsYou: { - kind: 'number', - variant: 'radio', - label: { - en: 'Does anything interest you?', - fr: "Est-ce qu'il y a des choses qui vous intéressent?" - }, - options: fieldOptionsLikertScale - }, - concernedAboutOwnCondition: { - kind: 'number', - variant: 'radio', - label: { - en: 'Are you concerned about your condition?', - fr: 'Êtes-vous préoccupé par votre état de santé ?' - }, - options: fieldOptionsLikertScale - }, - putMuchEffortIntoThings: { - kind: 'number', - variant: 'radio', - label: { - en: 'Do you put much effort into things?', - fr: "Est-ce que vous mettez beaucoup d'effort dans ce que vous faites?" - }, - options: fieldOptionsLikertScale - }, - alwaysLookForSomethingToDo: { - kind: 'number', - variant: 'radio', - label: { - en: 'Are you always looking for something to do?', - fr: "Vous êtes toujours à la recherche d'une activité ?" - }, - options: fieldOptionsLikertScale - }, - havePlanAndGoalsForFuture: { - kind: 'number', - variant: 'radio', - label: { - en: 'Do you have plans and goals for the future?', - fr: "Avez-vous des projets et des objectifs pour l'avenir ?" - }, - options: fieldOptionsLikertScale - }, - haveMotivation: { - kind: 'number', - variant: 'radio', - label: { - en: 'Do you have motivation?', - fr: 'Êtes-vous motivé (e)?' - }, - options: fieldOptionsLikertScale - }, - haveEnergyForDailyActivities: { - kind: 'number', - variant: 'radio', - label: { - en: 'Do you have the energy for daily activities?', - fr: "Avez-vous de l'énergie pour les activités quotidiennes? " - }, - options: fieldOptionsLikertScale - }, - needToTellYouWhatToDoEveryday: { - kind: 'number', - variant: 'radio', - label: { - en: 'Does someone have to tell you what to do each day?', - fr: "Est-ce que quelqu'un doit vous dire quoi faire à chaque jour? " - }, - options: fieldOptionsLikertScale - }, - indifferentToThings: { - kind: 'number', - variant: 'radio', - label: { - en: 'Are you indifferent to things?', - fr: 'Est-ce que les choses vous laissent indifférent(e)s? ' - }, - options: fieldOptionsLikertScale - }, - unconcernedWithManyThings: { - kind: 'number', - variant: 'radio', - label: { - en: 'Are you unconcerned with many things?', - fr: 'Êtes-vous indifférent à beaucoup de choses ?' - }, - options: fieldOptionsLikertScale - }, - needPushToGetStarted: { - kind: 'number', - variant: 'radio', - label: { - en: 'Do you need a push to get started on things?', - fr: "Avez-vous besoin d'être poussé(e) pour commencer des choses? " - }, - options: fieldOptionsLikertScale - }, - notHappyOrSadJustNeutral: { - kind: 'number', - variant: 'radio', - label: { - en: 'Are you neither happy nor sad, just in between?', - fr: "Vous n'êtes ni heureux ni triste, mais entre les deux ?" - }, - options: fieldOptionsLikertScale - }, - areYouApathetic: { - kind: 'number', - variant: 'radio', - label: { - en: 'Would you consider yourself apathetic?', - fr: 'Est-ce que vous vous considérez comme étant apathique?' - }, - options: fieldOptionsLikertScale - } - }, - internal: { - name: 'STARKSTEIN_APATHY_SCALE', - edition: 1 - }, - measures: { - interestedInLearningNewThings: { - kind: 'const', - ref: 'interestedInLearningNewThings' - }, - anythingInterestsYou: { - kind: 'const', - ref: 'anythingInterestsYou' - }, - concernedAboutOwnCondition: { - kind: 'const', - ref: 'concernedAboutOwnCondition' - }, - putMuchEffortIntoThings: { - kind: 'const', - ref: 'putMuchEffortIntoThings' - }, - alwaysLookForSomethingToDo: { - kind: 'const', - ref: 'alwaysLookForSomethingToDo' - }, - havePlanAndGoalsForFuture: { - kind: 'const', - ref: 'havePlanAndGoalsForFuture' - }, - haveMotivation: { - kind: 'const', - ref: 'haveMotivation' - }, - haveEnergyForDailyActivities: { - kind: 'const', - ref: 'haveEnergyForDailyActivities' - }, - needToTellYouWhatToDoEveryday: { - kind: 'const', - ref: 'needToTellYouWhatToDoEveryday' - }, - indifferentToThings: { - kind: 'const', - ref: 'indifferentToThings' - }, - unconcernedWithManyThings: { - kind: 'const', - ref: 'unconcernedWithManyThings' - }, - needPushToGetStarted: { - kind: 'const', - ref: 'needPushToGetStarted' - }, - notHappyOrSadJustNeutral: { - kind: 'const', - ref: 'notHappyOrSadJustNeutral' - }, - areYouApathetic: { - kind: 'const', - ref: 'areYouApathetic' - } - }, - tags: { - en: ['Apathy'], - fr: ['Apathie'] - } -}); diff --git a/packages/instrument-library/src/forms/TEN_ITEM_PERSONALITY_INVENTORY/index.ts b/packages/instrument-library/src/forms/TEN_ITEM_PERSONALITY_INVENTORY/index.ts deleted file mode 100644 index f591162fe..000000000 --- a/packages/instrument-library/src/forms/TEN_ITEM_PERSONALITY_INVENTORY/index.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; -import { z } from '/runtime/v1/zod@3.x'; - -const $IntScale = z.number().int().min(1).max(7); -const $ContinuousScale = z.number().min(1).max(7); - -const scaleOptions = { - en: { - 1: 'Disagree strongly', - 2: 'Disagree moderately', - 3: 'Disagree a little', - 4: 'Neither agree or disagree', - 5: 'Agree a little', - 6: 'Agree moderately', - 7: 'Agree strongly' - }, - fr: { - 1: 'Fortement en désaccord', - 2: 'En désaccord', - 3: 'Légèrement en désaccord', - 4: 'Ni en désaccord ni en accord', - 5: 'Légèrement en accord', - 6: 'En accord', - 7: 'Fortement en accord' - } -}; - -/** compute reverse score, i.e. 1 become 7, 2 becomes 6, etc. */ -function reverseScore(score: number): number { - return 8 - score; -} - -/** compute final score by doing ((reverseScore(a) + b) / 2) */ -const computeScore = (a: number, b: number) => (((reverseScore(a) + b) / 2) * 100) / 100; - -export default defineInstrument({ - kind: 'FORM', - language: ['en', 'fr'], - internal: { - name: 'TEN_ITEM_PERSONALITY_INVENTORY', - edition: 1 - }, - tags: { - en: [ - 'personality', - 'traits', - 'extraversion', - 'agreeableness', - 'conscientiousness', - 'emotional', - 'stability', - 'openness' - ], - fr: ['personnalité', 'traits', 'extraversion', 'amabilité', 'conscience', 'émotionnel', 'stabilité', 'ouverture'] - }, - details: { - description: { - en: 'The Ten-Item Personality Inventory (TIPI) is a brief instrument designed to assess the five-factor model (FFM) personality dimensions. It was specifically developed to provide a brief assessment option in situations where using more comprehensive FFM instruments would be unfeasible.', - fr: "L'inventaire de personnalité en dix éléments (TIPI) est un bref instrument conçu pour évaluer les dimensions de la personnalité du modèle à cinq facteurs (FFM). Il a été spécifiquement développé pour fournir une brève option d’évaluation dans les situations où l’utilisation d’instruments FFM plus complets serait impossible." - }, - estimatedDuration: 5, - instructions: { - en: ['Please respond to every question'], - fr: ['Veuillez répondre à toutes les questions'] - }, - license: 'FREE-NOS', - title: { - en: 'Ten-Item Personality Inventory (TIPI)', - fr: 'Ten-Item Personality Inventory (TIPI)' - } - }, - content: [ - { - description: { - en: 'Here are a number of personality traits that may or may not apply to you. Please select a number next to each statement to indicate the extent to which you agree or disagree with that statement. You should rate the extent to which the pair of traits applies to you, even if one characteristic applies more strongly than the other.', - fr: "Voici une liste de traits de caractère qui peuvent ou non vous correspondre. Veuillez indiquer dans quelle mesure vous pensez qu'ils vous correspondent. Veuillez évaluer la paire de caractéristique même si une caractéristique s'applique plus que l'autre." - }, - fields: { - extrovertedEnthusiastic: { - kind: 'number', - label: { - en: '1. Extroverted, enthusiastic.', - fr: '1. Extraverti(e), enthousiaste.' - }, - options: scaleOptions, - variant: 'select' - }, - criticalQuarrelsome: { - kind: 'number', - label: { - en: '2. Critical, quarrelsome.', - fr: '2. Critique, agressif(ve).' - }, - options: scaleOptions, - variant: 'select' - }, - dependableSelfDisciplined: { - kind: 'number', - label: { - en: '3. Dependable, self-disciplined.', - fr: '3. Digne de confiance, autodiscipliné(e).`' - }, - options: scaleOptions, - variant: 'select' - }, - anxiousEasilyUpset: { - kind: 'number', - label: { - en: '4. Anxious, easily upset.', - fr: '4. Anxieux(euse), facilement troublé(e).' - }, - options: scaleOptions, - variant: 'select' - }, - newExperiencesComplex: { - kind: 'number', - label: { - en: '5. Open to new experiences, complex.', - fr: "5. Ouvert(e) à de nouvelles expériences, d'une personnalité complexe." - }, - options: scaleOptions, - variant: 'select' - }, - reservedQuiet: { - kind: 'number', - label: { - en: '6. Reserved, quiet.', - fr: '6. Réservé(e), tranquille.' - }, - options: scaleOptions, - variant: 'select' - }, - sympatheticWarm: { - kind: 'number', - label: { - en: '7. Sympathetic, warm.', - fr: '7. Sympathique, chaleureux(euse).' - }, - options: scaleOptions, - variant: 'select' - }, - disorganizedCareless: { - kind: 'number', - label: { - en: '8. Disorganized, careless.', - fr: '8. Désorganisé(e), négligent(e).' - }, - options: scaleOptions, - variant: 'select' - }, - calmEmotionallyStable: { - kind: 'number', - label: { - en: '9. Calm, emotionally stable.', - fr: '9. Calme, émotionnellement stable.' - }, - options: scaleOptions, - variant: 'select' - }, - conventionalUncreative: { - kind: 'number', - label: { - en: '10. Conventional, uncreative.', - fr: '10. Conventionnel(le), peu créatif(ve).' - }, - options: scaleOptions, - variant: 'select' - } - } - } - ], - measures: { - extraversion: { - kind: 'computed', - label: { - en: 'Extraversion (higher score = more extroverted, range 1-7)', - fr: 'Extraversion (higher score = more extroverted, range 1-7)' - }, - value: (data) => { - // calculate the score = (reverse(q6) + q1) / 2 - const score1 = data.extrovertedEnthusiastic; - const score6 = data.reservedQuiet; - return computeScore(score6, score1); - } - }, - agreeableness: { - kind: 'computed', - label: { - en: 'Agreeableness (higher score = more agreeable, range 1-7)', - fr: 'Agreeableness (higher score = more agreeable, range 1-7)' - }, - value: (data) => { - // calculate the score = (reverse(q2) + q7) / 2 - const score2 = data.criticalQuarrelsome; - const score7 = data.sympatheticWarm; - return computeScore(score2, score7); - } - }, - conscientiousness: { - kind: 'computed', - label: { - en: 'Conscientiousness (higher score = more conscientious, range 1-7)', - fr: 'Conscientiousness (higher score = more conscientious, range 1-7)' - }, - value: (data) => { - // calculate the score = (reverse(q8) + q3) / 2 - const score3 = data.dependableSelfDisciplined; - const score8 = data.disorganizedCareless; - return computeScore(score8, score3); - } - }, - emotionalStability: { - kind: 'computed', - label: { - en: 'Emotional Stability (higher score = more stable, range 1-7)', - fr: 'Emotional Stability (higher score = more stable, range 1-7)' - }, - value: (data) => { - // calculate the score = (reverse(q4) + q9) / 2 - const score4 = data.anxiousEasilyUpset; - const score9 = data.calmEmotionallyStable; - return computeScore(score4, score9); - } - }, - openessToExperience: { - kind: 'computed', - label: { - en: 'Openness to Experience (higher score = more open, range 1-7)', - fr: 'Openness to Experience (higher score = more open, range 1-7)' - }, - value: (data) => { - // calculate the score = (reverse(q10) + q5) / 2 - const score5 = data.newExperiencesComplex; - const score10 = data.conventionalUncreative; - return computeScore(score10, score5); - } - } - }, - validationSchema: z.object({ - //answers - extrovertedEnthusiastic: $IntScale, - criticalQuarrelsome: $IntScale, - dependableSelfDisciplined: $IntScale, - anxiousEasilyUpset: $IntScale, - newExperiencesComplex: $IntScale, - reservedQuiet: $IntScale, - sympatheticWarm: $IntScale, - disorganizedCareless: $IntScale, - calmEmotionallyStable: $IntScale, - conventionalUncreative: $IntScale, - //measures - extraversion: $ContinuousScale.optional(), - agreeableness: $ContinuousScale.optional(), - conscientiousness: $ContinuousScale.optional(), - emotionalStability: $ContinuousScale.optional(), - opennessToExperience: $ContinuousScale.optional() - }) -}); diff --git a/packages/runtime-core/src/define.ts b/packages/runtime-core/src/define.ts index b1b206578..dfb46bf47 100644 --- a/packages/runtime-core/src/define.ts +++ b/packages/runtime-core/src/define.ts @@ -5,6 +5,7 @@ import type { ApprovedLicense } from '@opendatacapture/licenses'; import type { InstrumentKind, InstrumentLanguage, InstrumentValidationSchema } from './types/instrument.base.js'; import type { FormInstrument } from './types/instrument.form.js'; import type { InteractiveInstrument } from './types/instrument.interactive.js'; +import type { SeriesInstrument } from './types/instrument.series.js'; declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-empty-object-type @@ -60,3 +61,12 @@ export function defineInstrument< __runtimeVersion: 1 }) as unknown as DiscriminatedInstrument; } + +/** @public */ +export function defineSeriesInstrument( + def: Omit, '__runtimeVersion'> +): SeriesInstrument { + return Object.assign(def, { + __runtimeVersion: 1 as const + }); +} diff --git a/packages/runtime-core/src/types/instrument.series.ts b/packages/runtime-core/src/types/instrument.series.ts index 42bd71440..3188c5282 100644 --- a/packages/runtime-core/src/types/instrument.series.ts +++ b/packages/runtime-core/src/types/instrument.series.ts @@ -3,7 +3,7 @@ import type { Merge } from 'type-fest'; import type { Language } from './core.js'; import type { BaseInstrument, InstrumentLanguage, ScalarInstrumentInternal } from './instrument.base.js'; -/** @beta */ +/** @public */ declare type SeriesInstrument = Merge< BaseInstrument, { diff --git a/packages/schemas/src/auth/auth.ts b/packages/schemas/src/auth/auth.ts index e65f19bc3..6001e069a 100644 --- a/packages/schemas/src/auth/auth.ts +++ b/packages/schemas/src/auth/auth.ts @@ -8,13 +8,14 @@ export type AuthPayload = { accessToken: string; }; -export type LoginCredentials = z.infer; +export type $LoginCredentials = z.infer; export const $LoginCredentials = z.object({ - password: z.string(), - username: z.string() + password: z.string().min(1), + username: z.string().min(1) }); export type TokenPayload = { + additionalPermissions?: Permissions; basePermissionLevel: BasePermissionLevel | null; firstName: null | string; groups: Group[]; diff --git a/packages/schemas/src/instrument-records/instrument-records.ts b/packages/schemas/src/instrument-records/instrument-records.ts index 39b71f71d..019aaa466 100644 --- a/packages/schemas/src/instrument-records/instrument-records.ts +++ b/packages/schemas/src/instrument-records/instrument-records.ts @@ -6,6 +6,7 @@ import { $InstrumentMeasureValue } from '../instrument/instrument.js'; import type { SessionType } from '../session/session.js'; +export type $CreateInstrumentRecordData = z.infer; export const $CreateInstrumentRecordData = z.object({ assignmentId: z.string().optional(), data: $Json, @@ -16,6 +17,12 @@ export const $CreateInstrumentRecordData = z.object({ subjectId: z.string() }); +export type $UpdateInstrumentRecordData = z.infer; +export const $UpdateInstrumentRecordData = z.object({ + data: z.union([z.record(z.string(), z.any()), z.array(z.any())]) +}); + +export type $UploadInstrumentRecordsData = z.infer; export const $UploadInstrumentRecordsData = z.object({ groupId: z.string().optional(), instrumentId: z.string(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index add52db14..81379da29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ catalogs: motion: specifier: 11.15.0 version: 11.15.0 + neverthrow: + specifier: ^8.2.0 + version: 8.2.0 nodemon: specifier: ^3.1.9 version: 3.1.10 @@ -188,6 +191,12 @@ importers: apps/api: dependencies: + '@casl/ability': + specifier: ^6.7.3 + version: 6.7.3 + '@casl/prisma': + specifier: ^1.5.1 + version: 1.5.1(@casl/ability@6.7.3)(@prisma/client@6.11.1(prisma@6.11.1(typescript@5.6.3))(typescript@5.6.3)) '@douglasneuroinformatics/libcrypto': specifier: 'catalog:' version: 0.0.4 @@ -195,8 +204,8 @@ importers: specifier: 'catalog:' version: 3.1.0(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libnest': - specifier: ^7.3.3 - version: 7.4.0(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-express@11.1.3)(@nestjs/testing@11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-express@11.1.3))(@prisma/client@6.11.1(prisma@6.11.1(typescript@5.6.3))(typescript@5.6.3))(@swc/types@0.1.23)(express@5.1.0)(mongodb@6.17.0(socks@2.8.6))(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(reflect-metadata@0.1.14)(rollup@4.45.0)(rxjs@7.8.2)(socks@2.8.6)(typescript@5.6.3)(zod@vendor+zod@3.x) + specifier: ^8.0.1 + version: 8.0.1(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-fastify@11.1.8(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3))(@nestjs/testing@11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-express@11.1.3))(@prisma/client@6.11.1(prisma@6.11.1(typescript@5.6.3))(typescript@5.6.3))(@swc/types@0.1.23)(fastify@5.6.1)(neverthrow@8.2.0)(reflect-metadata@0.1.14)(rollup@4.45.0)(rxjs@7.8.2)(typescript@5.6.3)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libpasswd': specifier: 'catalog:' version: 0.0.3(typescript@5.6.3) @@ -215,6 +224,12 @@ importers: '@nestjs/core': specifier: ^11.0.11 version: 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@11.1.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^11.0.0 + version: 11.0.0(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': specifier: ^11.0.11 version: 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3) @@ -261,8 +276,14 @@ importers: specifier: ^6.15.0 version: 6.17.0(socks@2.8.6) neverthrow: - specifier: ^8.2.0 + specifier: 'catalog:' version: 8.2.0 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 reflect-metadata: specifier: ^0.1.14 version: 0.1.14 @@ -282,6 +303,15 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.3 + '@types/passport': + specifier: ^1.0.17 + version: 1.0.17 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + mongodb-memory-server: + specifier: ^10.3.0 + version: 10.3.0(socks@2.8.6) prisma: specifier: 'catalog:' version: 6.11.1(typescript@5.6.3) @@ -500,6 +530,9 @@ importers: jszip: specifier: ^3.10.1 version: 3.10.1 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 lodash-es: specifier: workspace:lodash-es__4.x@* version: link:../../vendor/lodash-es@4.x @@ -515,6 +548,9 @@ importers: motion: specifier: 'catalog:' version: 11.15.0(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) + neverthrow: + specifier: 'catalog:' + version: 8.2.0 react: specifier: workspace:react__19.x@* version: link:../../vendor/react@19.x @@ -1921,30 +1957,22 @@ packages: neverthrow: ^8.2.0 zod: ^3.25.x - '@douglasneuroinformatics/libnest@7.4.0': + '@douglasneuroinformatics/libnest@8.0.1': resolution: - { integrity: sha512-6budK2nDR+gfjHc1+aD7+wp30zeBxsdkGXfTvM70QAg9/tani4bK+pUNjoGUeTCt8tzPJ8Rk2L9wTCXS01jFag== } + { integrity: sha512-Fe6hKFZ0M3i4h6Nw/6AW42OyNrHpilMOLkemcqZvSe0ANyVE/MeNKHcl6XM5DJMNQ8eLEun0LBMV9T4DOM2Y4w== } engines: { node: 22.x } hasBin: true peerDependencies: '@nestjs/common': ^11.0.11 '@nestjs/core': ^11.0.11 - '@nestjs/platform-express': ^11.0.11 + '@nestjs/platform-fastify': ^11.1.6 '@nestjs/testing': ^11.0.11 '@prisma/client': ^6.9.0 - express: ^5.0.1 - mongodb: ^6.14.2 + fastify: ^5.6.1 neverthrow: ^8.2.0 - react: ^19.1.0 - react-dom: ^19.1.0 reflect-metadata: ~0.1.13 rxjs: ^7.8.2 zod: ^3.25.x - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true '@douglasneuroinformatics/libpasswd@0.0.3': resolution: @@ -2691,6 +2719,42 @@ packages: { integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA== } engines: { node: '>=18.0.0', npm: '>=9.0.0' } + '@fastify/ajv-compiler@4.0.5': + resolution: + { integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A== } + + '@fastify/cors@11.1.0': + resolution: + { integrity: sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA== } + + '@fastify/error@4.2.0': + resolution: + { integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ== } + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: + { integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ== } + + '@fastify/formbody@8.0.2': + resolution: + { integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA== } + + '@fastify/forwarded@3.0.1': + resolution: + { integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw== } + + '@fastify/merge-json-schemas@0.2.1': + resolution: + { integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A== } + + '@fastify/middie@9.0.3': + resolution: + { integrity: sha512-7OYovKXp9UKYeVMcjcFLMcSpoMkmcZmfnG+eAvtdiatN35W7c+r9y1dRfpA+pfFVNuHGGqI3W+vDTmjvcfLcMA== } + + '@fastify/proxy-addr@5.1.0': + resolution: + { integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw== } + '@floating-ui/core@1.7.2': resolution: { integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw== } @@ -3497,6 +3561,20 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/platform-fastify@11.1.8': + resolution: + { integrity: sha512-4XiiTiTkF9UbVDAuHDyVIzgr43L2sI3vLs9A52ov2lrOJcqyKwTYL/NiCQd4dtUQm1L6M0jOrJhzpYXobX5uMw== } + peerDependencies: + '@fastify/static': ^8.0.0 + '@fastify/view': ^10.0.0 || ^11.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + '@fastify/view': + optional: true + '@nestjs/swagger@11.2.0': resolution: { integrity: sha512-5wolt8GmpNcrQv34tIPUtPoV1EeFbCetm40Ij3+M0FNNnf2RJ3FyWfuQvI8SBlcJyfaounYVTKzKHreFXsUyOg== } @@ -5805,6 +5883,10 @@ packages: { integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== } engines: { node: '>=6.5' } + abstract-logging@2.0.1: + resolution: + { integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA== } + accepts@1.3.8: resolution: { integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== } @@ -6143,6 +6225,10 @@ packages: { integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== } engines: { node: '>= 0.4' } + avvio@9.1.0: + resolution: + { integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw== } + aws-sign2@0.7.0: resolution: { integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA== } @@ -6890,6 +6976,16 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: + { integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== } + engines: { node: '>=6.0' } + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js-light@2.5.1: resolution: { integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== } @@ -7602,6 +7698,10 @@ packages: resolution: { integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ== } + fast-decode-uri-component@1.0.1: + resolution: + { integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg== } + fast-deep-equal@3.1.3: resolution: { integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== } @@ -7624,10 +7724,18 @@ packages: resolution: { integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== } + fast-json-stringify@6.1.1: + resolution: + { integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ== } + fast-levenshtein@2.0.6: resolution: { integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== } + fast-querystring@1.1.2: + resolution: + { integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg== } + fast-redact@3.5.0: resolution: { integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A== } @@ -7646,6 +7754,14 @@ packages: { integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== } engines: { node: '>= 4.9.1' } + fastify-plugin@5.1.0: + resolution: + { integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw== } + + fastify@5.6.1: + resolution: + { integrity: sha512-WjjlOciBF0K8pDUPZoGPhqhKrQJ02I8DKaDIfO51EL0kbSMwQFl85cRwhOvmSDWoukNOdTo27gLN549pLCcH7Q== } + fastq@1.19.1: resolution: { integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== } @@ -7730,6 +7846,11 @@ packages: { integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== } engines: { node: '>=8' } + find-my-way@9.3.0: + resolution: + { integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg== } + engines: { node: '>=20' } + find-up@4.1.0: resolution: { integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== } @@ -7759,6 +7880,16 @@ packages: { integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ== } engines: { node: '>=8' } + follow-redirects@1.15.11: + resolution: + { integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== } + engines: { node: '>=4.0' } + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + follow-redirects@1.15.9: resolution: { integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== } @@ -8408,6 +8539,11 @@ packages: { integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== } engines: { node: '>= 0.10' } + ipaddr.js@2.2.0: + resolution: + { integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== } + engines: { node: '>= 10' } + iron-webcrypto@1.2.1: resolution: { integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg== } @@ -8783,6 +8919,10 @@ packages: resolution: { integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== } + json-schema-ref-resolver@3.0.0: + resolution: + { integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A== } + json-schema-traverse@0.4.1: resolution: { integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== } @@ -8931,6 +9071,10 @@ packages: resolution: { integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== } + light-my-request@6.6.0: + resolution: + { integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A== } + lightningcss-darwin-arm64@1.30.1: resolution: { integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ== } @@ -9642,14 +9786,14 @@ packages: resolution: { integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA== } - mongodb-memory-server-core@10.1.4: + mongodb-memory-server-core@10.3.0: resolution: - { integrity: sha512-o8fgY7ZalEd8pGps43fFPr/hkQu1L8i6HFEGbsTfA2zDOW0TopgpswaBCqDr0qD7ptibyPfB5DmC+UlIxbThzA== } + { integrity: sha512-tp+ZfTBAPqHXjROhAFg6HcVVzXaEhh/iHcbY7QPOIiLwr94OkBFAw4pixyGSfP5wI2SZeEA13lXyRmBAhugWgA== } engines: { node: '>=16.20.1' } - mongodb-memory-server@10.1.4: + mongodb-memory-server@10.3.0: resolution: - { integrity: sha512-+oKQ/kc3CX+816oPFRtaF0CN4vNcGKNjpOQe4bHo/21A3pMD+lC7Xz1EX5HP7siCX4iCpVchDMmCOFXVQSGkUg== } + { integrity: sha512-dRNr2uEhMgjEe6kgqS+ITBKBbl2cz0DNBjNZ12BGUckvEOAHbhd3R7q/lFPSZrZ6AMKa2EOUJdAmFF1WlqSbsA== } engines: { node: '>=16.20.1' } mongodb@6.17.0: @@ -10173,6 +10317,10 @@ packages: { integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== } engines: { node: '>=16' } + path-to-regexp@8.3.0: + resolution: + { integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== } + pathe@2.0.3: resolution: { integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== } @@ -10464,6 +10612,10 @@ packages: resolution: { integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== } + process-warning@4.0.1: + resolution: + { integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q== } + process-warning@5.0.0: resolution: { integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA== } @@ -10944,6 +11096,11 @@ packages: resolution: { integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw== } + ret@0.5.0: + resolution: + { integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw== } + engines: { node: '>=10' } + retext-latin@4.0.0: resolution: { integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA== } @@ -11026,6 +11183,10 @@ packages: { integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== } engines: { node: '>= 0.4' } + safe-regex2@5.0.0: + resolution: + { integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw== } + safe-stable-stringify@2.5.0: resolution: { integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== } @@ -11055,6 +11216,10 @@ packages: resolution: { integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== } + secure-json-parse@4.1.0: + resolution: + { integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA== } + seedrandom@3.0.5: resolution: { integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg== } @@ -11091,6 +11256,12 @@ packages: engines: { node: '>=10' } hasBin: true + semver@7.7.3: + resolution: + { integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== } + engines: { node: '>=10' } + hasBin: true + send@0.19.0: resolution: { integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== } @@ -11141,6 +11312,10 @@ packages: resolution: { integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== } + set-cookie-parser@2.7.2: + resolution: + { integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== } + set-function-length@1.2.2: resolution: { integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== } @@ -11797,6 +11972,11 @@ packages: { integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== } engines: { node: '>=8.0' } + toad-cache@3.7.0: + resolution: + { integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw== } + engines: { node: '>=12' } + toidentifier@1.0.1: resolution: { integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== } @@ -13410,16 +13590,12 @@ snapshots: type-fest: 4.41.0 zod: link:vendor/zod@3.x - '@douglasneuroinformatics/libnest@7.4.0(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-express@11.1.3)(@nestjs/testing@11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-express@11.1.3))(@prisma/client@6.11.1(prisma@6.11.1(typescript@5.6.3))(typescript@5.6.3))(@swc/types@0.1.23)(express@5.1.0)(mongodb@6.17.0(socks@2.8.6))(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(reflect-metadata@0.1.14)(rollup@4.45.0)(rxjs@7.8.2)(socks@2.8.6)(typescript@5.6.3)(zod@vendor+zod@3.x)': + '@douglasneuroinformatics/libnest@8.0.1(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-fastify@11.1.8(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3))(@nestjs/testing@11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-express@11.1.3))(@prisma/client@6.11.1(prisma@6.11.1(typescript@5.6.3))(typescript@5.6.3))(@swc/types@0.1.23)(fastify@5.6.1)(neverthrow@8.2.0)(reflect-metadata@0.1.14)(rollup@4.45.0)(rxjs@7.8.2)(typescript@5.6.3)(zod@vendor+zod@3.x)': dependencies: - '@casl/ability': 6.7.3 - '@casl/prisma': 1.5.1(@casl/ability@6.7.3)(@prisma/client@6.11.1(prisma@6.11.1(typescript@5.6.3))(typescript@5.6.3)) '@douglasneuroinformatics/libjs': 3.1.0(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@nestjs/common': 11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2) '@nestjs/core': 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@11.1.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/jwt': 11.0.0(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2)) - '@nestjs/passport': 11.0.5(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/platform-express': 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3) + '@nestjs/platform-fastify': 11.1.8(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3) '@nestjs/swagger': 11.2.0(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(reflect-metadata@0.1.14) '@nestjs/testing': 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-express@11.1.3) '@nestjs/throttler': 6.4.0(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(reflect-metadata@0.1.14) @@ -13429,22 +13605,15 @@ snapshots: '@swc-node/register': 1.10.10(@swc/core@1.12.11(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.6.3) '@swc/core': 1.12.11(@swc/helpers@0.5.17) '@swc/helpers': 0.5.17 - '@types/express': 5.0.3 '@types/nodemailer': 6.4.17 - '@types/passport': 1.0.17 - '@types/passport-jwt': 4.0.1 '@types/supertest': 6.0.3 chalk: 5.4.1 commander: 13.1.0 es-module-lexer: 1.7.0 esbuild: 0.25.6 - express: 5.1.0 - mongodb: 6.17.0(socks@2.8.6) - mongodb-memory-server: 10.1.4(socks@2.8.6) + fastify: 5.6.1 neverthrow: 8.2.0 nodemailer: 6.10.1 - passport: 0.7.0 - passport-jwt: 4.0.1 reflect-metadata: 0.1.14 rxjs: 7.8.2 serialize-error: 12.0.0 @@ -13453,22 +13622,12 @@ snapshots: type-fest: 4.41.0 unplugin-swc: 1.5.5(@swc/core@1.12.11(@swc/helpers@0.5.17))(rollup@4.45.0) zod: link:vendor/zod@3.x - optionalDependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: - - '@aws-sdk/credential-providers' - '@fastify/static' - - '@mongodb-js/zstd' - '@swc/types' - class-transformer - class-validator - - gcp-metadata - - kerberos - - mongodb-client-encryption - rollup - - snappy - - socks - supports-color - typescript @@ -14045,6 +14204,46 @@ snapshots: '@faker-js/faker@9.9.0': {} + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.0.6 + + '@fastify/cors@11.1.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.1.1 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/middie@9.0.3': + dependencies: + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + path-to-regexp: 8.3.0 + reusify: 1.1.0 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.2.0 + '@floating-ui/core@1.7.2': dependencies: '@floating-ui/utils': 0.2.10 @@ -14658,6 +14857,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/platform-fastify@11.1.8(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)': + dependencies: + '@fastify/cors': 11.1.0 + '@fastify/formbody': 8.0.2 + '@fastify/middie': 9.0.3 + '@nestjs/common': 11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2) + '@nestjs/core': 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@11.1.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) + fast-querystring: 1.1.2 + fastify: 5.6.1 + light-my-request: 6.6.0 + path-to-regexp: 8.3.0 + tslib: 2.8.1 + '@nestjs/swagger@11.2.0(@nestjs/common@11.1.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.3)(reflect-metadata@0.1.14)': dependencies: '@microsoft/tsdoc': 0.15.1 @@ -16742,7 +16954,7 @@ snapshots: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 '@types/node': 22.16.3 - form-data: 4.0.3 + form-data: 4.0.4 '@types/supertest@6.0.3': dependencies: @@ -17134,6 +17346,8 @@ snapshots: dependencies: event-target-shim: 5.0.1 + abstract-logging@2.0.1: {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -17179,6 +17393,10 @@ snapshots: optionalDependencies: ajv: 8.13.0 + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -17518,6 +17736,11 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.19.1 + aws-sign2@0.7.0: {} aws4@1.13.2: {} @@ -18178,6 +18401,10 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decimal.js-light@2.5.1: {} decode-named-character-reference@1.2.0: @@ -19026,6 +19253,8 @@ snapshots: fast-copy@3.0.2: {} + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-equals@5.2.2: {} @@ -19042,8 +19271,21 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.1.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.0.6 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-redact@3.5.0: {} fast-safe-stringify@2.1.1: {} @@ -19052,6 +19294,26 @@ snapshots: fastest-levenshtein@1.0.16: {} + fastify-plugin@5.1.0: {} + + fastify@5.6.1: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.1.0 + fast-json-stringify: 6.1.1 + find-my-way: 9.3.0 + light-my-request: 6.6.0 + pino: 9.7.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.3 + toad-cache: 3.7.0 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -19143,6 +19405,12 @@ snapshots: make-dir: 3.1.0 pkg-dir: 4.2.0 + find-my-way@9.3.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -19166,6 +19434,10 @@ snapshots: flattie@1.1.1: {} + follow-redirects@1.15.11(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + follow-redirects@1.15.9(debug@4.4.1): optionalDependencies: debug: 4.4.1(supports-color@8.1.1) @@ -19754,7 +20026,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -19845,6 +20117,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.2.0: {} + iron-webcrypto@1.2.1: {} is-alphabetical@2.0.1: {} @@ -20113,6 +20387,10 @@ snapshots: json-buffer@3.0.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -20255,6 +20533,12 @@ snapshots: dependencies: immediate: 3.0.6 + light-my-request@6.6.0: + dependencies: + cookie: 1.0.2 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lightningcss-darwin-arm64@1.30.1: optional: true @@ -21064,17 +21348,17 @@ snapshots: '@types/whatwg-url': 11.0.5 whatwg-url: 14.2.0 - mongodb-memory-server-core@10.1.4(socks@2.8.6): + mongodb-memory-server-core@10.3.0(socks@2.8.6): dependencies: async-mutex: 0.5.0 camelcase: 6.3.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 find-cache-dir: 3.3.2 - follow-redirects: 1.15.9(debug@4.4.1) + follow-redirects: 1.15.11(debug@4.4.3) https-proxy-agent: 7.0.6 mongodb: 6.17.0(socks@2.8.6) new-find-package-json: 2.0.0 - semver: 7.7.2 + semver: 7.7.3 tar-stream: 3.1.7 tslib: 2.8.1 yauzl: 3.2.0 @@ -21088,9 +21372,9 @@ snapshots: - socks - supports-color - mongodb-memory-server@10.1.4(socks@2.8.6): + mongodb-memory-server@10.3.0(socks@2.8.6): dependencies: - mongodb-memory-server-core: 10.1.4(socks@2.8.6) + mongodb-memory-server-core: 10.3.0(socks@2.8.6) tslib: 2.8.1 transitivePeerDependencies: - '@aws-sdk/credential-providers' @@ -21211,7 +21495,7 @@ snapshots: new-find-package-json@2.0.0: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -21563,6 +21847,8 @@ snapshots: path-to-regexp@8.2.0: {} + path-to-regexp@8.3.0: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -21756,6 +22042,8 @@ snapshots: process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} + process-warning@5.0.0: {} process@0.11.10: {} @@ -22304,6 +22592,8 @@ snapshots: restructure@3.0.2: {} + ret@0.5.0: {} + retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -22410,6 +22700,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} @@ -22428,6 +22722,8 @@ snapshots: secure-json-parse@2.7.0: {} + secure-json-parse@4.1.0: {} + seedrandom@3.0.5: {} seek-bzip@2.0.0: @@ -22448,6 +22744,8 @@ snapshots: semver@7.7.2: {} + semver@7.7.3: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -22521,6 +22819,8 @@ snapshots: set-blocking@2.0.0: optional: true + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -23058,7 +23358,7 @@ snapshots: cookiejar: 2.1.4 debug: 4.4.1(supports-color@8.1.1) fast-safe-stringify: 2.1.1 - form-data: 4.0.3 + form-data: 4.0.4 formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 @@ -23207,6 +23507,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + toidentifier@1.0.1: {} token-types@6.0.3: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 577e8e67f..e370faba5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,7 @@ catalog: esbuild-wasm: '0.23.x' happy-dom: '^20.0.2' motion: '11.15.0' + neverthrow: ^8.2.0 nodemon: '^3.1.9' prisma: '^6.9.0' tailwindcss: '^4.1.4' diff --git a/runtime/v1/package.json b/runtime/v1/package.json index 416a7c86d..a8c629025 100644 --- a/runtime/v1/package.json +++ b/runtime/v1/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/runtime-v1", "type": "module", - "version": "1.8.7", + "version": "1.8.8", "author": { "name": "Douglas Neuroinformatics", "email": "support@douglasneuroinformatics.ca" diff --git a/testing/e2e/playwright.config.ts b/testing/e2e/playwright.config.ts index 8912e0a25..9a2998a32 100644 --- a/testing/e2e/playwright.config.ts +++ b/testing/e2e/playwright.config.ts @@ -72,6 +72,8 @@ export default defineConfig({ signal: 'SIGINT', timeout: 1000 }, + stderr: 'pipe', + stdout: process.env.CI ? 'pipe' : 'ignore', timeout: 10_000, url: `http://localhost:${apiPort}` }, @@ -82,6 +84,8 @@ export default defineConfig({ signal: 'SIGINT', timeout: 1000 }, + stderr: 'pipe', + stdout: process.env.CI ? 'pipe' : 'ignore', timeout: 10_000, url: `http://localhost:${gatewayPort}/api/healthcheck` }, @@ -92,6 +96,8 @@ export default defineConfig({ signal: 'SIGINT', timeout: 1000 }, + stderr: 'pipe', + stdout: process.env.CI ? 'pipe' : 'ignore', timeout: 10_000, url: `http://localhost:${webPort}` } diff --git a/testing/e2e/src/global/global.setup.spec.ts b/testing/e2e/src/global/global.setup.spec.ts index 193d412a2..c298b9901 100644 --- a/testing/e2e/src/global/global.setup.spec.ts +++ b/testing/e2e/src/global/global.setup.spec.ts @@ -1,32 +1,40 @@ -import type { LoginCredentials } from '@opendatacapture/schemas/auth'; +import type { $LoginCredentials } from '@opendatacapture/schemas/auth'; import { initAppOptions } from '../helpers/data'; import { expect, test } from '../helpers/fixtures'; -test.skip(() => process.env.GLOBAL_SETUP_COMPLETE === '1'); - test.describe.serial(() => { test.describe.serial('setup', () => { - test('initial setup', async ({ request }) => { + test('should initially not be setup', async ({ request }) => { const response = await request.get('/api/v1/setup'); expect(response.status()).toBe(200); await expect(response.json()).resolves.toMatchObject({ isSetup: false }); }); - test('successful setup', async ({ setupPage }) => { + test('should successfully setup', async ({ setupPage }) => { await setupPage.fillSetupForm(initAppOptions); - await setupPage.expect.toHaveURL('/auth/login'); + await setupPage.expect.toHaveURL('/dashboard'); }); - test('setup state after initialization', async ({ request }) => { + test('should be setup after initialization', async ({ request }) => { const response = await request.get('/api/v1/setup'); expect(response.status()).toBe(200); await expect(response.json()).resolves.toMatchObject({ isSetup: true }); }); + test('should redirect to login page if setup', async ({ page }) => { + await page.goto('/setup'); + await expect(page).toHaveURL('/auth/login'); + }); + test('should block any further setup requests', async ({ request }) => { + const response = await request.post('/api/v1/setup', { + data: initAppOptions + }); + expect(response.status()).toBe(403); + }); }); test.describe.serial('auth', () => { - test('login', async ({ request }) => { + test('should login with the admin credentials', async ({ request }) => { const { password, username } = initAppOptions.admin; const response = await request.post('/api/v1/auth/login', { - data: { password, username } satisfies LoginCredentials + data: { password, username } satisfies $LoginCredentials }); expect(response.status()).toBe(200); const { accessToken } = await response.json(); @@ -34,7 +42,6 @@ test.describe.serial(() => { process.env.ADMIN_ACCESS_TOKEN = accessToken; process.env.ADMIN_USERNAME = username; process.env.ADMIN_PASSWORD = password; - process.env.GLOBAL_SETUP_COMPLETE = '1'; }); }); }); diff --git a/testing/e2e/src/helpers/types.ts b/testing/e2e/src/helpers/types.ts index 752e5d86a..8620858a4 100644 --- a/testing/e2e/src/helpers/types.ts +++ b/testing/e2e/src/helpers/types.ts @@ -7,10 +7,10 @@ declare global { ADMIN_ACCESS_TOKEN: string; ADMIN_PASSWORD: string; ADMIN_USERNAME: string; - GLOBAL_SETUP_COMPLETE?: '1'; } } } + export type BrowserTarget = 'Desktop Chrome' | 'Desktop Firefox' | 'Desktop Safari'; export type ProjectMetadata = { diff --git a/testing/k6/src/index.ts b/testing/k6/src/index.ts index b408eea36..94a72527f 100644 --- a/testing/k6/src/index.ts +++ b/testing/k6/src/index.ts @@ -1,5 +1,5 @@ import { DEMO_USERS } from '@opendatacapture/demo'; -import type { AuthPayload, LoginCredentials } from '@opendatacapture/schemas/auth'; +import type { $LoginCredentials, AuthPayload } from '@opendatacapture/schemas/auth'; import type { Group } from '@opendatacapture/schemas/group'; import type { InstrumentInfo } from '@opendatacapture/schemas/instrument'; import type { SetupState } from '@opendatacapture/schemas/setup'; @@ -51,7 +51,7 @@ export default function () { sleep(0.5); // login and get an access token - const loginResponse = client.post('/v1/auth/login', { + const loginResponse = client.post<$LoginCredentials, AuthPayload>('/v1/auth/login', { password: user.password, username: user.username });