diff --git a/.env.template b/.env.template index 248598885..8c891dbc0 100644 --- a/.env.template +++ b/.env.template @@ -19,6 +19,8 @@ GATEWAY_PORT=3500 RELEASE_CHANNEL=latest # The MongoDB version to use MONGODB_VERSION=7.0 +# The RustFS (S3-compatible storage) version to use +RUSTFS_VERSION=latest ## --------------------------------- ## PRODUCTION + DEVELOPMENT @@ -66,6 +68,30 @@ LOGIN_REQUEST_THROTTLER_LIMIT= # the duration in milliseconds to enforce LOGIN_REQUEST_THROTTLER_LIMIT (default: 60,000) LOGIN_REQUEST_THROTTLER_TTL= +# Enable S3-compatible object storage, required for file instruments. When set to true, all +# of STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY, STORAGE_BUCKET and STORAGE_ENDPOINT must be set. +# When false, file instruments are unavailable and the variables below are ignored. +STORAGE_ENABLED=true +# Storage service internal endpoint used by the backend/server for S3-compatible API calls +STORAGE_ENDPOINT=http://localhost:9000 +# Public endpoint used in generated download/file URLs returned to users (the browser +# uploads/downloads directly to these presigned URLs). +# In the production compose stack this may be left blank: the api service defaults it to +# http://localhost:${APP_PORT}/storage, which Caddy proxies to rustfs (see Caddyfile). +# Set it explicitly to the externally accessible storage URL for a real deployment +# (e.g. https://your-domain.com/storage). +STORAGE_PUBLIC_ENDPOINT= +# Access key for the S3 service (generated by scripts/generate-env.sh; also used as the +# rustfs admin access key in the production compose stack) +STORAGE_ACCESS_KEY= +# Secret key for the S3 service (generated by scripts/generate-env.sh; also used as the +# rustfs admin secret key in the production compose stack) +STORAGE_SECRET_KEY= +# Default bucket/container name where files are stored +STORAGE_BUCKET=open-data-capture +# Storage region name (required, can be set to anything if using self-hosted S3) +STORAGE_REGION=us-east-1 + # Disable iteration for password hashing (not recommended for production) # See https://pages.nist.gov/800-63-3/sp800-63b.html # DANGEROUSLY_DISABLE_PBKDF2_ITERATION= diff --git a/.gitignore b/.gitignore index 0103d3cf5..0c75ec7b5 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ apps/outreach/src/content/docs/en/runtime-core-docs/ # docker dbs mongo/ +rustfs/ sqlite/ # runtime core intermediate build diff --git a/Caddyfile b/Caddyfile index f468e9116..2cb404640 100644 --- a/Caddyfile +++ b/Caddyfile @@ -5,6 +5,12 @@ reverse_proxy api } + handle_path /storage/* { + reverse_proxy rustfs:9000 { + header_up Host rustfs:9000 + } + } + handle { reverse_proxy web } diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index c2ab27f25..82143074b 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -6,7 +6,7 @@ ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" ENV NODE_OPTIONS="--max-old-space-size=8192" RUN corepack enable -RUN pnpm install -g turbo@latest +RUN pnpm install -g turbo@2.9.16 # PRUNE WORKSPACE # Note: Here we cannot use --docker, as is recommended, since the generated diff --git a/apps/api/package.json b/apps/api/package.json index 2c04dda45..8acad7336 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,8 @@ "test": "env-cmd -f ../../.env vitest" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1044.0", + "@aws-sdk/s3-request-presigner": "^3.1044.0", "@casl/ability": "^6.7.5", "@casl/prisma": "^1.5.1", "@douglasneuroinformatics/libcrypto": "catalog:", diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 74594c8fd..82af8248f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -103,22 +103,23 @@ type GroupSettings { } model Group { - createdAt DateTime @default(now()) @db.Date - updatedAt DateTime @updatedAt @db.Date - id String @id @default(auto()) @map("_id") @db.ObjectId + createdAt DateTime @default(now()) @db.Date + updatedAt DateTime @updatedAt @db.Date + id String @id @default(auto()) @map("_id") @db.ObjectId accessibleInstrumentIds String[] - accessibleInstruments Instrument[] @relation(fields: [accessibleInstrumentIds], references: [id]) + accessibleInstruments Instrument[] @relation(fields: [accessibleInstrumentIds], references: [id]) assignments Assignment[] auditLogs AuditLog[] instrumentRecords InstrumentRecord[] - name String @unique + instrumentRecordFiles InstrumentRecordFile[] + name String @unique settings GroupSettings sessions Session[] - subjects Subject[] @relation(fields: [subjectIds], references: [id]) + subjects Subject[] @relation(fields: [subjectIds], references: [id]) subjectIds String[] type GroupType - userIds String[] @db.ObjectId - users User[] @relation(fields: [userIds], references: [id]) + userIds String[] @db.ObjectId + users User[] @relation(fields: [userIds], references: [id]) @@map("GroupModel") } @@ -126,30 +127,48 @@ model Group { /// Instrument Records model InstrumentRecord { - createdAt DateTime @default(now()) @db.Date - updatedAt DateTime @updatedAt @db.Date - id String @id @default(auto()) @map("_id") @db.ObjectId + createdAt DateTime @default(now()) @db.Date + updatedAt DateTime @updatedAt @db.Date + id String @id @default(auto()) @map("_id") @db.ObjectId /// [ComputedMeasures] computedMeasures Json? data Json? - date DateTime @db.Date - group Group? @relation(fields: [groupId], references: [id]) - groupId String? @db.ObjectId - subject Subject @relation(fields: [subjectId], references: [id]) + date DateTime @db.Date + files InstrumentRecordFile[] + group Group? @relation(fields: [groupId], references: [id]) + groupId String? @db.ObjectId + subject Subject @relation(fields: [subjectId], references: [id]) subjectId String - instrument Instrument @relation(fields: [instrumentId], references: [id]) + instrument Instrument @relation(fields: [instrumentId], references: [id]) instrumentId String - assignment Assignment? @relation(fields: [assignmentId], references: [id]) - assignmentId String? @unique - session Session @relation(fields: [sessionId], references: [id]) - sessionId String @db.ObjectId + assignment Assignment? @relation(fields: [assignmentId], references: [id]) + assignmentId String? @unique + session Session @relation(fields: [sessionId], references: [id]) + sessionId String @db.ObjectId + pending Boolean? @@map("InstrumentRecordModel") } -// Instruments +model InstrumentRecordFile { + basename String + createdAt DateTime @default(now()) @db.Date + group Group? @relation(fields: [groupId], references: [id]) + groupId String? @db.ObjectId + id String @id @default(auto()) @map("_id") @db.ObjectId + index Int + name String + record InstrumentRecord @relation(fields: [recordId], references: [id]) + recordId String @db.ObjectId + size Int + + @@map("InstrumentRecordFileModel") +} + +// Instruments enum InstrumentKind { + FILE FORM INTERACTIVE SERIES diff --git a/apps/api/src/auth/__tests__/ability.factory.test.ts b/apps/api/src/auth/__tests__/ability.factory.test.ts index 1c77e7595..ac9218129 100644 --- a/apps/api/src/auth/__tests__/ability.factory.test.ts +++ b/apps/api/src/auth/__tests__/ability.factory.test.ts @@ -66,4 +66,41 @@ describe('AbilityFactory', () => { expect(ability.can('read', subject('User', { id: 'user-1' }) as any)).toBe(true); expect(ability.can('read', subject('User', { id: 'user-2' }) as any)).toBe(false); }); + + it('should scope standard user instrument record file uploads to their groups', () => { + const payload = { + additionalPermissions: undefined, + basePermissionLevel: 'STANDARD', + firstName: 'Test', + groups: [{ id: 'group-1' }], + id: 'user-1', + lastName: 'User', + permissions: [] as any, + username: 'standard-user' + }; + + const ability = abilityFactory.createForPayload(payload as any); + + expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-1' }) as any)).toBe(true); + expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-2' }) as any)).toBe(false); + expect(ability.can('read', subject('InstrumentRecordFile', { groupId: 'group-1' }) as any)).toBe(false); + }); + + it('should scope group manager instrument record file uploads to their groups', () => { + const payload = { + additionalPermissions: undefined, + basePermissionLevel: 'GROUP_MANAGER', + firstName: 'Test', + groups: [{ id: 'group-1' }], + id: 'user-1', + lastName: 'User', + permissions: [] as any, + username: 'manager-user' + }; + + const ability = abilityFactory.createForPayload(payload as any); + + expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-1' }) as any)).toBe(true); + expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-2' }) as any)).toBe(false); + }); }); diff --git a/apps/api/src/auth/__tests__/ability.utils.test.ts b/apps/api/src/auth/__tests__/ability.utils.test.ts index 340aa3bfe..b016e9ba1 100644 --- a/apps/api/src/auth/__tests__/ability.utils.test.ts +++ b/apps/api/src/auth/__tests__/ability.utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { accessibleQuery } from '../ability.utils'; +import { accessibleQuery, createAppAbility, forcedAppSubject } from '../ability.utils'; const accessibleBy = vi.hoisted(() => vi.fn()); @@ -22,3 +22,22 @@ describe('accessibleQuery', () => { expect(accessibleBy).toHaveBeenCalledExactlyOnceWith(ability, 'manage'); }); }); + +describe('forcedAppSubject', () => { + it('should limit access by groupId when ability is scoped to a group', () => { + const ability = createAppAbility([ + { action: 'create', conditions: { groupId: 'group-1' }, subject: 'InstrumentRecordFile' } + ]); + + expect(ability.can('create', forcedAppSubject('InstrumentRecordFile', { groupId: 'group-1' }))).toBe(true); + expect(ability.can('create', forcedAppSubject('InstrumentRecordFile', { groupId: 'group-2' }))).toBe(false); + }); + + it('should deny access when no groupId is provided and ability requires one', () => { + const ability = createAppAbility([ + { action: 'create', conditions: { groupId: 'group-1' }, subject: 'InstrumentRecordFile' } + ]); + + expect(ability.can('create', forcedAppSubject('InstrumentRecordFile', {}))).toBe(false); + }); +}); diff --git a/apps/api/src/auth/ability.factory.ts b/apps/api/src/auth/ability.factory.ts index aae44dd0e..f9d1ebda9 100644 --- a/apps/api/src/auth/ability.factory.ts +++ b/apps/api/src/auth/ability.factory.ts @@ -28,7 +28,9 @@ export class AbilityFactory { ability.can('manage', 'Group', { id: { in: groupIds } }); ability.can('read', 'Instrument'); ability.can('create', 'InstrumentRecord'); + ability.can('create', 'InstrumentRecordFile', { groupId: { in: groupIds } }); ability.can('read', 'InstrumentRecord', { groupId: { in: groupIds } }); + ability.can('read', 'InstrumentRecordFile', { groupId: { in: groupIds } }); ability.can('create', 'Session'); ability.can('read', 'Session', { groupId: { in: groupIds } }); ability.can('create', 'Subject'); @@ -39,6 +41,7 @@ export class AbilityFactory { ability.can('read', 'Group', { id: { in: groupIds } }); ability.can('read', 'Instrument'); ability.can('create', 'InstrumentRecord'); + ability.can('create', 'InstrumentRecordFile', { groupId: { in: groupIds } }); ability.can('read', 'Session', { groupId: { in: groupIds } }); ability.can('create', 'Session'); ability.can('create', 'Subject'); diff --git a/apps/api/src/auth/ability.utils.ts b/apps/api/src/auth/ability.utils.ts index d3b061a5c..1e7888251 100644 --- a/apps/api/src/auth/ability.utils.ts +++ b/apps/api/src/auth/ability.utils.ts @@ -1,4 +1,4 @@ -import { detectSubjectType } from '@casl/ability'; +import { detectSubjectType, subject } from '@casl/ability'; import { createPrismaAbility } from '@casl/prisma'; import type { PrismaQuery } from '@casl/prisma'; import { createAccessibleByFactory } from '@casl/prisma/runtime'; @@ -6,7 +6,7 @@ import type { AppSubject, Prisma } from '@prisma/client'; import type { PrismaModelWhereInputMap } from '@/core/prisma'; -import type { AppAbilities, AppAbility, AppAction, Permission } from './auth.types'; +import type { AppAbilities, AppAbility, AppAction, AppSubjectModels, AppSubjectName, Permission } from './auth.types'; const accessibleBy = createAccessibleByFactory(); @@ -17,6 +17,16 @@ export function detectAppSubject(obj: { [key: string]: any }) { return detectSubjectType(obj) as AppSubject; } +export function forcedAppSubject>( + name: TSubjectName, + obj: Partial +) { + return subject(name, { + __modelName: name, + ...obj + } as unknown as AppSubjectModels[TSubjectName]); +} + export function createAppAbility(permissions: Permission[]): AppAbility { return createPrismaAbility(permissions, { detectSubjectType: detectAppSubject diff --git a/apps/api/src/auth/auth.types.ts b/apps/api/src/auth/auth.types.ts index f46a80b82..ca9d1d647 100644 --- a/apps/api/src/auth/auth.types.ts +++ b/apps/api/src/auth/auth.types.ts @@ -6,18 +6,20 @@ 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 AppSubjectModels = { + [K in keyof Prisma.TypeMap['model']]: DefaultSelection; +}; + +type AppSubjects = 'all' | Subjects; type AppSubjectName = Extract; +type AppSubjectModel = Extract; + type AppAbilities = [AppAction, AppSubjects]; type AppAbility = PureAbility; type Permission = RawRuleOf>; -export type { AppAbilities, AppAbility, AppAction, AppSubjectName, Permission }; +export type { AppAbilities, AppAbility, AppAction, AppSubjectModel, AppSubjectModels, AppSubjectName, Permission }; diff --git a/apps/api/src/core/schemas/env.schema.ts b/apps/api/src/core/schemas/env.schema.ts index 5abaca180..718d27901 100644 --- a/apps/api/src/core/schemas/env.schema.ts +++ b/apps/api/src/core/schemas/env.schema.ts @@ -13,7 +13,14 @@ export const $Env = $BaseEnv GATEWAY_ENABLED: $BooleanLike, GATEWAY_INTERNAL_NETWORK_URL: $UrlLike.optional(), GATEWAY_REFRESH_INTERVAL: $NumberLike.pipe(z.number().positive().int()), - GATEWAY_SITE_ADDRESS: $UrlLike.optional() + GATEWAY_SITE_ADDRESS: $UrlLike.optional(), + STORAGE_ACCESS_KEY: z.string().min(1).optional(), + STORAGE_BUCKET: z.string().min(1).optional(), + STORAGE_ENABLED: $BooleanLike.optional(), + STORAGE_ENDPOINT: z.url().optional(), + STORAGE_PUBLIC_ENDPOINT: z.url().optional(), + STORAGE_REGION: z.string().optional(), + STORAGE_SECRET_KEY: z.string().min(1).optional() }) .transform((env, ctx) => { if (env.NODE_ENV === 'production') { @@ -31,5 +38,16 @@ export const $Env = $BaseEnv }); } } + if (env.STORAGE_ENABLED) { + for (const key of ['STORAGE_ACCESS_KEY', 'STORAGE_BUCKET', 'STORAGE_ENDPOINT', 'STORAGE_SECRET_KEY'] as const) { + if (!env[key]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${key} must be defined when STORAGE_ENABLED is true`, + path: [key] + }); + } + } + } return { ...env, API_PORT: env.API_DEV_SERVER_PORT ?? 80 }; }); diff --git a/apps/api/src/demo/demo.service.ts b/apps/api/src/demo/demo.service.ts index 854a6210f..96c4e52a3 100644 --- a/apps/api/src/demo/demo.service.ts +++ b/apps/api/src/demo/demo.service.ts @@ -3,6 +3,8 @@ import { InjectPrismaClient, LoggingService } from '@douglasneuroinformatics/lib import { faker } from '@faker-js/faker'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { DEMO_GROUPS, DEMO_USERS } from '@opendatacapture/demo'; +import arbitrarySingleFileInstrument from '@opendatacapture/instrument-library/file/ARBITRARY_SINGLE_FILE.js'; +import mriScanSessionInstrument from '@opendatacapture/instrument-library/file/MRI_SCAN_SESSION.js'; 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'; @@ -72,6 +74,12 @@ export class DemoService { await this.instrumentsService.create({ bundle: happinessQuestionnaireWithConsent }); this.loggingService.debug('Done creating series instruments'); + await Promise.all([ + this.instrumentsService.create({ bundle: arbitrarySingleFileInstrument }), + this.instrumentsService.create({ bundle: mriScanSessionInstrument }) + ]); + this.loggingService.debug('Done creating file instruments'); + const groups: (Group & { dummyIdPrefix?: string })[] = []; for (const group of DEMO_GROUPS) { const { dummyIdPrefix, ...createGroupData } = group; diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts b/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts index 555291b69..88a0b3928 100644 --- a/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts +++ b/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts @@ -6,6 +6,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it } from 'vitest'; +import { StorageService } from '@/storage/storage.service'; import { UsersService } from '@/users/users.service'; import { GroupsService } from '../../groups/groups.service'; @@ -35,6 +36,7 @@ describe('InstrumentRecordsService', () => { MockFactory.createForService(InstrumentMeasuresService), MockFactory.createForService(InstrumentsService), MockFactory.createForService(SessionsService), + MockFactory.createForService(StorageService), MockFactory.createForService(SubjectsService) ] }).compile(); diff --git a/apps/api/src/instrument-records/files/files.controller.ts b/apps/api/src/instrument-records/files/files.controller.ts new file mode 100644 index 000000000..80f8d3a27 --- /dev/null +++ b/apps/api/src/instrument-records/files/files.controller.ts @@ -0,0 +1,43 @@ +import { CurrentUser, ValidObjectIdPipe } from '@douglasneuroinformatics/libnest'; +import type { RequestUser } from '@douglasneuroinformatics/libnest'; +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import type { $InstrumentRecordFiles } from '@opendatacapture/schemas/instrument-records'; +// import type { $InstrumentRecordFiles } from '@opendatacapture/schemas/instrument-records'; +import { $PresignedUrls, $UploadCompleteData } from '@opendatacapture/schemas/storage'; + +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + +import { FilesService } from './files.service'; + +@Controller('instrument-records/:recordId/files') +export class FilesController { + constructor(private readonly filesService: FilesService) {} + + @Get() + @RouteAccess({ action: 'read', subject: 'InstrumentRecordFile' }) + find( + @Param('recordId', ValidObjectIdPipe) recordId: string, + @CurrentUser() currentUser: RequestUser + ): Promise<$InstrumentRecordFiles> { + return this.filesService.find(recordId, currentUser); + } + + @Get('upload-urls') + @RouteAccess({ action: 'create', subject: 'InstrumentRecordFile' }) + getUploadUrls( + @Param('recordId', ValidObjectIdPipe) recordId: string, + @CurrentUser() currentUser: RequestUser + ): Promise<$PresignedUrls> { + return this.filesService.getPresignedUploadUrls(recordId, currentUser); + } + + @Post('upload-complete') + @RouteAccess({ action: 'create', subject: 'InstrumentRecordFile' }) + setUploadComplete( + @Param('recordId', ValidObjectIdPipe) recordId: string, + @Body() data: $UploadCompleteData, + @CurrentUser() currentUser: RequestUser + ): Promise { + return this.filesService.setUploadComplete(recordId, data, currentUser); + } +} diff --git a/apps/api/src/instrument-records/files/files.service.ts b/apps/api/src/instrument-records/files/files.service.ts new file mode 100644 index 000000000..ed7a80a20 --- /dev/null +++ b/apps/api/src/instrument-records/files/files.service.ts @@ -0,0 +1,189 @@ +import { InjectModel } from '@douglasneuroinformatics/libnest'; +import type { Model, RequestUser } from '@douglasneuroinformatics/libnest'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, + UnprocessableEntityException +} from '@nestjs/common'; +import type { FileInstrument } from '@opendatacapture/runtime-core'; +import type { $InstrumentRecordFile, $InstrumentRecordFiles } from '@opendatacapture/schemas/instrument-records'; +import type { $FileMetadata, $PresignedUrls, $UploadCompleteData } from '@opendatacapture/schemas/storage'; +import { range } from 'lodash-es'; + +import { accessibleQuery, forcedAppSubject } from '@/auth/ability.utils'; +import { InstrumentsService } from '@/instruments/instruments.service'; +import { StorageService } from '@/storage/storage.service'; + +import type { FileUploadAssociations } from './files.types'; + +@Injectable() +export class FilesService { + constructor( + @InjectModel('InstrumentRecord') private readonly instrumentRecordModel: Model<'InstrumentRecord'>, + private readonly instrumentsService: InstrumentsService, + private readonly storageService: StorageService + ) {} + + async find(recordId: string, currentUser: RequestUser): Promise<$InstrumentRecordFiles> { + const record = await this.instrumentRecordModel.findUnique({ + include: { + files: true + }, + where: { + AND: [ + accessibleQuery(currentUser.ability, 'read', 'InstrumentRecord'), + { + files: { + every: accessibleQuery(currentUser.ability, 'read', 'InstrumentRecordFile') + } + } + ], + id: recordId + } + }); + + if (!record) { + throw new NotFoundException(`Could not find record with ID '${recordId}'`); + } + + const instrument = await this.findFileInstrumentById(record.instrumentId); + + const files: { [basename: string]: $InstrumentRecordFile[] } = {}; + for (const fileGroup of instrument.content.fileGroups) { + const groupFiles = record.files + .filter((file) => file.basename === fileGroup.basename) + .toSorted((a, b) => a.index - b.index); + files[fileGroup.basename] = await Promise.all( + groupFiles.map(async (file) => { + const presigned = await this.storageService.getPresignedDownloadUrl({ + groupId: file.groupId, + location: { + basename: file.basename, + index: file.index + }, + recordId: file.recordId + }); + return { + exp: presigned.exp, + name: file.name, + size: file.size, + url: presigned.url + }; + }) + ); + } + + return files; + } + + async getPresignedUploadUrls(recordId: string, currentUser: RequestUser): Promise<$PresignedUrls> { + const { groupId, instrument } = await this.getAssociations(recordId, currentUser); + + const presignedUrls: $PresignedUrls = {}; + for (const fileGroup of instrument.content.fileGroups) { + presignedUrls[fileGroup.basename] = await Promise.all( + range(fileGroup.count.max).map((index) => { + return this.storageService.getPresignedUploadUrl({ + groupId, + location: { + basename: fileGroup.basename, + index + }, + recordId + }); + }) + ); + } + + return presignedUrls; + } + + async setUploadComplete(recordId: string, data: $UploadCompleteData, currentUser: RequestUser): Promise { + const { groupId, instrument } = await this.getAssociations(recordId, currentUser); + await this.instrumentRecordModel.update({ + data: { + files: { + create: this.validateFiles(data.uploads, instrument.content.fileGroups).map((file) => ({ + basename: file.location.basename, + group: groupId ? { connect: { id: groupId } } : undefined, + index: file.location.index, + name: file.name, + size: file.size + })) + }, + pending: false + }, + where: { + id: recordId + } + }); + } + + private async findFileInstrumentById(instrumentId: string): Promise { + const instrument = await this.instrumentsService.findById(instrumentId); + if (instrument.kind !== 'FILE') { + throw new UnprocessableEntityException('Cannot perform operation for non-file instrument'); + } + return instrument; + } + + private async getAssociations(recordId: string, currentUser: RequestUser): Promise { + const record = await this.instrumentRecordModel.findUnique({ + select: { + groupId: true, + instrumentId: true, + pending: true + }, + where: { + id: recordId + } + }); + + if (!record) { + throw new NotFoundException(`Could not find record with ID '${recordId}'`); + } else if (!record.pending) { + throw new ConflictException('Upload already completed'); + } + + const canUpload = currentUser.ability.can( + 'create', + forcedAppSubject('InstrumentRecordFile', { groupId: record.groupId }) + ); + + if (!canUpload) { + throw new ForbiddenException(`Cannot upload files for record with ID '${recordId}'`); + } + + const instrument = await this.findFileInstrumentById(record.instrumentId); + + return { + groupId: record.groupId, + instrument + }; + } + + private validateFiles( + uploads: { [id: string]: $FileMetadata[] }, + fileGroups: FileInstrument.FileGroup[] + ): $FileMetadata[] { + const validatedFiles: $FileMetadata[] = []; + for (const fileGroup of fileGroups) { + const uploadedFiles = uploads[fileGroup.basename]; + const actual = uploadedFiles?.length ?? 0; + const { max, min } = fileGroup.count; + if (actual < min || actual > max) { + const expected = min === max ? `${min}` : `between ${min} and ${max}`; + throw new BadRequestException( + `Invalid file count for file group '${fileGroup.basename}': expected ${expected} file(s), but got '${actual}'` + ); + } + for (const file of uploadedFiles!) { + validatedFiles.push(file); + } + } + return validatedFiles; + } +} diff --git a/apps/api/src/instrument-records/files/files.types.ts b/apps/api/src/instrument-records/files/files.types.ts new file mode 100644 index 000000000..81d832b1f --- /dev/null +++ b/apps/api/src/instrument-records/files/files.types.ts @@ -0,0 +1,6 @@ +import type { FileInstrument } from '@opendatacapture/runtime-core'; + +export type FileUploadAssociations = { + groupId: null | string; + instrument: FileInstrument; +}; diff --git a/apps/api/src/instrument-records/instrument-records.module.ts b/apps/api/src/instrument-records/instrument-records.module.ts index 65c86d058..1c365e703 100644 --- a/apps/api/src/instrument-records/instrument-records.module.ts +++ b/apps/api/src/instrument-records/instrument-records.module.ts @@ -3,17 +3,20 @@ import { Module } from '@nestjs/common'; import { GroupsModule } from '@/groups/groups.module'; import { InstrumentsModule } from '@/instruments/instruments.module'; import { SessionsModule } from '@/sessions/sessions.module'; +import { StorageModule } from '@/storage/storage.module'; import { SubjectsModule } from '@/subjects/subjects.module'; import { UsersModule } from '@/users/users.module'; +import { FilesController } from './files/files.controller'; +import { FilesService } from './files/files.service'; import { InstrumentMeasuresService } from './instrument-measures.service'; import { InstrumentRecordsController } from './instrument-records.controller'; import { InstrumentRecordsService } from './instrument-records.service'; @Module({ - controllers: [InstrumentRecordsController], + controllers: [FilesController, InstrumentRecordsController], exports: [InstrumentRecordsService], - imports: [GroupsModule, InstrumentsModule, SessionsModule, SubjectsModule, UsersModule], - providers: [InstrumentMeasuresService, InstrumentRecordsService] + imports: [GroupsModule, InstrumentsModule, SessionsModule, SubjectsModule, StorageModule, UsersModule], + providers: [FilesService, InstrumentMeasuresService, InstrumentRecordsService] }) export class InstrumentRecordsModule {} diff --git a/apps/api/src/instrument-records/instrument-records.service.ts b/apps/api/src/instrument-records/instrument-records.service.ts index e032395ca..e9808f9fb 100644 --- a/apps/api/src/instrument-records/instrument-records.service.ts +++ b/apps/api/src/instrument-records/instrument-records.service.ts @@ -11,6 +11,7 @@ import { ForbiddenException, Injectable, NotFoundException, + ServiceUnavailableException, UnprocessableEntityException } from '@nestjs/common'; import type { Json, ScalarInstrument } from '@opendatacapture/runtime-core'; @@ -32,6 +33,7 @@ import type { EntityOperationOptions } from '@/core/types'; import { GroupsService } from '@/groups/groups.service'; import { InstrumentsService } from '@/instruments/instruments.service'; import { SessionsService } from '@/sessions/sessions.service'; +import { StorageService } from '@/storage/storage.service'; import { CreateSubjectDto } from '@/subjects/dto/create-subject.dto'; import { SubjectsService } from '@/subjects/subjects.service'; import { UsersService } from '@/users/users.service'; @@ -56,6 +58,7 @@ export class InstrumentRecordsService { private readonly instrumentMeasuresService: InstrumentMeasuresService, private readonly instrumentsService: InstrumentsService, private readonly sessionsService: SessionsService, + private readonly storageService: StorageService, private readonly subjectsService: SubjectsService ) {} @@ -81,6 +84,11 @@ export class InstrumentRecordsService { `Cannot create instrument record for series instrument '${instrument.id}'` ); } + if (instrument.kind === 'FILE' && !this.storageService.isEnabled) { + throw new ServiceUnavailableException( + `Cannot create instrument record for file instrument '${instrument.id}': file storage is not configured` + ); + } await this.subjectsService.findById(subjectId); await this.sessionsService.findById(sessionId); @@ -112,6 +120,7 @@ export class InstrumentRecordsService { id: instrumentId } }, + pending: instrument.kind === 'FILE', session: { connect: { id: sessionId @@ -230,7 +239,8 @@ export class InstrumentRecordsService { { instrumentId }, { instrumentId: { in: instrumentKindIds } }, accessibleQuery(ability, 'read', 'InstrumentRecord'), - { subjectId } + { subjectId }, + { NOT: { pending: true } } ] } }); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 3215261b5..2e45064a2 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -11,6 +11,7 @@ import { InstrumentRecordsModule } from './instrument-records/instrument-records import { InstrumentsModule } from './instruments/instruments.module'; import { SessionsModule } from './sessions/sessions.module'; import { SetupModule } from './setup/setup.module'; +import { StorageModule } from './storage/storage.module'; import { SubjectsModule } from './subjects/subjects.module'; import { SummaryModule } from './summary/summary.module'; import { UsersModule } from './users/users.module'; @@ -48,6 +49,7 @@ export default AppFactory.create({ }), SessionsModule, SetupModule, + StorageModule, SubjectsModule, SummaryModule, UsersModule, diff --git a/apps/api/src/storage/storage.module.ts b/apps/api/src/storage/storage.module.ts new file mode 100644 index 000000000..1cbb3e9f5 --- /dev/null +++ b/apps/api/src/storage/storage.module.ts @@ -0,0 +1,31 @@ +import { S3Client } from '@aws-sdk/client-s3'; +import { ConfigService } from '@douglasneuroinformatics/libnest'; +import { Module } from '@nestjs/common'; + +import { StorageService } from './storage.service'; + +@Module({ + exports: [StorageService], + providers: [ + { + inject: [ConfigService], + provide: S3Client, + useFactory: (configService: ConfigService): null | S3Client => { + if (!configService.get('STORAGE_ENABLED')) { + return null; + } + return new S3Client({ + credentials: { + accessKeyId: configService.get('STORAGE_ACCESS_KEY')!, + secretAccessKey: configService.get('STORAGE_SECRET_KEY')! + }, + endpoint: configService.get('STORAGE_ENDPOINT'), + forcePathStyle: true, + region: configService.get('STORAGE_REGION') + }); + } + }, + StorageService + ] +}) +export class StorageModule {} diff --git a/apps/api/src/storage/storage.service.ts b/apps/api/src/storage/storage.service.ts new file mode 100644 index 000000000..107f0a423 --- /dev/null +++ b/apps/api/src/storage/storage.service.ts @@ -0,0 +1,105 @@ +import { + CreateBucketCommand, + GetObjectCommand, + HeadBucketCommand, + PutObjectCommand, + S3Client +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { ConfigService } from '@douglasneuroinformatics/libnest'; +import { Injectable, Optional, ServiceUnavailableException } from '@nestjs/common'; +import type { OnModuleInit } from '@nestjs/common'; +import type { $FileLocation, $PresignedUrlInfo } from '@opendatacapture/schemas/storage'; + +type FileSearchParams = { + groupId: null | string; + location: $FileLocation; + recordId: string; +}; + +export type StorageKey = `groups/${string}/records/${string}/files/${string}`; + +@Injectable() +export class StorageService implements OnModuleInit { + private readonly bucket?: string; + private readonly enabled: boolean; + private readonly publicStorageEndpoint?: string; + private readonly storageEndpoint?: string; + + constructor( + private readonly configService: ConfigService, + @Optional() private readonly s3: null | S3Client + ) { + this.enabled = !!this.configService.get('STORAGE_ENABLED'); + this.bucket = this.configService.get('STORAGE_BUCKET'); + this.storageEndpoint = this.configService.get('STORAGE_ENDPOINT'); + this.publicStorageEndpoint = this.configService.get('STORAGE_PUBLIC_ENDPOINT') ?? this.storageEndpoint; + } + + get isEnabled(): boolean { + return this.enabled; + } + + async getPresignedDownloadUrl(params: FileSearchParams): Promise<$PresignedUrlInfo> { + const { bucket, s3 } = this.requireStorage(); + const key = this.getStorageKey(params); + + const command = new GetObjectCommand({ + Bucket: bucket, + Key: key, + ResponseContentDisposition: `attachment; filename="${this.getFileKey(params.location)}"` + }); + + const url = await getSignedUrl(s3, command, { expiresIn: 60 * 15 }); + const publicUrl = this.transformUrlForPublicAccess(url); + const exp = Date.now() + 1000 * 60 * 15; // 15 minutes for downloads + return { exp, location: params.location, url: publicUrl }; + } + + async getPresignedUploadUrl(params: FileSearchParams): Promise<$PresignedUrlInfo> { + const { bucket, s3 } = this.requireStorage(); + const key = this.getStorageKey(params); + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key + }); + const url = await getSignedUrl(s3, command, { expiresIn: 60 * 5 }); + const publicUrl = this.transformUrlForPublicAccess(url); + const exp = Date.now() + 1000 * 60 * 5; // make sure computed after generation + return { exp, location: params.location, url: publicUrl }; + } + + async onModuleInit(): Promise { + if (!this.enabled || this.configService.get('NODE_ENV') === 'test') { + return; + } + const { bucket, s3 } = this.requireStorage(); + try { + await s3.send(new HeadBucketCommand({ Bucket: bucket })); + } catch { + await s3.send(new CreateBucketCommand({ Bucket: bucket })); + } + } + + private getFileKey(location: $FileLocation) { + return `${location.basename}_${location.index}`; + } + + private getStorageKey({ groupId, location, recordId }: FileSearchParams): StorageKey { + return `groups/${groupId ?? '__ROOT__'}/records/${recordId}/files/${this.getFileKey(location)}`; + } + + private requireStorage(): { bucket: string; s3: S3Client } { + if (!this.enabled || !this.s3 || !this.bucket) { + throw new ServiceUnavailableException('File storage is not configured'); + } + return { bucket: this.bucket, s3: this.s3 }; + } + + private transformUrlForPublicAccess(url: string): string { + if (!this.storageEndpoint || !this.publicStorageEndpoint || this.publicStorageEndpoint === this.storageEndpoint) { + return url; + } + return url.replace(this.storageEndpoint, this.publicStorageEndpoint); + } +} diff --git a/apps/gateway/Dockerfile b/apps/gateway/Dockerfile index 606643cd7..e25860c0c 100644 --- a/apps/gateway/Dockerfile +++ b/apps/gateway/Dockerfile @@ -6,7 +6,7 @@ ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" ENV NODE_OPTIONS="--max-old-space-size=8192" RUN corepack enable -RUN pnpm install -g turbo@latest +RUN pnpm install -g turbo@2.9.16 # PRUNE WORKSPACE # Note: Here we cannot use --docker, as is recommended, since the generated diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 9e262a0eb..2fc4f2835 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -13,7 +13,8 @@ "dev": "NODE_ENV=development env-cmd -f ../../.env tsx scripts/dev.ts && env-cmd -f ../../.env node dist/main.js", "dev:test": "NODE_ENV=test env-cmd -f ../../.env tsx scripts/dev.ts && env-cmd -f ../../.env node dist/main.js", "format": "prettier --write src", - "lint": "tsc && eslint --fix src" + "lint": "tsc && eslint --fix src", + "start": "NODE_ENV=production env-cmd -f ../../.env node dist/main.js" }, "dependencies": { "@douglasneuroinformatics/libcrypto": "catalog:", diff --git a/apps/playground/Dockerfile b/apps/playground/Dockerfile index f6b9b10f0..233dd7700 100644 --- a/apps/playground/Dockerfile +++ b/apps/playground/Dockerfile @@ -5,7 +5,7 @@ ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" ENV NODE_OPTIONS="--max-old-space-size=8192" RUN npm i -g corepack@latest RUN corepack enable -RUN pnpm install -g turbo@latest +RUN pnpm install -g turbo@2.9.16 # PRUNE WORKSPACE # Note: Here we cannot use --docker, as is recommended, since the generated diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 3b7de50e1..1be9a67d6 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -5,7 +5,7 @@ ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" ENV NODE_OPTIONS="--max-old-space-size=8192" RUN corepack enable -RUN pnpm install -g turbo@latest +RUN pnpm install -g turbo@2.9.16 # PRUNE WORKSPACE # Note: Here we cannot use --docker, as is recommended, since the generated diff --git a/apps/web/package.json b/apps/web/package.json index f22fabd1b..f3bc55557 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "format:translations": "find src/translations -name '*.json' -exec pnpm exec sort-json {} \\;", "inject": "pnpm exec import-meta-env -x .env.public -p dist/index.html", "lint": "tsc && eslint --fix src", + "start": "NODE_ENV=production env-cmd -f ../../.env pnpm exec import-meta-env -x .env.public -p dist/index.html && python -m http.server -d dist 3000", "storybook": "NODE_OPTIONS='--import=tsx' env-cmd -f ../../.env storybook dev -p 6006", "test": "env-cmd -f ../../.env vitest" }, diff --git a/apps/web/src/components/NavigationBlocker.tsx b/apps/web/src/components/NavigationBlocker.tsx new file mode 100644 index 000000000..6045c203c --- /dev/null +++ b/apps/web/src/components/NavigationBlocker.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { NavigationBlockerDialog } from '@opendatacapture/react-core'; +import type { NavigationBlockerProps } from '@opendatacapture/react-core'; +import { useBlocker } from '@tanstack/react-router'; + +export const NavigationBlocker: React.FC = ({ active, message }) => { + const blocker = useBlocker({ + enableBeforeUnload: true, + shouldBlockFn: () => active, + withResolver: true + }); + return ( + + ); +}; diff --git a/apps/web/src/hooks/useCreateSetupStateMutation.ts b/apps/web/src/hooks/useCreateSetupStateMutation.ts index d4e2a329c..115515df8 100644 --- a/apps/web/src/hooks/useCreateSetupStateMutation.ts +++ b/apps/web/src/hooks/useCreateSetupStateMutation.ts @@ -9,7 +9,12 @@ export function useCreateSetupStateMutation() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: (data: InitAppOptions) => axios.post('/v1/setup', data), + mutationFn: (data: InitAppOptions) => + axios.post('/v1/setup', data, { + meta: { + disableDefaultTimeout: true + } + }), onSuccess: async () => { addNotification({ type: 'success' }); await queryClient.invalidateQueries({ queryKey: [SETUP_STATE_QUERY_KEY] }); diff --git a/apps/web/src/hooks/useInstrumentRecordFilesQuery.ts b/apps/web/src/hooks/useInstrumentRecordFilesQuery.ts new file mode 100644 index 000000000..97ea6ed5a --- /dev/null +++ b/apps/web/src/hooks/useInstrumentRecordFilesQuery.ts @@ -0,0 +1,17 @@ +import { $InstrumentRecordFiles } from '@opendatacapture/schemas/instrument-records'; +import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +export const instrumentRecordFilesQueryOptions = ({ params }: { params: { id: string } }) => { + return queryOptions({ + queryFn: async () => { + const response = await axios.get(`/v1/instrument-records/${params.id}/files`); + return $InstrumentRecordFiles.parse(response.data); + }, + queryKey: ['instrument-records', `id-${params.id}`, 'files'] + }); +}; + +export function useInstrumentRecordFilesQuery({ params }: { params: { id: string } }) { + return useSuspenseQuery(instrumentRecordFilesQueryOptions({ params })); +} diff --git a/apps/web/src/hooks/useUploadInstrumentRecordsMutation.ts b/apps/web/src/hooks/useUploadInstrumentRecordsMutation.ts index 9f37787ad..fc435e3b6 100644 --- a/apps/web/src/hooks/useUploadInstrumentRecordsMutation.ts +++ b/apps/web/src/hooks/useUploadInstrumentRecordsMutation.ts @@ -9,13 +9,21 @@ export function useUploadInstrumentRecordsMutation() { const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ mutationFn: async (data: UploadInstrumentRecordsData) => { - await axios.post('/v1/instrument-records/upload', { - ...data, - records: data.records.map((record) => ({ - ...record, - data: JSON.parse(JSON.stringify(record.data, replacer)) as Json - })) - } satisfies UploadInstrumentRecordsData); + await axios.post( + '/v1/instrument-records/upload', + { + ...data, + records: data.records.map((record) => ({ + ...record, + data: JSON.parse(JSON.stringify(record.data, replacer)) as Json + })) + } satisfies UploadInstrumentRecordsData, + { + meta: { + disableDefaultTimeout: true + } + } + ); }, onSuccess() { addNotification({ type: 'success' }); diff --git a/apps/web/src/route-tree.ts b/apps/web/src/route-tree.ts index 2ebe4eecf..feccde578 100644 --- a/apps/web/src/route-tree.ts +++ b/apps/web/src/route-tree.ts @@ -28,13 +28,13 @@ import { Route as AppDatahubSubjectIdRouteRouteImport } from './routes/_app/data import { Route as AppAdminUsersIndexRouteImport } from './routes/_app/admin/users/index' import { Route as AppAdminGroupsIndexRouteImport } from './routes/_app/admin/groups/index' import { Route as AppInstrumentsRenderIdRouteImport } from './routes/_app/instruments/render/$id' -import { Route as AppDatahubSubjectIdTableRouteImport } from './routes/_app/datahub/$subjectId/table' import { Route as AppDatahubSubjectIdGraphRouteImport } from './routes/_app/datahub/$subjectId/graph' import { Route as AppDatahubSubjectIdAssignmentsRouteImport } from './routes/_app/datahub/$subjectId/assignments' -import { Route as AppDatahubSubjectIdRecordIdRouteImport } from './routes/_app/datahub/$subjectId/$recordId' import { Route as AppAdminUsersCreateRouteImport } from './routes/_app/admin/users/create' import { Route as AppAdminGroupsCreateRouteImport } from './routes/_app/admin/groups/create' import { Route as AppAdminAuditLogsRouteImport } from './routes/_app/admin/audit/logs' +import { Route as AppDatahubSubjectIdTableIndexRouteImport } from './routes/_app/datahub/$subjectId/table/index' +import { Route as AppDatahubSubjectIdTableRecordIdRouteImport } from './routes/_app/datahub/$subjectId/table/$recordId' const SetupRoute = SetupRouteImport.update({ id: '/setup', @@ -132,12 +132,6 @@ const AppInstrumentsRenderIdRoute = AppInstrumentsRenderIdRouteImport.update({ path: '/instruments/render/$id', getParentRoute: () => AppRouteRoute, } as any) -const AppDatahubSubjectIdTableRoute = - AppDatahubSubjectIdTableRouteImport.update({ - id: '/table', - path: '/table', - getParentRoute: () => AppDatahubSubjectIdRouteRoute, - } as any) const AppDatahubSubjectIdGraphRoute = AppDatahubSubjectIdGraphRouteImport.update({ id: '/graph', @@ -150,12 +144,6 @@ const AppDatahubSubjectIdAssignmentsRoute = path: '/assignments', getParentRoute: () => AppDatahubSubjectIdRouteRoute, } as any) -const AppDatahubSubjectIdRecordIdRoute = - AppDatahubSubjectIdRecordIdRouteImport.update({ - id: '/$recordId', - path: '/$recordId', - getParentRoute: () => AppDatahubSubjectIdRouteRoute, - } as any) const AppAdminUsersCreateRoute = AppAdminUsersCreateRouteImport.update({ id: '/admin/users/create', path: '/admin/users/create', @@ -171,6 +159,18 @@ const AppAdminAuditLogsRoute = AppAdminAuditLogsRouteImport.update({ path: '/admin/audit/logs', getParentRoute: () => AppRouteRoute, } as any) +const AppDatahubSubjectIdTableIndexRoute = + AppDatahubSubjectIdTableIndexRouteImport.update({ + id: '/table/', + path: '/table/', + getParentRoute: () => AppDatahubSubjectIdRouteRoute, + } as any) +const AppDatahubSubjectIdTableRecordIdRoute = + AppDatahubSubjectIdTableRecordIdRouteImport.update({ + id: '/table/$recordId', + path: '/table/$recordId', + getParentRoute: () => AppDatahubSubjectIdRouteRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof AppIndexRoute @@ -191,13 +191,13 @@ export interface FileRoutesByFullPath { '/admin/audit/logs': typeof AppAdminAuditLogsRoute '/admin/groups/create': typeof AppAdminGroupsCreateRoute '/admin/users/create': typeof AppAdminUsersCreateRoute - '/datahub/$subjectId/$recordId': typeof AppDatahubSubjectIdRecordIdRoute '/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute '/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute - '/datahub/$subjectId/table': typeof AppDatahubSubjectIdTableRoute '/instruments/render/$id': typeof AppInstrumentsRenderIdRoute '/admin/groups/': typeof AppAdminGroupsIndexRoute '/admin/users/': typeof AppAdminUsersIndexRoute + '/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute + '/datahub/$subjectId/table/': typeof AppDatahubSubjectIdTableIndexRoute } export interface FileRoutesByTo { '/setup': typeof SetupRoute @@ -218,13 +218,13 @@ export interface FileRoutesByTo { '/admin/audit/logs': typeof AppAdminAuditLogsRoute '/admin/groups/create': typeof AppAdminGroupsCreateRoute '/admin/users/create': typeof AppAdminUsersCreateRoute - '/datahub/$subjectId/$recordId': typeof AppDatahubSubjectIdRecordIdRoute '/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute '/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute - '/datahub/$subjectId/table': typeof AppDatahubSubjectIdTableRoute '/instruments/render/$id': typeof AppInstrumentsRenderIdRoute '/admin/groups': typeof AppAdminGroupsIndexRoute '/admin/users': typeof AppAdminUsersIndexRoute + '/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute + '/datahub/$subjectId/table': typeof AppDatahubSubjectIdTableIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -247,13 +247,13 @@ export interface FileRoutesById { '/_app/admin/audit/logs': typeof AppAdminAuditLogsRoute '/_app/admin/groups/create': typeof AppAdminGroupsCreateRoute '/_app/admin/users/create': typeof AppAdminUsersCreateRoute - '/_app/datahub/$subjectId/$recordId': typeof AppDatahubSubjectIdRecordIdRoute '/_app/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute '/_app/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute - '/_app/datahub/$subjectId/table': typeof AppDatahubSubjectIdTableRoute '/_app/instruments/render/$id': typeof AppInstrumentsRenderIdRoute '/_app/admin/groups/': typeof AppAdminGroupsIndexRoute '/_app/admin/users/': typeof AppAdminUsersIndexRoute + '/_app/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute + '/_app/datahub/$subjectId/table/': typeof AppDatahubSubjectIdTableIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -276,13 +276,13 @@ export interface FileRouteTypes { | '/admin/audit/logs' | '/admin/groups/create' | '/admin/users/create' - | '/datahub/$subjectId/$recordId' | '/datahub/$subjectId/assignments' | '/datahub/$subjectId/graph' - | '/datahub/$subjectId/table' | '/instruments/render/$id' | '/admin/groups/' | '/admin/users/' + | '/datahub/$subjectId/table/$recordId' + | '/datahub/$subjectId/table/' fileRoutesByTo: FileRoutesByTo to: | '/setup' @@ -303,13 +303,13 @@ export interface FileRouteTypes { | '/admin/audit/logs' | '/admin/groups/create' | '/admin/users/create' - | '/datahub/$subjectId/$recordId' | '/datahub/$subjectId/assignments' | '/datahub/$subjectId/graph' - | '/datahub/$subjectId/table' | '/instruments/render/$id' | '/admin/groups' | '/admin/users' + | '/datahub/$subjectId/table/$recordId' + | '/datahub/$subjectId/table' id: | '__root__' | '/_app' @@ -331,13 +331,13 @@ export interface FileRouteTypes { | '/_app/admin/audit/logs' | '/_app/admin/groups/create' | '/_app/admin/users/create' - | '/_app/datahub/$subjectId/$recordId' | '/_app/datahub/$subjectId/assignments' | '/_app/datahub/$subjectId/graph' - | '/_app/datahub/$subjectId/table' | '/_app/instruments/render/$id' | '/_app/admin/groups/' | '/_app/admin/users/' + | '/_app/datahub/$subjectId/table/$recordId' + | '/_app/datahub/$subjectId/table/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -481,13 +481,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppInstrumentsRenderIdRouteImport parentRoute: typeof AppRouteRoute } - '/_app/datahub/$subjectId/table': { - id: '/_app/datahub/$subjectId/table' - path: '/table' - fullPath: '/datahub/$subjectId/table' - preLoaderRoute: typeof AppDatahubSubjectIdTableRouteImport - parentRoute: typeof AppDatahubSubjectIdRouteRoute - } '/_app/datahub/$subjectId/graph': { id: '/_app/datahub/$subjectId/graph' path: '/graph' @@ -502,13 +495,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppDatahubSubjectIdAssignmentsRouteImport parentRoute: typeof AppDatahubSubjectIdRouteRoute } - '/_app/datahub/$subjectId/$recordId': { - id: '/_app/datahub/$subjectId/$recordId' - path: '/$recordId' - fullPath: '/datahub/$subjectId/$recordId' - preLoaderRoute: typeof AppDatahubSubjectIdRecordIdRouteImport - parentRoute: typeof AppDatahubSubjectIdRouteRoute - } '/_app/admin/users/create': { id: '/_app/admin/users/create' path: '/admin/users/create' @@ -530,22 +516,37 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAdminAuditLogsRouteImport parentRoute: typeof AppRouteRoute } + '/_app/datahub/$subjectId/table/': { + id: '/_app/datahub/$subjectId/table/' + path: '/table' + fullPath: '/datahub/$subjectId/table/' + preLoaderRoute: typeof AppDatahubSubjectIdTableIndexRouteImport + parentRoute: typeof AppDatahubSubjectIdRouteRoute + } + '/_app/datahub/$subjectId/table/$recordId': { + id: '/_app/datahub/$subjectId/table/$recordId' + path: '/table/$recordId' + fullPath: '/datahub/$subjectId/table/$recordId' + preLoaderRoute: typeof AppDatahubSubjectIdTableRecordIdRouteImport + parentRoute: typeof AppDatahubSubjectIdRouteRoute + } } } interface AppDatahubSubjectIdRouteRouteChildren { - AppDatahubSubjectIdRecordIdRoute: typeof AppDatahubSubjectIdRecordIdRoute AppDatahubSubjectIdAssignmentsRoute: typeof AppDatahubSubjectIdAssignmentsRoute AppDatahubSubjectIdGraphRoute: typeof AppDatahubSubjectIdGraphRoute - AppDatahubSubjectIdTableRoute: typeof AppDatahubSubjectIdTableRoute + AppDatahubSubjectIdTableRecordIdRoute: typeof AppDatahubSubjectIdTableRecordIdRoute + AppDatahubSubjectIdTableIndexRoute: typeof AppDatahubSubjectIdTableIndexRoute } const AppDatahubSubjectIdRouteRouteChildren: AppDatahubSubjectIdRouteRouteChildren = { - AppDatahubSubjectIdRecordIdRoute: AppDatahubSubjectIdRecordIdRoute, AppDatahubSubjectIdAssignmentsRoute: AppDatahubSubjectIdAssignmentsRoute, AppDatahubSubjectIdGraphRoute: AppDatahubSubjectIdGraphRoute, - AppDatahubSubjectIdTableRoute: AppDatahubSubjectIdTableRoute, + AppDatahubSubjectIdTableRecordIdRoute: + AppDatahubSubjectIdTableRecordIdRoute, + AppDatahubSubjectIdTableIndexRoute: AppDatahubSubjectIdTableIndexRoute, } const AppDatahubSubjectIdRouteRouteWithChildren = diff --git a/apps/web/src/routes/_app/datahub/$subjectId/$recordId.tsx b/apps/web/src/routes/_app/datahub/$subjectId/$recordId.tsx deleted file mode 100644 index 66a9b36de..000000000 --- a/apps/web/src/routes/_app/datahub/$subjectId/$recordId.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { InstrumentSummary } from '@opendatacapture/react-core'; -import { createFileRoute } from '@tanstack/react-router'; - -import { useInstrument } from '@/hooks/useInstrument'; -import { instrumentRecordQueryOptions, useInstrumentRecordQuery } from '@/hooks/useInstrumentRecordQuery'; -import { subjectQueryOptions, useSubjectQuery } from '@/hooks/useSubjectQuery'; - -const RouteComponent = () => { - const recordId = Route.useParams({ select: (params) => params.recordId }); - - const { data: instrumentRecord } = useInstrumentRecordQuery({ params: { id: recordId } }); - const { data: subject } = useSubjectQuery({ params: { id: instrumentRecord.subjectId } }); - - const instrument = useInstrument(instrumentRecord.instrumentId); - - if (!instrument) { - return null; - } - - return ( -
- -
- ); -}; - -export const Route = createFileRoute('/_app/datahub/$subjectId/$recordId')({ - component: RouteComponent, - loader: async ({ context, params }) => { - const record = await context.queryClient.ensureQueryData( - instrumentRecordQueryOptions({ params: { id: params.recordId } }) - ); - await context.queryClient.ensureQueryData(subjectQueryOptions({ params: { id: record.subjectId } })); - } -}); diff --git a/apps/web/src/routes/_app/datahub/$subjectId/route.tsx b/apps/web/src/routes/_app/datahub/$subjectId/route.tsx index f043f5cc5..69e0bf3e7 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/route.tsx +++ b/apps/web/src/routes/_app/datahub/$subjectId/route.tsx @@ -13,7 +13,7 @@ import { useAppStore } from '@/store'; const TabLink = ({ label, pathname, testId }: { label: string; pathname: string; testId?: string }) => { const location = useLocation(); - const isActive = location.pathname === pathname; + const isActive = location.pathname.startsWith(pathname); return ( { + const { resolvedLanguage, t } = useTranslation(); + const notifications = useNotificationsStore(); + const { data: files } = useInstrumentRecordFilesQuery({ params: { id: record.id } }); + + const handleDownload = async (file: { name: string; url: string }) => { + try { + const response = await fetch(file.url); + if (!response.ok) { + throw new Error(`Failed to download (${response.status})`); + } + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = file.name; + document.body.appendChild(anchor); + anchor.click(); + URL.revokeObjectURL(objectUrl); + anchor.remove(); + } catch (error) { + console.error(error); + notifications.addNotification({ + message: t({ + en: 'An unexpected error occurred', + fr: "Une erreur inattendue s'est produite" + }), + title: 'Error', + type: 'error' + }); + } + }; + + const dateCompleted = record.date.toLocaleString(resolvedLanguage, { + dateStyle: 'long', + timeStyle: 'long' + }); + + return ( +
+
+ {instrument.details.title} +

+ {t({ + en: `Completed on ${dateCompleted}`, + fr: `Remplie le ${dateCompleted}` + })} +

+
+ {record.pending ? ( +
+

{t({ en: 'Upload pending', fr: 'Téléversement en attente' })}

+
+ ) : ( +
+ {instrument.content.fileGroups.map((fileGroup) => { + const groupFiles = files[fileGroup.basename] ?? []; + return ( +
+ {fileGroup.label} + {groupFiles.length === 0 ? ( +

{t({ en: 'No files', fr: 'Aucun fichier' })}

+ ) : ( +
    + {groupFiles.map((file) => ( +
  • +
    +

    {file.name}

    +

    {formatByteSize(file.size)}

    +
    + +
  • + ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); +}; + +const RouteComponent = () => { + const recordId = Route.useParams({ select: (params) => params.recordId }); + + const { data: instrumentRecord } = useInstrumentRecordQuery({ params: { id: recordId } }); + const { data: subject } = useSubjectQuery({ params: { id: instrumentRecord.subjectId } }); + + const instrument = useInstrument(instrumentRecord.instrumentId); + + if (!instrument) { + return null; + } + + return ( +
+ {instrument.kind === 'FILE' ? ( + + ) : ( + + )} +
+ ); +}; + +export const Route = createFileRoute('/_app/datahub/$subjectId/table/$recordId')({ + component: RouteComponent, + loader: async ({ context, params }) => { + const record = await context.queryClient.ensureQueryData( + instrumentRecordQueryOptions({ params: { id: params.recordId } }) + ); + await context.queryClient.ensureQueryData(subjectQueryOptions({ params: { id: record.subjectId } })); + } +}); diff --git a/apps/web/src/routes/_app/datahub/$subjectId/table.tsx b/apps/web/src/routes/_app/datahub/$subjectId/table/index.tsx similarity index 54% rename from apps/web/src/routes/_app/datahub/$subjectId/table.tsx rename to apps/web/src/routes/_app/datahub/$subjectId/table/index.tsx index 79d7d5dac..e29b88cb0 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/table.tsx +++ b/apps/web/src/routes/_app/datahub/$subjectId/table/index.tsx @@ -1,11 +1,14 @@ +import { useMemo } from 'react'; + import { camelToSnakeCase, toBasicISOString } from '@douglasneuroinformatics/libjs'; -import { ActionDropdown, ClientTable } from '@douglasneuroinformatics/libui/components'; +import { ActionDropdown, DataTable, TanstackTable } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { createFileRoute } from '@tanstack/react-router'; import { SelectInstrument } from '@/components/SelectInstrument'; import { TimeDropdown } from '@/components/TimeDropdown'; import { useInstrumentVisualization } from '@/hooks/useInstrumentVisualization'; +import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; const RouteComponent = () => { const navigate = Route.useNavigate(); @@ -16,19 +19,27 @@ const RouteComponent = () => { const { t } = useTranslation(); - const fields: { field: string; label: string }[] = []; - for (const subItem in records[0]) { - if (!subItem.startsWith('__')) { - fields.push({ - field: subItem, - label: camelToSnakeCase(subItem).toUpperCase() - }); + const columns = useMemo[]>(() => { + const columns: TanstackTable.ColumnDef[] = []; + for (const subItem in records[0]) { + if (!subItem.startsWith('__')) { + columns.push({ + accessorKey: subItem, + cell: (ctx) => { + const value = ctx.getValue(); + return

{String(value)}

; + }, + header: camelToSnakeCase(subItem).toUpperCase(), + id: subItem + }); + } } - } + return columns; + }, [records[0]]); return (
-
+
@@ -47,28 +58,40 @@ const RouteComponent = () => {
- toBasicISOString(value), - label: 'DATE_COLLECTED' + accessorKey: '__date__', + cell: (ctx) => { + const value = ctx.getValue(); + if (value instanceof Date) { + return toBasicISOString(value); + } + return value; + }, + header: 'DATE_COLLECTED' }, - ...fields + ...columns ]} data={records} data-testid="subject-table" - entriesPerPage={15} - minRows={15} - onEntryClick={(row) => { - void navigate({ params: { recordId: row.__id__ }, to: '/datahub/$subjectId/$recordId' }); + rowActions={[ + { + label: t('common.view'), + onSelect: (row) => { + void navigate({ params: { recordId: row.__id__ }, to: './$recordId' }); + } + } + ]} + onRowDoubleClick={(row) => { + void navigate({ params: { recordId: row.__id__ }, to: './$recordId' }); }} />
); }; -export const Route = createFileRoute('/_app/datahub/$subjectId/table')({ +export const Route = createFileRoute('/_app/datahub/$subjectId/table/')({ component: RouteComponent }); diff --git a/apps/web/src/routes/_app/datahub/index.tsx b/apps/web/src/routes/_app/datahub/index.tsx index 7c418a3cb..0a18cb31f 100644 --- a/apps/web/src/routes/_app/datahub/index.tsx +++ b/apps/web/src/routes/_app/datahub/index.tsx @@ -213,6 +213,9 @@ const Toggles: React.FC<{ const getExportRecords = async () => { const response = await axios.get('/v1/instrument-records/export', { + meta: { + disableDefaultTimeout: true + }, params: { groupId: currentGroup?.id }, diff --git a/apps/web/src/routes/_app/instruments/render/$id.tsx b/apps/web/src/routes/_app/instruments/render/$id.tsx index a642d6f42..02329cc55 100644 --- a/apps/web/src/routes/_app/instruments/render/$id.tsx +++ b/apps/web/src/routes/_app/instruments/render/$id.tsx @@ -5,10 +5,12 @@ import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/ import { InstrumentRenderer } from '@opendatacapture/react-core'; import type { InstrumentSubmitHandler } from '@opendatacapture/react-core'; import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument'; -import type { CreateInstrumentRecordData } from '@opendatacapture/schemas/instrument-records'; +import type { CreateInstrumentRecordData, InstrumentRecord } from '@opendatacapture/schemas/instrument-records'; +import { $FileMetadata, $PresignedUrls, $UploadCompleteData } from '@opendatacapture/schemas/storage'; import { createFileRoute, useLocation, useNavigate } from '@tanstack/react-router'; import axios from 'axios'; +import { NavigationBlocker } from '@/components/NavigationBlocker'; import { PageHeader } from '@/components/PageHeader'; import { useInstrumentBundle } from '@/hooks/useInstrumentBundle'; import { useAppStore } from '@/store'; @@ -35,16 +37,64 @@ const RouteComponent = () => { } }, [currentSession?.id]); - const handleSubmit: InstrumentSubmitHandler = async ({ data, instrumentId }) => { - await axios.post('/v1/instrument-records', { - data, + const handleSubmit: InstrumentSubmitHandler = async (result) => { + const createRecordResponse = await axios.post('/v1/instrument-records', { + data: result.data, date: currentSession!.date, groupId: currentGroup?.id, - instrumentId, + instrumentId: result.instrumentId, sessionId: currentSession!.id, subjectId: currentSession!.subject!.id } satisfies CreateInstrumentRecordData); - notifications.addNotification({ type: 'success' }); + if (result.kind !== 'FILE') { + notifications.addNotification({ type: 'success' }); + return; + } + const record = createRecordResponse.data; + const { onNext, onProgress, uploadMap } = result; + + const presignedUrlsResponse = await axios.get<$PresignedUrls>( + `/v1/instrument-records/${record.id}/files/upload-urls` + ); + + const presignedUrls = presignedUrlsResponse.data; + + for (const basename in presignedUrls) { + const filesToUpload = uploadMap[basename]; + if ((filesToUpload?.length ?? 0) > (presignedUrls[basename]?.length ?? 0)) { + throw new Error( + `Files to upload (${filesToUpload?.length}) for file group with basename '${basename}' exceeds available presigned URLs (${presignedUrls[basename]?.length})` + ); + } + } + + const uploads: { [id: string]: $FileMetadata[] } = {}; + for (const basename in presignedUrls) { + uploads[basename] = []; + const filesToUpload = uploadMap[basename]!; + for (let i = 0; i < filesToUpload.length; i++) { + const file = filesToUpload[i]!; + const { location, url } = presignedUrls[basename]![i]!; + await axios.put(url, file, { + headers: { + 'Content-Type': file.type + }, + meta: { + disableDefaultAuth: true, + disableDefaultTimeout: true + }, + onUploadProgress: (event) => { + onProgress(file, event); + } + }); + uploads[basename].push({ location, name: file.name, size: file.size }); + onNext(); + } + } + + await axios.post(`/v1/instrument-records/${record.id}/files/upload-complete`, { + uploads + } satisfies $UploadCompleteData); }; if (!instrumentBundleQuery.data) { @@ -61,6 +111,7 @@ const RouteComponent = () => {
{ content={[ { fields: { + email: { + kind: 'string', + label: t({ + en: 'Email', + fr: 'Courriel' + }), + variant: 'input' + }, password: { calculateStrength: (password) => { return estimatePasswordStrength(password).score; @@ -135,14 +143,6 @@ const RouteComponent = () => { label: t('common.confirmPassword'), variant: 'password' }, - email: { - kind: 'string', - label: t({ - en: 'Email', - fr: 'Courriel' - }), - variant: 'input' - }, phoneNumber: { kind: 'string', label: t({ diff --git a/apps/web/src/services/axios.ts b/apps/web/src/services/axios.ts index 7d772b723..1e65b7419 100644 --- a/apps/web/src/services/axios.ts +++ b/apps/web/src/services/axios.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; import { i18n } from '@douglasneuroinformatics/libui/i18n'; import axios, { isAxiosError } from 'axios'; @@ -5,6 +7,16 @@ import axios, { isAxiosError } from 'axios'; import { config } from '@/config'; import { useAppStore } from '@/store'; +declare module 'axios' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface AxiosRequestConfig { + meta?: { + disableDefaultAuth?: boolean; + disableDefaultTimeout?: boolean; + }; + } +} + axios.defaults.baseURL = config.setup.apiBaseUrl; axios.interceptors.request.use((config) => { @@ -13,11 +25,7 @@ axios.interceptors.request.use((config) => { config.headers.setAccept(['application/json', 'application/x-msgpack']); // Do not set timeout for setup (can be CPU intensive, especially on slow server) - if ( - config.url !== '/v1/setup' && - config.url !== '/v1/instrument-records/upload' && - config.url !== '/v1/instrument-records/export' - ) { + if (!config.meta?.disableDefaultTimeout) { config.timeout = 10000; // abort request after 10 seconds config.timeoutErrorMessage = i18n.t({ en: 'Network Error', @@ -25,7 +33,7 @@ axios.interceptors.request.use((config) => { }); } - if (accessToken) { + if (accessToken && !config.meta?.disableDefaultAuth) { config.headers.set('Authorization', `Bearer ${accessToken}`); } diff --git a/apps/web/src/translations/datahub.json b/apps/web/src/translations/datahub.json index 588f16108..09500ddf2 100644 --- a/apps/web/src/translations/datahub.json +++ b/apps/web/src/translations/datahub.json @@ -117,12 +117,34 @@ "fr": "Centre de données" } }, + "files": { + "dateCollected": { + "en": "Date Collected", + "fr": "Date de collecte" + }, + "download": { + "en": "Download", + "fr": "Télécharger" + }, + "empty": { + "en": "No files have been uploaded for this subject.", + "fr": "Aucun fichier n'a été téléversé pour ce client." + }, + "instrument": { + "en": "Instrument", + "fr": "Instrument" + } + }, "layout": { "tabs": { "assignments": { "en": "Assignments", "fr": "Assignations" }, + "files": { + "en": "Files", + "fr": "Fichiers" + }, "graph": { "en": "Graph", "fr": "Graphique" diff --git a/docker-compose.yaml b/docker-compose.yaml index 73f286087..160da381f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,6 +26,7 @@ services: - RELEASE_VERSION depends_on: - mongo + - rustfs restart: unless-stopped environment: - GATEWAY_INTERNAL_NETWORK_URL=http://gateway:80 @@ -34,6 +35,13 @@ services: - MONGO_WRITE_CONCERN=majority - MONGO_DIRECT_CONNECTION=true - MONGO_URI=mongodb://mongo:27017 + - STORAGE_ENABLED + - STORAGE_ENDPOINT=http://rustfs:9000 + - STORAGE_PUBLIC_ENDPOINT=${STORAGE_PUBLIC_ENDPOINT:-http://localhost:${APP_PORT}/storage} + - STORAGE_ACCESS_KEY + - STORAGE_SECRET_KEY + - STORAGE_BUCKET + - STORAGE_REGION - NODE_ENV=production - DANGEROUSLY_DISABLE_PBKDF2_ITERATION - DEBUG @@ -116,6 +124,31 @@ services: - '' #load by default without having to specify docker compose --profile - fullstack - nogateway + rustfs: + image: rustfs/rustfs:${RUSTFS_VERSION:-latest} + restart: unless-stopped + environment: + - RUSTFS_VOLUMES=/data + - RUSTFS_ADDRESS=0.0.0.0:9000 + # Fall back to placeholder credentials so the container still boots cleanly when + # storage is disabled (STORAGE_ENABLED=false) and no keys have been generated. + - RUSTFS_ACCESS_KEY=${STORAGE_ACCESS_KEY:-odc-storage} + - RUSTFS_SECRET_KEY=${STORAGE_SECRET_KEY:-odc-storage-secret} + - RUSTFS_CONSOLE_ENABLE=false + expose: + - 9000 + volumes: + - ./rustfs/data:/data + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:9000/health'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + profiles: + - '' #load by default without having to specify docker compose --profile + - fullstack + - nogateway playground: build: context: . diff --git a/docs/en/6-updating/v1.7.0.md b/docs/en/6-updating/v1.7.0.md deleted file mode 100644 index 3a7bdad67..000000000 --- a/docs/en/6-updating/v1.7.0.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: v1.7.0 -slug: en/docs/updating/v1.7.0 -sidebar: - order: 0 ---- - -### Changes - -- Allow users to assign series instruments for completion on the gateway -- Add thank you message to series instrument diff --git a/docs/en/6-updating/v2.0.0.md b/docs/en/6-updating/v2.0.0.md new file mode 100644 index 000000000..c227d169b --- /dev/null +++ b/docs/en/6-updating/v2.0.0.md @@ -0,0 +1,96 @@ +--- +title: v2.0.0 +slug: en/docs/updating/v2.0.0 +sidebar: + order: 1 +--- + +### Overview + +This release introduces **file instruments**: instruments whose responses are uploaded files (for example single documents or multi-file MRI scan sessions) rather than form data. To support this, Open Data Capture can now use an **S3-compatible object storage service**. Files are not stored in MongoDB; the browser uploads and downloads them directly via short-lived presigned URLs, and only file metadata is kept in the database. + +The default Docker Compose stack now ships a self-hosted [RustFS](https://rustfs.com) service for this purpose. + +> **This is a non-breaking upgrade.** Object storage is opt-in via the new `STORAGE_ENABLED` flag, which defaults to `false`. Existing deployments upgrade and boot unchanged; everything except file instruments works exactly as before. File instruments only become available once you enable storage (see below). When `STORAGE_ENABLED=false`, attempting to create a file-instrument record returns a clear `503 Service Unavailable` rather than failing at startup. + +### What's new + +- **File instruments** — a new `FILE` instrument kind with one or more file groups, each constrained by an allowed file type and a `count` (`min`/`max`). +- **Object storage service** — a new `rustfs` service in the Compose stack, backed by a `./rustfs/data` volume. +- **Direct browser uploads/downloads** — files transfer directly between the browser and the storage service using presigned URLs, keeping large transfers off the API. +- Two demo file instruments (`ARBITRARY_SINGLE_FILE`, `MRI_SCAN_SESSION`) are now seeded by the demo data. + +### New environment variables + +The following variables have been added (see `.env.template`). "Required" means required **only when `STORAGE_ENABLED=true`**; when storage is disabled these are ignored. + +| Variable | Required | Description | +| ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_ENABLED` | No | Master switch for object storage / file instruments (default `false`). When `true`, the four variables marked "Yes" must all be set or the API refuses to start. | +| `STORAGE_ENDPOINT` | Yes | Internal endpoint the API uses for S3 API calls. In Compose this is `http://rustfs:9000`. | +| `STORAGE_PUBLIC_ENDPOINT` | No | Externally reachable endpoint embedded in presigned URLs. Defaults to `http://localhost:${APP_PORT}/storage`, which Caddy proxies to `rustfs`. Set this to your public storage URL (e.g. `https://your-domain.com/storage`) for real deployments. | +| `STORAGE_ACCESS_KEY` | Yes | S3 access key. Also used as the RustFS admin access key in the Compose stack. | +| `STORAGE_SECRET_KEY` | Yes | S3 secret key. Also used as the RustFS admin secret key in the Compose stack. | +| `STORAGE_BUCKET` | Yes | Bucket name where files are stored (default `open-data-capture`). Created automatically on startup if absent. | +| `STORAGE_REGION` | No | Region name. Can be any value for self-hosted S3 (default `us-east-1`). | +| `RUSTFS_VERSION` | No | RustFS image tag for the Compose stack (default `latest`). | + +### Upgrading without enabling storage + +If you don't need file instruments yet, **no action is required** — pull the new images and restart. `STORAGE_ENABLED` defaults to `false`, the bundled `rustfs` container boots with placeholder credentials but is otherwise unused, and the rest of the application is unchanged. + +To enable file instruments later, follow the steps below. + +### Enabling object storage + +#### 1. Add the storage configuration + +Set `STORAGE_ENABLED=true` and provide the storage credentials. If you generate your `.env` with `scripts/generate-env.sh`, re-run it — it now generates `STORAGE_ACCESS_KEY` and `STORAGE_SECRET_KEY` for you. **Back up your existing `.env` first**, since regenerating will also produce new values for other secrets. + +Otherwise, copy the new `STORAGE_*` entries from `.env.template` into your existing `.env` and fill them in. At minimum: + +```sh +STORAGE_ENABLED=true +STORAGE_ENDPOINT=http://rustfs:9000 +STORAGE_PUBLIC_ENDPOINT= +STORAGE_ACCESS_KEY=$(openssl rand -hex 16) +STORAGE_SECRET_KEY=$(openssl rand -hex 32) +STORAGE_BUCKET=open-data-capture +STORAGE_REGION=us-east-1 +``` + +When `STORAGE_ENABLED=true`, all of `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET` and `STORAGE_ENDPOINT` must be set or the API will refuse to start. Leave `STORAGE_PUBLIC_ENDPOINT` blank to use the Caddy-proxied default (`http://localhost:${APP_PORT}/storage`). Set it explicitly to your externally accessible storage URL for a production deployment behind a custom domain. + +#### 2. Update your reverse proxy + +The bundled `Caddyfile` now proxies `/storage/*` to the storage service so the browser can reach presigned URLs: + +```caddy +handle_path /storage/* { + reverse_proxy rustfs:9000 { + header_up Host rustfs:9000 + } +} +``` + +If you use the bundled Caddy configuration, pull the updated `Caddyfile`. **If you run your own reverse proxy**, add an equivalent route forwarding `/storage/*` to the storage service on port `9000`, and ensure the upstream `Host` header matches the storage host. Otherwise, set `STORAGE_PUBLIC_ENDPOINT` to a URL your users' browsers can reach directly. + +#### 3. Pull the updated Compose stack and restart + +The updated `docker-compose.yaml` adds the `rustfs` service (with a `./rustfs/data` bind mount) and wires the new variables into the `api` service. Make sure `./rustfs/data` is on persistent, backed-up storage. + +```sh +docker compose pull +docker compose up -d +``` + +On first boot the API creates the `STORAGE_BUCKET` automatically if it does not already exist. + +### Backups + +File contents now live in object storage, **not** in MongoDB. Update your backup strategy to include the storage volume (`./rustfs/data` for the bundled stack, or your S3 bucket) alongside your existing database backups. A database backup alone will no longer capture uploaded files. + +### Notes + +- File uploads and downloads go directly from the browser to the storage service via presigned URLs, so the storage `STORAGE_PUBLIC_ENDPOINT` must be reachable from end users' browsers, not just from the API container. +- If you supply your own managed S3 (e.g. AWS S3, MinIO) instead of the bundled RustFS, point `STORAGE_ENDPOINT`/`STORAGE_PUBLIC_ENDPOINT` at it, set the matching credentials and region, and you can drop the `rustfs` service. diff --git a/package.json b/package.json index cdb33a508..7da96ae3a 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "opendatacapture", "type": "module", - "version": "1.16.6", + "version": "2.0.0", "private": true, - "packageManager": "pnpm@10.34.1", + "packageManager": "pnpm@10.34.3", "license": "Apache-2.0", "engines": { "node": ">=v24.15.0" @@ -26,6 +26,7 @@ "postinstall": "turbo telemetry disable", "prepare": "husky", "preview:core": "env-cmd turbo run preview --filter=@opendatacapture/api --filter=@opendatacapture/gateway --filter=@opendatacapture/web", + "start": "turbo run start", "test": "env-cmd vitest", "test:coverage": "vitest --coverage", "test:e2e": "env-cmd turbo run test:e2e", diff --git a/packages/instrument-bundler/package.json b/packages/instrument-bundler/package.json index 833569994..949162190 100644 --- a/packages/instrument-bundler/package.json +++ b/packages/instrument-bundler/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/instrument-bundler", "type": "module", - "version": "1.16.6", + "version": "2.0.0", "license": "Apache-2.0", "publishConfig": { "access": "public" diff --git a/packages/instrument-guidelines/package.json b/packages/instrument-guidelines/package.json index c7f990e14..2389e23e1 100644 --- a/packages/instrument-guidelines/package.json +++ b/packages/instrument-guidelines/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/instrument-guidelines", "type": "module", - "version": "1.16.6", + "version": "2.0.0", "description": "Guidelines for authoring Open Data Capture instruments, intended to be read by an AI agent (e.g. Claude Code).", "license": "Apache-2.0", "publishConfig": { diff --git a/packages/instrument-library/package.json b/packages/instrument-library/package.json index 8aa86bc3d..19cf03957 100644 --- a/packages/instrument-library/package.json +++ b/packages/instrument-library/package.json @@ -4,6 +4,7 @@ "version": "0.0.0", "license": "Apache-2.0", "exports": { + "./file/*": "./dist/file/*", "./forms/*": "./dist/forms/*", "./interactive/*": "./dist/interactive/*", "./series/*": "./dist/series/*" diff --git a/packages/instrument-library/scripts/available.ts b/packages/instrument-library/scripts/available.ts index 1ca08b259..b50097cfe 100644 --- a/packages/instrument-library/scripts/available.ts +++ b/packages/instrument-library/scripts/available.ts @@ -14,7 +14,8 @@ const distDir = path.resolve(import.meta.dirname, '../dist'); const results: { [K in InstrumentKind as Lowercase]: { title: string }[] } = { form: [], interactive: [], - series: [] + series: [], + file: [] }; for (const kindDir of await fs.readdir(distDir, 'utf-8')) { const targetDir = path.join(distDir, kindDir); diff --git a/packages/instrument-library/src/file/ARBITRARY_SINGLE_FILE/index.ts b/packages/instrument-library/src/file/ARBITRARY_SINGLE_FILE/index.ts new file mode 100644 index 000000000..a0672a0c3 --- /dev/null +++ b/packages/instrument-library/src/file/ARBITRARY_SINGLE_FILE/index.ts @@ -0,0 +1,44 @@ +import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; +import { z } from '/runtime/v1/zod@3.x/v4'; + +export default defineInstrument({ + kind: 'FILE', + language: ['en', 'fr'], + tags: { + en: ['File'], + fr: ['Fichier'] + }, + internal: { + edition: 1, + name: 'ARBITRARY_SINGLE_FILE' + }, + content: { + fileGroups: [ + { + basename: 'file', + count: { + max: 1, + min: 1 + }, + type: null, + label: { + en: 'File', + fr: 'Fichier' + } + } + ] + }, + measures: null, + details: { + description: { + en: 'This instrument is for a single arbitrary file.', + fr: 'Cet instrument est destiné à un seul fichier arbitraire.' + }, + license: 'Apache-2.0', + title: { + en: 'Arbitrary File Upload', + fr: 'Téléchargement de fichier arbitraire' + } + }, + validationSchema: z.any() +}); diff --git a/packages/instrument-library/src/file/MRI_SCAN_SESSION/index.ts b/packages/instrument-library/src/file/MRI_SCAN_SESSION/index.ts new file mode 100644 index 000000000..ebeb594dc --- /dev/null +++ b/packages/instrument-library/src/file/MRI_SCAN_SESSION/index.ts @@ -0,0 +1,56 @@ +import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; +import { z } from '/runtime/v1/zod@3.x/v4'; + +export default defineInstrument({ + kind: 'FILE', + language: ['en', 'fr'], + tags: { + en: ['File', 'Neuroimaging', 'MRI'], + fr: ['Fichier', 'Neuroimagerie', 'IRM'] + }, + internal: { + edition: 1, + name: 'MRI_SCAN_SESSION' + }, + content: { + fileGroups: [ + { + basename: 'rawScans', + count: { + max: 20, + min: 1 + }, + type: 'application/octet-stream', + label: { + en: 'Raw DICOM Scans', + fr: 'Scans DICOM bruts' + } + }, + { + basename: 'radiologistReport', + count: { + max: 1, + min: 1 + }, + type: 'application/pdf', + label: { + en: 'Radiologist Report', + fr: 'Rapport du radiologue' + } + } + ] + }, + measures: null, + details: { + description: { + en: 'Upload the raw DICOM files from an MRI session along with the radiologist report PDF.', + fr: 'Téléchargez les fichiers DICOM bruts d’une séance d’IRM accompagnés du rapport du radiologue en PDF.' + }, + license: 'Apache-2.0', + title: { + en: 'MRI Scan Session', + fr: 'Séance d’IRM' + } + }, + validationSchema: z.any() +}); diff --git a/packages/instrument-stubs/package.json b/packages/instrument-stubs/package.json index 52fd29026..799384228 100644 --- a/packages/instrument-stubs/package.json +++ b/packages/instrument-stubs/package.json @@ -4,6 +4,7 @@ "version": "0.0.0", "license": "Apache-2.0", "exports": { + "./file": "./src/file.js", "./forms": "./src/forms.js", "./interactive": "./src/interactive.js", "./series": "./src/series.js" diff --git a/packages/instrument-stubs/src/file.js b/packages/instrument-stubs/src/file.js new file mode 100644 index 000000000..8747e358c --- /dev/null +++ b/packages/instrument-stubs/src/file.js @@ -0,0 +1,49 @@ +import { createInstrumentStub } from './utils.js'; + +/** @type {import('./utils.js').InstrumentStub>} */ +export const bilingualFileInstrument = await createInstrumentStub(async () => { + const { z } = await import('zod/v4'); + return { + __runtimeVersion: 1, + content: { + fileGroups: [ + { + basename: 'file', + count: { + max: 1, + min: 1 + }, + id: 'file', + label: { + en: 'File', + fr: 'Fichier' + }, + type: null + } + ] + }, + details: { + description: { + en: 'This instrument is for a single arbitrary file.', + fr: 'Cet instrument est destiné à un seul fichier arbitraire.' + }, + license: 'Apache-2.0', + title: { + en: 'Arbitrary File', + fr: 'Fichier arbitraire' + } + }, + internal: { + edition: 1, + name: 'ARBITRARY_SINGLE_FILE' + }, + kind: 'FILE', + language: ['en', 'fr'], + measures: null, + tags: { + en: ['File'], + fr: ['Fichier'] + }, + validationSchema: z.any() + }; +}); diff --git a/packages/instrument-stubs/src/interactive.js b/packages/instrument-stubs/src/interactive.js index 8716d986e..19f2152ee 100644 --- a/packages/instrument-stubs/src/interactive.js +++ b/packages/instrument-stubs/src/interactive.js @@ -47,3 +47,85 @@ export const interactiveInstrument = await createInstrumentStub(async () => { }) }; }); + +/** @type {import('./utils.js').InstrumentStub>} */ +export const bilingualInteractiveInstrument = await createInstrumentStub(async () => { + const { z } = await import('zod/v4'); + const { Translator } = await import('@opendatacapture/runtime-core'); + + const translator = new Translator({ + translations: { + changeLanguage: { + en: 'Change Language', + fr: 'Changer de langue' + }, + greetings: { + hello: { + en: 'Hello', + fr: 'Bonjour' + } + }, + submit: { + en: 'Submit', + fr: 'Soumettre' + } + } + }); + + return { + __runtimeVersion: 1, + kind: 'INTERACTIVE', + language: ['en', 'fr'], + tags: ['Example', 'Useless'], + internal: { + edition: 1, + name: 'BILINGUAL_INTERACTIVE_INSTRUMENT' + }, + content: { + enableLanguageSelect: true, + enableLanguageToggle: true, + enableLanguageLock: true, + render(done) { + translator.init(); + + const changeLanguageButton = document.createElement('button'); + changeLanguageButton.textContent = translator.t('changeLanguage'); + document.body.appendChild(changeLanguageButton); + + changeLanguageButton.addEventListener('click', () => { + translator.changeLanguage(translator.resolvedLanguage === 'en' ? 'fr' : 'en'); + }); + + const submitButton = document.createElement('button'); + submitButton.textContent = translator.t('submit'); + document.body.appendChild(submitButton); + + translator.onLanguageChange = () => { + changeLanguageButton.textContent = translator.t('changeLanguage'); + submitButton.textContent = translator.t('submit'); + }; + + submitButton.addEventListener('click', () => { + done({ message: translator.t('greetings.hello') }); + }); + } + }, + details: { + description: { + en: 'This is an interactive instrument', + fr: "Il s'agit d'un instrument interactif" + }, + estimatedDuration: 1, + instructions: [], + license: 'UNLICENSED', + title: { + en: 'Interactive Instrument', + fr: 'Instrument interactif' + } + }, + measures: {}, + validationSchema: z.object({ + message: z.string() + }) + }; +}); diff --git a/packages/instrument-utils/src/guards.ts b/packages/instrument-utils/src/guards.ts index 0facd3dcb..d5b197c70 100644 --- a/packages/instrument-utils/src/guards.ts +++ b/packages/instrument-utils/src/guards.ts @@ -3,6 +3,7 @@ import type { AnyMultilingualInstrument, AnyScalarInstrument, AnyUnilingualInstrument, + FileInstrument, FormInstrument, InteractiveInstrument, ScalarInstrumentInternal, @@ -30,6 +31,10 @@ export function isMultilingualInstrumentInfo(info: InstrumentInfo): info is Mult return Array.isArray(info.language); } +export function isFileInstrument(instrument: AnyInstrument): instrument is FileInstrument { + return instrument.kind === 'FILE'; +} + export function isFormInstrument(instrument: AnyInstrument): instrument is FormInstrument { return instrument.kind === 'FORM'; } diff --git a/packages/instrument-utils/src/translate.ts b/packages/instrument-utils/src/translate.ts index d9bf17791..298836c09 100644 --- a/packages/instrument-utils/src/translate.ts +++ b/packages/instrument-utils/src/translate.ts @@ -1,7 +1,9 @@ import type { AnyInstrument, + AnyMultilingualFileInstrument, AnyMultilingualFormInstrument, AnyMultilingualInteractiveInstrument, + AnyUnilingualFileInstrument, AnyUnilingualFormInstrument, AnyUnilingualInstrument, AnyUnilingualInteractiveInstrument, @@ -20,6 +22,7 @@ import { mapValues, wrap } from 'lodash-es'; import { match, P } from 'ts-pattern'; import { + isFileInstrument, isFormInstrument, isInteractiveInstrument, isMultilingualInstrument, @@ -288,6 +291,23 @@ function translateInteractive( }; } +function translateFile(instrument: AnyMultilingualFileInstrument, language: Language): AnyUnilingualFileInstrument { + return { + ...instrument, + clientDetails: translateClientDetails(instrument.clientDetails, language), + content: { + fileGroups: instrument.content.fileGroups.map((group) => ({ + ...group, + label: group.label[language] + })) + }, + details: translateDetails(instrument.details, language), + language, + measures: translateMeasures(instrument.measures, language), + tags: instrument.tags[language] + }; +} + function translateSeries(series: SeriesInstrument, language: Language): SeriesInstrument { return { ...series, @@ -350,6 +370,8 @@ export function translateInstrument(instrument: AnyInstrument, preferredLanguage return translateSeries(instrument, targetLanguage); } else if (isInteractiveInstrument(instrument)) { return translateInteractive(instrument, targetLanguage); + } else if (isFileInstrument(instrument)) { + return translateFile(instrument, targetLanguage); } throw new Error(`Unexpected instrument kind: ${(instrument as AnyInstrument).kind}`); } diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 296913433..53d5084f6 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -15,12 +15,18 @@ "storybook": "storybook dev -p 6006" }, "peerDependencies": { + "@tanstack/react-router": "^1.127.3", "axios": "catalog:", "react": "workspace:react__19.x@*", "react-dom": "workspace:react-dom__19.x@*", "tailwindcss": "catalog:", "zod": "workspace:zod__3.x@*" }, + "peerDependenciesMeta": { + "@tanstack/react-router": { + "optional": true + } + }, "dependencies": { "@douglasneuroinformatics/libjs": "catalog:", "@douglasneuroinformatics/libui": "catalog:", diff --git a/packages/react-core/src/components/FileInstrumentContent/Dropzone.tsx b/packages/react-core/src/components/FileInstrumentContent/Dropzone.tsx new file mode 100644 index 000000000..87925241f --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/Dropzone.tsx @@ -0,0 +1,183 @@ +import { memo, useCallback, useMemo, useState } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { FileInstrument } from '@opendatacapture/runtime-core'; +import { BracesIcon, FileIcon, FileTextIcon, ImageIcon, SheetIcon, UploadIcon } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { useDropzone } from 'react-dropzone'; +import type { FileRejection } from 'react-dropzone'; + +import { ErrorBox } from './ErrorBox'; +import { useFileInstrumentContentStore } from './store'; + +const FILE_TYPE_DESCRIPTORS: { + [K in FileInstrument.FileType]: { extensions: string[]; icon: LucideIcon; label: string }; +} = { + 'application/json': { extensions: ['.json'], icon: BracesIcon, label: 'JSON' }, + 'application/msword': { extensions: ['.doc'], icon: FileTextIcon, label: 'Word' }, + 'application/octet-stream': { extensions: [], icon: FileIcon, label: 'Binary' }, + 'application/pdf': { extensions: ['.pdf'], icon: FileTextIcon, label: 'PDF' }, + 'application/rtf': { extensions: ['.rtf'], icon: FileTextIcon, label: 'RTF' }, + 'application/vnd.ms-excel': { extensions: ['.xls'], icon: SheetIcon, label: 'Excel' }, + 'application/vnd.oasis.opendocument.spreadsheet': { extensions: ['.ods'], icon: SheetIcon, label: 'ODS' }, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { + extensions: ['.xlsx'], + icon: SheetIcon, + label: 'Excel' + }, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { + extensions: ['.docx'], + icon: FileTextIcon, + label: 'Word' + }, + 'application/xml': { extensions: ['.xml'], icon: BracesIcon, label: 'XML' }, + 'image/bmp': { extensions: ['.bmp'], icon: ImageIcon, label: 'BMP' }, + 'image/gif': { extensions: ['.gif'], icon: ImageIcon, label: 'GIF' }, + 'image/jpeg': { extensions: ['.jpg', '.jpeg'], icon: ImageIcon, label: 'JPEG' }, + 'image/png': { extensions: ['.png'], icon: ImageIcon, label: 'PNG' }, + 'image/svg+xml': { extensions: ['.svg'], icon: ImageIcon, label: 'SVG' }, + 'image/tiff': { extensions: ['.tif', '.tiff'], icon: ImageIcon, label: 'TIFF' }, + 'text/csv': { extensions: ['.csv'], icon: SheetIcon, label: 'CSV' }, + 'text/html': { extensions: ['.html', '.htm'], icon: FileTextIcon, label: 'HTML' }, + 'text/markdown': { extensions: ['.md', '.markdown'], icon: FileTextIcon, label: 'Markdown' }, + 'text/plain': { extensions: ['.txt'], icon: FileTextIcon, label: 'Text' }, + 'text/tsv': { extensions: ['.tsv'], icon: SheetIcon, label: 'TSV' } +}; + +export const Dropzone = memo<{ index: number }>(function Dropzone({ index }) { + const { basename, count, label, type } = useFileInstrumentContentStore((store) => { + return store.props.instrument.content.fileGroups[index]!; + }); + const files = useFileInstrumentContentStore((store) => store.uploadMap[basename]!); + const issues = useFileInstrumentContentStore((store) => store.errors[basename]); + const setFiles = useFileInstrumentContentStore((store) => store.actions.setFiles); + const { t } = useTranslation(); + + const [rejectedNames, setRejectedNames] = useState([]); + + const handleDrop = useCallback( + (acceptedFiles: File[], fileRejections: FileRejection[]) => { + setRejectedNames(fileRejections.map((rejection) => rejection.file.name)); + setFiles(basename, acceptedFiles); + }, + [basename] + ); + + const acceptConfig = useMemo(() => { + if (type === null || !FILE_TYPE_DESCRIPTORS[type].extensions.length) { + return undefined; + } + return { [type]: FILE_TYPE_DESCRIPTORS[type].extensions }; + }, [type]); + + const { getInputProps, getRootProps } = useDropzone({ + accept: acceptConfig, + maxFiles: count.max, + onDrop: handleDrop + }); + + const displayText = useMemo(() => { + const filenames = files.map((file) => file.name); + const fileCount = filenames.length; + + if (fileCount === 0) { + return t({ + en: 'Drag and Drop or Click to Upload', + fr: 'Glissez-déposez ou cliquez pour télécharger' + }); + } else if (fileCount <= 2) { + return filenames.join(', '); + } + + const displayed = filenames.slice(0, 2).join(', '); + const remaining = fileCount - 2; + + return t({ + en: `${displayed}, and ${remaining} more files...`, + fr: `${displayed} et ${remaining} autres fichiers...` + }); + }, [files, t]); + + const typeDescriptor = useMemo(() => { + if (type === null) { + return { + icon: FileIcon, + label: t({ en: 'Any file type', fr: 'Tout type de fichier' }) + }; + } + return FILE_TYPE_DESCRIPTORS[type]; + }, [type, t]); + + const allowanceText = useMemo(() => { + const { max, min } = count; + if (min === max) { + return t({ + en: min === 1 ? 'Exactly 1 File Required' : `Exactly ${min} Files Required`, + fr: min === 1 ? 'Exactement 1 fichier requis' : `Exactement ${min} fichiers requis` + }); + } + return t({ + en: `Between ${min} and ${max} Files Allowed`, + fr: `Entre ${min} et ${max} fichiers autorisés` + }); + }, [count.min, count.max, t]); + + const hasFiles = files.length > 0; + + return ( +
+
+
+ + {t({ en: 'File Group', fr: 'Groupe de fichiers' })} {index + 1} + +

{label}

+
+
+ + + {typeDescriptor.label} + +
+
+
+
+
+ +
+

+ {displayText} +

+

+ {hasFiles + ? t({ + en: files.length === 1 ? '1 file selected' : `${files.length} files selected`, + fr: files.length === 1 ? '1 fichier sélectionné' : `${files.length} fichiers sélectionnés` + }) + : allowanceText} +

+
+ +
+ {rejectedNames.length > 0 && ( + + t({ + en: `"${name}" was rejected — only ${typeDescriptor.label} files are accepted`, + fr: `« ${name} » a été rejeté — seuls les fichiers ${typeDescriptor.label} sont acceptés` + }) + )} + title={t({ en: 'Unsupported file type', fr: 'Type de fichier non pris en charge' })} + /> + )} + {issues && t(issue))} title={t({ en: 'Invalid Input' })} />} +
+ ); +}); diff --git a/packages/react-core/src/components/FileInstrumentContent/ErrorBox.tsx b/packages/react-core/src/components/FileInstrumentContent/ErrorBox.tsx new file mode 100644 index 000000000..664b10f7b --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/ErrorBox.tsx @@ -0,0 +1,34 @@ +import type React from 'react'; + +import { cn } from '@douglasneuroinformatics/libui/utils'; +import { AlertCircleIcon } from 'lucide-react'; + +export const ErrorBox: React.FC< + { className?: string; issues?: string[]; message?: never; title: string } | { className?: string; message: string } +> = ({ className, ...props }) => { + const { issues, title } = typeof props.message === 'string' ? { issues: null, title: props.message } : props; + return ( +
+
+ +

{title}

+
+ {issues && ( +
    + {issues.map((issue, index) => ( +
  • + + {issue} +
  • + ))} +
+ )} +
+ ); +}; diff --git a/packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx b/packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx new file mode 100644 index 000000000..9045b3b9c --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx @@ -0,0 +1,88 @@ +import React, { Fragment, useEffect, useRef } from 'react'; + +import { Button } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { RefreshCwIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { Dropzone } from './Dropzone'; +import { ErrorBox } from './ErrorBox'; +import { + createFileInstrumentContentStore, + FileInstrumentContentStoreContext, + useFileInstrumentContentStore +} from './store'; +import { UploadProgressBar } from './UploadProgressBar'; + +import type { FileInstrumentContentProps } from './types'; + +const _FileInstrumentContent: React.FC = ({ NavigationBlocker }) => { + const actions = useFileInstrumentContentStore((store) => store.actions); + const fileGroups = useFileInstrumentContentStore((store) => store.props.instrument.content.fileGroups); + const onSuccess = useFileInstrumentContentStore((store) => store.props.onSuccess); + const status = useFileInstrumentContentStore((store) => store.status); + + const { t } = useTranslation(); + + useEffect(() => { + if (status === 'SUBMITTED') { + onSuccess?.(); + } + }, [onSuccess, status]); + + return ( + +
+
+ {fileGroups.map((_, index) => ( + + ))} +
+
+ {match(status) + .with('PENDING', () => ) + .with('FAILED', () => ( + + )) + .otherwise(() => null)} + +
+
+ {NavigationBlocker && ( + + )} +
+ ); +}; + +export const FileInstrumentContent: React.FC = (props) => { + const storeRef = useRef(createFileInstrumentContentStore(props)); + return ( + + <_FileInstrumentContent {...props} /> + + ); +}; diff --git a/packages/react-core/src/components/FileInstrumentContent/UploadProgressBar.tsx b/packages/react-core/src/components/FileInstrumentContent/UploadProgressBar.tsx new file mode 100644 index 000000000..6ff5d255f --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/UploadProgressBar.tsx @@ -0,0 +1,38 @@ +import { memo, useEffect } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { clamp } from 'lodash-es'; +import { motion, useSpring, useTransform } from 'motion/react'; + +import { useFileInstrumentContentStore } from './store'; + +export const UploadProgressBar = memo(function UploadProgressBar() { + const { loadedFiles, totalFiles, totalProgress } = useFileInstrumentContentStore((store) => store.uploadState!); + + const spring = useSpring(0, { damping: 20, restDelta: 0.001, stiffness: 50 }); + const percentage = useTransform(spring, (latest: number) => `${clamp(latest, 0, 100).toFixed(1)}%`); + + const { t } = useTranslation(); + + useEffect(() => { + if (!totalProgress) { + spring.jump(0); + } else { + spring.set(totalProgress); + } + }, [totalProgress]); + + return ( +
+
+ + {t({ en: `Uploading files... (${loadedFiles}/${totalFiles} complete)` })} + + {percentage} +
+
+ +
+
+ ); +}); diff --git a/packages/react-core/src/components/FileInstrumentContent/index.ts b/packages/react-core/src/components/FileInstrumentContent/index.ts new file mode 100644 index 000000000..eacb53604 --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/index.ts @@ -0,0 +1,2 @@ +export * from './FileInstrumentContent'; +export type * from './types'; diff --git a/packages/react-core/src/components/FileInstrumentContent/store.ts b/packages/react-core/src/components/FileInstrumentContent/store.ts new file mode 100644 index 000000000..e7281b013 --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/store.ts @@ -0,0 +1,111 @@ +import { createContext, useContext } from 'react'; + +import { createStore, useStore } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; + +import type { FileInstrumentContentProps, FileInstrumentContentStore, UploadProgressEvent } from './types'; + +export const FileInstrumentContentStoreContext = createContext<{ + store: ReturnType; +}>(null!); + +export function createFileInstrumentContentStore(props: FileInstrumentContentProps) { + return createStore( + immer((set, get) => ({ + actions: { + setFiles(id, files) { + set((state) => { + state.uploadMap[id] = files; + }); + }, + submit: async () => { + const { props, uploadMap } = get(); + const errors: FileInstrumentContentStore.Errors = {}; + props.instrument.content.fileGroups.forEach(({ basename, count }) => { + const actual = uploadMap[basename]!.length; + if (actual < count.min || actual > count.max) { + const required = count.min === count.max ? `${count.min}` : `between ${count.min} and ${count.max}`; + const requiredFr = count.min === count.max ? `${count.min}` : `entre ${count.min} et ${count.max}`; + const isPluralRequired = count.min !== count.max || count.min !== 1; + errors[basename] ??= []; + errors[basename].push({ + en: `You uploaded ${actual} file${actual === 1 ? '' : 's'}, but ${required} ${isPluralRequired ? 'are' : 'is'} required`, + fr: `Vous avez téléchargé ${actual} fichier${actual === 1 ? '' : 's'}, mais ${requiredFr} ${isPluralRequired ? 'sont' : 'est'} requis` + }); + } + }); + + if (Object.keys(errors).length) { + set((state) => { + state.errors = errors; + }); + return; + } + + const allFiles = Object.values(uploadMap).flat(); + const loaded = new Map(allFiles.map((file) => [file, 0])); + + set((state) => { + state.errors = {}; + state.status = 'PENDING'; + state.uploadState = { + loadedFiles: 0, + loadedSize: 0, + totalFiles: allFiles.length, + totalProgress: 0, + totalSize: allFiles.reduce((sum, file) => sum + file.size, 0) + }; + }); + + const onProgress = (file: File, event: UploadProgressEvent) => { + set((state) => { + const diff = event.loaded - loaded.get(file)!; + state.uploadState!.loadedSize += diff; + state.uploadState!.totalProgress = (state.uploadState!.loadedSize / state.uploadState!.totalSize) * 100; + loaded.set(file, event.loaded); + }); + }; + + const onNext = () => { + set((state) => { + state.uploadState!.loadedFiles += 1; + }); + }; + + const [result] = await Promise.allSettled([ + props.onSubmit({ data: {}, kind: 'FILE', onNext, onProgress, uploadMap }), + new Promise((resolve) => setTimeout(resolve, 300)) + ]); + + if (result.status === 'rejected') { + console.error(result.reason); + set((state) => { + state.status = 'FAILED'; + state.uploadState = null; + }); + } else { + // make sure animation goes to 100% for aesthetics + set((state) => { + state.uploadState!.totalProgress = 100; + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + set((state) => { + state.status = 'SUBMITTED'; + state.uploadState = null; + }); + } + } + }, + errors: {}, + props, + status: 'READY', + uploadMap: Object.fromEntries(props.instrument.content.fileGroups.map((file) => [file.basename, []])), + uploadState: null + })) + ); +} + +export function useFileInstrumentContentStore(selector: (state: FileInstrumentContentStore) => T) { + const context = useContext(FileInstrumentContentStoreContext); + return useStore(context.store, selector); +} diff --git a/packages/react-core/src/components/FileInstrumentContent/types.ts b/packages/react-core/src/components/FileInstrumentContent/types.ts new file mode 100644 index 000000000..68f3c0b05 --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/types.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +import type { + AnyUnilingualFileInstrument, + FileInstrument, + InstrumentKind, + Language +} from '@opendatacapture/runtime-core'; +import type { AxiosProgressEvent } from 'axios'; +import type { Promisable } from 'type-fest'; + +import type { NavigationBlockerComponent } from '../NavigationBlockerDialog'; + +export type UploadMap = { + [basename: string]: File[]; +}; + +export type UploadProgressEvent = Pick; + +export namespace FileInstrumentContentStore { + export type Errors = { + [basename: string]: { + [L in Language]: string; + }[]; + }; + + type UploadState = { + loadedFiles: number; + loadedSize: number; + totalFiles: number; + totalProgress: number; + totalSize: number; + }; + + type Status = 'FAILED' | 'PENDING' | 'READY' | 'SUBMITTED'; + + export type StoreType = { + actions: { + setFiles: (id: string, files: File[]) => void; + submit: () => Promise; + }; + errors: Errors; + readonly props: FileInstrumentContentProps; + status: Status; + uploadMap: UploadMap; + uploadState: null | UploadState; + }; +} + +export type FileInstrumentContentStore = FileInstrumentContentStore.StoreType; + +export type FileInstrumentContentSubmitResult = { + data: FileInstrument.Data; + kind: Extract; + onNext: () => void; + onProgress: (file: File, event: UploadProgressEvent) => void; + uploadMap: UploadMap; +}; + +export type FileInstrumentContentProps = { + instrument: AnyUnilingualFileInstrument & { id: string }; + NavigationBlocker?: NavigationBlockerComponent; + onSubmit: (result: FileInstrumentContentSubmitResult) => Promisable; + onSuccess?: () => Promisable; +}; diff --git a/packages/react-core/src/components/FormContent/FormContent.tsx b/packages/react-core/src/components/FormContent/FormContent.tsx index d76d20250..1f0d0616c 100644 --- a/packages/react-core/src/components/FormContent/FormContent.tsx +++ b/packages/react-core/src/components/FormContent/FormContent.tsx @@ -1,12 +1,14 @@ import { Button, Dialog, Form, Heading } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; -import type { AnyUnilingualFormInstrument, FormInstrument } from '@opendatacapture/runtime-core'; +import type { AnyUnilingualFormInstrument, FormInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; import { InfoIcon } from 'lucide-react'; import type { Promisable } from 'type-fest'; +export type FormContentSubmitResult = { data: FormInstrument.Data; kind: Extract }; + export type FormContentProps = { instrument: AnyUnilingualFormInstrument; - onSubmit: (data: FormInstrument.Data) => Promisable; + onSubmit: (result: FormContentSubmitResult) => Promisable; }; export const FormContent = ({ instrument, onSubmit }: FormContentProps) => { @@ -22,7 +24,7 @@ export const FormContent = ({ instrument, onSubmit }: FormContentProps) => { - + {t({ @@ -43,7 +45,7 @@ export const FormContent = ({ instrument, onSubmit }: FormContentProps) => { data-testid="form-content" initialValues={instrument.initialValues} validationSchema={instrument.validationSchema} - onSubmit={(data) => void onSubmit(data)} + onSubmit={(data) => void onSubmit({ data, kind: 'FORM' })} />
); diff --git a/packages/react-core/src/components/InstrumentIcon/InstrumentIcon.tsx b/packages/react-core/src/components/InstrumentIcon/InstrumentIcon.tsx index be8236262..955e6049e 100644 --- a/packages/react-core/src/components/InstrumentIcon/InstrumentIcon.tsx +++ b/packages/react-core/src/components/InstrumentIcon/InstrumentIcon.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { InstrumentKind } from '@opendatacapture/runtime-core'; -import { ClipboardCheckIcon, FileQuestionIcon, ListChecksIcon, MonitorCheckIcon } from 'lucide-react'; +import { ClipboardCheckIcon, FileQuestionIcon, FileTextIcon, ListChecksIcon, MonitorCheckIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; export type InstrumentIconProps = React.ComponentPropsWithoutRef & { @@ -10,6 +10,8 @@ export type InstrumentIconProps = React.ComponentPropsWithoutRef & { export const InstrumentIcon = ({ kind, ...props }: InstrumentIconProps) => { switch (kind) { + case 'FILE': + return ; case 'FORM': return ; case 'INTERACTIVE': diff --git a/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.stories.tsx b/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.stories.tsx index 64947f9c6..428619e72 100644 --- a/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.stories.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.stories.tsx @@ -1,9 +1,11 @@ import { Card } from '@douglasneuroinformatics/libui/components'; +import { bilingualFileInstrument } from '@opendatacapture/instrument-stubs/file'; import { bilingualFormInstrument, unilingualFormInstrument } from '@opendatacapture/instrument-stubs/forms'; -import { interactiveInstrument } from '@opendatacapture/instrument-stubs/interactive'; +import { bilingualInteractiveInstrument, interactiveInstrument } from '@opendatacapture/instrument-stubs/interactive'; import { seriesInstrument } from '@opendatacapture/instrument-stubs/series'; import type { ScalarInstrumentBundleContainer } from '@opendatacapture/schemas/instrument'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { createMemoryHistory, createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router'; import { InstrumentRenderer } from './InstrumentRenderer'; @@ -21,6 +23,18 @@ const unilingualInteractiveTarget: ScalarInstrumentBundleContainer = { kind: 'INTERACTIVE' }; +const bilingualInteractiveTarget: ScalarInstrumentBundleContainer = { + bundle: bilingualInteractiveInstrument.bundle, + id: crypto.randomUUID(), + kind: 'INTERACTIVE' +}; + +const bilingualFileTarget: ScalarInstrumentBundleContainer = { + bundle: bilingualFileInstrument.bundle, + id: crypto.randomUUID(), + kind: 'FILE' +}; + export default { component: InstrumentRenderer, decorators: [ @@ -53,12 +67,64 @@ export const BilingualForm: Story = { } }; +export const BilingualFile: Story = { + args: { + onSubmit: async (result) => { + if (result.kind !== 'FILE') { + throw new Error(); + } + const { onNext, onProgress, uploadMap } = result; + for (const file of Object.values(uploadMap).flat()) { + const totalBytes = file.size; + let loadedBytes = 0; + await new Promise((resolve) => { + const interval = setInterval(() => { + if (loadedBytes >= totalBytes) { + clearInterval(interval); + return resolve(); + } + const chunkSize = Math.floor(Math.random() * 65536) + 32768; + loadedBytes = Math.min(loadedBytes + chunkSize, totalBytes); + onProgress(file, { + loaded: loadedBytes, + progress: loadedBytes / totalBytes, + total: totalBytes + }); + }, 100); + }); + onNext(); + } + }, + target: bilingualFileTarget + }, + decorators: [ + (Story) => { + return ( + + ); + } + ] +}; + export const UnilingualInteractive: Story = { args: { target: unilingualInteractiveTarget } }; +export const BilingualInteractive: Story = { + args: { + target: bilingualInteractiveTarget + } +}; + export const InteractiveWithError: Story = { args: { target: { diff --git a/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx b/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx index 2cc2e49a9..06a7ecd4e 100644 --- a/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx @@ -3,19 +3,22 @@ import type { InstrumentBundleContainer } from '@opendatacapture/schemas/instrum import { ScalarInstrumentRenderer } from './ScalarInstrumentRenderer'; import { SeriesInstrumentRenderer } from './SeriesInstrumentRenderer'; -import type { InstrumentSubmitHandler, SubjectDisplayInfo } from '../../types'; +import type { SubjectDisplayInfo } from '../../types'; +import type { NavigationBlockerComponent } from '../NavigationBlockerDialog'; +import type { InstrumentSubmitHandler } from './types'; export type InstrumentRendererProps = { className?: string; initialSeriesIndex?: number; + NavigationBlocker?: NavigationBlockerComponent; onSubmit: InstrumentSubmitHandler; subject?: SubjectDisplayInfo; target: InstrumentBundleContainer; }; -export const InstrumentRenderer = ({ target, ...props }: InstrumentRendererProps) => { +export const InstrumentRenderer = ({ NavigationBlocker, target, ...props }: InstrumentRendererProps) => { if (target.kind === 'SERIES') { return ; } - return ; + return ; }; diff --git a/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx b/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx index 73c81d595..5ba71f360 100644 --- a/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx @@ -4,11 +4,12 @@ import { replacer } from '@douglasneuroinformatics/libjs'; import { Spinner } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { InterpretOptions } from '@opendatacapture/instrument-interpreter'; -import type { Json } from '@opendatacapture/schemas/core'; +import type { AnyScalarInstrument } from '@opendatacapture/runtime-core'; import type { ScalarInstrumentBundleContainer } from '@opendatacapture/schemas/instrument'; import { match } from 'ts-pattern'; import { useInterpretedInstrument } from '../../hooks/useInterpretedInstrument'; +import { FileInstrumentContent } from '../FileInstrumentContent'; import { FormContent } from '../FormContent'; import { InstrumentOverview } from '../InstrumentOverview'; import { InstrumentSummary } from '../InstrumentSummary'; @@ -16,13 +17,16 @@ import { InteractiveContent } from '../InteractiveContent'; import { ContentPlaceholder } from './ContentPlaceholder'; import { InstrumentRendererContainer } from './InstrumentRendererContainer'; -import type { InstrumentSubmitHandler, SubjectDisplayInfo } from '../../types'; +import type { SubjectDisplayInfo } from '../../types'; +import type { NavigationBlockerComponent } from '../NavigationBlockerDialog'; +import type { AnyContentResult, InstrumentSubmitHandler } from './types'; export type ScalarInstrumentRendererProps = { className?: string; + NavigationBlocker?: NavigationBlockerComponent; /** @deprecated */ onCompileError?: (error: Error) => void; - onSubmit: InstrumentSubmitHandler; + onSubmit: InstrumentSubmitHandler; /** @deprecated */ options?: InterpretOptions; subject?: SubjectDisplayInfo; @@ -31,6 +35,7 @@ export type ScalarInstrumentRendererProps = { export const ScalarInstrumentRenderer = ({ className, + NavigationBlocker, onCompileError, onSubmit, options, @@ -42,9 +47,11 @@ export const ScalarInstrumentRenderer = ({ const [index, setIndex] = useState<0 | 1 | 2>(0); const { t } = useTranslation(); - const handleSubmit = async (data: unknown) => { - await onSubmit({ - data: JSON.parse(JSON.stringify(data, replacer)) as Json, + const handleSubmit = async ({ data, ...result }: AnyContentResult) => { + await onSubmit?.({ + ...result, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + data: JSON.parse(JSON.stringify(data, replacer)), instrumentId: target.id }); setIndex(2); @@ -82,9 +89,22 @@ export const ScalarInstrumentRenderer = ({ )) + .with({ index: 1, instrument: { kind: 'FILE' } }, ({ instrument }) => { + return ( + + ); + }) .with({ index: 2 }, () => ( )) diff --git a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentContent.tsx b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentContent.tsx index 637955c69..a47b96e43 100644 --- a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentContent.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentContent.tsx @@ -3,15 +3,18 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Heading } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { createPortal } from 'react-dom'; +import type { Simplify } from 'type-fest'; import type { ScalarInstrumentRendererProps } from './ScalarInstrumentRenderer'; -export type SeriesInstrumentContentProps = ScalarInstrumentRendererProps & { - status: { - completedInstruments: number; - totalInstruments: number; - }; -}; +export type SeriesInstrumentContentProps = Simplify< + ScalarInstrumentRendererProps & { + status: { + completedInstruments: number; + totalInstruments: number; + }; + } +>; export const SeriesInstrumentContent = ({ status }: SeriesInstrumentContentProps) => { const { t } = useTranslation(); diff --git a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx index ca3e5339a..5baa7b152 100644 --- a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx @@ -16,12 +16,15 @@ import { InteractiveContent } from '../InteractiveContent'; import { ContentPlaceholder } from './ContentPlaceholder'; import { InstrumentRendererContainer } from './InstrumentRendererContainer'; -import type { InstrumentSubmitHandler, SubjectDisplayInfo } from '../../types'; +import type { SubjectDisplayInfo } from '../../types'; +import type { FormContentSubmitResult } from '../FormContent'; +import type { InteractiveContentSubmitResult } from '../InteractiveContent'; +import type { InstrumentSubmitHandler } from './types'; export type SeriesInstrumentRendererProps = { className?: string; initialSeriesIndex?: number; - onSubmit: InstrumentSubmitHandler; + onSubmit: InstrumentSubmitHandler<'SERIES'>; subject?: SubjectDisplayInfo; target: SeriesInstrumentBundleContainer; }; @@ -58,8 +61,8 @@ export const SeriesInstrumentRenderer = ({ ? (getSeriesInstrumentParams(rootState.instrument.content).skipProgress ?? false) : false; - const handleSubmit = async (data: unknown) => { - await onSubmit({ + const handleSubmit = async ({ data }: FormContentSubmitResult | InteractiveContentSubmitResult) => { + await onSubmit?.({ data: JSON.parse(JSON.stringify(data, replacer)) as Json, index, instrumentId: scalarId!, diff --git a/packages/react-core/src/components/InstrumentRenderer/index.ts b/packages/react-core/src/components/InstrumentRenderer/index.ts index 302267f0f..81aab02f7 100644 --- a/packages/react-core/src/components/InstrumentRenderer/index.ts +++ b/packages/react-core/src/components/InstrumentRenderer/index.ts @@ -1,3 +1,4 @@ export * from './InstrumentRenderer'; export * from './ScalarInstrumentRenderer'; export * from './SeriesInstrumentRenderer'; +export type * from './types'; diff --git a/packages/react-core/src/components/InstrumentRenderer/types.ts b/packages/react-core/src/components/InstrumentRenderer/types.ts new file mode 100644 index 000000000..8f20dc279 --- /dev/null +++ b/packages/react-core/src/components/InstrumentRenderer/types.ts @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +import type { InstrumentKind, Json } from '@opendatacapture/runtime-core'; +import type { Promisable, Simplify } from 'type-fest'; + +import type { FileInstrumentContentSubmitResult } from '../FileInstrumentContent'; +import type { FormContentSubmitResult } from '../FormContent'; +import type { InteractiveContentSubmitResult } from '../InteractiveContent'; + +export type AnyContentResult = + | FileInstrumentContentSubmitResult + | FormContentSubmitResult + | InteractiveContentSubmitResult; + +export namespace InstrumentSubmitHandler { + type ContextMixin = Simplify< + TResult & { + data: Json; + instrumentId: string; + } + >; + + type FileContext = ContextMixin; + + type FormContext = ContextMixin; + + type InteractiveContext = ContextMixin; + + type SeriesContext = ContextMixin<{ + index: number; + kind: Extract; + }>; + + export type ScalarContext = FileContext | FormContext | InteractiveContext; + + export type AnyContext = ScalarContext | SeriesContext; +} + +export type InstrumentSubmitHandler = ( + context: Extract +) => Promisable; diff --git a/packages/react-core/src/components/InteractiveContent/InteractiveContent.stories.tsx b/packages/react-core/src/components/InteractiveContent/InteractiveContent.stories.tsx index 728631656..bae7af097 100644 --- a/packages/react-core/src/components/InteractiveContent/InteractiveContent.stories.tsx +++ b/packages/react-core/src/components/InteractiveContent/InteractiveContent.stories.tsx @@ -21,7 +21,7 @@ export default { export const Default: Story = { args: { bundle: interactiveInstrument.bundle, - onSubmit(data) { + onSubmit({ data }) { alert(JSON.stringify(data, null, 2)); } } diff --git a/packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx b/packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx index 5b4206dcf..9a01f9a9a 100644 --- a/packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx +++ b/packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx @@ -1,40 +1,80 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Button, Separator } from '@douglasneuroinformatics/libui/components'; +import { Button, Dialog, DropdownMenu, Separator } from '@douglasneuroinformatics/libui/components'; import { useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { Theme } from '@douglasneuroinformatics/libui/hooks'; -import type { Language, RuntimeNotification } from '@opendatacapture/runtime-core'; +import type { + InstrumentKind, + InteractiveInstrument, + Language, + RuntimeNotification +} from '@opendatacapture/runtime-core'; import { $Json } from '@opendatacapture/schemas/core'; import type { Json } from '@opendatacapture/schemas/core'; -import { FullscreenIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react'; -import type { Promisable } from 'type-fest'; +import { ChevronRightIcon, FullscreenIcon, LanguagesIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react'; +import type { Promisable, Simplify } from 'type-fest'; -export type InteractiveContentProps = { - bundle: string; - defaultFullscreen?: boolean; - onSubmit: (data: Json) => Promisable; +const ALL_LANGUAGES: { [K in Language]: { [P in Language]: string } } = { + en: { + en: 'English', + fr: 'Anglais' + }, + fr: { + en: 'French', + fr: 'Français' + } }; -export const InteractiveContent = React.memo(function InteractiveContent({ +export type InteractiveContentSubmitResult = { + data: Json; + kind: Extract; +}; + +export type InteractiveContentProps = Simplify< + Pick< + InteractiveInstrument['content'], + 'defaultFullscreen' | 'enableLanguageLock' | 'enableLanguageSelect' | 'enableLanguageToggle' + > & { + bundle: string; + onSubmit: (result: InteractiveContentSubmitResult) => Promisable; + supportedLanguages?: Language[]; + } +>; + +export const _InteractiveContent = React.memo(function _InteractiveContent({ bundle, defaultFullscreen, - onSubmit + enableLanguageLock, + enableLanguageSelect, + enableLanguageToggle, + onSubmit, + supportedLanguages = [] }) { const addNotification = useNotificationsStore((store) => store.addNotification); - const { changeLanguage, resolvedLanguage } = useTranslation(); + const { changeLanguage, resolvedLanguage, t } = useTranslation(); const [_, updateTheme] = useTheme(); const [scale, setScale] = useState(100); + const [hasSelectedLanguage, setHasSelectedLanguage] = useState( + !enableLanguageSelect || supportedLanguages.length <= 1 + ); + const [lockDialogOpen, setLockDialogOpen] = useState(false); const iFrameRef = useRef(null); + const isLocked = Boolean(enableLanguageLock) && hasSelectedLanguage; + const handleChangeLanguageEvent = useCallback( (event: CustomEvent) => { - if (event.detail === 'en' || event.detail === 'fr') { - void changeLanguage(event.detail); - } else { + if (event.detail !== 'en' && event.detail !== 'fr') { console.error(`Cannot change language: invalid language '${event.detail}', expected 'en' or 'fr'`); + return; } + if (isLocked) { + setLockDialogOpen(true); + return; + } + void changeLanguage(event.detail); }, - [updateTheme] + [changeLanguage, isLocked] ); const handleChangeThemeEvent = useCallback( @@ -52,7 +92,7 @@ export const InteractiveContent = React.memo(function I (event: CustomEvent) => { void (async function () { const data = await $Json.parseAsync(event.detail); - await onSubmit(data); + await onSubmit({ data, kind: 'INTERACTIVE' }); })(); }, [onSubmit] @@ -67,10 +107,10 @@ export const InteractiveContent = React.memo(function I }; useEffect(() => { - if (defaultFullscreen) { + if (defaultFullscreen && hasSelectedLanguage) { void iFrameRef.current?.requestFullscreen(); } - }, []); + }, [hasSelectedLanguage]); useEffect(() => { document.addEventListener('changeLanguage', handleChangeLanguageEvent, false); @@ -97,41 +137,124 @@ export const InteractiveContent = React.memo(function I return (
-
- {scale}% - - - - -
-
-