Enhance pagination and ordering capabilities in API responses

- Updated pagination-response and read-write-controllers documentation to include `orderBy` and `orderType` parameters for sorting results.
- Introduced new `order-clause` module to handle ordering logic, including validation for order types and columns.
- Enhanced `PaginationQueryDto` to support ordering fields in API requests.
- Updated various repository and service classes to implement ordering in database queries.
- Added unit tests for new ordering functionality and ensured existing tests cover the updated behavior.
- Refactored related DTOs to include user and code relations for better data representation in responses.
This commit is contained in:
shancheas
2026-08-27 13:09:41 +07:00
parent 790725e227
commit 4c45a4371e
85 changed files with 1824 additions and 365 deletions
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -27,6 +28,8 @@ export type Customer = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
};
export type CustomerContactInput = {
@@ -79,6 +82,8 @@ export type ListCustomersFilters = {
readonly nfcId?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
@@ -67,7 +67,13 @@ describe('CustomersRepository', () => {
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning }));
where.mockImplementation(() =>
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
limit,
orderBy,
returning,
}),
);
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]);
@@ -5,6 +5,8 @@ import {
NotFoundException,
} from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import { attachAuditUsers } from '../../../database/load-user-refs';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -26,6 +28,18 @@ import type {
UpdateCustomerInput,
} from './customer';
const CUSTOMER_ORDER_COLUMNS = {
id: customers.id,
code: customers.code,
name: customers.name,
phone: customers.phone,
address: customers.address,
nfcId: customers.nfcId,
status: customers.status,
createdAt: customers.createdAt,
updatedAt: customers.updatedAt,
};
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
@Injectable()
@@ -46,12 +60,16 @@ export class CustomersRepository {
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(customers.code))
.orderBy(
...toOrderClauses(CUSTOMER_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row, [])),
data: await Promise.all(rows.map((row) => this.hydrate(row, []))),
total: Number(totalRow?.total ?? 0),
};
}
@@ -75,7 +93,7 @@ export class CustomersRepository {
return null;
}
const contacts = await this.selectContacts(this.db, id);
return this.toDomain(row, contacts);
return this.hydrate(row, contacts);
}
async findByCode(code: string): Promise<Customer | null> {
@@ -89,7 +107,7 @@ export class CustomersRepository {
return null;
}
const contacts = await this.selectContacts(this.db, row.id);
return this.toDomain(row, contacts);
return this.hydrate(row, contacts);
}
async create(input: CreateCustomerInput): Promise<Customer> {
@@ -104,7 +122,7 @@ export class CustomersRepository {
const row = inserted[0];
await this.replaceContacts(tx, row.id, input.contacts ?? []);
const contacts = await this.selectContacts(tx, row.id);
return this.toDomain(row, contacts);
return this.hydrate(row, contacts);
});
} catch (error) {
this.rethrowConstraintViolation(error);
@@ -170,7 +188,7 @@ export class CustomersRepository {
await this.replaceContacts(tx, id, input.contacts);
}
const contacts = await this.selectContacts(tx, id);
return this.toDomain(row, contacts);
return this.hydrate(row, contacts);
});
} catch (error) {
this.rethrowConstraintViolation(error);
@@ -197,7 +215,7 @@ export class CustomersRepository {
throw new NotFoundException('Customer not found');
}
const contacts = await this.selectContacts(this.db, id);
return this.toDomain(row, contacts);
return this.hydrate(row, contacts);
}
async bulkUpdateStatus(
@@ -437,10 +455,17 @@ export class CustomersRepository {
};
}
private toDomain(
private async hydrate(
row: CustomerRow,
contactRows: CustomerContactRow[],
): Customer {
): Promise<Customer> {
const [item] = await attachAuditUsers(this.db, [
this.toDomain(row, contactRows),
]);
return item;
}
private toDomain(row: CustomerRow, contactRows: CustomerContactRow[]) {
return {
id: row.id,
code: row.code,
@@ -52,6 +52,8 @@ describe('CustomersService', () => {
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
updatedBy: 'user-1',
};
@@ -4,7 +4,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } from '../../../common/http/response';
import { pickUserRelation, toListPage } from '../../../common/http/response';
import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -38,6 +38,8 @@ export type ListCustomersQuery = {
readonly nfcId?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -85,6 +87,8 @@ export class CustomersService {
nfcId: query.nfcId,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
@@ -350,8 +354,8 @@ export class CustomersService {
status: customer.status.value,
createdAt: customer.createdAt.value,
updatedAt: customer.updatedAt.value,
createdBy: customer.createdBy,
updatedBy: customer.updatedBy,
createdBy: pickUserRelation(customer.createdByUser),
updatedBy: pickUserRelation(customer.updatedByUser),
};
}
@@ -15,7 +15,10 @@ import {
Min,
ValidateNested,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import {
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import {
CONTACT_JOB_TITLE_MAX_LENGTH,
@@ -347,9 +350,9 @@ export class CustomerDto {
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}