Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .agents/docs/packages/libnest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
12 changes: 5 additions & 7 deletions .agents/docs/playbooks/add-api-endpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<feature>/dto/<verb>-<x>.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/<feature>/<feature>.controller.ts`. Every handler needs
`@RouteAccess` (eslint-enforced; missing it is a 500 at request time, not an open route), and any
Expand Down
23 changes: 12 additions & 11 deletions apps/api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -39,7 +39,6 @@ src/<feature>/
<feature>.module.ts @Module({ controllers, exports: [Service], providers: [Service] })
<feature>.controller.ts @Controller(path); @RouteAccess on every handler
<feature>.service.ts @Injectable; @InjectModel(...) per model
dto/create-<x>.dto.ts @ValidationSchema($CreateXData) class CreateXDto implements CreateXData
__tests__/<feature>.service.spec.ts
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:*",
Expand Down
29 changes: 15 additions & 14 deletions apps/api/src/assignments/assignments.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CurrentUser } from '@douglasneuroinformatics/libnest';
import { ApiOperation, CurrentUser } from '@douglasneuroinformatics/libnest';
import type { RequestUser } from '@douglasneuroinformatics/libnest';
import {
BadRequestException,
Expand All @@ -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';
Expand All @@ -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 {
Expand All @@ -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<BulkAssignmentPreflightResult> {
return this.assignmentsService.bulkPreflight(data, currentUser);
Expand All @@ -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<Assignment> {
create(@Body() data: $CreateAssignmentData, @CurrentUser() currentUser: RequestUser): Promise<Assignment> {
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<Assignment[]> {
createBulk(@Body() data: $CreateBulkAssignmentsData, @CurrentUser() currentUser: RequestUser): Promise<Assignment[]> {
return this.assignmentsService.createBulk(data, currentUser);
}

Expand All @@ -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<DeleteBulkAssignmentsResult> {
return this.assignmentsService.deleteBulk(data.ids, { ability });
Expand All @@ -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<EmailDeliveryResult> {
const { ability } = currentUser;
Expand Down Expand Up @@ -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);
}
}
Loading
Loading