- 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.
253 lines
6.9 KiB
TypeScript
253 lines
6.9 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import type { PaginationResponse } from '../../../common/http/response';
|
|
import { pickUserRelation, toListPage } from '../../../common/http/response';
|
|
import { Status } from '../../../common/value-objects/status/status';
|
|
import type {
|
|
CreateDivisionInput,
|
|
Division,
|
|
UpdateDivisionInput,
|
|
} from './division';
|
|
import { isValidDivisionCode, isValidDivisionName } from './division-fields';
|
|
import { DivisionsRepository } from './divisions.repository';
|
|
|
|
export type ListDivisionsQuery = {
|
|
readonly name?: string;
|
|
readonly code?: string;
|
|
readonly status?: string;
|
|
readonly search?: string;
|
|
readonly orderBy?: string;
|
|
readonly orderType?: string;
|
|
readonly page?: number;
|
|
readonly limit?: number;
|
|
readonly offset?: number;
|
|
};
|
|
|
|
const VISIBLE_FIELDS = [
|
|
'id',
|
|
'name',
|
|
'code',
|
|
'status',
|
|
'createdAt',
|
|
'updatedAt',
|
|
'createdBy',
|
|
'updatedBy',
|
|
] as const;
|
|
|
|
@Injectable()
|
|
export class DivisionsService {
|
|
constructor(private readonly divisionsRepository: DivisionsRepository) {}
|
|
|
|
async list(
|
|
query: ListDivisionsQuery,
|
|
): Promise<PaginationResponse<ReturnType<DivisionsService['toListItem']>>> {
|
|
const page = toListPage(query);
|
|
const { data, total } = await this.divisionsRepository.list({
|
|
name: query.name,
|
|
code: query.code,
|
|
status: query.status,
|
|
search: query.search,
|
|
orderBy: query.orderBy,
|
|
orderType: query.orderType,
|
|
limit: page.limit,
|
|
offset: page.offset,
|
|
});
|
|
return {
|
|
data: data.map((item) => this.toListItem(item)),
|
|
total,
|
|
};
|
|
}
|
|
|
|
async findById(
|
|
id: string,
|
|
): Promise<ReturnType<DivisionsService['toListItem']>> {
|
|
const division = await this.divisionsRepository.findById(id);
|
|
if (!division) {
|
|
throw new NotFoundException('Division not found');
|
|
}
|
|
return this.toListItem(division);
|
|
}
|
|
|
|
async create(input: {
|
|
name: string;
|
|
code: string;
|
|
status?: string;
|
|
userId: string;
|
|
}): Promise<ReturnType<DivisionsService['toListItem']>> {
|
|
const name = this.assertName(input.name);
|
|
const code = this.assertCode(input.code);
|
|
const created = await this.divisionsRepository.create({
|
|
name,
|
|
code,
|
|
status: input.status
|
|
? Status.create(input.status)
|
|
: Status.create(Status.DEFAULT),
|
|
userId: input.userId,
|
|
});
|
|
return this.toListItem(created);
|
|
}
|
|
|
|
async update(
|
|
id: string,
|
|
input: {
|
|
name?: string;
|
|
code?: string;
|
|
status?: unknown;
|
|
userId: string;
|
|
},
|
|
): Promise<ReturnType<DivisionsService['toListItem']>> {
|
|
if (input.status !== undefined) {
|
|
throw new BadRequestException('status cannot be updated via PATCH');
|
|
}
|
|
const payload: UpdateDivisionInput = {
|
|
name: input.name !== undefined ? this.assertName(input.name) : undefined,
|
|
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
|
userId: input.userId,
|
|
};
|
|
const updated = await this.divisionsRepository.update(id, payload);
|
|
return this.toListItem(updated);
|
|
}
|
|
|
|
async updateStatus(
|
|
id: string,
|
|
statusRaw: string,
|
|
userId: string,
|
|
): Promise<ReturnType<DivisionsService['toListItem']>> {
|
|
const status = Status.create(statusRaw);
|
|
const updated = await this.divisionsRepository.updateStatus(
|
|
id,
|
|
status,
|
|
userId,
|
|
);
|
|
return this.toListItem(updated);
|
|
}
|
|
|
|
async bulkUpdateStatus(
|
|
ids: string[],
|
|
statusRaw: string,
|
|
userId: string,
|
|
): Promise<{ updated: number }> {
|
|
const status = Status.create(statusRaw);
|
|
const updated = await this.divisionsRepository.bulkUpdateStatus(
|
|
ids,
|
|
status,
|
|
userId,
|
|
);
|
|
return { updated };
|
|
}
|
|
|
|
async delete(id: string): Promise<void> {
|
|
await this.divisionsRepository.delete(id);
|
|
}
|
|
|
|
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
|
const deleted = await this.divisionsRepository.bulkDelete(ids);
|
|
return { deleted };
|
|
}
|
|
|
|
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
|
const lines = csv
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
if (lines.length === 0) {
|
|
throw new BadRequestException('CSV is empty');
|
|
}
|
|
if (lines.length > 501) {
|
|
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
|
}
|
|
|
|
const header = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
|
const nameIdx = header.indexOf('name');
|
|
const codeIdx = header.indexOf('code');
|
|
const statusIdx = header.indexOf('status');
|
|
if (nameIdx < 0 || codeIdx < 0) {
|
|
throw new BadRequestException('CSV must include name and code headers');
|
|
}
|
|
|
|
const errors: string[] = [];
|
|
const rows: { name: string; code: string; status?: string }[] = [];
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const cols = lines[i].split(',').map((c) => c.trim());
|
|
const name = cols[nameIdx] ?? '';
|
|
const code = cols[codeIdx] ?? '';
|
|
const status = statusIdx >= 0 ? cols[statusIdx] : undefined;
|
|
if (!name || !code) {
|
|
errors.push(`row ${i + 1}: name and code are required`);
|
|
continue;
|
|
}
|
|
if (!isValidDivisionName(name)) {
|
|
errors.push(`row ${i + 1}: invalid name`);
|
|
continue;
|
|
}
|
|
if (!isValidDivisionCode(code)) {
|
|
errors.push(`row ${i + 1}: invalid code`);
|
|
continue;
|
|
}
|
|
if (status) {
|
|
try {
|
|
Status.create(status);
|
|
} catch {
|
|
errors.push(`row ${i + 1}: invalid status`);
|
|
continue;
|
|
}
|
|
}
|
|
rows.push({ name, code, status: status || undefined });
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
throw new BadRequestException({
|
|
message: 'CSV validation failed',
|
|
errors,
|
|
});
|
|
}
|
|
|
|
const inputs: CreateDivisionInput[] = rows.map((row) => ({
|
|
name: row.name,
|
|
code: row.code,
|
|
status: row.status
|
|
? Status.create(row.status)
|
|
: Status.create(Status.DEFAULT),
|
|
userId,
|
|
}));
|
|
await this.divisionsRepository.createMany(inputs);
|
|
return { imported: rows.length };
|
|
}
|
|
|
|
toListItem(division: Division) {
|
|
return {
|
|
id: division.id,
|
|
name: division.name,
|
|
code: division.code,
|
|
status: division.status.value,
|
|
createdAt: division.createdAt.value,
|
|
updatedAt: division.updatedAt.value,
|
|
createdBy: pickUserRelation(division.createdByUser),
|
|
updatedBy: pickUserRelation(division.updatedByUser),
|
|
};
|
|
}
|
|
|
|
get visibleFields(): readonly string[] {
|
|
return VISIBLE_FIELDS;
|
|
}
|
|
|
|
private assertName(raw: string): string {
|
|
const name = raw.trim();
|
|
if (!isValidDivisionName(name)) {
|
|
throw new BadRequestException('Invalid division name');
|
|
}
|
|
return name;
|
|
}
|
|
|
|
private assertCode(raw: string): string {
|
|
const code = raw.trim();
|
|
if (!isValidDivisionCode(code)) {
|
|
throw new BadRequestException('Invalid division code');
|
|
}
|
|
return code;
|
|
}
|
|
}
|