- 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.
527 lines
15 KiB
TypeScript
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;
|
|
}
|
|
}
|