Files
trackgo-be/src/modules/configuration/customers/customers.service.ts
T
shancheas 4c45a4371e 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.
2026-08-27 13:09:41 +07:00

527 lines
15 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } 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';
import type {
CreateCustomerInput,
Customer,
CustomerContact,
CustomerContactInput,
UpdateCustomerContactInput,
UpdateCustomerInput,
} from './customer';
import {
isValidContactJobTitle,
isValidContactName,
isValidContactNotes,
isValidCustomerAddress,
isValidCustomerCode,
isValidCustomerName,
isValidLatitude,
isValidLongitude,
isValidNfcId,
parseCsvRecord,
} from './customer-fields';
import { CustomersRepository } from './customers.repository';
export type ListCustomersQuery = {
readonly code?: string;
readonly name?: string;
readonly phone?: string;
readonly address?: string;
readonly nfcId?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
};
export type ContactBody = {
readonly name: string;
readonly jobTitle?: string | null;
readonly phone?: string | null;
readonly mobilePhone?: string | null;
readonly notes?: string | null;
};
const VISIBLE_FIELDS = [
'id',
'code',
'name',
'phone',
'address',
'latitude',
'longitude',
'nfcId',
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
] as const;
const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'address'] as const;
@Injectable()
export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {}
async list(
query: ListCustomersQuery,
): Promise<PaginationResponse<ReturnType<CustomersService['toListItem']>>> {
const page = toListPage(query);
const { data, total } = await this.customersRepository.list({
code: query.code,
name: query.name,
phone: query.phone,
address: query.address,
nfcId: query.nfcId,
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<CustomersService['toDetail']>> {
const customer = await this.customersRepository.findById(id);
if (!customer) {
throw new NotFoundException('Customer not found');
}
return this.toDetail(customer);
}
async findByCode(
code: string,
): Promise<ReturnType<CustomersService['toDetail']>> {
const customer = await this.customersRepository.findByCode(code);
if (!customer) {
throw new NotFoundException('Customer not found');
}
return this.toDetail(customer);
}
async create(input: {
code: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
nfcId?: string | null;
status?: string;
contacts?: ContactBody[];
userId: string;
}): Promise<ReturnType<CustomersService['toDetail']>> {
const created = await this.customersRepository.create(
this.toCreateInput(input),
);
return this.toDetail(created);
}
async update(
id: string,
input: {
code?: string;
name?: string;
phone?: string;
address?: string;
latitude?: number | null;
longitude?: number | null;
nfcId?: string | null;
contacts?: ContactBody[];
status?: unknown;
userId: string;
},
): Promise<ReturnType<CustomersService['toDetail']>> {
if (input.status !== undefined) {
throw new BadRequestException('status cannot be updated via PATCH');
}
const payload: UpdateCustomerInput = {
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
name: input.name !== undefined ? this.assertName(input.name) : undefined,
phone:
input.phone !== undefined ? this.assertPhone(input.phone) : undefined,
address:
input.address !== undefined
? this.assertAddress(input.address)
: undefined,
latitude:
input.latitude !== undefined
? this.assertLatitude(input.latitude)
: undefined,
longitude:
input.longitude !== undefined
? this.assertLongitude(input.longitude)
: undefined,
nfcId:
input.nfcId !== undefined ? this.assertNfcId(input.nfcId) : undefined,
contacts:
input.contacts !== undefined
? input.contacts.map((contact) => this.assertContact(contact))
: undefined,
userId: input.userId,
};
const updated = await this.customersRepository.update(id, payload);
return this.toDetail(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
): Promise<ReturnType<CustomersService['toDetail']>> {
const status = Status.create(statusRaw);
const updated = await this.customersRepository.updateStatus(
id,
status,
userId,
);
return this.toDetail(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
): Promise<{ updated: number }> {
const status = Status.create(statusRaw);
const updated = await this.customersRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string): Promise<void> {
await this.customersRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.customersRepository.bulkDelete(ids);
return { deleted };
}
async addContact(
customerId: string,
body: ContactBody,
userId: string,
): Promise<ReturnType<CustomersService['toDetail']>> {
const updated = await this.customersRepository.addContact(
customerId,
this.assertContact(body),
userId,
);
return this.toDetail(updated);
}
async updateContact(
customerId: string,
contactId: string,
body: Partial<ContactBody> & { userId: string },
): Promise<ReturnType<CustomersService['toDetail']>> {
const payload: UpdateCustomerContactInput = {
name:
body.name !== undefined ? this.assertContactName(body.name) : undefined,
jobTitle:
body.jobTitle !== undefined
? this.assertOptionalJobTitle(body.jobTitle)
: undefined,
phone:
body.phone !== undefined
? this.assertOptionalPhone(body.phone)
: undefined,
mobilePhone:
body.mobilePhone !== undefined
? this.assertOptionalPhone(body.mobilePhone)
: undefined,
notes:
body.notes !== undefined
? this.assertOptionalNotes(body.notes)
: undefined,
userId: body.userId,
};
const updated = await this.customersRepository.updateContact(
customerId,
contactId,
payload,
);
return this.toDetail(updated);
}
async deleteContact(customerId: string, contactId: string): Promise<void> {
await this.customersRepository.deleteContact(customerId, contactId);
}
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
const rawLines = csv.split(/\r?\n/);
const filled = rawLines
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
.filter((entry) => entry.line.length > 0);
if (filled.length === 0) {
throw new BadRequestException('CSV is empty');
}
if (filled.length > 501) {
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
}
const header = parseCsvRecord(filled[0].line).map((h) =>
h.trim().toLowerCase(),
);
const missing = CSV_REQUIRED_HEADERS.filter((h) => header.indexOf(h) < 0);
if (missing.length > 0) {
throw new BadRequestException('CSV must include required headers');
}
const idx = (key: string) => header.indexOf(key);
const errors: string[] = [];
const rows: CreateCustomerInput[] = [];
for (let i = 1; i < filled.length; i++) {
const cols = parseCsvRecord(filled[i].line);
const rowNum = filled[i].lineNo;
try {
const statusRaw = idx('status') >= 0 ? cols[idx('status')] : '';
const latitudeRaw =
idx('latitude') >= 0 ? cols[idx('latitude')] : undefined;
const longitudeRaw =
idx('longitude') >= 0 ? cols[idx('longitude')] : undefined;
const nfcRaw = idx('nfcid') >= 0 ? cols[idx('nfcid')] : undefined;
rows.push(
this.toCreateInput({
code: cols[idx('code')] ?? '',
name: cols[idx('name')] ?? '',
phone: cols[idx('phone')] ?? '',
address: cols[idx('address')] ?? '',
latitude:
latitudeRaw === undefined || latitudeRaw === ''
? undefined
: Number(latitudeRaw),
longitude:
longitudeRaw === undefined || longitudeRaw === ''
? undefined
: Number(longitudeRaw),
nfcId: nfcRaw || undefined,
status: statusRaw || undefined,
userId,
}),
);
} catch (error) {
const reason =
error instanceof BadRequestException ? error.message : 'invalid data';
errors.push(`row ${rowNum}: ${reason}`);
}
}
if (errors.length > 0) {
throw new BadRequestException({
message: 'CSV validation failed',
errors,
});
}
await this.customersRepository.createMany(rows);
return { imported: rows.length };
}
toListItem(customer: Customer) {
return {
id: customer.id,
code: customer.code,
name: customer.name,
phone: customer.phone.value,
address: customer.address,
latitude: customer.latitude,
longitude: customer.longitude,
nfcId: customer.nfcId,
status: customer.status.value,
createdAt: customer.createdAt.value,
updatedAt: customer.updatedAt.value,
createdBy: pickUserRelation(customer.createdByUser),
updatedBy: pickUserRelation(customer.updatedByUser),
};
}
toDetail(customer: Customer) {
return {
...this.toListItem(customer),
contacts: customer.contacts.map((contact) => this.toContactItem(contact)),
};
}
get visibleFields(): readonly string[] {
return VISIBLE_FIELDS;
}
private toContactItem(contact: CustomerContact) {
return {
id: contact.id,
customerId: contact.customerId,
name: contact.name,
jobTitle: contact.jobTitle,
phone: contact.phone?.value ?? null,
mobilePhone: contact.mobilePhone?.value ?? null,
notes: contact.notes,
};
}
private toCreateInput(input: {
code: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
nfcId?: string | null;
status?: string;
contacts?: ContactBody[];
userId: string;
}): CreateCustomerInput {
return {
code: this.assertCode(input.code),
name: this.assertName(input.name),
phone: this.assertPhone(input.phone),
address: this.assertAddress(input.address),
latitude: this.assertLatitude(input.latitude ?? null),
longitude: this.assertLongitude(input.longitude ?? null),
nfcId: this.assertNfcId(input.nfcId ?? null),
contacts: (input.contacts ?? []).map((contact) =>
this.assertContact(contact),
),
status: input.status
? Status.create(input.status)
: Status.create(Status.DEFAULT),
userId: input.userId,
};
}
private assertContact(raw: ContactBody): CustomerContactInput {
return {
name: this.assertContactName(raw.name),
jobTitle: this.assertOptionalJobTitle(raw.jobTitle ?? null),
phone: this.assertOptionalPhone(raw.phone ?? null),
mobilePhone: this.assertOptionalPhone(raw.mobilePhone ?? null),
notes: this.assertOptionalNotes(raw.notes ?? null),
};
}
private assertName(raw: string): string {
const name = raw.trim();
if (!isValidCustomerName(name)) {
throw new BadRequestException('Invalid customer name');
}
return name;
}
private assertCode(raw: string): string {
const code = raw.trim();
if (!isValidCustomerCode(code)) {
throw new BadRequestException('Invalid customer code');
}
return code;
}
private assertAddress(raw: string): string {
const address = raw.trim();
if (!isValidCustomerAddress(address)) {
throw new BadRequestException('Invalid customer address');
}
return address;
}
private assertPhone(raw: string): PhoneNumber {
try {
return PhoneNumber.create(raw);
} catch (error) {
if (error instanceof InvalidPhoneNumberError) {
throw new BadRequestException('Invalid phone number');
}
throw error;
}
}
private assertOptionalPhone(raw: string | null): PhoneNumber | null {
if (raw === null || raw.trim() === '') {
return null;
}
return this.assertPhone(raw);
}
private assertLatitude(raw: number | null): number | null {
if (raw === null) {
return null;
}
if (!isValidLatitude(raw)) {
throw new BadRequestException('Invalid latitude');
}
return raw;
}
private assertLongitude(raw: number | null): number | null {
if (raw === null) {
return null;
}
if (!isValidLongitude(raw)) {
throw new BadRequestException('Invalid longitude');
}
return raw;
}
private assertNfcId(raw: string | null): string | null {
if (raw === null || raw.trim() === '') {
return null;
}
const value = raw.trim();
if (!isValidNfcId(value)) {
throw new BadRequestException('Invalid NFC ID');
}
return value;
}
private assertContactName(raw: string): string {
const name = raw.trim();
if (!isValidContactName(name)) {
throw new BadRequestException('Invalid contact name');
}
return name;
}
private assertOptionalJobTitle(raw: string | null): string | null {
if (raw === null || raw.trim() === '') {
return null;
}
const value = raw.trim();
if (!isValidContactJobTitle(value)) {
throw new BadRequestException('Invalid contact job title');
}
return value;
}
private assertOptionalNotes(raw: string | null): string | null {
if (raw === null) {
return null;
}
if (!isValidContactNotes(raw)) {
throw new BadRequestException('Invalid contact notes');
}
return raw;
}
}