Add customers management module with database schema and validation
- Introduced `CustomersModule` to manage customer data, including read and write controllers. - Created database migrations for the `customers` and `customer_contacts` tables, including constraints and unique indexes. - Implemented validation for customer fields such as name, code, and address with corresponding utility functions. - Developed service and repository layers for handling customer data operations. - Added unit tests for the customers service, repository, and controllers to ensure functionality and correctness. - Updated application module to include the new `CustomersModule` for better organization.
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { 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 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,
|
||||
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 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: customer.createdBy,
|
||||
updatedBy: customer.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user