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:
shancheas
2026-08-21 15:56:54 +07:00
parent d01fd6e2ef
commit 0550cbe764
28 changed files with 1177 additions and 19 deletions
+23
View File
@@ -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 handlers `{ 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/`
+8 -3
View File
@@ -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
+62
View File
@@ -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
View File
@@ -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>
+56
View File
@@ -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
+55
View File
@@ -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 resources own attributes **plus** `search` (case-insensitive match on the modules 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`.
+51
View File
@@ -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