Implement pagination response handling and related enhancements
- Introduced `@Pagination()` decorator to mark list endpoints for pagination.
- Added `TransformInterceptor` to wrap responses in a standardized format `{ data, meta }`.
- Created pagination-related utility functions and constants for managing pagination logic.
- Defined `PaginationQueryDto` for handling pagination query parameters.
- Established `PaginationMetaDto` for OpenAPI documentation of pagination metadata.
- Updated existing controller and service structures to support pagination in responses.
- Added unit tests for pagination utilities and interceptor to ensure correct functionality.
This commit is contained in:
@@ -32,6 +32,29 @@ export class AuthController {
|
||||
- Public (`@Public()`) routes must omit bearer auth
|
||||
- Document expected errors (`@ApiBadRequestResponse`, `@ApiUnauthorizedResponse`, etc.) when meaningful
|
||||
|
||||
### Paginated list endpoints
|
||||
|
||||
- Mark with `@Pagination()`; document the **public** envelope `{ data, meta }`, not the handler’s `{ data, total }`
|
||||
- Use a response DTO class for list items plus `PaginationMetaDto` from `src/common/http/response/`
|
||||
- Detail / create / update / delete stay typed to the resource DTO (unwrapped)
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
@Pagination()
|
||||
@ApiOperation({ summary: 'List shipments' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: { type: 'array', items: { $ref: '#/components/schemas/ShipmentDto' } },
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
list(@Query() query: ListShipmentsQueryDto): Promise<PaginationResponse<ShipmentDto>> {
|
||||
return this.service.list(query)
|
||||
}
|
||||
```
|
||||
|
||||
## DTOs
|
||||
|
||||
- Request and response DTOs live under `dto/`
|
||||
|
||||
@@ -11,8 +11,12 @@ Stack: NestJS + PostgreSQL + Drizzle ORM. Follow `.agents/skills/nestjs-best-pra
|
||||
## Modules
|
||||
|
||||
- One feature folder under `src/modules/`
|
||||
- Each feature has `*.module.ts`, `*.controller.ts`, `*.service.ts`, `dto/`
|
||||
- Share cross-cutting code via `src/common/` (filters, guards, pipes, interceptors)
|
||||
- **Main (CRUD) features:** `*.module.ts`, `*-read.controller.ts`, `*-write.controller.ts`, service(s), repository, `dto/`
|
||||
- **Auth-style / non-CRUD modules** may keep a single `*.controller.ts`
|
||||
- Share cross-cutting code via `src/common/` (filters, guards, pipes, interceptors, HTTP response helpers)
|
||||
- Primary entities: `.cursor/rules/primary-entity.mdc`, `.cursor/rules/status.mdc`
|
||||
- Read/write split: `.cursor/rules/read-write-controllers.mdc`
|
||||
- List pagination: `.cursor/rules/pagination-response.mdc`
|
||||
- Document HTTP endpoints per `.cursor/rules/nestjs-swagger.mdc`
|
||||
|
||||
## Tests
|
||||
@@ -24,6 +28,7 @@ Stack: NestJS + PostgreSQL + Drizzle ORM. Follow `.agents/skills/nestjs-best-pra
|
||||
|
||||
## Database
|
||||
|
||||
- Schema and SQL migrations in `drizzle/`
|
||||
- Schema and SQL migrations in `drizzle/` (table defs may live in `src/database/schema.ts`)
|
||||
- Primary tables spread `primaryEntityColumns(users)` from `src/database/primary-entity-columns.ts`
|
||||
- Config in `drizzle.config.ts`
|
||||
- Never mutate production schema without a migration
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
description: List endpoints use @Pagination() and return { data, total }; interceptor emits { data, meta }
|
||||
globs: "src/**/*.controller.ts,src/common/http/**/*.ts,src/common/configure-app.ts"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Pagination Response Envelope
|
||||
|
||||
Canonical list HTTP shape (TrackGo-compatible). Shared code lives in `src/common/http/response/`.
|
||||
|
||||
## Handler vs public response
|
||||
|
||||
```typescript
|
||||
// Handler return when marked @Pagination()
|
||||
interface PaginationResponse<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// Public response after TransformInterceptor
|
||||
interface SuccessResponse<T> {
|
||||
data: T
|
||||
meta?: PaginationMeta
|
||||
}
|
||||
|
||||
interface PaginationMeta {
|
||||
currentPage: number
|
||||
itemCount: number
|
||||
itemsPerPage: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
```
|
||||
|
||||
## Mandatory
|
||||
|
||||
- Mark every list endpoint with `@Pagination()`
|
||||
- Return `{ data, total }` from the handler — **never** build `meta` in the service or controller
|
||||
- Query: `page`/`limit` or `offset`/`limit` (defaults `page=1`, `limit=10`; max limit `200`)
|
||||
- Use `@RawResponse()` for file downloads / health probes that must skip wrapping
|
||||
- Non-list handlers (detail, create, update, delete, status, import) pass through **unwrapped**
|
||||
|
||||
```typescript
|
||||
// BAD — hand-rolled meta / success wrapper
|
||||
return { success: true, data: items, meta: { total, page, limit } }
|
||||
|
||||
// GOOD
|
||||
@Get()
|
||||
@Pagination()
|
||||
list(@Query() query: ListQueryDto): Promise<PaginationResponse<ItemDto>> {
|
||||
return this.service.list(query) // { data, total }
|
||||
}
|
||||
```
|
||||
|
||||
## Forbidden
|
||||
|
||||
- `success: boolean` response wrappers for lists
|
||||
- Putting `total` on `meta` instead of `totalItems`
|
||||
- Computing `PaginationMeta` in feature services
|
||||
- Documenting the internal `{ data, total }` shape in OpenAPI — document `{ data, meta }` instead
|
||||
|
||||
`TransformInterceptor` is registered globally in `configureApp`.
|
||||
+28
-14
@@ -8,36 +8,50 @@ alwaysApply: false
|
||||
|
||||
## API Response Format
|
||||
|
||||
Paginated lists (after `@Pagination()` + `TransformInterceptor`):
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
meta?: {
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
interface PaginationMeta {
|
||||
currentPage: number
|
||||
itemCount: number
|
||||
itemsPerPage: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
interface SuccessResponse<T> {
|
||||
data: T
|
||||
meta?: PaginationMeta
|
||||
}
|
||||
|
||||
// Handler return for @Pagination() routes only
|
||||
interface PaginationResponse<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
}
|
||||
```
|
||||
|
||||
Detail / create / update / delete responses are the resource DTO (unwrapped). See `.cursor/rules/pagination-response.mdc`.
|
||||
|
||||
## Feature Module
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService, UsersRepository],
|
||||
exports: [UsersService],
|
||||
controllers: [ShipmentsReadController, ShipmentsWriteController],
|
||||
providers: [ShipmentsService, ShipmentsRepository],
|
||||
exports: [ShipmentsService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
export class ShipmentsModule {}
|
||||
```
|
||||
|
||||
Auth-style modules may register a single controller.
|
||||
|
||||
## Repository Pattern (Drizzle)
|
||||
|
||||
```typescript
|
||||
interface Repository<T> {
|
||||
findAll(filters?: Filters): Promise<T[]>
|
||||
findAll(filters?: Filters): Promise<{ data: T[]; total: number }>
|
||||
findById(id: string): Promise<T | null>
|
||||
create(data: CreateDto): Promise<T>
|
||||
update(id: string, data: UpdateDto): Promise<T>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: Primary/aggregate entities must have status, created_at, updated_at, created_by, updated_by via primaryEntityColumns
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Primary Entity Audit Fields
|
||||
|
||||
## Mandatory
|
||||
|
||||
Every **primary / aggregate** table and its domain model MUST include:
|
||||
|
||||
| Field | DB | Domain |
|
||||
| ----- | -- | ------ |
|
||||
| `status` | `text`, default `draft` | `Status` |
|
||||
| `created_at` / `updated_at` | `bigint` unix ms | `DateTime` |
|
||||
| `created_by` / `updated_by` | `uuid` → `users.id` | `string` userId |
|
||||
|
||||
Use `primaryEntityColumns(users)` from `src/database/primary-entity-columns.ts` — do not re-declare these five columns by hand.
|
||||
|
||||
Timestamps follow `.cursor/rules/date-time.mdc`. Status follows `.cursor/rules/status.mdc`. Actor ids come from `@CurrentUser()` on write.
|
||||
|
||||
```typescript
|
||||
export const shipments = pgTable('shipments', {
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
...primaryEntityColumns(users),
|
||||
})
|
||||
```
|
||||
|
||||
On create/update in the repository:
|
||||
|
||||
```typescript
|
||||
const now = DateTime.fromUnixMs(Date.now())
|
||||
await db.insert(table).values({
|
||||
...data,
|
||||
status: (status ?? Status.create(Status.DEFAULT)).value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
})
|
||||
```
|
||||
|
||||
## Exemptions
|
||||
|
||||
Do **not** require these columns on:
|
||||
|
||||
- Auth/session tables (`refresh_tokens`, `revoked_access_tokens`)
|
||||
- Junction / child rows that are not first-class resources
|
||||
- Existing `users` until an explicit migration adds them
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Omitting audit fields on a new primary table
|
||||
- Using plain `Date` / ISO strings for `created_at` / `updated_at` in domain code
|
||||
- Hardcoding actor ids or leaving `created_by` / `updated_by` nullable on primary entities
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
description: Main modules use separate read and write controllers with list/detail, CRUD, status, bulk, and CSV import
|
||||
globs: "src/modules/**/*.ts"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Read / Write Controllers
|
||||
|
||||
## Structure
|
||||
|
||||
Every **main** (CRUD) feature module under `src/modules/` MUST expose at least:
|
||||
|
||||
- `*-read.controller.ts`
|
||||
- `*-write.controller.ts`
|
||||
|
||||
Same resource path and `@ApiTags`. Example: `@Controller('shipments')`.
|
||||
|
||||
Exempt: auth and similar non-CRUD modules (single controller is fine).
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
controllers: [ShipmentsReadController, ShipmentsWriteController],
|
||||
providers: [ShipmentsService, ShipmentsRepository],
|
||||
})
|
||||
export class ShipmentsModule {}
|
||||
```
|
||||
|
||||
Register static write paths (`import`, `bulk-delete`, `bulk-status`) **before** parameterized `:id` routes so they never collide.
|
||||
|
||||
## Read controller
|
||||
|
||||
1. `GET /` — list
|
||||
2. `GET /:id` — detail
|
||||
|
||||
List requirements:
|
||||
|
||||
- Query filters for the resource’s own attributes **plus** `search` (case-insensitive match on the module’s searchable text columns; AND with other filters)
|
||||
- Shared pagination query (`page`/`limit` or `offset`/`limit`) via `PaginationQueryDto`
|
||||
- Handler **must** use `@Pagination()` and return `{ data, total }` — never build `meta` here (see `.cursor/rules/pagination-response.mdc`)
|
||||
- Service `visibleFields` whitelist: default **all non-secret** attributes; modules may narrow. Project in the **service**, not the controller
|
||||
- List query must be extendable (e.g. `extendListQuery(qb, filters)` on the repository/service) so joins/extra predicates can be added without forking list
|
||||
|
||||
## Write controller
|
||||
|
||||
1. `POST /` — create (status defaults to `draft` unless body sets a valid status)
|
||||
2. `PATCH /:id` — update (**must not** change `status`; reject if `status` is present)
|
||||
3. `DELETE /:id` — delete (hard delete unless the module documents otherwise)
|
||||
4. `PATCH /:id/status` — body is **only** `{ status }`
|
||||
5. `POST /bulk-delete` — `{ ids: string[] }`
|
||||
6. `POST /bulk-status` — `{ ids: string[], status }`
|
||||
7. `POST /import` — multipart CSV `file`; headers map to create fields; omitted status → `draft`; `created_by` / `updated_by` = current user; fail the batch on validation errors with row-level messages (do not echo raw invalid phones/dates beyond VO policy)
|
||||
|
||||
Set `created_at` / `updated_at` (`DateTime`) and `created_by` / `updated_by` (current user id) in the write path.
|
||||
|
||||
Document both controllers per `.cursor/rules/nestjs-swagger.mdc`.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
description: All status data must use the Status value object; extend CORE_STATUSES via allowed list — do not invent parallel helpers
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Status Value Object
|
||||
|
||||
## Mandatory
|
||||
|
||||
ALL status fields on primary entities in the domain and application layers MUST use `Status` from `src/common/value-objects/status/`.
|
||||
|
||||
- Construct via `Status.create(raw)` (core set) or `Status.create(raw, allowed)` (module extensions)
|
||||
- Default new records with `Status.DEFAULT` (`draft`) when status is omitted
|
||||
- Compare with `equals()`, serialize with `value` / `toString()` / `toJSON()`
|
||||
- Persist and transmit the canonical string from `status.value`
|
||||
|
||||
Core statuses (source of truth): `draft`, `active`, `archived`. Modules may pass an `allowed` list that adds values (e.g. `in_transit`).
|
||||
|
||||
## Forbidden
|
||||
|
||||
Do NOT:
|
||||
|
||||
- Store or pass status as an unvalidated plain `string` in domain models or services (beyond the DTO/HTTP or DB string boundary)
|
||||
- Add status enums, validators, maps, or helpers that bypass the VO
|
||||
- Create new files for status validation or normalization
|
||||
- Echo raw invalid input in error messages
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
user.status = 'Draft'
|
||||
function normalizeStatus(raw: string): string { /* ... */ }
|
||||
|
||||
// GOOD
|
||||
const status = Status.create(dto.status ?? Status.DEFAULT)
|
||||
entity.status = status
|
||||
await repo.save({ status: status.value })
|
||||
|
||||
// GOOD — module extension
|
||||
const status = Status.create(dto.status, [...CORE_STATUSES, 'in_transit'])
|
||||
```
|
||||
|
||||
## When the VO is not enough
|
||||
|
||||
1. **Update** `src/common/value-objects/status/` (implementation + colocated tests), or pass a wider `allowed` list at the call site
|
||||
2. Do **not** invent a parallel status type or module
|
||||
|
||||
## Boundaries
|
||||
|
||||
- HTTP DTOs may accept `string`; map to `Status.create()` at the service boundary
|
||||
- Database columns may store `text` (default `'draft'`); map to/from `Status` in the repository
|
||||
- `InvalidStatusError` is the only status validation error; do not echo raw input in messages
|
||||
Reference in New Issue
Block a user