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
|
||||
@@ -3,11 +3,13 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from '../app.controller';
|
||||
import { AppService } from '../app.service';
|
||||
import { configureApp } from './configure-app';
|
||||
import { TransformInterceptor } from './http/response';
|
||||
import * as setupSwaggerModule from './swagger/setup-swagger';
|
||||
|
||||
describe('configureApp', () => {
|
||||
let app: INestApplication;
|
||||
let useGlobalPipesSpy: jest.SpyInstance;
|
||||
let useGlobalInterceptorsSpy: jest.SpyInstance;
|
||||
let setupSwaggerSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -18,6 +20,7 @@ describe('configureApp', () => {
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
useGlobalPipesSpy = jest.spyOn(app, 'useGlobalPipes');
|
||||
useGlobalInterceptorsSpy = jest.spyOn(app, 'useGlobalInterceptors');
|
||||
setupSwaggerSpy = jest
|
||||
.spyOn(setupSwaggerModule, 'setupSwagger')
|
||||
.mockImplementation(() => undefined);
|
||||
@@ -29,14 +32,18 @@ describe('configureApp', () => {
|
||||
|
||||
afterEach(() => {
|
||||
useGlobalPipesSpy.mockClear();
|
||||
useGlobalInterceptorsSpy.mockClear();
|
||||
setupSwaggerSpy.mockClear();
|
||||
});
|
||||
|
||||
it('registers ValidationPipe and setupSwagger', () => {
|
||||
it('registers ValidationPipe, TransformInterceptor, and setupSwagger', () => {
|
||||
const env = { NODE_ENV: 'test' } as NodeJS.ProcessEnv;
|
||||
configureApp(app, env);
|
||||
|
||||
expect(useGlobalPipesSpy).toHaveBeenCalledWith(expect.any(ValidationPipe));
|
||||
expect(useGlobalInterceptorsSpy).toHaveBeenCalledWith(
|
||||
expect.any(TransformInterceptor),
|
||||
);
|
||||
expect(setupSwaggerSpy).toHaveBeenCalledWith(app, env);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { TransformInterceptor } from './http/response';
|
||||
import { setupSwagger } from './swagger/setup-swagger';
|
||||
|
||||
/** Shared Nest app configuration for bootstrap and E2E. */
|
||||
@@ -13,5 +15,6 @@ export function configureApp(
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalInterceptors(new TransformInterceptor(new Reflector()));
|
||||
setupSwagger(app, env);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Reflector metadata key set by `@Pagination()`. */
|
||||
export const PAGINATION_RESPONSE = 'http.paginationResponse';
|
||||
|
||||
/**
|
||||
* Reflector metadata key set by `@RawResponse()`.
|
||||
* When present, the transform interceptor leaves the handler result untouched.
|
||||
*/
|
||||
export const RAW_RESPONSE = 'http.rawResponse';
|
||||
@@ -0,0 +1,24 @@
|
||||
export { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
|
||||
export { Pagination, RawResponse } from './pagination.decorator';
|
||||
export type {
|
||||
PaginationMeta,
|
||||
PaginationResponse,
|
||||
SuccessResponse,
|
||||
} from './ok-response.interface';
|
||||
export { PaginationMetaDto } from './pagination-meta.dto';
|
||||
export { TransformInterceptor } from './transform.interceptor';
|
||||
export {
|
||||
createPaginationMeta,
|
||||
createPaginationResponse,
|
||||
resolvePaginationQuery,
|
||||
} from './pagination-meta.helper';
|
||||
export { PaginationQueryDto } from './pagination-query.dto';
|
||||
export {
|
||||
toListPage,
|
||||
type ListPage,
|
||||
type PaginationQueryInput,
|
||||
} from './list-page';
|
||||
export {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
import { toListPage } from './list-page';
|
||||
|
||||
describe('list-page', () => {
|
||||
describe('toListPage', () => {
|
||||
it('defaults to page 1 with default limit', () => {
|
||||
expect(toListPage({})).toEqual({
|
||||
limit: PAGINATION_DEFAULT_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives offset from page and limit', () => {
|
||||
expect(toListPage({ page: 3, limit: 10 })).toEqual({
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses explicit offset when page is omitted', () => {
|
||||
expect(toListPage({ offset: 15, limit: 5 })).toEqual({
|
||||
limit: 5,
|
||||
offset: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers page over offset when both are present', () => {
|
||||
expect(toListPage({ page: 2, offset: 50, limit: 10 })).toEqual({
|
||||
limit: 10,
|
||||
offset: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps limit to max', () => {
|
||||
expect(toListPage({ limit: PAGINATION_MAX_LIMIT + 50 })).toEqual({
|
||||
limit: PAGINATION_MAX_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
|
||||
export type PaginationQueryInput = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListPage = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves repository page args from list query params.
|
||||
* When `page` is present it wins over `offset` (aligned with `resolvePaginationQuery`).
|
||||
* Otherwise derives from `offset`, or from page 1 when both are omitted.
|
||||
*/
|
||||
export function toListPage(
|
||||
query: PaginationQueryInput,
|
||||
defaults?: { limit?: number; maxLimit?: number },
|
||||
): ListPage {
|
||||
const maxLimit = defaults?.maxLimit ?? PAGINATION_MAX_LIMIT;
|
||||
const limit = clampLimit(
|
||||
query.limit ?? defaults?.limit ?? PAGINATION_DEFAULT_LIMIT,
|
||||
maxLimit,
|
||||
);
|
||||
if (query.page != null && query.page >= 1) {
|
||||
const page = Math.trunc(query.page);
|
||||
return { limit, offset: (page - 1) * limit };
|
||||
}
|
||||
if (query.offset != null) {
|
||||
return { limit, offset: Math.max(0, Math.trunc(query.offset)) };
|
||||
}
|
||||
return { limit, offset: 0 };
|
||||
}
|
||||
|
||||
function clampLimit(limit: number, maxLimit: number): number {
|
||||
return Math.max(1, Math.min(Math.trunc(limit), maxLimit));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface PaginationMeta {
|
||||
currentPage: number;
|
||||
itemCount: number;
|
||||
itemsPerPage: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface SuccessResponse<T> {
|
||||
data: T;
|
||||
meta?: PaginationMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape a list handler must return when the route is marked with `@Pagination()`.
|
||||
* The transform interceptor turns this into `{ data, meta }`.
|
||||
*/
|
||||
export interface PaginationResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { PaginationMeta } from './ok-response.interface';
|
||||
|
||||
/** OpenAPI-visible pagination meta (DTO class, not an interface). */
|
||||
export class PaginationMetaDto implements PaginationMeta {
|
||||
@ApiProperty({ example: 1 })
|
||||
currentPage!: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
itemCount!: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
itemsPerPage!: number;
|
||||
|
||||
@ApiProperty({ example: 42 })
|
||||
totalItems!: number;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
totalPages!: number;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
createPaginationMeta,
|
||||
createPaginationResponse,
|
||||
resolvePaginationQuery,
|
||||
} from './pagination-meta.helper';
|
||||
import {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
import { toListPage } from './list-page';
|
||||
|
||||
describe('pagination-meta.helper', () => {
|
||||
describe('createPaginationMeta', () => {
|
||||
it('builds meta from page, limit, data count, and total', () => {
|
||||
expect(createPaginationMeta(2, 10, 10, 25)).toEqual({
|
||||
currentPage: 2,
|
||||
itemCount: 10,
|
||||
itemsPerPage: 10,
|
||||
totalItems: 25,
|
||||
totalPages: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps page and limit to at least 1', () => {
|
||||
expect(createPaginationMeta(0, 0, 0, 0)).toEqual({
|
||||
currentPage: 1,
|
||||
itemCount: 0,
|
||||
itemsPerPage: 1,
|
||||
totalItems: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPaginationResponse', () => {
|
||||
it('wraps handler payload as { data, meta }', () => {
|
||||
const items = [{ id: '1' }, { id: '2' }];
|
||||
|
||||
expect(
|
||||
createPaginationResponse({ data: items, total: 12 }, 1, 2),
|
||||
).toEqual({
|
||||
data: items,
|
||||
meta: {
|
||||
currentPage: 1,
|
||||
itemCount: 2,
|
||||
itemsPerPage: 2,
|
||||
totalItems: 12,
|
||||
totalPages: 6,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through payloads that are not PaginationResponse shapes', () => {
|
||||
const payload = { id: 'not-a-list' };
|
||||
expect(createPaginationResponse(payload as never, 1, 10)).toEqual(
|
||||
payload,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePaginationQuery', () => {
|
||||
it('defaults to page 1 and default limit', () => {
|
||||
expect(resolvePaginationQuery({})).toEqual({
|
||||
page: 1,
|
||||
limit: PAGINATION_DEFAULT_LIMIT,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads page and limit from query', () => {
|
||||
expect(resolvePaginationQuery({ page: '3', limit: '20' })).toEqual({
|
||||
page: 3,
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives page from offset and limit', () => {
|
||||
expect(resolvePaginationQuery({ offset: '20', limit: '10' })).toEqual({
|
||||
page: 3,
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers page over offset when both are present', () => {
|
||||
expect(
|
||||
resolvePaginationQuery({ page: '2', offset: '50', limit: '10' }),
|
||||
).toEqual({ page: 2, limit: 10 });
|
||||
});
|
||||
|
||||
it('falls back on invalid page or limit', () => {
|
||||
expect(resolvePaginationQuery({ page: 'abc', limit: '-1' })).toEqual({
|
||||
page: 1,
|
||||
limit: PAGINATION_DEFAULT_LIMIT,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps limit to max', () => {
|
||||
expect(
|
||||
resolvePaginationQuery({
|
||||
limit: String(PAGINATION_MAX_LIMIT + 50),
|
||||
}),
|
||||
).toEqual({ page: 1, limit: PAGINATION_MAX_LIMIT });
|
||||
});
|
||||
|
||||
it('stays aligned with toListPage for page and offset', () => {
|
||||
const query = { page: 2, offset: 50, limit: 10 };
|
||||
const resolved = resolvePaginationQuery(query);
|
||||
const listPage = toListPage(query);
|
||||
|
||||
expect(resolved.limit).toBe(listPage.limit);
|
||||
expect((resolved.page - 1) * resolved.limit).toBe(listPage.offset);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
import type {
|
||||
PaginationMeta,
|
||||
PaginationResponse,
|
||||
} from './ok-response.interface';
|
||||
|
||||
export function createPaginationMeta(
|
||||
page: number,
|
||||
limit: number,
|
||||
dataCount: number,
|
||||
total: number,
|
||||
): PaginationMeta {
|
||||
const safeLimit = Math.max(1, limit);
|
||||
return {
|
||||
currentPage: Math.max(1, page),
|
||||
itemCount: dataCount,
|
||||
itemsPerPage: safeLimit,
|
||||
totalItems: total,
|
||||
totalPages: Math.ceil(total / safeLimit) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the public paginated envelope:
|
||||
* `{ data, meta: { currentPage, itemsPerPage, totalItems, totalPages, itemCount } }`.
|
||||
* Returns the original payload when it is not a PaginationResponse shape.
|
||||
*/
|
||||
export function createPaginationResponse(
|
||||
response: unknown,
|
||||
page: number,
|
||||
limit: number,
|
||||
): unknown {
|
||||
if (!isPaginationResponse(response)) {
|
||||
return response;
|
||||
}
|
||||
const { data, total } = response;
|
||||
return {
|
||||
data,
|
||||
meta: createPaginationMeta(page, limit, data.length, total),
|
||||
};
|
||||
}
|
||||
|
||||
function isPaginationResponse(
|
||||
value: unknown,
|
||||
): value is PaginationResponse<unknown> {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return Array.isArray(record.data) && typeof record.total === 'number';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves page/limit from query params.
|
||||
* Supports `?page=&limit=` and `?offset=&limit=`
|
||||
* (page is derived as `floor(offset / limit) + 1`).
|
||||
* When both `page` and `offset` are present, `page` wins (same as `toListPage`).
|
||||
*/
|
||||
export function resolvePaginationQuery(query: Record<string, unknown>): {
|
||||
page: number;
|
||||
limit: number;
|
||||
} {
|
||||
const limit = clampLimit(
|
||||
toPositiveInt(query.limit, PAGINATION_DEFAULT_LIMIT),
|
||||
PAGINATION_MAX_LIMIT,
|
||||
);
|
||||
|
||||
if (query.page != null && query.page !== '') {
|
||||
return { page: toPositiveInt(query.page, 1), limit };
|
||||
}
|
||||
|
||||
const offset = toNonNegativeInt(query.offset, 0);
|
||||
return { page: Math.floor(offset / limit) + 1, limit };
|
||||
}
|
||||
|
||||
function clampLimit(limit: number, maxLimit: number): number {
|
||||
return Math.max(1, Math.min(limit, maxLimit));
|
||||
}
|
||||
|
||||
function toPositiveInt(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(n);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { PAGINATION_MAX_LIMIT } from './pagination.constants';
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(PAGINATION_MAX_LIMIT)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Default max page size for collection list endpoints. */
|
||||
export const PAGINATION_DEFAULT_LIMIT = 10;
|
||||
export const PAGINATION_MAX_LIMIT = 200;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
|
||||
|
||||
/**
|
||||
* Marks a handler as a paginated list endpoint.
|
||||
*
|
||||
* The handler must return `{ data: T[]; total: number }`. The global
|
||||
* transform interceptor then wraps it as `{ data, meta }` using `page` /
|
||||
* `limit` (or `offset` / `limit`) from the query string.
|
||||
*/
|
||||
export const Pagination = (
|
||||
isPagination = true,
|
||||
): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(PAGINATION_RESPONSE, isPagination);
|
||||
|
||||
/**
|
||||
* Skips response wrapping for the handler (e.g. file downloads, health probes).
|
||||
*/
|
||||
export const RawResponse = (): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(RAW_RESPONSE, true);
|
||||
@@ -0,0 +1,116 @@
|
||||
import { CallHandler, ExecutionContext, StreamableFile } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { of, firstValueFrom } from 'rxjs';
|
||||
import { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
|
||||
import { TransformInterceptor } from './transform.interceptor';
|
||||
|
||||
describe('TransformInterceptor', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn(),
|
||||
};
|
||||
const interceptor = new TransformInterceptor(
|
||||
reflector as unknown as Reflector,
|
||||
);
|
||||
|
||||
function createContext(
|
||||
query: Record<string, unknown> = {},
|
||||
): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => jest.fn(),
|
||||
getClass: () => class TestController {},
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ query }),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function createHandler(payload: unknown): CallHandler {
|
||||
return { handle: () => of(payload) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reflector.getAllAndOverride.mockReset();
|
||||
});
|
||||
|
||||
it('passes through when not marked as pagination', async () => {
|
||||
reflector.getAllAndOverride.mockReturnValue(false);
|
||||
const payload = { id: '1' };
|
||||
|
||||
const result = await firstValueFrom(
|
||||
interceptor.intercept(createContext(), createHandler(payload)),
|
||||
);
|
||||
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('passes through when marked @RawResponse()', async () => {
|
||||
reflector.getAllAndOverride.mockImplementation((key: string) => {
|
||||
if (key === RAW_RESPONSE) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const payload = { data: [], total: 0 };
|
||||
|
||||
const result = await firstValueFrom(
|
||||
interceptor.intercept(createContext(), createHandler(payload)),
|
||||
);
|
||||
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('wraps @Pagination() handler return as { data, meta }', async () => {
|
||||
reflector.getAllAndOverride.mockImplementation((key: string) => {
|
||||
if (key === RAW_RESPONSE) {
|
||||
return false;
|
||||
}
|
||||
if (key === PAGINATION_RESPONSE) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(
|
||||
interceptor.intercept(
|
||||
createContext({ page: '2', limit: '5' }),
|
||||
createHandler({ data: [{ id: 'a' }], total: 11 }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [{ id: 'a' }],
|
||||
meta: {
|
||||
currentPage: 2,
|
||||
itemCount: 1,
|
||||
itemsPerPage: 5,
|
||||
totalItems: 11,
|
||||
totalPages: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not wrap null payloads', async () => {
|
||||
reflector.getAllAndOverride.mockImplementation((key: string) => {
|
||||
return key === PAGINATION_RESPONSE;
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(
|
||||
interceptor.intercept(createContext(), createHandler(null)),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('does not wrap StreamableFile payloads', async () => {
|
||||
reflector.getAllAndOverride.mockImplementation((key: string) => {
|
||||
return key === PAGINATION_RESPONSE;
|
||||
});
|
||||
const file = new StreamableFile(Buffer.from('x'));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
interceptor.intercept(createContext(), createHandler(file)),
|
||||
);
|
||||
|
||||
expect(result).toBe(file);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { PAGINATION_RESPONSE, RAW_RESPONSE } from './constants';
|
||||
import {
|
||||
createPaginationResponse,
|
||||
resolvePaginationQuery,
|
||||
} from './pagination-meta.helper';
|
||||
|
||||
/**
|
||||
* Applies pagination enveloping when a handler is marked with `@Pagination()`.
|
||||
*
|
||||
* Non-paginated handlers pass through unchanged. `@RawResponse()`, empty
|
||||
* bodies, and `StreamableFile` downloads are never wrapped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor implements NestInterceptor {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const isRaw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isRaw) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const isPagination = this.reflector.getAllAndOverride<boolean>(
|
||||
PAGINATION_RESPONSE,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!isPagination) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
query: Record<string, unknown>;
|
||||
}>();
|
||||
const { page, limit } = resolvePaginationQuery(request.query);
|
||||
|
||||
return next.handle().pipe(
|
||||
map((payload: unknown): unknown => {
|
||||
if (payload == null || payload instanceof StreamableFile) {
|
||||
return payload;
|
||||
}
|
||||
return createPaginationResponse(payload, page, limit);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidStatusError extends Error {
|
||||
constructor() {
|
||||
super('Invalid status');
|
||||
this.name = 'InvalidStatusError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { InvalidStatusError } from './invalid-status.error';
|
||||
import { CORE_STATUSES, Status } from './status';
|
||||
|
||||
describe('Status', () => {
|
||||
describe('CORE_STATUSES and DEFAULT', () => {
|
||||
it('exposes draft, active, and archived as core statuses', () => {
|
||||
expect(CORE_STATUSES).toEqual(['draft', 'active', 'archived']);
|
||||
});
|
||||
|
||||
it('defaults to draft', () => {
|
||||
expect(Status.DEFAULT).toBe('draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it.each(CORE_STATUSES)('accepts core status %s', (raw) => {
|
||||
const status = Status.create(raw);
|
||||
|
||||
expect(status.value).toBe(raw);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
const status = Status.create(' draft ');
|
||||
|
||||
expect(status.value).toBe('draft');
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(() => Status.create('')).toThrow(InvalidStatusError);
|
||||
});
|
||||
|
||||
it('rejects whitespace-only input', () => {
|
||||
expect(() => Status.create(' ')).toThrow(InvalidStatusError);
|
||||
});
|
||||
|
||||
it('rejects non-string input', () => {
|
||||
expect(() => Status.create(null as unknown as string)).toThrow(
|
||||
InvalidStatusError,
|
||||
);
|
||||
expect(() => Status.create(123 as unknown as string)).toThrow(
|
||||
InvalidStatusError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown status', () => {
|
||||
expect(() => Status.create('in_transit')).toThrow(InvalidStatusError);
|
||||
});
|
||||
|
||||
it('rejects casing variants of core statuses', () => {
|
||||
expect(() => Status.create('Draft')).toThrow(InvalidStatusError);
|
||||
expect(() => Status.create('ACTIVE')).toThrow(InvalidStatusError);
|
||||
});
|
||||
|
||||
it('does not echo raw input in the error message', () => {
|
||||
expect(() => Status.create('secret-status')).toThrow('Invalid status');
|
||||
try {
|
||||
Status.create('secret-status');
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toContain('secret-status');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts an extended allowed list', () => {
|
||||
const status = Status.create('in_transit', [
|
||||
...CORE_STATUSES,
|
||||
'in_transit',
|
||||
]);
|
||||
|
||||
expect(status.value).toBe('in_transit');
|
||||
});
|
||||
|
||||
it('still accepts core statuses when allowed is extended', () => {
|
||||
const allowed = [...CORE_STATUSES, 'in_transit'];
|
||||
|
||||
expect(Status.create('draft', allowed).value).toBe('draft');
|
||||
expect(Status.create('active', allowed).value).toBe('active');
|
||||
});
|
||||
|
||||
it('rejects values outside the extended allowed list', () => {
|
||||
expect(() =>
|
||||
Status.create('cancelled', [...CORE_STATUSES, 'in_transit']),
|
||||
).toThrow(InvalidStatusError);
|
||||
});
|
||||
|
||||
it('rejects empty allowed list', () => {
|
||||
expect(() => Status.create('draft', [])).toThrow(InvalidStatusError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('returns true for the same status value', () => {
|
||||
const a = Status.create('draft');
|
||||
const b = Status.create('draft');
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for different status values', () => {
|
||||
const a = Status.create('draft');
|
||||
const b = Status.create('active');
|
||||
|
||||
expect(a.equals(b)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-Status values', () => {
|
||||
const status = Status.create('draft');
|
||||
|
||||
expect(status.equals({ value: 'draft' } as unknown as Status)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialization', () => {
|
||||
it('toString returns the canonical status', () => {
|
||||
expect(Status.create('archived').toString()).toBe('archived');
|
||||
});
|
||||
|
||||
it('toJSON returns the canonical status', () => {
|
||||
expect(Status.create('active').toJSON()).toBe('active');
|
||||
expect(JSON.stringify({ status: Status.create('active') })).toBe(
|
||||
'{"status":"active"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('construction', () => {
|
||||
it('cannot be constructed with new Status()', () => {
|
||||
expect(
|
||||
() => new (Status as unknown as new (...args: unknown[]) => Status)(),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { InvalidStatusError } from './invalid-status.error';
|
||||
|
||||
export const CORE_STATUSES = ['draft', 'active', 'archived'] as const;
|
||||
|
||||
export type CoreStatus = (typeof CORE_STATUSES)[number];
|
||||
|
||||
export class Status {
|
||||
static readonly DEFAULT: CoreStatus = 'draft';
|
||||
|
||||
private static readonly createToken = Symbol('Status.create');
|
||||
|
||||
private constructor(
|
||||
private readonly status: string,
|
||||
token: symbol,
|
||||
) {
|
||||
if (token !== Status.createToken) {
|
||||
throw new TypeError('Status can only be created via Status.create()');
|
||||
}
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Status from a raw string.
|
||||
* @param raw - status string (trimmed)
|
||||
* @param allowed - optional allow-list; defaults to CORE_STATUSES
|
||||
*/
|
||||
static create(
|
||||
raw: string,
|
||||
allowed: readonly string[] = CORE_STATUSES,
|
||||
): Status {
|
||||
if (typeof raw !== 'string') {
|
||||
throw new InvalidStatusError();
|
||||
}
|
||||
|
||||
if (!Array.isArray(allowed) || allowed.length === 0) {
|
||||
throw new InvalidStatusError();
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '' || !allowed.includes(trimmed)) {
|
||||
throw new InvalidStatusError();
|
||||
}
|
||||
|
||||
return new Status(trimmed, Status.createToken);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
equals(other: Status): boolean {
|
||||
return other instanceof Status && this.status === other.status;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
toJSON(): string {
|
||||
return this.status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
describe('primaryEntityColumns', () => {
|
||||
const columns = primaryEntityColumns(users);
|
||||
|
||||
it('returns status, createdAt, updatedAt, createdBy, and updatedBy', () => {
|
||||
expect(Object.keys(columns).sort()).toEqual(
|
||||
['createdAt', 'createdBy', 'status', 'updatedAt', 'updatedBy'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps status to text column status with default draft', () => {
|
||||
const config = getConfig(columns.status);
|
||||
|
||||
expect(config.name).toBe('status');
|
||||
expect(config.dataType).toBe('string');
|
||||
expect(config.notNull).toBe(true);
|
||||
expect(config.hasDefault).toBe(true);
|
||||
expect(config.default).toBe('draft');
|
||||
});
|
||||
|
||||
it('maps createdAt and updatedAt to bigint unix millisecond columns', () => {
|
||||
const createdAt = getConfig(columns.createdAt);
|
||||
const updatedAt = getConfig(columns.updatedAt);
|
||||
|
||||
expect(createdAt.name).toBe('created_at');
|
||||
expect(updatedAt.name).toBe('updated_at');
|
||||
expect(createdAt.dataType).toBe('number');
|
||||
expect(updatedAt.dataType).toBe('number');
|
||||
expect(createdAt.notNull).toBe(true);
|
||||
expect(updatedAt.notNull).toBe(true);
|
||||
});
|
||||
|
||||
it('maps createdBy and updatedBy to uuid columns referencing users.id', () => {
|
||||
const createdBy = getConfig(columns.createdBy);
|
||||
const updatedBy = getConfig(columns.updatedBy);
|
||||
|
||||
expect(createdBy.name).toBe('created_by');
|
||||
expect(updatedBy.name).toBe('updated_by');
|
||||
expect(createdBy.notNull).toBe(true);
|
||||
expect(updatedBy.notNull).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a minimal user table shape without importing circular schema deps', () => {
|
||||
const stub = { id: users.id as AnyPgColumn };
|
||||
const cols = primaryEntityColumns(stub);
|
||||
|
||||
expect(getConfig(cols.status).name).toBe('status');
|
||||
expect(getConfig(cols.createdBy).name).toBe('created_by');
|
||||
});
|
||||
});
|
||||
|
||||
function getConfig(column: { config: Record<string, unknown> }): {
|
||||
name: string;
|
||||
dataType: string;
|
||||
notNull: boolean;
|
||||
hasDefault?: boolean;
|
||||
default?: unknown;
|
||||
} {
|
||||
return column.config as {
|
||||
name: string;
|
||||
dataType: string;
|
||||
notNull: boolean;
|
||||
hasDefault?: boolean;
|
||||
default?: unknown;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { bigint, text, uuid, type AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||
import { Status } from '../common/value-objects/status/status';
|
||||
|
||||
/**
|
||||
* Standard audit columns for primary / aggregate tables.
|
||||
* Pass the users table (or `{ id }`) so FKs do not circular-import schema.ts.
|
||||
*/
|
||||
export function primaryEntityColumns(userTable: { id: AnyPgColumn }) {
|
||||
return {
|
||||
status: text('status').notNull().default(Status.DEFAULT),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
|
||||
createdBy: uuid('created_by')
|
||||
.notNull()
|
||||
.references(() => userTable.id),
|
||||
updatedBy: uuid('updated_by')
|
||||
.notNull()
|
||||
.references(() => userTable.id),
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user