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>> { 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> { const customer = await this.customersRepository.findById(id); if (!customer) { throw new NotFoundException('Customer not found'); } return this.toDetail(customer); } async findByCode( code: string, ): Promise> { 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> { 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> { 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> { 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 { 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> { const updated = await this.customersRepository.addContact( customerId, this.assertContact(body), userId, ); return this.toDetail(updated); } async updateContact( customerId: string, contactId: string, body: Partial & { userId: string }, ): Promise> { 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 { 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; } }