diff --git a/.agents/docs/packages/libnest.md b/.agents/docs/packages/libnest.md index f8a2b3a3d..b8938a608 100644 --- a/.agents/docs/packages/libnest.md +++ b/.agents/docs/packages/libnest.md @@ -9,18 +9,18 @@ Generic NestJS decorators, pipes, modules, and utilities used across DNP project - Bootstrapping the app, wiring middleware, or generating OpenAPI docs — use `AppFactory` instead of assembling `NestFactory` boilerplate by hand. - Reading typed/validated env vars — inject `ConfigService` instead of touching `process.env`. - Talking to Prisma — use `InjectModel` / `Model<'X'>` / `InjectPrismaClient` instead of instantiating `PrismaClient`. -- Validating a request DTO against a Zod schema — use the `@ValidationSchema()` decorator, or `ParseSchemaPipe` / `ValidObjectIdPipe`, instead of a custom pipe. +- Validating a request body against a Zod schema — type the `@Body()` parameter as the schema (the global `ValidationPipe` reads it, and the OpenAPI generator documents it); for params and query strings, `ParseSchemaPipe` / `ValidObjectIdPipe`. Never a custom pipe, and never the deprecated `@ValidationSchema()` DTO class, which the docs render as an empty body. - Mocking a Prisma model or libnest service in a unit test — use `MockFactory` from `./testing` instead of hand-rolling a stub. - Logging, hashing/encrypting, or running virtualized code inside Nest — use `LoggingModule`/`CryptoModule`/`VirtualizationModule` instead of a third-party Nest integration. (`MailModule` is the one deliberate exception in this repo — see below.) ## Subpath exports -| Subpath | Purpose | Used in this repo | -| ------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `.` (root) | App bootstrap, modules, pipes, decorators | `AppFactory`, `ConfigService`, `LoggingService`, `CryptoService`, `CurrentUser`, `ValidationSchema`, `PrismaModule`, `InjectModel`, `InjectPrismaClient`, `LibnestPrismaExtension`, `getModelToken`, `PRISMA_CLIENT_TOKEN`, `Model`, `PrismaModelKey`, `PrismaModelName`, `PrismaModuleOptions`, `ParseSchemaPipe`, `ValidObjectIdPipe`, `VirtualizationModule`, `VirtualizationService`, `$BaseEnv`, `$MongoEnv`, `RequestUser` | -| `./testing` | Test doubles for Nest providers | `MockFactory`, `MockedInstance` — used in 13 `__tests__` spec files in `apps/api`; also exports `MockPrismaClient` and `e2e` | -| `./testing/plugin` | Vitest plugin (path aliases, env) | `apps/api/vitest.config.ts` | -| `./user-config` | User-supplied app config typing | `defineUserConfig` in `apps/api/libnest.config.ts` | +| Subpath | Purpose | Used in this repo | +| ------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `.` (root) | App bootstrap, modules, pipes, decorators | `AppFactory`, `ApiOperation`, `ConfigService`, `LoggingService`, `CryptoService`, `CurrentUser`, `ValidationSchema`, `PrismaModule`, `InjectModel`, `InjectPrismaClient`, `LibnestPrismaExtension`, `getModelToken`, `PRISMA_CLIENT_TOKEN`, `Model`, `PrismaModelKey`, `PrismaModelName`, `PrismaModuleOptions`, `ParseSchemaPipe`, `ValidObjectIdPipe`, `VirtualizationModule`, `VirtualizationService`, `$BaseEnv`, `$MongoEnv`, `RequestUser` | +| `./testing` | Test doubles for Nest providers | `MockFactory`, `MockedInstance` — used in 13 `__tests__` spec files in `apps/api`; also exports `MockPrismaClient` and `e2e` | +| `./testing/plugin` | Vitest plugin (path aliases, env) | `apps/api/vitest.config.ts` | +| `./user-config` | User-supplied app config typing | `defineUserConfig` in `apps/api/libnest.config.ts` | **`MailModule`/`MailService` are deliberately not used.** `apps/api/src/mail/` builds its own nodemailer transporter instead, because libnest's `MailService` constructs `this.transporter` in @@ -126,7 +126,7 @@ const moduleRef = await Test.createTestingModule({ groupModel = moduleRef.get(getModelToken('Group')); ``` -See `apps/api/src/auth/` (`auth.module.ts`, `guards/jwt-auth.guard.ts`, `ability.factory.ts`) for the `CurrentUser`/guard/CASL patterns, and `apps/api/src/instrument-repos/` for a compact controller + DTO example using `@ValidationSchema()`. +See `apps/api/src/auth/` (`auth.module.ts`, `guards/jwt-auth.guard.ts`, `ability.factory.ts`) for the `CurrentUser`/guard/CASL patterns, and `apps/api/src/instrument-repos/` for a compact controller with a schema-typed body. ## Reading the source diff --git a/.agents/docs/playbooks/add-api-endpoint.md b/.agents/docs/playbooks/add-api-endpoint.md index ac22ada88..0396b1418 100644 --- a/.agents/docs/playbooks/add-api-endpoint.md +++ b/.agents/docs/playbooks/add-api-endpoint.md @@ -31,13 +31,11 @@ examples live in other controllers, as noted there). `accessibleQuery(undefined, ...)` returns `{}`, meaning **no restriction at all**. Nothing catches an omitted ability: not tsc, not eslint, not a green test suite. -4. **Request body typing**, if the handler takes a body. Pick one of the two patterns in use: - - a DTO class in `apps/api/src//dto/-.dto.ts` carrying **both** - `@ValidationSchema($CreateXData)` and `implements CreateXData` — see - `apps/api/src/groups/dto/create-group.dto.ts`; - - or the schema as the parameter type (`@Body() data: $CreateSeriesInstrumentData`), which **must - be a value import**. `import type` erases the runtime binding and the pipe throws when the route - is called. See `apps/api/src/instruments/instruments.controller.ts`. +4. **Request body typing**, if the handler takes a body. Type the parameter as the schema + (`@Body() data: $CreateXData`), declared in `packages/schemas` as a `$`-prefixed type and const + sharing one name. It **must be a value import**: `import type` erases the runtime binding and the + pipe throws when the route is called. The same schema is what the API reference documents the body + from. See `apps/api/src/groups/groups.controller.ts`. 5. **Controller handler** in `apps/api/src//.controller.ts`. Every handler needs `@RouteAccess` (eslint-enforced; missing it is a 500 at request time, not an open route), and any diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index c5f1a9342..d4a404a43 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -20,7 +20,7 @@ drives it. `libnest.config.ts` is the real entry configuration. | --------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `@nestjs/config` | libnest `ConfigService`, typed from `$Env` — `.get(key)` is fully inferred | | a `PrismaService` you write | `@InjectModel('Group') private readonly groupModel: Model<'Group'>` | -| `class-validator` DTOs | Zod, via `@ValidationSchema($Schema)` and a global `ValidationPipe` | +| `class-validator` DTOs | Zod, via a schema-typed `@Body()` parameter and a global `ValidationPipe` | | you register global pipes/filters | `AppFactory` registers the exception filter, validation pipe, throttler, config, crypto and logging modules | `libnest.config.ts` also carries the `declare module` augmentation that types `ConfigService`, @@ -39,7 +39,6 @@ src// .module.ts @Module({ controllers, exports: [Service], providers: [Service] }) .controller.ts @Controller(path); @RouteAccess on every handler .service.ts @Injectable; @InjectModel(...) per model - dto/create-.dto.ts @ValidationSchema($CreateXData) class CreateXDto implements CreateXData __tests__/.service.spec.ts ``` @@ -142,14 +141,17 @@ Background: `.agents/docs/architecture/auth-and-permissions.md`. ## Validation -The global `ValidationPipe` runs on **request bodies only**. Two patterns, both in use: +The global `ValidationPipe` runs on **request bodies only**. A body is typed as its schema: +`@Body() data: $CreateGroupData`, where `packages/schemas` exports the schema as a type and a const +under the same name. **It must be imported as a value.** `import type` erases the runtime binding +and the pipe throws when the route is called. -- **DTO class** (dominant): `@ValidationSchema($CreateGroupData)` on a class that also - `implements CreateGroupData`. The decorator supplies both validation and Swagger metadata; the - `implements` is what stops the class drifting from the schema. Both are required. -- **Schema as the parameter type**: `@Body() data: $CreateSeriesInstrumentData`, where the schema is - exported as a type and a const under the same name. **It must be imported as a value.** - `import type` erases the runtime binding and the pipe throws when the route is called. +That one identifier is also the API reference. libnest generates `/spec.json` from the Zod schema in +each parameter's decorator metadata, so a body typed as anything else — a class, an interface — is +documented as an empty schema. libnest's `@ValidationSchema` DTO classes still validate but are +deprecated for exactly that reason; `test/suites/01-boot.suite.ts` fails on any undocumented body. +`ApiOperation` comes from libnest, not `@nestjs/swagger` (which this app does not depend on), and an +operation's tag is its controller's class name minus `Controller`. **Params and query strings are not validated automatically.** Use `new ParseSchemaPipe({ schema })` or `ValidObjectIdPipe` explicitly — see `src/audit/audit.controller.ts` and @@ -236,8 +238,7 @@ per file. Three consequences worth knowing: - **`__RELEASE__` must be stubbed.** `libnest build` replaces it with a literal, so under vitest the identifier is undefined and any route reading it (`GET /v1/setup`) throws a `ReferenceError`. `test/helpers.ts` stubs it once for every suite; a unit spec touching that path stubs its own. -- **The OpenAPI document is built before `enableVersioning`**, so paths in `/spec.json` carry no - `/v1` prefix even though the live routes do. +- **Paths in `/spec.json` carry the `/v1` prefix**, exactly as the live routes do. - **`GatewaySynchronizer` owns a timer for the lifetime of the suite.** It schedules each pass with `setTimeout` only once the previous pass has finished, stores the handle, and clears it in `onApplicationShutdown`, where it also awaits the in-flight pass — so `app.close()` leaves nothing diff --git a/apps/api/package.json b/apps/api/package.json index f1c8066ad..d6d124b04 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,7 +21,7 @@ "@casl/prisma": "^1.5.1", "@douglasneuroinformatics/libcrypto": "catalog:", "@douglasneuroinformatics/libjs": "catalog:", - "@douglasneuroinformatics/libnest": "^8.4.1", + "@douglasneuroinformatics/libnest": "^9.0.0", "@douglasneuroinformatics/libpasswd": "catalog:", "@douglasneuroinformatics/libstats": "catalog:", "@faker-js/faker": "^9.4.0", @@ -32,7 +32,6 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.11", "@nestjs/platform-fastify": "^11.1.11", - "@nestjs/swagger": "^11.0.6", "@nestjs/throttler": "^6.5.0", "@opendatacapture/demo": "workspace:*", "@opendatacapture/instrument-bundler": "workspace:*", diff --git a/apps/api/src/assignments/assignments.controller.ts b/apps/api/src/assignments/assignments.controller.ts index 3d3bba314..5b19af140 100644 --- a/apps/api/src/assignments/assignments.controller.ts +++ b/apps/api/src/assignments/assignments.controller.ts @@ -1,4 +1,4 @@ -import { CurrentUser } from '@douglasneuroinformatics/libnest'; +import { ApiOperation, CurrentUser } from '@douglasneuroinformatics/libnest'; import type { RequestUser } from '@douglasneuroinformatics/libnest'; import { BadRequestException, @@ -12,14 +12,20 @@ import { Post, Query } from '@nestjs/common'; -import { ApiOperation } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; +import { + $BulkAssignmentPreflightData, + $CancelAssignmentData, + $CreateAssignmentData, + $CreateBulkAssignmentsData, + $DeleteBulkAssignmentsData +} from '@opendatacapture/schemas/assignment'; import type { Assignment, BulkAssignmentPreflightResult, DeleteBulkAssignmentsResult } from '@opendatacapture/schemas/assignment'; -import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail'; +import { $SendAssignmentEmailData, DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail'; import type { EmailDeliveryResult, MailTemplate } from '@opendatacapture/schemas/mail'; import { AuditLogger } from '@/audit/audit.logger'; @@ -30,11 +36,6 @@ import { GroupsService } from '@/groups/groups.service'; import { MailService } from '@/mail/mail.service'; import { AssignmentsService } from './assignments.service'; -import { BulkAssignmentPreflightDto, CreateBulkAssignmentsDto } from './dto/bulk-assignment.dto'; -import { CreateAssignmentDto } from './dto/create-assignment.dto'; -import { DeleteBulkAssignmentsDto } from './dto/delete-bulk-assignments.dto'; -import { SendAssignmentEmailDto } from './dto/send-assignment-email.dto'; -import { UpdateAssignmentDto } from './dto/update-assignment.dto'; @Controller('assignments') export class AssignmentsController { @@ -59,7 +60,7 @@ export class AssignmentsController { @Post('bulk/preflight') @RouteAccess({ action: 'create', subject: 'Assignment' }) bulkPreflight( - @Body() data: BulkAssignmentPreflightDto, + @Body() data: $BulkAssignmentPreflightData, @CurrentUser() currentUser: RequestUser ): Promise { return this.assignmentsService.bulkPreflight(data, currentUser); @@ -68,14 +69,14 @@ export class AssignmentsController { @ApiOperation({ summary: 'Create Assignment' }) @Post() @RouteAccess({ action: 'create', subject: 'Assignment' }) - create(@Body() data: CreateAssignmentDto, @CurrentUser() currentUser: RequestUser): Promise { + create(@Body() data: $CreateAssignmentData, @CurrentUser() currentUser: RequestUser): Promise { return this.assignmentsService.create(data, currentUser); } @ApiOperation({ summary: 'Create Assignments in Bulk' }) @Post('bulk') @RouteAccess({ action: 'create', subject: 'Assignment' }) - createBulk(@Body() data: CreateBulkAssignmentsDto, @CurrentUser() currentUser: RequestUser): Promise { + createBulk(@Body() data: $CreateBulkAssignmentsData, @CurrentUser() currentUser: RequestUser): Promise { return this.assignmentsService.createBulk(data, currentUser); } @@ -84,7 +85,7 @@ export class AssignmentsController { @Post('bulk/delete') @RouteAccess({ action: 'delete', subject: 'Assignment' }) deleteBulk( - @Body() data: DeleteBulkAssignmentsDto, + @Body() data: $DeleteBulkAssignmentsData, @CurrentUser('ability') ability?: AppAbility ): Promise { return this.assignmentsService.deleteBulk(data.ids, { ability }); @@ -109,7 +110,7 @@ export class AssignmentsController { @Throttle({ long: { limit: ASSIGNMENT_EMAIL_THROTTLER_LIMIT, ttl: ASSIGNMENT_EMAIL_THROTTLER_TTL } }) async sendEmail( @Param('id') id: string, - @Body() { language, recipient, templateId }: SendAssignmentEmailDto, + @Body() { language, recipient, templateId }: $SendAssignmentEmailData, @CurrentUser() currentUser: RequestUser ): Promise { const { ability } = currentUser; @@ -152,7 +153,7 @@ export class AssignmentsController { @ApiOperation({ summary: 'Update Assignment' }) @Patch(':id') @RouteAccess({ action: 'update', subject: 'Assignment' }) - updateById(@Param('id') id: string, @Body() data: UpdateAssignmentDto, @CurrentUser() currentUser: RequestUser) { + updateById(@Param('id') id: string, @Body() data: $CancelAssignmentData, @CurrentUser() currentUser: RequestUser) { return this.assignmentsService.updateById(id, data, currentUser); } } diff --git a/apps/api/src/assignments/assignments.service.ts b/apps/api/src/assignments/assignments.service.ts index a9d15f045..f375acbca 100644 --- a/apps/api/src/assignments/assignments.service.ts +++ b/apps/api/src/assignments/assignments.service.ts @@ -5,14 +5,15 @@ import { HybridCrypto } from '@douglasneuroinformatics/libcrypto'; import { ConfigService, InjectModel, LoggingService } from '@douglasneuroinformatics/libnest'; import type { Model, RequestUser } from '@douglasneuroinformatics/libnest'; import { ForbiddenException, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; +import { $CreateAssignmentData } from '@opendatacapture/schemas/assignment'; import type { + $BulkAssignmentPreflightData, + $CreateBulkAssignmentsData, + $UpdateAssignmentData, Assignment, BulkAssignmentFailure, BulkAssignmentIssue, - BulkAssignmentPreflightData, - BulkAssignmentPreflightResult, - CreateBulkAssignmentsData, - UpdateAssignmentData + BulkAssignmentPreflightResult } from '@opendatacapture/schemas/assignment'; import { AuditLogger } from '@/audit/audit.logger'; @@ -20,8 +21,6 @@ import { accessibleQuery, forcedAppSubject } from '@/auth/ability.utils'; import type { EntityOperationOptions } from '@/core/types'; import { GatewayService } from '@/gateway/gateway.service'; -import { CreateAssignmentDto } from './dto/create-assignment.dto'; - /** * How many assignments a batch prepares at once. Key generation is CPU-bound, so an unbounded * `Promise.all` over 500 subjects would hold the event loop for the whole batch and stall every @@ -65,7 +64,7 @@ export class AssignmentsService { * with every issue attached when it is not — a bulk operation is all-or-nothing, so there is no * such thing as a partially acceptable request. */ - async bulkPreflight(data: BulkAssignmentPreflightData, currentUser: RequestUser) { + async bulkPreflight(data: $BulkAssignmentPreflightData, currentUser: RequestUser) { const { subjectIds, timepoints } = await this.resolveBulkRequest(data, currentUser); return { assignmentCount: subjectIds.length * timepoints.length, @@ -75,7 +74,7 @@ export class AssignmentsService { } async create( - { expiresAt, groupId, instrumentId, subjectId }: CreateAssignmentDto, + { expiresAt, groupId, instrumentId, subjectId }: $CreateAssignmentData, currentUser: RequestUser ): Promise { if (groupId) { @@ -105,7 +104,7 @@ export class AssignmentsService { * anything fails — validation, staging, or the gateway — every row staged by this call is deleted * and the caller is told what was wrong, leaving the instance exactly as it was. */ - async createBulk(data: CreateBulkAssignmentsData, currentUser: RequestUser): Promise { + async createBulk(data: $CreateBulkAssignmentsData, currentUser: RequestUser): Promise { const { groupId, subjectIds, timepoints } = await this.resolveBulkRequest(data, currentUser); const staged: { assignment: Assignment; publicKey: webcrypto.CryptoKey }[] = []; @@ -190,7 +189,7 @@ export class AssignmentsService { return assignment; } - async updateById(id: string, data: UpdateAssignmentData, currentUser: RequestUser) { + async updateById(id: string, data: $UpdateAssignmentData, currentUser: RequestUser) { const where = { AND: [accessibleQuery(currentUser.ability, 'update', 'Assignment')], id }; if (!(await this.assignmentModel.exists(where))) { throw new NotFoundException(`Failed to find assignment with ID: ${id}`); @@ -204,7 +203,7 @@ export class AssignmentsService { } /** used by the gateway internal system */ - async updateStatusById(id: string, status: UpdateAssignmentData['status']) { + async updateStatusById(id: string, status: $UpdateAssignmentData['status']) { return this.assignmentModel.update({ data: { status @@ -238,7 +237,7 @@ export class AssignmentsService { * returns the resolved request when there are none. */ private async resolveBulkRequest( - { allowDuplicates, groupId, subjectIds, timepoints }: BulkAssignmentPreflightData, + { allowDuplicates, groupId, subjectIds, timepoints }: $BulkAssignmentPreflightData, { ability }: RequestUser ) { // A group the caller cannot read is indistinguishable from one that does not exist. diff --git a/apps/api/src/assignments/dto/bulk-assignment.dto.ts b/apps/api/src/assignments/dto/bulk-assignment.dto.ts deleted file mode 100644 index b06d621bc..000000000 --- a/apps/api/src/assignments/dto/bulk-assignment.dto.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import { $BulkAssignmentPreflightData, $CreateBulkAssignmentsData } from '@opendatacapture/schemas/assignment'; -import type { - BulkAssignmentPreflightData, - BulkAssignmentTimepoint, - CreateBulkAssignmentsData -} from '@opendatacapture/schemas/assignment'; - -@ValidationSchema($BulkAssignmentPreflightData) -export class BulkAssignmentPreflightDto implements BulkAssignmentPreflightData { - @ApiProperty() - allowDuplicates: boolean; - - @ApiProperty() - groupId: string; - - @ApiProperty() - subjectIds: string[]; - - @ApiProperty() - timepoints: BulkAssignmentTimepoint[]; -} - -@ValidationSchema($CreateBulkAssignmentsData) -export class CreateBulkAssignmentsDto implements CreateBulkAssignmentsData { - @ApiProperty() - allowDuplicates: boolean; - - @ApiProperty() - groupId: string; - - @ApiProperty() - subjectIds: string[]; - - @ApiProperty() - timepoints: BulkAssignmentTimepoint[]; -} diff --git a/apps/api/src/assignments/dto/create-assignment.dto.ts b/apps/api/src/assignments/dto/create-assignment.dto.ts deleted file mode 100644 index 015101a70..000000000 --- a/apps/api/src/assignments/dto/create-assignment.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import { $CreateAssignmentData } from '@opendatacapture/schemas/assignment'; -import type { CreateAssignmentData } from '@opendatacapture/schemas/assignment'; - -@ValidationSchema($CreateAssignmentData) -export class CreateAssignmentDto implements CreateAssignmentData { - @ApiProperty() - expiresAt: Date; - - @ApiProperty() - groupId?: null | string; - - @ApiProperty() - instrumentId: string; - - @ApiProperty() - subjectId: string; -} diff --git a/apps/api/src/assignments/dto/delete-bulk-assignments.dto.ts b/apps/api/src/assignments/dto/delete-bulk-assignments.dto.ts deleted file mode 100644 index e84e53ec9..000000000 --- a/apps/api/src/assignments/dto/delete-bulk-assignments.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import { $DeleteBulkAssignmentsData } from '@opendatacapture/schemas/assignment'; -import type { DeleteBulkAssignmentsData } from '@opendatacapture/schemas/assignment'; - -@ValidationSchema($DeleteBulkAssignmentsData) -export class DeleteBulkAssignmentsDto implements DeleteBulkAssignmentsData { - @ApiProperty() - ids: string[]; -} diff --git a/apps/api/src/assignments/dto/send-assignment-email.dto.ts b/apps/api/src/assignments/dto/send-assignment-email.dto.ts deleted file mode 100644 index 992d27030..000000000 --- a/apps/api/src/assignments/dto/send-assignment-email.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import type { Language } from '@opendatacapture/schemas/core'; -import { $SendAssignmentEmailData } from '@opendatacapture/schemas/mail'; -import type { SendAssignmentEmailData } from '@opendatacapture/schemas/mail'; - -@ValidationSchema($SendAssignmentEmailData) -export class SendAssignmentEmailDto implements SendAssignmentEmailData { - @ApiProperty({ description: 'The language to send the email in', enum: ['en', 'fr'] }) - language: Language; - - @ApiProperty({ description: "The participant's email address" }) - recipient: string; - - @ApiProperty({ - description: 'Template id to use; null for the built-in default, omitted to use the group active template', - required: false - }) - templateId?: null | string; -} diff --git a/apps/api/src/assignments/dto/update-assignment.dto.ts b/apps/api/src/assignments/dto/update-assignment.dto.ts deleted file mode 100644 index c0e6453d0..000000000 --- a/apps/api/src/assignments/dto/update-assignment.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import { $UpdateAssignmentData } from '@opendatacapture/schemas/assignment'; -import type { AssignmentStatus, UpdateAssignmentData } from '@opendatacapture/schemas/assignment'; -import { z } from 'zod/v4'; - -@ValidationSchema( - $UpdateAssignmentData.extend({ - status: z.literal('CANCELED') - }) -) -export class UpdateAssignmentDto implements UpdateAssignmentData { - @ApiProperty() - status: Extract; -} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 26d858210..ce3515dd5 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,7 +1,6 @@ -import { CurrentUser } from '@douglasneuroinformatics/libnest'; +import { ApiOperation, CurrentUser } from '@douglasneuroinformatics/libnest'; import type { RequestUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; -import { ApiOperation } from '@nestjs/swagger'; import { $LoginCredentials } from '@opendatacapture/schemas/auth'; import { RouteAccess } from '@/core/decorators/route-access.decorator.js'; diff --git a/apps/api/src/gateway/gateway.controller.ts b/apps/api/src/gateway/gateway.controller.ts index 8df71af38..fff60135c 100644 --- a/apps/api/src/gateway/gateway.controller.ts +++ b/apps/api/src/gateway/gateway.controller.ts @@ -1,12 +1,10 @@ import { Controller, Get } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; import type { GatewayHealthcheckResult } from '@opendatacapture/schemas/gateway'; import { RouteAccess } from '@/core/decorators/route-access.decorator'; import { GatewayService } from './gateway.service'; -@ApiTags('Gateway') @Controller({ path: 'gateway' }) export class GatewayController { constructor(private readonly gatewayService: GatewayService) {} diff --git a/apps/api/src/groups/dto/create-group.dto.ts b/apps/api/src/groups/dto/create-group.dto.ts deleted file mode 100644 index a2766fbbd..000000000 --- a/apps/api/src/groups/dto/create-group.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import { $CreateGroupData } from '@opendatacapture/schemas/group'; -import type { CreateGroupData, GroupSettings, GroupType } from '@opendatacapture/schemas/group'; - -@ValidationSchema($CreateGroupData) -export class CreateGroupDto implements CreateGroupData { - @ApiProperty({ example: 'Depression Clinic' }) - name: string; - settings?: GroupSettings; - type: GroupType; -} diff --git a/apps/api/src/groups/dto/update-group.dto.ts b/apps/api/src/groups/dto/update-group.dto.ts deleted file mode 100644 index 373e3260d..000000000 --- a/apps/api/src/groups/dto/update-group.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { $UpdateGroupData } from '@opendatacapture/schemas/group'; -import type { GroupEmailTemplate, GroupSettings, GroupType, UpdateGroupData } from '@opendatacapture/schemas/group'; - -@ValidationSchema($UpdateGroupData) -export class UpdateGroupDto implements UpdateGroupData { - accessibleInstrumentIds?: string[]; - activeAssignmentEmailTemplateId?: null | string; - emailTemplates?: GroupEmailTemplate[]; - expectedUpdatedAt?: Date; - instrumentRepoIds?: string[]; - name?: string; - settings?: Partial; - type?: GroupType; -} diff --git a/apps/api/src/groups/groups.controller.ts b/apps/api/src/groups/groups.controller.ts index 56acb1c72..2f15db1c7 100644 --- a/apps/api/src/groups/groups.controller.ts +++ b/apps/api/src/groups/groups.controller.ts @@ -1,15 +1,12 @@ -import { CurrentUser } from '@douglasneuroinformatics/libnest'; +import { ApiOperation, CurrentUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { $CreateGroupData, $UpdateGroupData } from '@opendatacapture/schemas/group'; import type { AppAbility } from '@/auth/auth.types'; import { ADMIN_ONLY, RouteAccess } from '@/core/decorators/route-access.decorator'; -import { CreateGroupDto } from './dto/create-group.dto'; -import { UpdateGroupDto } from './dto/update-group.dto'; import { GroupsService } from './groups.service'; -@ApiTags('Groups') @Controller('groups') export class GroupsController { constructor(private readonly groupsService: GroupsService) {} @@ -21,8 +18,8 @@ export class GroupsController { @ApiOperation({ summary: 'Create Group' }) @Post() @RouteAccess(ADMIN_ONLY) - create(@Body() createGroupDto: CreateGroupDto) { - return this.groupsService.create(createGroupDto); + create(@Body() data: $CreateGroupData) { + return this.groupsService.create(data); } @ApiOperation({ summary: 'Delete Group' }) @@ -49,7 +46,7 @@ export class GroupsController { @ApiOperation({ summary: 'Update Group' }) @Patch(':id') @RouteAccess({ action: 'update', subject: 'Group' }) - updateById(@Param('id') id: string, @Body() update: UpdateGroupDto, @CurrentUser('ability') ability?: AppAbility) { + updateById(@Param('id') id: string, @Body() update: $UpdateGroupData, @CurrentUser('ability') ability?: AppAbility) { return this.groupsService.updateById(id, update, { ability }); } } diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts index f20dea756..293b8b1b0 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -1,6 +1,7 @@ import { InjectModel } from '@douglasneuroinformatics/libnest'; import type { Model } from '@douglasneuroinformatics/libnest'; import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { $CreateGroupData, $UpdateGroupData } from '@opendatacapture/schemas/group'; import type { GroupEmailTemplate } from '@opendatacapture/schemas/group'; import type { Prisma } from '@prisma/client'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; @@ -8,9 +9,6 @@ import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import { accessibleQuery } from '@/auth/ability.utils'; import type { EntityOperationOptions } from '@/core/types'; -import { CreateGroupDto } from './dto/create-group.dto'; -import { UpdateGroupDto } from './dto/update-group.dto'; - @Injectable() export class GroupsService { constructor( @@ -18,7 +16,7 @@ export class GroupsService { @InjectModel('Instrument') private readonly instrumentModel: Model<'Instrument'> ) {} - async create({ name, settings, type, ...data }: CreateGroupDto) { + async create({ name, settings, type, ...data }: $CreateGroupData) { const exists = await this.groupModel.exists({ name }); if (exists) { throw new ConflictException(`Group with name '${name}' already exists!`); @@ -84,7 +82,7 @@ export class GroupsService { instrumentRepoIds, settings, ...data - }: UpdateGroupDto, + }: $UpdateGroupData, { ability }: EntityOperationOptions = {} ) { const where: Prisma.GroupWhereInput = { AND: [accessibleQuery(ability, 'update', 'Group')], id }; diff --git a/apps/api/src/instrument-records/instrument-records.controller.ts b/apps/api/src/instrument-records/instrument-records.controller.ts index c225835ac..b7081f09a 100644 --- a/apps/api/src/instrument-records/instrument-records.controller.ts +++ b/apps/api/src/instrument-records/instrument-records.controller.ts @@ -1,6 +1,6 @@ /* eslint-disable perfectionist/sort-classes */ -import { CurrentUser, ParseSchemaPipe, ValidObjectIdPipe } from '@douglasneuroinformatics/libnest'; +import { ApiOperation, CurrentUser, ParseSchemaPipe, ValidObjectIdPipe } from '@douglasneuroinformatics/libnest'; import { Body, Controller, @@ -14,7 +14,6 @@ import { Query, UseInterceptors } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; import type { InstrumentKind } from '@opendatacapture/runtime-core'; import { $CreateInstrumentRecordData, @@ -29,7 +28,6 @@ import { MsgpackInterceptor } from '@/core/interceptors/msgpack.interceptor'; import { InstrumentRecordsService } from './instrument-records.service'; -@ApiTags('Instrument Records') @Controller('instrument-records') export class InstrumentRecordsController { constructor(private readonly instrumentRecordsService: InstrumentRecordsService) {} diff --git a/apps/api/src/instrument-repos/dto/create-instrument-repo.dto.ts b/apps/api/src/instrument-repos/dto/create-instrument-repo.dto.ts deleted file mode 100644 index ee6e468f5..000000000 --- a/apps/api/src/instrument-repos/dto/create-instrument-repo.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; -import type { CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; - -@ValidationSchema($CreateInstrumentRepoData) -export class CreateInstrumentRepoDto implements CreateInstrumentRepoData { - accessToken?: string; - url: string; -} diff --git a/apps/api/src/instrument-repos/instrument-repos.controller.ts b/apps/api/src/instrument-repos/instrument-repos.controller.ts index 7f8b63629..fc1e05e57 100644 --- a/apps/api/src/instrument-repos/instrument-repos.controller.ts +++ b/apps/api/src/instrument-repos/instrument-repos.controller.ts @@ -1,14 +1,12 @@ -import { CurrentUser } from '@douglasneuroinformatics/libnest'; +import { ApiOperation, CurrentUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; import type { AppAbility } from '@/auth/auth.types'; import { RouteAccess } from '@/core/decorators/route-access.decorator'; -import { CreateInstrumentRepoDto } from './dto/create-instrument-repo.dto'; import { InstrumentReposService } from './instrument-repos.service'; -@ApiTags('InstrumentRepos') @Controller('instrument-repos') export class InstrumentReposController { constructor(private readonly instrumentReposService: InstrumentReposService) {} @@ -16,7 +14,7 @@ export class InstrumentReposController { @ApiOperation({ summary: 'Create Instrument Repo' }) @Post() @RouteAccess({ action: 'create', subject: 'InstrumentRepo' }) - create(@Body() dto: CreateInstrumentRepoDto) { + create(@Body() dto: $CreateInstrumentRepoData) { return this.instrumentReposService.create(dto); } diff --git a/apps/api/src/instrument-repos/instrument-repos.service.ts b/apps/api/src/instrument-repos/instrument-repos.service.ts index e7a357607..cf0e8dbc3 100644 --- a/apps/api/src/instrument-repos/instrument-repos.service.ts +++ b/apps/api/src/instrument-repos/instrument-repos.service.ts @@ -14,6 +14,7 @@ import { import type { OnModuleInit } from '@nestjs/common'; import { bundle, BUNDLER_FILE_EXT_REGEX, inferLoader } from '@opendatacapture/instrument-bundler'; import type { BundlerInput } from '@opendatacapture/instrument-bundler'; +import { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; import JSZip from 'jszip'; import { accessibleQuery } from '@/auth/ability.utils'; @@ -21,8 +22,6 @@ import { decryptSecret, encryptSecret } from '@/core/secret-cipher'; import type { EntityOperationOptions } from '@/core/types'; import { InstrumentsService } from '@/instruments/instruments.service'; -import type { CreateInstrumentRepoDto } from './dto/create-instrument-repo.dto'; - @Injectable() export class InstrumentReposService implements OnModuleInit { constructor( @@ -34,7 +33,7 @@ export class InstrumentReposService implements OnModuleInit { private readonly loggingService: LoggingService ) {} - async create({ accessToken, url }: CreateInstrumentRepoDto) { + async create({ accessToken, url }: $CreateInstrumentRepoData) { const { owner, repoName } = this.parseGitHubUrl(url); const normalizedUrl = this.normalizeUrl(url); diff --git a/apps/api/src/instruments/dto/create-instrument.dto.ts b/apps/api/src/instruments/dto/create-instrument.dto.ts deleted file mode 100644 index 3f7d1230d..000000000 --- a/apps/api/src/instruments/dto/create-instrument.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { $CreateInstrumentData } from '@opendatacapture/schemas/instrument'; - -@ValidationSchema($CreateInstrumentData) -export class CreateInstrumentDto { - bundle: string; -} diff --git a/apps/api/src/instruments/instruments.controller.ts b/apps/api/src/instruments/instruments.controller.ts index 14ef2dc74..04b256972 100644 --- a/apps/api/src/instruments/instruments.controller.ts +++ b/apps/api/src/instruments/instruments.controller.ts @@ -1,11 +1,10 @@ -import { CurrentUser } from '@douglasneuroinformatics/libnest'; +import { ApiOperation, CurrentUser } from '@douglasneuroinformatics/libnest'; import type { RequestUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Delete, Get, Param, ParseBoolPipe, Post, Query } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; import type { InstrumentKind } from '@opendatacapture/runtime-core'; // Imported as a value (not a type-only import) so it doubles as the validation schema for the request // body while also annotating its type — no dedicated DTO class is needed. -import { $CreateSeriesInstrumentData } from '@opendatacapture/schemas/instrument'; +import { $CreateInstrumentData, $CreateSeriesInstrumentData } from '@opendatacapture/schemas/instrument'; import type { CreateSeriesInstrumentResult, InstrumentBundleContainer, @@ -15,10 +14,8 @@ import type { import { AcceptsInstrumentToken } from '@/core/decorators/accepts-instrument-token.decorator'; import { RouteAccess } from '@/core/decorators/route-access.decorator'; -import { CreateInstrumentDto } from './dto/create-instrument.dto'; import { InstrumentsService } from './instruments.service'; -@ApiTags('Instruments') @Controller({ path: 'instruments' }) export class InstrumentsController { constructor(private readonly instrumentsService: InstrumentsService) {} @@ -27,7 +24,7 @@ export class InstrumentsController { @ApiOperation({ summary: 'Create Instrument' }) @Post() @RouteAccess({ action: 'manage', subject: 'Instrument' }) - create(@Body() data: CreateInstrumentDto): Promise { + create(@Body() data: $CreateInstrumentData): Promise { return this.instrumentsService.create(data); } diff --git a/apps/api/src/instruments/instruments.service.ts b/apps/api/src/instruments/instruments.service.ts index 7334082b0..62ac829b1 100644 --- a/apps/api/src/instruments/instruments.service.ts +++ b/apps/api/src/instruments/instruments.service.ts @@ -23,7 +23,7 @@ import type { SomeInstrument } from '@opendatacapture/runtime-core'; import type { WithID } from '@opendatacapture/schemas/core'; -import { $AnyInstrument } from '@opendatacapture/schemas/instrument'; +import { $AnyInstrument, $CreateInstrumentData } from '@opendatacapture/schemas/instrument'; import type { $CreateSeriesInstrumentData, CreateSeriesInstrumentResult, @@ -39,8 +39,6 @@ import { accessibleQuery } from '@/auth/ability.utils'; import type { AppAbility } from '@/auth/auth.types'; import type { EntityOperationOptions } from '@/core/types'; -import { CreateInstrumentDto } from './dto/create-instrument.dto'; - /** The localized "series" tag applied to every generated series instrument. */ const seriesTag = (language: Language): string => (language === 'fr' ? 'Série' : 'Series'); const SERIES_INSTRUMENT_ID_PREFIX = '__V2__'; @@ -88,7 +86,7 @@ export class InstrumentsService { } async create( - { bundle }: CreateInstrumentDto, + { bundle }: $CreateInstrumentData, { seriesGroupId }: { seriesGroupId?: string } = {} ): Promise> { const result = await this.virtualizationService.eval(bundle); diff --git a/apps/api/src/mail/dto/test-mail.dto.ts b/apps/api/src/mail/dto/test-mail.dto.ts deleted file mode 100644 index 89928fdc1..000000000 --- a/apps/api/src/mail/dto/test-mail.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import { $TestMailData } from '@opendatacapture/schemas/mail'; -import type { TestMailData, UpdateMailConfigData } from '@opendatacapture/schemas/mail'; - -@ValidationSchema($TestMailData) -export class TestMailDto implements TestMailData { - @ApiProperty({ - description: 'The (possibly unsaved) configuration to test; omit to test the saved one', - required: false - }) - config?: UpdateMailConfigData; - - @ApiProperty({ description: 'If provided, a real test email is delivered to this address', required: false }) - recipient?: string; -} diff --git a/apps/api/src/mail/dto/update-mail-settings.dto.ts b/apps/api/src/mail/dto/update-mail-settings.dto.ts deleted file mode 100644 index a570b09b2..000000000 --- a/apps/api/src/mail/dto/update-mail-settings.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import { $UpdateMailSettingsData } from '@opendatacapture/schemas/mail'; -import type { MailTemplate, UpdateMailConfigData, UpdateMailSettingsData } from '@opendatacapture/schemas/mail'; - -@ValidationSchema($UpdateMailSettingsData) -export class UpdateMailSettingsDto implements UpdateMailSettingsData { - @ApiProperty({ required: false }) - config?: UpdateMailConfigData; - - @ApiProperty({ required: false }) - newUserEmailTemplate?: MailTemplate; -} diff --git a/apps/api/src/mail/mail.controller.ts b/apps/api/src/mail/mail.controller.ts index 0cd729359..5a5bade5a 100644 --- a/apps/api/src/mail/mail.controller.ts +++ b/apps/api/src/mail/mail.controller.ts @@ -1,14 +1,12 @@ +import { ApiOperation } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Get, Patch, Post } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { $TestMailData, $UpdateMailSettingsData } from '@opendatacapture/schemas/mail'; import type { MailSettings, TestMailResult } from '@opendatacapture/schemas/mail'; import { ADMIN_ONLY, RouteAccess } from '@/core/decorators/route-access.decorator'; -import { TestMailDto } from './dto/test-mail.dto'; -import { UpdateMailSettingsDto } from './dto/update-mail-settings.dto'; import { MailService } from './mail.service'; -@ApiTags('Mail') @Controller({ path: 'mail' }) export class MailController { constructor(private readonly mailService: MailService) {} @@ -26,14 +24,14 @@ export class MailController { @ApiOperation({ description: 'Verify the SMTP connection and optionally send a test email', summary: 'Test Mail' }) @Post('test') @RouteAccess(ADMIN_ONLY) - test(@Body() data: TestMailDto): Promise { + test(@Body() data: $TestMailData): Promise { return this.mailService.test(data); } @ApiOperation({ description: 'Update the mail configuration and/or templates', summary: 'Update Mail Settings' }) @Patch('settings') @RouteAccess(ADMIN_ONLY) - updateSettings(@Body() data: UpdateMailSettingsDto): Promise { + updateSettings(@Body() data: $UpdateMailSettingsData): Promise { return this.mailService.updateSettings(data); } } diff --git a/apps/api/src/mail/mail.service.ts b/apps/api/src/mail/mail.service.ts index f6d2415af..eeac042f5 100644 --- a/apps/api/src/mail/mail.service.ts +++ b/apps/api/src/mail/mail.service.ts @@ -15,14 +15,14 @@ import { MAIL_TRANSPORT_TIMEOUTS } from '@opendatacapture/schemas/mail'; import type { + $TestMailData, + $UpdateMailSettingsData, EmailDeliveryResult, MailConfig, MailSettings, MailTemplate, - TestMailData, TestMailResult, - UpdateMailConfigData, - UpdateMailSettingsData + UpdateMailConfigData } from '@opendatacapture/schemas/mail'; import type { SetupState } from '@prisma/client'; import { createTransport } from 'nodemailer'; @@ -153,7 +153,7 @@ export class MailService { * Test the SMTP connection, optionally sending a real message to `recipient`. When `config` is * supplied the (possibly unsaved) values are tested; otherwise the saved configuration is used. */ - async test({ config, recipient }: TestMailData): Promise { + async test({ config, recipient }: $TestMailData): Promise { const { config: saved } = await this.readState(); if (this.requiresNewPassword(config, saved)) { return { error: 'PASSWORD_REQUIRED', success: false }; @@ -185,7 +185,7 @@ export class MailService { * preserves the stored one so the secret never has to leave the server. Returns the * admin-facing settings (password stripped). */ - async updateSettings(data: UpdateMailSettingsData): Promise { + async updateSettings(data: $UpdateMailSettingsData): Promise { const setupState = await this.setupStateModel.findFirst(); if (!setupState?.isSetup) { throw new ServiceUnavailableException('Cannot update mail settings before setup'); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index c2745450e..baff0327c 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -36,7 +36,6 @@ export default AppFactory.create({ url: 'https://www.apache.org/licenses/LICENSE-2.0' }, path: '/', - tags: ['Authentication', 'Groups', 'Instruments', 'Instrument Records', 'Mail', 'Subjects', 'Users'], title: 'Open Data Capture' }, envSchema: $Env, diff --git a/apps/api/src/sessions/dto/create-session.dto.ts b/apps/api/src/sessions/dto/create-session.dto.ts deleted file mode 100644 index 4766da535..000000000 --- a/apps/api/src/sessions/dto/create-session.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { $CreateSessionData } from '@opendatacapture/schemas/session'; -import type { SessionType } from '@opendatacapture/schemas/session'; -import type { CreateSubjectData } from '@opendatacapture/schemas/subject'; - -@ValidationSchema($CreateSessionData) -export class CreateSessionDto { - date: Date; - groupId: null | string; - subjectData: CreateSubjectData; - type: SessionType; - userId?: null | string; -} diff --git a/apps/api/src/sessions/sessions.controller.ts b/apps/api/src/sessions/sessions.controller.ts index e627658c8..ea98c517a 100644 --- a/apps/api/src/sessions/sessions.controller.ts +++ b/apps/api/src/sessions/sessions.controller.ts @@ -1,13 +1,12 @@ -import { CurrentUser } from '@douglasneuroinformatics/libnest'; +import { ApiOperation, CurrentUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; -import { ApiOperation } from '@nestjs/swagger'; +import { $CreateSessionData } from '@opendatacapture/schemas/session'; import type { SessionWithUser } from '@opendatacapture/schemas/session'; import type { Session } from '@prisma/client'; import type { AppAbility } from '@/auth/auth.types'; import { RouteAccess } from '@/core/decorators/route-access.decorator'; -import { CreateSessionDto } from './dto/create-session.dto'; import { SessionsService } from './sessions.service'; @Controller('sessions') @@ -17,7 +16,7 @@ export class SessionsController { @ApiOperation({ description: 'Create Session' }) @Post() @RouteAccess({ action: 'create', subject: 'Session' }) - create(@Body() data: CreateSessionDto, @CurrentUser('ability') ability: AppAbility): Promise { + create(@Body() data: $CreateSessionData, @CurrentUser('ability') ability: AppAbility): Promise { return this.sessionsService.create(data, { ability }); } diff --git a/apps/api/src/sessions/sessions.service.ts b/apps/api/src/sessions/sessions.service.ts index 88d8be72c..bc2ccb917 100644 --- a/apps/api/src/sessions/sessions.service.ts +++ b/apps/api/src/sessions/sessions.service.ts @@ -2,7 +2,7 @@ import { InjectModel, LoggingService } from '@douglasneuroinformatics/libnest'; import type { Model } from '@douglasneuroinformatics/libnest'; import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import type { Group } from '@opendatacapture/schemas/group'; -import type { CreateSessionData } from '@opendatacapture/schemas/session'; +import type { $CreateSessionData } from '@opendatacapture/schemas/session'; import type { Prisma, Session } from '@prisma/client'; import { ObjectId } from 'mongodb'; @@ -12,9 +12,9 @@ import { GroupsService } from '@/groups/groups.service'; import { SubjectsService } from '@/subjects/subjects.service'; import { UsersService } from '@/users/users.service'; -/** The batched form of `CreateSessionData`: what varies per session, and what the batch shares. */ -type CreateManySessionsData = Pick & { - entries: Pick[]; +/** The batched form of `$CreateSessionData`: what varies per session, and what the batch shares. */ +type CreateManySessionsData = Pick<$CreateSessionData, 'groupId' | 'type' | 'username'> & { + entries: Pick<$CreateSessionData, 'date' | 'subjectData'>[]; }; @Injectable() @@ -34,7 +34,7 @@ export class SessionsService { } async create( - { date, groupId, subjectData, type, username }: CreateSessionData, + { date, groupId, subjectData, type, username }: $CreateSessionData, options?: EntityOperationOptions ): Promise { const [session] = await this.createMany( diff --git a/apps/api/src/setup/dto/init-app.dto.ts b/apps/api/src/setup/dto/init-app.dto.ts deleted file mode 100644 index 81f5f3422..000000000 --- a/apps/api/src/setup/dto/init-app.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import { $InitAppOptions } from '@opendatacapture/schemas/setup'; -import type { CreateAdminData, InitAppOptions } from '@opendatacapture/schemas/setup'; - -@ValidationSchema($InitAppOptions) -export class InitAppDto implements InitAppOptions { - @ApiProperty() - admin: CreateAdminData; - - @ApiProperty() - dummySubjectCount?: number; - - @ApiProperty() - enableExperimentalFeatures: boolean; - - @ApiProperty() - initDemo: boolean; - - @ApiProperty() - recordsPerSubject?: number; -} diff --git a/apps/api/src/setup/dto/update-setup-state.dto.ts b/apps/api/src/setup/dto/update-setup-state.dto.ts deleted file mode 100644 index f3a93e569..000000000 --- a/apps/api/src/setup/dto/update-setup-state.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import type { ActiveLanguages } from '@opendatacapture/schemas/core'; -import { $UpdateSetupStateData } from '@opendatacapture/schemas/setup'; -import type { BrandingConfig, UpdateSetupStateData } from '@opendatacapture/schemas/setup'; - -@ValidationSchema($UpdateSetupStateData) -export class UpdateSetupStateDto implements UpdateSetupStateData { - @ApiProperty({ required: false }) - activeLanguages?: ActiveLanguages; - - @ApiProperty({ required: false }) - branding?: BrandingConfig | null; - - @ApiProperty({ required: false }) - defaultAssignmentDurationDays?: null | number; - - @ApiProperty({ required: false }) - isBulkRemoteAssignmentsEnabled?: boolean; - - @ApiProperty({ required: false }) - isExperimentalFeaturesEnabled?: boolean; -} diff --git a/apps/api/src/setup/setup.controller.ts b/apps/api/src/setup/setup.controller.ts index c2905cb64..4772d6dd0 100644 --- a/apps/api/src/setup/setup.controller.ts +++ b/apps/api/src/setup/setup.controller.ts @@ -1,14 +1,12 @@ +import { ApiOperation } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Delete, Get, Patch, Post } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { $InitAppOptions, $UpdateSetupStateData } from '@opendatacapture/schemas/setup'; import type { SetupState } from '@opendatacapture/schemas/setup'; import { ADMIN_ONLY, RouteAccess } from '@/core/decorators/route-access.decorator'; -import { InitAppDto } from './dto/init-app.dto'; -import { UpdateSetupStateDto } from './dto/update-setup-state.dto'; import { SetupService } from './setup.service'; -@ApiTags('Setup') @Controller({ path: 'setup' }) export class SetupController { constructor(private readonly setupService: SetupService) {} @@ -38,8 +36,8 @@ export class SetupController { }) @Post() @RouteAccess('public') - initApp(@Body() initAppDto: InitAppDto): Promise<{ success: boolean }> { - return this.setupService.initApp(initAppDto); + initApp(@Body() data: $InitAppOptions): Promise<{ success: boolean }> { + return this.setupService.initApp(data); } @ApiOperation({ @@ -48,7 +46,7 @@ export class SetupController { }) @Patch() @RouteAccess(ADMIN_ONLY) - updateState(@Body() data: UpdateSetupStateDto): Promise> { + updateState(@Body() data: $UpdateSetupStateData): Promise> { return this.setupService.updateState(data); } } diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index 9d5c5f88c..bc5236104 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -10,7 +10,12 @@ import { import { DEFAULT_ACTIVE_LANGUAGES } from '@opendatacapture/schemas/core'; import { isMailEnabled } from '@opendatacapture/schemas/mail'; import { $BrandingConfig } from '@opendatacapture/schemas/setup'; -import type { CreateAdminData, InitAppOptions, SetupState, UpdateSetupStateData } from '@opendatacapture/schemas/setup'; +import type { + $InitAppOptions, + $UpdateSetupStateData, + CreateAdminData, + SetupState +} from '@opendatacapture/schemas/setup'; import type { RuntimePrismaClient } from '@/core/prisma'; import { DemoService } from '@/demo/demo.service'; @@ -78,7 +83,13 @@ export class SetupService { } satisfies SetupState; } - async initApp({ admin, dummySubjectCount, enableExperimentalFeatures, initDemo, recordsPerSubject }: InitAppOptions) { + async initApp({ + admin, + dummySubjectCount, + enableExperimentalFeatures, + initDemo, + recordsPerSubject + }: $InitAppOptions) { const isDev = this.configService.get('NODE_ENV') === 'development'; const savedOptions = await this.getSavedOptions(); if (savedOptions?.isSetup && !isDev) { @@ -99,7 +110,7 @@ export class SetupService { return { success: true }; } - async updateState({ branding, ...rest }: UpdateSetupStateData): Promise> { + async updateState({ branding, ...rest }: $UpdateSetupStateData): Promise> { const setupState = await this.getSavedOptions(); if (!setupState?.isSetup) { throw new ServiceUnavailableException('Cannot update state before setup'); diff --git a/apps/api/src/subjects/dto/create-subject.dto.ts b/apps/api/src/subjects/dto/create-subject.dto.ts deleted file mode 100644 index 6afa7fdc5..000000000 --- a/apps/api/src/subjects/dto/create-subject.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import { $CreateSubjectData } from '@opendatacapture/schemas/subject'; -import type { Sex } from '@opendatacapture/schemas/subject'; - -@ValidationSchema($CreateSubjectData) -export class CreateSubjectDto { - @ApiProperty() - dateOfBirth?: Date | null; - - @ApiProperty() - firstName?: null | string; - - @ApiProperty() - id: string; - - @ApiProperty() - lastName?: null | string; - - @ApiProperty() - sex?: null | Sex; -} diff --git a/apps/api/src/subjects/subjects.controller.ts b/apps/api/src/subjects/subjects.controller.ts index e5286cb4d..2b8db2846 100644 --- a/apps/api/src/subjects/subjects.controller.ts +++ b/apps/api/src/subjects/subjects.controller.ts @@ -1,16 +1,14 @@ import { $BooleanLike } from '@douglasneuroinformatics/libjs'; -import { CurrentUser, ParseSchemaPipe, ValidObjectIdPipe } from '@douglasneuroinformatics/libnest'; +import { ApiOperation, CurrentUser, ParseSchemaPipe, ValidObjectIdPipe } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { $CreateSubjectData } from '@opendatacapture/schemas/subject'; import z from 'zod/v4'; import type { AppAbility } from '@/auth/auth.types'; import { RouteAccess } from '@/core/decorators/route-access.decorator'; -import { CreateSubjectDto } from './dto/create-subject.dto'; import { SubjectsService } from './subjects.service'; -@ApiTags('Subjects') @Controller('subjects') export class SubjectsController { constructor(private readonly subjectsService: SubjectsService) {} @@ -18,7 +16,7 @@ export class SubjectsController { @ApiOperation({ summary: 'Create Subject' }) @Post() @RouteAccess({ action: 'create', subject: 'Subject' }) - create(@Body() subject: CreateSubjectDto) { + create(@Body() subject: $CreateSubjectData) { return this.subjectsService.create(subject); } diff --git a/apps/api/src/subjects/subjects.service.ts b/apps/api/src/subjects/subjects.service.ts index c4f5516c7..2345e3dca 100644 --- a/apps/api/src/subjects/subjects.service.ts +++ b/apps/api/src/subjects/subjects.service.ts @@ -2,6 +2,7 @@ import { InjectModel, InjectPrismaClient } from '@douglasneuroinformatics/libnes import type { Model } from '@douglasneuroinformatics/libnest'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { DEFAULT_GROUP_NAME } from '@opendatacapture/schemas/core'; +import { $CreateSubjectData } from '@opendatacapture/schemas/subject'; import { encodeScopedSubjectId } from '@opendatacapture/subject-utils'; import type { Prisma } from '@prisma/client'; @@ -10,8 +11,6 @@ import type { AppAbility } from '@/auth/auth.types'; import type { RuntimePrismaClient } from '@/core/prisma'; import type { EntityOperationOptions } from '@/core/types'; -import { CreateSubjectDto } from './dto/create-subject.dto'; - const PERSONAL_INFO_FIELDS = ['dateOfBirth', 'firstName', 'lastName', 'sex'] as const; /** @@ -62,7 +61,7 @@ export class SubjectsService { async create({ id, ...data - }: CreateSubjectDto & { + }: $CreateSubjectData & { /** for demo purposes need to set createdAt manually */ createdAt?: Date; }) { @@ -78,9 +77,9 @@ export class SubjectsService { }); } - async createMany(data: CreateSubjectDto[], { ability }: EntityOperationOptions = {}) { + async createMany(data: $CreateSubjectData[], { ability }: EntityOperationOptions = {}) { // keyed by id so duplicates within the request collapse, keeping the first entry for each - const requested = new Map(); + const requested = new Map(); for (const subject of data) { if (!requested.has(subject.id)) { requested.set(subject.id, subject); diff --git a/apps/api/src/users/dto/create-user.dto.ts b/apps/api/src/users/dto/create-user.dto.ts deleted file mode 100644 index a2d1e3450..000000000 --- a/apps/api/src/users/dto/create-user.dto.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import type { Sex } from '@opendatacapture/schemas/subject'; -import { $CreateUserData } from '@opendatacapture/schemas/user'; -import type { BasePermissionLevel, CreateUserData } from '@opendatacapture/schemas/user'; - -// Note: password strength, username-match, and breached-password checks are enforced -// centrally in `UsersService.validatePassword` so they apply to every flow that sets a -// password (user creation, admin edits, self-service updates, and initial setup). -@ValidationSchema($CreateUserData) -export class CreateUserDto implements CreateUserData { - @ApiProperty({ - description: "Determines the user's base permissions, which may later be modified by an admin", - enum: ['ADMIN', 'GROUP_MANAGER', 'STANDARD'] satisfies BasePermissionLevel[], - type: String - }) - basePermissionLevel: BasePermissionLevel | null; - - @ApiProperty({ description: 'Date of Birth' }) - dateOfBirth?: Date; - - @ApiProperty({ description: 'Disabled' }) - disabled?: boolean; - - @ApiProperty({ description: 'Email' }) - email?: string; - - @ApiProperty({ description: 'First Name' }) - firstName: string; - - @ApiProperty({ - description: 'The IDs of the group(s) to which the user belongs' - }) - groupIds: string[]; - - @ApiProperty({ description: 'Last Name' }) - lastName: string; - - @ApiProperty({ - description: 'Whether the user must choose a new password before they may use the app' - }) - mustResetPassword?: boolean; - - @ApiProperty({ - description: 'A password with an accessed strength of three or more, see https://github.com/zxcvbn-ts/zxcvbn' - }) - password: string; - - @ApiProperty({ description: 'Phone Number' }) - phoneNumber?: string; - - @ApiProperty({ description: 'Sex at Birth' }) - @ApiProperty() - sex?: Sex; - - @ApiProperty({ - description: 'A unique descriptive name associated with this user', - example: 'JaneDoeMemoryClinic' - }) - username: string; -} diff --git a/apps/api/src/users/dto/update-user-permissions.dto.ts b/apps/api/src/users/dto/update-user-permissions.dto.ts deleted file mode 100644 index c6f7579e3..000000000 --- a/apps/api/src/users/dto/update-user-permissions.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty } from '@nestjs/swagger'; -import type { Permissions } from '@opendatacapture/schemas/core'; -import { $UpdateUserPermissionsData } from '@opendatacapture/schemas/user'; -import type { UpdateUserPermissionsData } from '@opendatacapture/schemas/user'; - -@ValidationSchema($UpdateUserPermissionsData) -export class UpdateUserPermissionsDto implements UpdateUserPermissionsData { - @ApiProperty({ description: 'The complete set of additional permissions; replaces what is stored' }) - permissions: Permissions; -} diff --git a/apps/api/src/users/dto/update-user.dto.ts b/apps/api/src/users/dto/update-user.dto.ts deleted file mode 100644 index 0619436bf..000000000 --- a/apps/api/src/users/dto/update-user.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ValidationSchema } from '@douglasneuroinformatics/libnest'; -import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger'; -import { $UpdateUserData } from '@opendatacapture/schemas/user'; -import type { UpdateUserData } from '@opendatacapture/schemas/user'; - -import { CreateUserDto } from './create-user.dto'; - -/** Contact details are redeclared because `$UpdateUserData` widens them to null, so an update can clear one. */ -@ValidationSchema($UpdateUserData) -export class UpdateUserDto extends PartialType(OmitType(CreateUserDto, ['email', 'phoneNumber'] as const)) { - @ApiProperty({ description: 'Email, or null to clear the one on record' }) - email?: UpdateUserData['email']; - - @ApiProperty({ description: 'Phone Number, or null to clear the one on record' }) - phoneNumber?: UpdateUserData['phoneNumber']; -} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 982c6fc9f..ccb4de143 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -1,4 +1,4 @@ -import { CurrentUser, ParseSchemaPipe } from '@douglasneuroinformatics/libnest'; +import { ApiOperation, CurrentUser, ParseSchemaPipe } from '@douglasneuroinformatics/libnest'; import type { RequestUser } from '@douglasneuroinformatics/libnest'; import { Body, @@ -13,10 +13,14 @@ import { Put, Query } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { $Language } from '@opendatacapture/schemas/core'; import type { Language } from '@opendatacapture/schemas/core'; -import { $SelfUpdateUserData } from '@opendatacapture/schemas/user'; +import { + $CreateUserData, + $SelfUpdateUserData, + $UpdateUserData, + $UpdateUserPermissionsData +} from '@opendatacapture/schemas/user'; import type { CreateUserResponse } from '@opendatacapture/schemas/user'; import type { AppAbility } from '@/auth/auth.types'; @@ -24,9 +28,6 @@ import { ADMIN_ONLY, RouteAccess } from '@/core/decorators/route-access.decorato import { GroupsService } from '@/groups/groups.service'; import { MailService } from '@/mail/mail.service'; -import { CreateUserDto } from './dto/create-user.dto'; -import { UpdateUserPermissionsDto } from './dto/update-user-permissions.dto'; -import { UpdateUserDto } from './dto/update-user.dto'; import { UsersService } from './users.service'; /** @@ -35,7 +36,6 @@ import { UsersService } from './users.service'; * grant reaching any of these would let its holder make themselves, or an account whose password * they chose, an administrator. */ -@ApiTags('Users') @Controller({ path: 'users' }) export class UsersController { constructor( @@ -55,7 +55,7 @@ export class UsersController { @Post() @RouteAccess(ADMIN_ONLY) async create( - @Body() user: CreateUserDto, + @Body() user: $CreateUserData, @CurrentUser('ability') ability: AppAbility, @Headers('origin') origin?: string, @Query('language', new ParseSchemaPipe({ schema: $Language.optional() })) language?: Language @@ -100,7 +100,7 @@ export class UsersController { @ApiOperation({ summary: 'Update User' }) @Patch(':id') @RouteAccess(ADMIN_ONLY) - updateById(@Param('id') id: string, @Body() update: UpdateUserDto, @CurrentUser() currentUser: RequestUser) { + updateById(@Param('id') id: string, @Body() update: $UpdateUserData, @CurrentUser() currentUser: RequestUser) { return this.usersService.updateById(id, update, currentUser); } @@ -109,7 +109,7 @@ export class UsersController { @RouteAccess(ADMIN_ONLY) updatePermissions( @Param('id') id: string, - @Body() { permissions }: UpdateUserPermissionsDto, + @Body() { permissions }: $UpdateUserPermissionsData, @CurrentUser('ability') ability: AppAbility ) { return this.usersService.updatePermissions(id, permissions, { ability }); diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 0534cb256..6dab92a88 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -10,7 +10,7 @@ import { NotFoundException } from '@nestjs/common'; import type { Permissions } from '@opendatacapture/schemas/core'; -import { $SelfUpdateUserData } from '@opendatacapture/schemas/user'; +import { $CreateUserData, $SelfUpdateUserData, $UpdateUserData } from '@opendatacapture/schemas/user'; import type { PasswordErrorCode } from '@opendatacapture/schemas/user'; import { pwnedPassword } from 'hibp'; @@ -18,10 +18,6 @@ import { accessibleQuery } from '@/auth/ability.utils'; import type { EntityOperationOptions } from '@/core/types'; import { GroupsService } from '@/groups/groups.service'; -import { CreateUserDto } from './dto/create-user.dto'; - -import type { UpdateUserDto } from './dto/update-user.dto'; - @Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name); @@ -70,7 +66,7 @@ export class UsersService { phoneNumber, sex, username - }: CreateUserDto, + }: $CreateUserData, options?: EntityOperationOptions ) { if (await this.userModel.exists({ username })) { @@ -177,7 +173,7 @@ export class UsersService { return user; } - async updateById(id: string, { groupIds, password, ...data }: UpdateUserDto, currentUser: RequestUser) { + async updateById(id: string, { groupIds, password, ...data }: $UpdateUserData, currentUser: RequestUser) { const { ability } = currentUser; const isDemotion = data.basePermissionLevel !== undefined && data.basePermissionLevel !== 'ADMIN'; // Only an administrator reaches this route, so the last one to do this to themselves would leave diff --git a/apps/api/test/suites/01-boot.suite.ts b/apps/api/test/suites/01-boot.suite.ts index 0160cb03e..9426931fe 100644 --- a/apps/api/test/suites/01-boot.suite.ts +++ b/apps/api/test/suites/01-boot.suite.ts @@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest'; import { defineSuite } from '../helpers'; +type OpenApiOperation = { + requestBody?: { content: { 'application/json': { schema: { [key: string]: unknown } } } }; +}; + +type OpenApiDocument = { + paths: { [path: string]: { [method: string]: OpenApiOperation } }; +}; + export default defineSuite('boot', function () { describe('GET /v1/setup', () => { it('should report that a fresh instance requires setup', async () => { @@ -12,11 +20,35 @@ export default defineSuite('boot', function () { }); describe('GET /spec.json', () => { - it('should serve an OpenAPI document, so every controller is introspectable', async () => { + const getDocument = async (): Promise => { const response = await this.app.inject({ method: 'GET', url: '/spec.json' }); expect(response.statusCode).toBe(200); - // Swagger builds the document before `enableVersioning`, so its paths carry no `/v1` prefix. - expect(Object.keys(response.json<{ paths: object }>().paths)).toContain('/setup'); + return response.json(); + }; + + it('should document every route under the version prefix it is served at', async () => { + const { paths } = await getDocument(); + expect(Object.keys(paths)).toContain('/v1/setup'); + }); + + it('should document every request body from its Zod schema, so no body is rendered as unknown', async () => { + const { paths } = await getDocument(); + const undocumentedBodies = Object.entries(paths).flatMap(([path, operations]) => { + return Object.entries(operations) + .filter(([, { requestBody }]) => { + return requestBody && Object.keys(requestBody.content['application/json'].schema).length === 0; + }) + .map(([method]) => `${method.toUpperCase()} ${path}`); + }); + expect(undocumentedBodies).toEqual([]); + }); + + it('should document the fields of a request body', async () => { + const { paths } = await getDocument(); + expect(paths['/v1/groups']!.post!.requestBody!.content['application/json'].schema).toMatchObject({ + properties: { name: { type: 'string' }, type: { enum: ['CLINICAL', 'RESEARCH'] } }, + required: ['name', 'type'] + }); }); }); }); diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx index 1f658b200..3817ede55 100644 --- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx +++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx @@ -9,7 +9,7 @@ import type { FormTypes } from '@opendatacapture/runtime-core'; import { DEFAULT_GROUP_NAME } from '@opendatacapture/schemas/core'; import type { Group } from '@opendatacapture/schemas/group'; import { $SessionType } from '@opendatacapture/schemas/session'; -import type { CreateSessionData } from '@opendatacapture/schemas/session'; +import type { $CreateSessionData } from '@opendatacapture/schemas/session'; import { $SubjectIdentificationMethod } from '@opendatacapture/schemas/subject'; import type { Sex, SubjectIdentificationMethod } from '@opendatacapture/schemas/subject'; import { encodeScopedSubjectId, generateSubjectHash } from '@opendatacapture/subject-utils'; @@ -44,7 +44,7 @@ type StartSessionFormProps = { currentGroup: Group | null; customSubjectIds: string[]; initialValues?: FormTypes.PartialNullableData; - onSubmit: (data: CreateSessionData) => Promisable; + onSubmit: (data: $CreateSessionData) => Promisable; readOnly: boolean; username?: null | string; }; diff --git a/apps/web/src/hooks/useBulkAssignments.ts b/apps/web/src/hooks/useBulkAssignments.ts index dae60dd84..c6c4fa9a0 100644 --- a/apps/web/src/hooks/useBulkAssignments.ts +++ b/apps/web/src/hooks/useBulkAssignments.ts @@ -5,9 +5,9 @@ import { $BulkAssignmentPreflightResult } from '@opendatacapture/schemas/assignment'; import type { - BulkAssignmentFailure, - BulkAssignmentPreflightData, - CreateBulkAssignmentsData + $BulkAssignmentPreflightData, + $CreateBulkAssignmentsData, + BulkAssignmentFailure } from '@opendatacapture/schemas/assignment'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios, { isAxiosError } from 'axios'; @@ -36,7 +36,7 @@ export function useBulkAssignmentPreflightMutation() { return useMutation({ // A refusal is a considered answer from the server, not a transient fault - retrying would // re-run the same validation and produce the same refusal. - mutationFn: async (data: BulkAssignmentPreflightData) => { + mutationFn: async (data: $BulkAssignmentPreflightData) => { const response = await axios.post('/v1/assignments/bulk/preflight', data, { meta: { disableDefaultErrorNotification: true } }); @@ -55,7 +55,7 @@ export function useCreateBulkAssignmentsMutation() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: async (data: CreateBulkAssignmentsData) => { + mutationFn: async (data: $CreateBulkAssignmentsData) => { const response = await axios.post('/v1/assignments/bulk', data, { meta: { disableDefaultErrorNotification: true } }); diff --git a/apps/web/src/hooks/useCreateAssignment.ts b/apps/web/src/hooks/useCreateAssignment.ts index 51ece4c10..e1d892468 100644 --- a/apps/web/src/hooks/useCreateAssignment.ts +++ b/apps/web/src/hooks/useCreateAssignment.ts @@ -1,6 +1,6 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; import { $Assignment } from '@opendatacapture/schemas/assignment'; -import type { CreateAssignmentData } from '@opendatacapture/schemas/assignment'; +import type { $CreateAssignmentData } from '@opendatacapture/schemas/assignment'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -10,7 +10,7 @@ export function useCreateAssignment() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: async ({ data }: { data: CreateAssignmentData }) => { + mutationFn: async ({ data }: { data: $CreateAssignmentData }) => { const response = await axios.post('/v1/assignments', data); return $Assignment.parse(response.data); }, diff --git a/apps/web/src/hooks/useCreateGroupMutation.ts b/apps/web/src/hooks/useCreateGroupMutation.ts index 2e8ae385e..9faa6567b 100644 --- a/apps/web/src/hooks/useCreateGroupMutation.ts +++ b/apps/web/src/hooks/useCreateGroupMutation.ts @@ -1,5 +1,5 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; -import type { CreateGroupData } from '@opendatacapture/schemas/group'; +import type { $CreateGroupData } from '@opendatacapture/schemas/group'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -9,7 +9,7 @@ export function useCreateGroupMutation() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: ({ data }: { data: CreateGroupData }) => axios.post('/v1/groups', data), + mutationFn: ({ data }: { data: $CreateGroupData }) => axios.post('/v1/groups', data), onSuccess() { addNotification({ type: 'success' }); void queryClient.invalidateQueries({ queryKey: [GROUPS_QUERY_KEY] }); diff --git a/apps/web/src/hooks/useCreateInstrumentRepoMutation.ts b/apps/web/src/hooks/useCreateInstrumentRepoMutation.ts index 10ab93fbc..0ecf65665 100644 --- a/apps/web/src/hooks/useCreateInstrumentRepoMutation.ts +++ b/apps/web/src/hooks/useCreateInstrumentRepoMutation.ts @@ -1,5 +1,5 @@ import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; -import type { CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; +import type { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -12,7 +12,7 @@ export function useCreateInstrumentRepoMutation() { const addNotification = useNotificationsStore((store) => store.addNotification); const { t } = useTranslation(); return useMutation({ - mutationFn: ({ data }: { data: CreateInstrumentRepoData }) => + mutationFn: ({ data }: { data: $CreateInstrumentRepoData }) => axios.post('/v1/instrument-repos', data, { meta: { disableDefaultErrorNotification: true } }), onError(err) { addNotification({ diff --git a/apps/web/src/hooks/useCreateSessionMutation.ts b/apps/web/src/hooks/useCreateSessionMutation.ts index 373185513..e53bb8fbd 100644 --- a/apps/web/src/hooks/useCreateSessionMutation.ts +++ b/apps/web/src/hooks/useCreateSessionMutation.ts @@ -1,13 +1,13 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; import { $Session } from '@opendatacapture/schemas/session'; -import type { CreateSessionData } from '@opendatacapture/schemas/session'; +import type { $CreateSessionData } from '@opendatacapture/schemas/session'; import { useMutation } from '@tanstack/react-query'; import axios from 'axios'; export function useCreateSessionMutation() { const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: async (data: CreateSessionData) => { + mutationFn: async (data: $CreateSessionData) => { const response = await axios.post('/v1/sessions', data); return $Session.parseAsync(response.data); }, diff --git a/apps/web/src/hooks/useCreateSetupStateMutation.ts b/apps/web/src/hooks/useCreateSetupStateMutation.ts index 115515df8..d3c82f51c 100644 --- a/apps/web/src/hooks/useCreateSetupStateMutation.ts +++ b/apps/web/src/hooks/useCreateSetupStateMutation.ts @@ -1,5 +1,5 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; -import type { InitAppOptions } from '@opendatacapture/schemas/setup'; +import type { $InitAppOptions } from '@opendatacapture/schemas/setup'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -9,7 +9,7 @@ export function useCreateSetupStateMutation() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: (data: InitAppOptions) => + mutationFn: (data: $InitAppOptions) => axios.post('/v1/setup', data, { meta: { disableDefaultTimeout: true diff --git a/apps/web/src/hooks/useCreateUserMutation.ts b/apps/web/src/hooks/useCreateUserMutation.ts index 77dee31a4..e089f0105 100644 --- a/apps/web/src/hooks/useCreateUserMutation.ts +++ b/apps/web/src/hooks/useCreateUserMutation.ts @@ -1,7 +1,7 @@ import type { Language } from '@opendatacapture/schemas/core'; import { MAIL_CLIENT_TIMEOUT } from '@opendatacapture/schemas/mail'; import { $CreateUserResponse } from '@opendatacapture/schemas/user'; -import type { CreateUserData } from '@opendatacapture/schemas/user'; +import type { $CreateUserData } from '@opendatacapture/schemas/user'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -10,7 +10,7 @@ import { USERS_QUERY_KEY } from './useUsersQuery'; export function useCreateUserMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ data, language }: { data: CreateUserData; language?: Language }) => { + mutationFn: async ({ data, language }: { data: $CreateUserData; language?: Language }) => { // The welcome-email language is a query param so it stays out of the user record itself. // The timeout must outlast the server's whole SMTP failure budget: aborting sooner leaves // the user already created, so a retry hits "username exists" and the admin never learns diff --git a/apps/web/src/hooks/useTestMailMutation.ts b/apps/web/src/hooks/useTestMailMutation.ts index fc93cdb26..6c6c7e94c 100644 --- a/apps/web/src/hooks/useTestMailMutation.ts +++ b/apps/web/src/hooks/useTestMailMutation.ts @@ -1,11 +1,11 @@ import { $TestMailResult, MAIL_CLIENT_TIMEOUT } from '@opendatacapture/schemas/mail'; -import type { TestMailData } from '@opendatacapture/schemas/mail'; +import type { $TestMailData } from '@opendatacapture/schemas/mail'; import { useMutation } from '@tanstack/react-query'; import axios from 'axios'; export function useTestMailMutation() { return useMutation({ - mutationFn: async (data: TestMailData) => { + mutationFn: async (data: $TestMailData) => { // The timeout must outlast the server's whole SMTP failure budget (which the global 10s // default would cut short), and the generic error toast is suppressed because this // mutation surfaces its own success/failure notifications. diff --git a/apps/web/src/hooks/useUpdateAssignment.ts b/apps/web/src/hooks/useUpdateAssignment.ts index 42ee0d00e..c6d0844ca 100644 --- a/apps/web/src/hooks/useUpdateAssignment.ts +++ b/apps/web/src/hooks/useUpdateAssignment.ts @@ -1,5 +1,5 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; -import type { UpdateAssignmentData } from '@opendatacapture/schemas/assignment'; +import type { $UpdateAssignmentData } from '@opendatacapture/schemas/assignment'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -9,7 +9,7 @@ export function useUpdateAssignment() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: ({ data, params }: { data: UpdateAssignmentData; params: { id: string } }) => { + mutationFn: ({ data, params }: { data: $UpdateAssignmentData; params: { id: string } }) => { return axios.patch(`/v1/assignments/${params.id}`, data); }, onSuccess() { diff --git a/apps/web/src/hooks/useUpdateGroupMutation.ts b/apps/web/src/hooks/useUpdateGroupMutation.ts index 73c9fa98f..cdb08274b 100644 --- a/apps/web/src/hooks/useUpdateGroupMutation.ts +++ b/apps/web/src/hooks/useUpdateGroupMutation.ts @@ -1,6 +1,6 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; import { $Group } from '@opendatacapture/schemas/group'; -import type { UpdateGroupData } from '@opendatacapture/schemas/group'; +import type { $UpdateGroupData } from '@opendatacapture/schemas/group'; import { useMutation } from '@tanstack/react-query'; import axios from 'axios'; @@ -23,7 +23,7 @@ export function useUpdateGroupMutation({ const addNotification = useNotificationsStore((store) => store.addNotification); const currentGroup = useAppStore((store) => store.currentGroup); return useMutation({ - mutationFn: async (data: UpdateGroupData) => { + mutationFn: async (data: $UpdateGroupData) => { // A caller that opts out of throwing is declaring it renders its own failure feedback, so // the interceptor's generic error toast would double it; the default path keeps the toast. const response = await axios.patch(`/v1/groups/${currentGroup?.id}`, data, { diff --git a/apps/web/src/hooks/useUpdateMailSettingsMutation.ts b/apps/web/src/hooks/useUpdateMailSettingsMutation.ts index cca7086c3..72e5936b9 100644 --- a/apps/web/src/hooks/useUpdateMailSettingsMutation.ts +++ b/apps/web/src/hooks/useUpdateMailSettingsMutation.ts @@ -1,5 +1,5 @@ import { $MailSettings } from '@opendatacapture/schemas/mail'; -import type { UpdateMailSettingsData } from '@opendatacapture/schemas/mail'; +import type { $UpdateMailSettingsData } from '@opendatacapture/schemas/mail'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -13,7 +13,7 @@ import { SETUP_STATE_QUERY_KEY } from './useSetupStateQuery'; export function useUpdateMailSettingsMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (data: UpdateMailSettingsData) => { + mutationFn: async (data: $UpdateMailSettingsData) => { const response = await axios.patch('/v1/mail/settings', data, { meta: { disableDefaultErrorNotification: true } }); diff --git a/apps/web/src/hooks/useUpdateSetupStateMutation.ts b/apps/web/src/hooks/useUpdateSetupStateMutation.ts index ea70da091..89f9637e2 100644 --- a/apps/web/src/hooks/useUpdateSetupStateMutation.ts +++ b/apps/web/src/hooks/useUpdateSetupStateMutation.ts @@ -1,5 +1,5 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; -import type { UpdateSetupStateData } from '@opendatacapture/schemas/setup'; +import type { $UpdateSetupStateData } from '@opendatacapture/schemas/setup'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -28,7 +28,7 @@ export function useUpdateSetupStateMutation({ const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: async (data: UpdateSetupStateData) => { + mutationFn: async (data: $UpdateSetupStateData) => { await axios.patch('/v1/setup', data); }, onSuccess() { diff --git a/apps/web/src/hooks/useUpdateUserMutation.ts b/apps/web/src/hooks/useUpdateUserMutation.ts index 7864d4ebd..cd571d276 100644 --- a/apps/web/src/hooks/useUpdateUserMutation.ts +++ b/apps/web/src/hooks/useUpdateUserMutation.ts @@ -1,5 +1,5 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; -import type { UpdateUserData } from '@opendatacapture/schemas/user'; +import type { $UpdateUserData } from '@opendatacapture/schemas/user'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -9,7 +9,7 @@ export function useUpdateUserMutation() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: async ({ data, id }: { data: UpdateUserData; id: string }) => { + mutationFn: async ({ data, id }: { data: $UpdateUserData; id: string }) => { await axios.patch(`/v1/users/${id}`, data); }, onSuccess() { diff --git a/apps/web/src/hooks/useUpdateUserPermissionsMutation.ts b/apps/web/src/hooks/useUpdateUserPermissionsMutation.ts index e74eb7cc2..5e0f77af4 100644 --- a/apps/web/src/hooks/useUpdateUserPermissionsMutation.ts +++ b/apps/web/src/hooks/useUpdateUserPermissionsMutation.ts @@ -1,7 +1,7 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; import type { Permissions } from '@opendatacapture/schemas/core'; import { $User } from '@opendatacapture/schemas/user'; -import type { UpdateUserPermissionsData } from '@opendatacapture/schemas/user'; +import type { $UpdateUserPermissionsData } from '@opendatacapture/schemas/user'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -12,7 +12,7 @@ export function useUpdateUserPermissionsMutation() { const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ mutationFn: async ({ id, permissions }: { id: string; permissions: Permissions }) => { - const data: UpdateUserPermissionsData = { permissions }; + const data: $UpdateUserPermissionsData = { permissions }; const response = await axios.put(`/v1/users/${id}/permissions`, data); return $User.parse(response.data); }, diff --git a/apps/web/src/routes/_app/admin/groups/create.tsx b/apps/web/src/routes/_app/admin/groups/create.tsx index 7f7982e1a..00cae7b3d 100644 --- a/apps/web/src/routes/_app/admin/groups/create.tsx +++ b/apps/web/src/routes/_app/admin/groups/create.tsx @@ -1,7 +1,6 @@ import { Form, Heading } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { $CreateGroupData } from '@opendatacapture/schemas/group'; -import type { CreateGroupData } from '@opendatacapture/schemas/group'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { PageHeader } from '@/components/PageHeader'; @@ -12,7 +11,7 @@ const RouteComponent = () => { const navigate = useNavigate(); const createGroupMutation = useCreateGroupMutation(); - const handleSubmit = (data: CreateGroupData) => { + const handleSubmit = (data: $CreateGroupData) => { createGroupMutation.mutate({ data }); void navigate({ to: '..' }); }; diff --git a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx index 959338e0e..1464312f4 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx @@ -4,7 +4,7 @@ import type { FormEvent } from 'react'; import { Button, DataTable, Dialog, Heading, Input, Label, Spinner } from '@douglasneuroinformatics/libui/components'; import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; -import type { CreateInstrumentRepoData, InstrumentRepo } from '@opendatacapture/schemas/instrument-repo'; +import type { InstrumentRepo } from '@opendatacapture/schemas/instrument-repo'; import { createFileRoute } from '@tanstack/react-router'; import { AnimatePresence, motion } from 'motion/react'; @@ -21,7 +21,7 @@ const AddRepoForm = ({ onSubmit }: { isPending: boolean; - onSubmit: (data: CreateInstrumentRepoData) => void; + onSubmit: (data: $CreateInstrumentRepoData) => void; }) => { const { t } = useTranslation(); const [url, setUrl] = useState(''); diff --git a/apps/web/src/routes/_app/admin/users/create.tsx b/apps/web/src/routes/_app/admin/users/create.tsx index 081ab5666..69ed7265c 100644 --- a/apps/web/src/routes/_app/admin/users/create.tsx +++ b/apps/web/src/routes/_app/admin/users/create.tsx @@ -7,7 +7,6 @@ import { Button, CopyButton, Dialog, Form, Heading, Label } from '@douglasneuroi import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { Language } from '@opendatacapture/schemas/core'; import { $BasePermissionLevel, $CreateUserData } from '@opendatacapture/schemas/user'; -import type { CreateUserData } from '@opendatacapture/schemas/user'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import axios from 'axios'; import { z } from 'zod/v4'; @@ -39,7 +38,7 @@ const RouteComponent = () => { const [fallbackMessage, setFallbackMessage] = React.useState(null); const [emailLanguage, setEmailLanguage] = React.useState(resolvedLanguage); - const handleSubmit = async (data: CreateUserData) => { + const handleSubmit = async (data: $CreateUserData) => { // check if username exists const existingUsername = await axios.get<{ success: boolean }>( `/v1/users/check-username/${encodeURIComponent(data.username)}` diff --git a/apps/web/src/routes/_app/group/manage.tsx b/apps/web/src/routes/_app/group/manage.tsx index ff3b250c0..fd6145de8 100644 --- a/apps/web/src/routes/_app/group/manage.tsx +++ b/apps/web/src/routes/_app/group/manage.tsx @@ -17,7 +17,7 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { InstrumentRenderer } from '@opendatacapture/react-core'; import type { ScalarInstrumentInternal } from '@opendatacapture/runtime-core'; import { $RegexString, toInstrumentAuthoringLanguage } from '@opendatacapture/schemas/core'; -import type { UpdateGroupData } from '@opendatacapture/schemas/group'; +import type { $UpdateGroupData } from '@opendatacapture/schemas/group'; import type { $CreateSeriesInstrumentData } from '@opendatacapture/schemas/instrument'; import { $SubjectIdentificationMethod } from '@opendatacapture/schemas/subject'; import type { SubjectIdentificationMethod } from '@opendatacapture/schemas/subject'; @@ -127,7 +127,7 @@ type ManageGroupFormProps = { instruments: CategorizedInstruments; settingsInitialValues: SettingsValues; }; - onSubmit: (data: Partial) => Promisable; + onSubmit: (data: Partial<$UpdateGroupData>) => Promisable; readOnly: boolean; }; diff --git a/apps/web/src/routes/_app/session/remote-assignment.tsx b/apps/web/src/routes/_app/session/remote-assignment.tsx index 291a0dbeb..51e7a18f4 100644 --- a/apps/web/src/routes/_app/session/remote-assignment.tsx +++ b/apps/web/src/routes/_app/session/remote-assignment.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'; import { Button, Dialog, Form, Heading, Input, Label, Sheet } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { CopyButton } from '@opendatacapture/react-core'; -import type { Assignment, CreateAssignmentData } from '@opendatacapture/schemas/assignment'; +import type { $CreateAssignmentData, Assignment } from '@opendatacapture/schemas/assignment'; import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'; import { z } from 'zod/v4'; @@ -189,7 +189,7 @@ const RouteComponent = () => { fr: "La date d'expiration doit être dans le futur" }) }) - }) satisfies z.ZodType> + }) satisfies z.ZodType> } onSubmit={async ({ expiresAt }) => { const created = await createAssignmentMutation.mutateAsync({ diff --git a/docs/en/5-reference/5.3-api.md b/docs/en/5-reference/5.3-api.md index 8582c3afe..06f9495ea 100644 --- a/docs/en/5-reference/5.3-api.md +++ b/docs/en/5-reference/5.3-api.md @@ -15,8 +15,7 @@ Controllers handle the incoming HTTP requests, and are responsible for handling ### OpenAPI -The NestJS Swagger module is used to automatically generate the OpenAPI specification for the application. This is stored in -the form of an `openapi.json` file. In Open Data Capture, this specification is used with [redoc](https://github.com/Redocly/redoc) to document our core REST API. +The OpenAPI specification is generated automatically from the Zod schemas that each route's request body is typed with, and served as `spec.json`. In Open Data Capture, this specification is used with [redoc](https://github.com/Redocly/redoc) to document our core REST API. :::note For an example of this documentation, checkout [our demo version](https://demo.opendatacapture.org/api/). diff --git a/packages/demo/AGENTS.md b/packages/demo/AGENTS.md index db983a0b3..16ac9f79d 100644 --- a/packages/demo/AGENTS.md +++ b/packages/demo/AGENTS.md @@ -14,7 +14,7 @@ log in (the password itself is never rendered as text). Nothing here is a secret nothing here may be reused for a real account or copied into a seed path that runs against a real database. -**`dummyIdPrefix` is not part of the group schema.** `DemoGroup` is `CreateGroupData` plus that +**`dummyIdPrefix` is not part of the group schema.** `DemoGroup` is `$CreateGroupData` plus that extra key; `DemoService` destructures it off before calling `groupsService.create` and prefixes the generated research IDs with it. A group whose `settings.idValidationRegex` requires a prefix but that carries no matching `dummyIdPrefix` seeds subject IDs that fail their own group's validation. diff --git a/packages/demo/src/index.ts b/packages/demo/src/index.ts index 985aed0d3..7465051d7 100644 --- a/packages/demo/src/index.ts +++ b/packages/demo/src/index.ts @@ -1,5 +1,5 @@ import { deepFreeze } from '@douglasneuroinformatics/libjs'; -import type { CreateGroupData } from '@opendatacapture/schemas/group'; +import type { $CreateGroupData } from '@opendatacapture/schemas/group'; import type { User } from '@opendatacapture/schemas/user'; type DemoUser = Pick & { @@ -7,7 +7,7 @@ type DemoUser = Pick new Set(values).size === values.length, { message: 'Values must be unique' }); /** The DTO transferred from the web client to the core API when creating an assignment */ -type CreateAssignmentData = z.infer; +type $CreateAssignmentData = z.infer; const $CreateAssignmentData = z.object({ expiresAt: $FutureDate, groupId: z.string().nullish(), @@ -86,10 +86,10 @@ const $BulkAssignmentRequestBase = z.object({ }) }); -type BulkAssignmentPreflightData = z.infer; +type $BulkAssignmentPreflightData = z.infer; const $BulkAssignmentPreflightData = $BulkAssignmentRequestBase; -type CreateBulkAssignmentsData = z.infer; +type $CreateBulkAssignmentsData = z.infer; const $CreateBulkAssignmentsData = $BulkAssignmentRequestBase; /** @@ -177,7 +177,7 @@ const $MutateAssignmentResponseBody = z.object({ success: z.boolean() }); -type DeleteBulkAssignmentsData = z.infer; +type $DeleteBulkAssignmentsData = z.infer; const $DeleteBulkAssignmentsData = z.object({ ids: z.array(z.string().min(1)).min(1) }); @@ -188,11 +188,17 @@ const $DeleteBulkAssignmentsResult = z.object({ failedIds: z.array(z.string()) }); -type UpdateAssignmentData = z.infer; +type $UpdateAssignmentData = z.infer; const $UpdateAssignmentData = z.object({ status: $AssignmentStatus }); +/** A client may only cancel an assignment; every other status is set by the gateway synchronizer */ +type $CancelAssignmentData = z.infer; +const $CancelAssignmentData = $UpdateAssignmentData.extend({ + status: z.literal('CANCELED') +}); + type UpdateRemoteAssignmentData = z.infer; const $UpdateRemoteAssignmentData = z.object({ data: $Json.optional(), @@ -205,18 +211,13 @@ export type { AssignmentStatus, BulkAssignmentFailure, BulkAssignmentIssue, - BulkAssignmentPreflightData, BulkAssignmentPreflightResult, BulkAssignmentTimepoint, - CreateAssignmentData, - CreateBulkAssignmentsData, CreateRemoteAssignmentInputData, CreateRemoteAssignmentsInputData, - DeleteBulkAssignmentsData, DeleteBulkAssignmentsResult, MutateAssignmentResponseBody, RemoteAssignment, - UpdateAssignmentData, UpdateRemoteAssignmentData }; @@ -228,6 +229,7 @@ export { $BulkAssignmentPreflightData, $BulkAssignmentPreflightResult, $BulkAssignmentTimepoint, + $CancelAssignmentData, $CreateAssignmentData, $CreateBulkAssignmentsData, $CreateRemoteAssignmentData, diff --git a/packages/schemas/src/group/group.ts b/packages/schemas/src/group/group.ts index 9fde6935d..c4a09b2ec 100644 --- a/packages/schemas/src/group/group.ts +++ b/packages/schemas/src/group/group.ts @@ -44,14 +44,14 @@ export const $Group = $BaseModel.extend({ userIds: z.array(z.string()) }); -export type CreateGroupData = z.infer; +export type $CreateGroupData = z.infer; export const $CreateGroupData = z.object({ name: z.string().min(1), settings: $GroupSettings.optional(), type: $GroupType }); -export type UpdateGroupData = z.infer; +export type $UpdateGroupData = z.infer; export const $UpdateGroupData = $Group .omit({ subjectIds: true, diff --git a/packages/schemas/src/instrument-repo/instrument-repo.ts b/packages/schemas/src/instrument-repo/instrument-repo.ts index 2023f46a0..64fbbed0f 100644 --- a/packages/schemas/src/instrument-repo/instrument-repo.ts +++ b/packages/schemas/src/instrument-repo/instrument-repo.ts @@ -13,7 +13,7 @@ export const $InstrumentRepo = $BaseModel.extend({ url: z.string().url() }); -export type CreateInstrumentRepoData = z.infer; +export type $CreateInstrumentRepoData = z.infer; export const $CreateInstrumentRepoData = z.object({ // Optional GitHub personal access token, required only for private repositories. It is stored // encrypted on the server and never returned to the client. diff --git a/packages/schemas/src/instrument/instrument.base.ts b/packages/schemas/src/instrument/instrument.base.ts index f27030cdb..37b1c357b 100644 --- a/packages/schemas/src/instrument/instrument.base.ts +++ b/packages/schemas/src/instrument/instrument.base.ts @@ -301,7 +301,7 @@ type TranslatedInstrumentInfo = UnilingualInstrumentInfo & { /** @internal */ type MultilingualInstrumentInfo = Simplify>>; -type CreateInstrumentData = z.infer; +type $CreateInstrumentData = z.infer; const $CreateInstrumentData = z.object({ bundle: z.string().min(1) }); @@ -389,7 +389,6 @@ export { }; export type { - CreateInstrumentData, CreateSeriesInstrumentResult, InstrumentBundleContainer, InstrumentInfo, diff --git a/packages/schemas/src/mail/mail.ts b/packages/schemas/src/mail/mail.ts index 9d7bf5402..af9b79f7a 100644 --- a/packages/schemas/src/mail/mail.ts +++ b/packages/schemas/src/mail/mail.ts @@ -270,11 +270,11 @@ export type MailEncryption = z.infer; export type MailErrorCode = z.infer; export type MailSettings = z.infer; export type MailTemplate = z.infer; -export type SendAssignmentEmailData = z.infer; -export type TestMailData = z.infer; +export type $SendAssignmentEmailData = z.infer; +export type $TestMailData = z.infer; export type TestMailResult = z.infer; export type UpdateMailConfigData = z.infer; -export type UpdateMailSettingsData = z.infer; +export type $UpdateMailSettingsData = z.infer; export { $EmailDeliveryResult, diff --git a/packages/schemas/src/session/session.ts b/packages/schemas/src/session/session.ts index 5088cd8b2..7ed0f7130 100644 --- a/packages/schemas/src/session/session.ts +++ b/packages/schemas/src/session/session.ts @@ -16,7 +16,7 @@ export const $Session = $BaseModel.extend({ userId: z.string().nullish() }); -export type CreateSessionData = z.infer; +export type $CreateSessionData = z.infer; export const $CreateSessionData = z.object({ date: z.coerce.date(), groupId: z.string().nullable(), diff --git a/packages/schemas/src/setup/setup.ts b/packages/schemas/src/setup/setup.ts index 42bfd3475..1eeb90b7e 100644 --- a/packages/schemas/src/setup/setup.ts +++ b/packages/schemas/src/setup/setup.ts @@ -238,7 +238,7 @@ export type BrandingText = z.infer; export type CreateAdminData = z.infer; export type DevelopmentReleaseInfo = z.infer; export type FontSize = (typeof FONT_SIZES)[number]; -export type InitAppOptions = z.infer; +export type $InitAppOptions = z.infer; export type LoginTheme = (typeof LOGIN_THEMES)[number]; export type LogoAlignment = (typeof LOGO_ALIGNMENTS)[number]; export type LogoSize = (typeof LOGO_SIZES)[number]; @@ -248,7 +248,7 @@ export type ProductionReleaseInfo = z.infer; export type ReleaseInfo = z.infer; export type ResourceLink = z.infer; export type SetupState = z.infer; -export type UpdateSetupStateData = z.infer; +export type $UpdateSetupStateData = z.infer; export { $BrandingConfig, diff --git a/packages/schemas/src/subject/subject.ts b/packages/schemas/src/subject/subject.ts index 4df0bb893..c1c2c4a5e 100644 --- a/packages/schemas/src/subject/subject.ts +++ b/packages/schemas/src/subject/subject.ts @@ -34,7 +34,7 @@ export const $SubjectDemographics = $Subject.pick({ dateOfBirth: true, sex: true export type SubjectCustomIds = z.infer; export const $SubjectCustomIds = $Subject.shape.id.array(); -export type CreateSubjectData = z.infer; +export type $CreateSubjectData = z.infer; export const $CreateSubjectData = $Subject.pick({ dateOfBirth: true, firstName: true, diff --git a/packages/schemas/src/user/user.ts b/packages/schemas/src/user/user.ts index f4f80fe3c..f12869bd2 100644 --- a/packages/schemas/src/user/user.ts +++ b/packages/schemas/src/user/user.ts @@ -83,7 +83,7 @@ export const $User = $BaseModel.extend({ export type CreateUserResponse = z.infer; export const $CreateUserResponse = $User.extend({ welcomeEmail: $EmailDeliveryResult }); -export type CreateUserData = z.infer; +export type $CreateUserData = z.infer; export const $CreateUserData = $User .pick({ basePermissionLevel: true, @@ -107,14 +107,14 @@ export const $CreateUserData = $User * `additionalPermissions` is deliberately absent: it is written only through * {@link $UpdateUserPermissionsData}, whose route is gated more tightly than a profile update. */ -export type UpdateUserData = z.infer; +export type $UpdateUserData = z.infer; export const $UpdateUserData = $CreateUserData.partial().extend({ email: z.email().nullish(), phoneNumber: $PhoneNumber.nullish() }); /** The complete set of a user's additional permissions; a write replaces what is stored. */ -export type UpdateUserPermissionsData = z.infer; +export type $UpdateUserPermissionsData = z.infer; export const $UpdateUserPermissionsData = z.object({ permissions: $Permissions.check((ctx) => { ctx.value.forEach((permission, index) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a40c5b3d..743879015 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -255,8 +255,8 @@ importers: specifier: 'catalog:' version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libnest': - specifier: ^8.4.1 - version: 8.4.1(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-fastify@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24))(@nestjs/testing@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24))(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3))(@swc/types@0.1.26)(fastify@5.8.5)(neverthrow@8.2.0)(reflect-metadata@0.1.14)(rollup@4.61.1)(rxjs@7.8.2)(typescript@6.0.3)(vite@6.4.3(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.8.2)(yaml@2.9.0))(vitest@4.1.8)(zod@vendor+zod@3.x) + specifier: ^9.0.0 + version: 9.0.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-fastify@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24))(@nestjs/testing@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24))(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3))(@swc/types@0.1.26)(fastify@5.8.5)(neverthrow@8.2.0)(reflect-metadata@0.1.14)(rollup@4.61.1)(rxjs@7.8.2)(typescript@6.0.3)(vite@6.4.3(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.8.2)(yaml@2.9.0))(vitest@4.1.8)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libpasswd': specifier: 'catalog:' version: 0.0.3(typescript@6.0.3) @@ -287,9 +287,6 @@ importers: '@nestjs/platform-fastify': specifier: ^11.1.11 version: 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24) - '@nestjs/swagger': - specifier: ^11.0.6 - version: 11.4.4(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.1.14) '@nestjs/throttler': specifier: ^6.5.0 version: 6.5.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.1.14) @@ -2269,8 +2266,8 @@ packages: neverthrow: ^8.2.0 zod: ^3.25.67 || 4.x - '@douglasneuroinformatics/libnest@8.4.1': - resolution: {integrity: sha512-3DMm1P1NeaK1CsI1T5GwvFpaCxK0qZ8Dy5P7++Cn7exuDnR3Ym/AiclDPsuD94I2bciMBSL+a4NZdhv7wt0Agg==} + '@douglasneuroinformatics/libnest@9.0.0': + resolution: {integrity: sha512-uVFJEin/mkhW+FvvGM0032qXpnv5T2astzAGCziesGvBGWAf6PG0TQptl5WQcNUIptdN+GR/O7co813jy8I5yQ==} engines: {node: 22.x || 24.x} hasBin: true peerDependencies: @@ -12233,7 +12230,7 @@ snapshots: type-fest: 4.41.0 zod: link:vendor/zod@3.x - '@douglasneuroinformatics/libnest@8.4.1(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-fastify@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24))(@nestjs/testing@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24))(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3))(@swc/types@0.1.26)(fastify@5.8.5)(neverthrow@8.2.0)(reflect-metadata@0.1.14)(rollup@4.61.1)(rxjs@7.8.2)(typescript@6.0.3)(vite@6.4.3(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.8.2)(yaml@2.9.0))(vitest@4.1.8)(zod@vendor+zod@3.x)': + '@douglasneuroinformatics/libnest@9.0.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-fastify@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24))(@nestjs/testing@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24))(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3))(@swc/types@0.1.26)(fastify@5.8.5)(neverthrow@8.2.0)(reflect-metadata@0.1.14)(rollup@4.61.1)(rxjs@7.8.2)(typescript@6.0.3)(vite@6.4.3(@types/node@24.13.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.8.2)(yaml@2.9.0))(vitest@4.1.8)(zod@vendor+zod@3.x)': dependencies: '@douglasneuroinformatics/libjs': 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@nestjs/common': 11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.2) diff --git a/testing/AGENTS.md b/testing/AGENTS.md index 4a98b9818..bf812ef5c 100644 --- a/testing/AGENTS.md +++ b/testing/AGENTS.md @@ -106,6 +106,9 @@ external database, and nothing to start by hand. It also starts the playground t by absolute URL from `playgroundURL` in `src/support/env.ts`: the playground is not an `apps/web` route, so `src/pages/playground/index.page.ts` extends nothing and is not in `pageModels`. Its preview is an iframe on the other loopback name, so the spec drives it through a `FrameLocator`. +`src/specs/api-docs.spec.ts` likewise reaches the API reference by absolute URL, from `apiURL`: it +is served from the root of the API, and its Redoc page requests `/spec.json`, which the web origin's +`/api` proxy does not route. Projects: `setup` (with `teardown` attached), `chromium` (every spec), and `firefox`, which greps `/@smoke/`. **Cross-browser coverage exists only for tests whose title contains `@smoke`** — add the diff --git a/testing/src/pages/setup.page.ts b/testing/src/pages/setup.page.ts index 9bd0e2888..866b0369a 100644 --- a/testing/src/pages/setup.page.ts +++ b/testing/src/pages/setup.page.ts @@ -1,4 +1,4 @@ -import type { InitAppOptions } from '@opendatacapture/schemas/setup'; +import type { $InitAppOptions } from '@opendatacapture/schemas/setup'; import type { Locator, Page } from '@playwright/test'; import { RootPage } from './__root.page'; @@ -12,7 +12,7 @@ export class SetupPage extends RootPage { this.setupForm = page.getByTestId('setup-form'); } - async fillSetupForm({ admin, dummySubjectCount, recordsPerSubject }: InitAppOptions) { + async fillSetupForm({ admin, dummySubjectCount, recordsPerSubject }: $InitAppOptions) { await this.setupForm.locator('input[name="firstName"]').fill(admin.firstName); await this.setupForm.locator('input[name="lastName"]').fill(admin.lastName); await this.setupForm.locator('input[name="username"]').fill(admin.username); diff --git a/testing/src/specs/api-docs.spec.ts b/testing/src/specs/api-docs.spec.ts new file mode 100644 index 000000000..9149e77df --- /dev/null +++ b/testing/src/specs/api-docs.spec.ts @@ -0,0 +1,15 @@ +import { apiURL } from '../support/env'; +import { expect, test } from '../support/fixtures'; + +test.describe('api docs', () => { + test('should render the fields of a request body from its Zod schema', async ({ page }) => { + await page.goto(`${apiURL}/#tag/Groups/operation/GroupsController_create`); + const operation = page.locator('[data-section-id="tag/Groups/operation/GroupsController_create"]'); + await expect(operation.getByRole('heading', { name: 'Create Group' })).toBeVisible(); + await expect(operation.getByRole('heading', { name: /^Request Body schema/ })).toBeVisible(); + await expect(operation.getByRole('row', { name: /^name required string/ })).toBeVisible(); + await expect( + operation.getByRole('row', { name: /^type required string Enum: "CLINICAL" "RESEARCH"/ }) + ).toBeVisible(); + }); +}); diff --git a/testing/src/specs/authorization.spec.ts b/testing/src/specs/authorization.spec.ts index cc2c2dada..cf7fe9717 100644 --- a/testing/src/specs/authorization.spec.ts +++ b/testing/src/specs/authorization.spec.ts @@ -1,4 +1,4 @@ -import type { CreateAssignmentData } from '@opendatacapture/schemas/assignment'; +import type { $CreateAssignmentData } from '@opendatacapture/schemas/assignment'; import type { Permissions } from '@opendatacapture/schemas/core'; import type { InstrumentInfo } from '@opendatacapture/schemas/instrument'; import type { InstrumentRecord, UploadInstrumentRecordsData } from '@opendatacapture/schemas/instrument-records'; @@ -304,7 +304,7 @@ function uploadRecord(request: APIRequestContext, token: string, data: UploadIns } /** `POST /assignments` as the holder of the given token, expiring tomorrow, with the raw response. */ -function createAssignment(request: APIRequestContext, token: string, data: Omit) { +function createAssignment(request: APIRequestContext, token: string, data: Omit<$CreateAssignmentData, 'expiresAt'>) { return request.post(`${API}/assignments`, { data: { ...data, expiresAt: new Date(Date.now() + 86_400_000) }, headers: { Authorization: `Bearer ${token}` } diff --git a/testing/src/specs/sessions.spec.ts b/testing/src/specs/sessions.spec.ts index 8d556548a..b45d914fc 100644 --- a/testing/src/specs/sessions.spec.ts +++ b/testing/src/specs/sessions.spec.ts @@ -1,5 +1,5 @@ -import type { CreateSessionData } from '@opendatacapture/schemas/session'; -import type { CreateSubjectData } from '@opendatacapture/schemas/subject'; +import type { $CreateSessionData } from '@opendatacapture/schemas/session'; +import type { $CreateSubjectData } from '@opendatacapture/schemas/subject'; import { ApiClient } from '../support/api-client'; import { expect, test } from '../support/fixtures'; @@ -41,7 +41,7 @@ test.describe('sessions', () => { uniqueId }) => { const group = await api.createGroup(); - const subjectData: CreateSubjectData = { + const subjectData: $CreateSubjectData = { dateOfBirth: new Date('1990-05-17T00:00:00.000Z'), firstName: 'Ada', id: `clinical-${uniqueId}`, @@ -75,7 +75,7 @@ test.describe('sessions', () => { const { credentials } = await api.createUser({ basePermissionLevel: 'GROUP_MANAGER', groupIds: [ownGroup.id] }); const accessToken = await ApiClient.login(apiRequestContext, credentials); const subjectId = `foreign-${uniqueId}`; - const data: CreateSessionData = { + const data: $CreateSessionData = { date: new Date(), groupId: foreignGroup.id, subjectData: { id: subjectId }, diff --git a/testing/src/support/api-client.ts b/testing/src/support/api-client.ts index 7e5bdb1b8..16325e320 100644 --- a/testing/src/support/api-client.ts +++ b/testing/src/support/api-client.ts @@ -1,12 +1,12 @@ -import type { Assignment, CreateAssignmentData } from '@opendatacapture/schemas/assignment'; +import type { $CreateAssignmentData, Assignment } from '@opendatacapture/schemas/assignment'; import type { $LoginCredentials } from '@opendatacapture/schemas/auth'; import type { Permissions } from '@opendatacapture/schemas/core'; -import type { CreateGroupData, Group } from '@opendatacapture/schemas/group'; +import type { $CreateGroupData, Group } from '@opendatacapture/schemas/group'; import type { InstrumentInfo } from '@opendatacapture/schemas/instrument'; import type { UploadInstrumentRecordsData } from '@opendatacapture/schemas/instrument-records'; -import type { CreateSessionData, Session } from '@opendatacapture/schemas/session'; -import type { CreateSubjectData } from '@opendatacapture/schemas/subject'; -import type { CreateUserData, UpdateUserData, User } from '@opendatacapture/schemas/user'; +import type { $CreateSessionData, Session } from '@opendatacapture/schemas/session'; +import type { $CreateSubjectData } from '@opendatacapture/schemas/subject'; +import type { $CreateUserData, $UpdateUserData, User } from '@opendatacapture/schemas/user'; import type { APIRequestContext } from '@playwright/test'; import { E2E_MAIL_CONFIG, SEEDED_USER_PASSWORD } from './constants'; @@ -40,7 +40,7 @@ export class ApiClient { } /** Creates a remote assignment through the single-assignment route the web app uses. */ - async createAssignment(data: CreateAssignmentData): Promise { + async createAssignment(data: $CreateAssignmentData): Promise { return this.expectJson( this.request.post(`${API}/assignments`, { data, headers: this.authHeaders }), 201, @@ -49,8 +49,8 @@ export class ApiClient { } /** Creates a group and grants it access to every available instrument, so seeded users can use them. */ - async createGroup(overrides: Partial = {}): Promise { - const data: CreateGroupData = { name: `Group ${randomId()}`, type: 'CLINICAL', ...overrides }; + async createGroup(overrides: Partial<$CreateGroupData> = {}): Promise { + const data: $CreateGroupData = { name: `Group ${randomId()}`, type: 'CLINICAL', ...overrides }; const group = await this.expectJson( this.request.post(`${API}/groups`, { data, headers: this.authHeaders }), 201, @@ -68,8 +68,8 @@ export class ApiClient { * Creates a session, and with it the subject it names. A subject seeded this way holds no * instrument records, which is what distinguishes it under the "with records only" filter. */ - async createSession(groupId: null | string, subjectData: CreateSubjectData): Promise { - const data: CreateSessionData = { date: new Date(), groupId, subjectData, type: 'IN_PERSON' }; + async createSession(groupId: null | string, subjectData: $CreateSubjectData): Promise { + const data: $CreateSessionData = { date: new Date(), groupId, subjectData, type: 'IN_PERSON' }; return this.expectJson( this.request.post(`${API}/sessions`, { data, headers: this.authHeaders }), 201, @@ -84,10 +84,10 @@ export class ApiClient { } /** Creates a user (GROUP_MANAGER by default) and returns the login credentials for it. */ - async createUser(overrides: Partial = {}): Promise<{ credentials: $LoginCredentials; user: User }> { + async createUser(overrides: Partial<$CreateUserData> = {}): Promise<{ credentials: $LoginCredentials; user: User }> { const username = overrides.username ?? `user_${randomId()}`; const password = overrides.password ?? SEEDED_USER_PASSWORD; - const data: CreateUserData = { + const data: $CreateUserData = { basePermissionLevel: 'GROUP_MANAGER', firstName: 'Test', groupIds: [], @@ -201,7 +201,7 @@ export class ApiClient { } /** Updates a user's profile fields, which is everything except their permissions. */ - async updateUser(id: string, data: Partial): Promise { + async updateUser(id: string, data: Partial<$UpdateUserData>): Promise { const response = await this.request.patch(`${API}/users/${id}`, { data, headers: this.authHeaders }); if (!response.ok()) { throw new Error(`Failed to update user '${id}' (${response.status()}): ${await response.text()}`); diff --git a/testing/src/support/constants.ts b/testing/src/support/constants.ts index 9dbcf8cfa..cfd4a641e 100644 --- a/testing/src/support/constants.ts +++ b/testing/src/support/constants.ts @@ -1,4 +1,4 @@ -import type { InitAppOptions } from '@opendatacapture/schemas/setup'; +import type { $InitAppOptions } from '@opendatacapture/schemas/setup'; /** The admin account created during global setup; role fixtures log in with these credentials. */ export const ADMIN = { @@ -14,7 +14,7 @@ export const INIT_APP_OPTIONS = { enableExperimentalFeatures: false, initDemo: true, recordsPerSubject: 10 -} satisfies InitAppOptions; +} satisfies $InitAppOptions; /** A minimal payload satisfying the seeded happiness questionnaire's validation schema. */ export const HAPPINESS_RECORD = { diff --git a/testing/src/support/env.ts b/testing/src/support/env.ts index a276ea086..53308cc7e 100644 --- a/testing/src/support/env.ts +++ b/testing/src/support/env.ts @@ -31,6 +31,9 @@ export const gatewayRefreshInterval = requiredNumber('GATEWAY_REFRESH_INTERVAL') // Tests run against the web origin; vite proxies `/api` through to the API server. export const baseURL = `http://localhost:${webPort}`; +// The API reference is served from the root of the API itself, which the web proxy does not reach. +export const apiURL = `http://localhost:${apiPort}`; + // The patient-facing gateway is its own origin; assignment links point here. export const gatewayURL = `http://localhost:${gatewayPort}`;