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 { CreateEmployeeInput, Employee, UpdateEmployeeInput, } from './employee'; import { isValidEmployeeCode, isValidEmployeeName, isValidEmployeePosition, parseCsvRecord, type EmployeePosition, } from './employee-fields'; import { UsersService } from '../../users/users.service'; import { EmployeesRepository } from './employees.repository'; export type ListEmployeesQuery = { readonly code?: string; readonly name?: string; readonly phone?: string; readonly position?: string; readonly status?: string; readonly userId?: string; readonly search?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; }; const VISIBLE_FIELDS = [ 'id', 'code', 'name', 'phone', 'position', 'status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'user', ] as const; const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const; @Injectable() export class EmployeesService { constructor( private readonly employeesRepository: EmployeesRepository, private readonly usersService: UsersService, ) {} async list( query: ListEmployeesQuery, ): Promise>> { const page = toListPage(query); const { data, total } = await this.employeesRepository.list({ code: query.code, name: query.name, phone: query.phone, position: query.position, status: query.status, userId: query.userId, search: query.search, limit: page.limit, offset: page.offset, }); return { data: data.map((item) => this.toListItem(item)), total, }; } async findById( id: string, ): Promise> { const employee = await this.employeesRepository.findById(id); if (!employee) { throw new NotFoundException('Employee not found'); } return this.toListItem(employee); } async findByCode( code: string, ): Promise> { const employee = await this.employeesRepository.findByCode(code); if (!employee) { throw new NotFoundException('Employee not found'); } return this.toListItem(employee); } async create(input: { code: string; name: string; phone: string; position: string; status?: string; userId: string; assignedUserId?: string | null; }): Promise> { const created = await this.employeesRepository.create( await this.toCreateInput(input), ); return this.toListItem(created); } async update( id: string, input: { code?: string; name?: string; phone?: string; position?: string; status?: unknown; userId: string; assignedUserId?: string | null; }, ): Promise> { if (input.status !== undefined) { throw new BadRequestException('status cannot be updated via PATCH'); } const payload: UpdateEmployeeInput = { 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, position: input.position !== undefined ? this.assertPosition(input.position) : undefined, userId: input.userId, assignedUserId: await this.assertAssignedUserId(input.assignedUserId), }; const updated = await this.employeesRepository.update(id, payload); return this.toListItem(updated); } async updateStatus( id: string, statusRaw: string, userId: string, ): Promise> { const status = Status.create(statusRaw); const updated = await this.employeesRepository.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.employeesRepository.bulkUpdateStatus( ids, status, userId, ); return { updated }; } async delete(id: string): Promise { await this.employeesRepository.delete(id); } async bulkDelete(ids: string[]): Promise<{ deleted: number }> { const deleted = await this.employeesRepository.bulkDelete(ids); return { deleted }; } 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: CreateEmployeeInput[] = []; 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 assignedRaw = idx('userid') >= 0 ? cols[idx('userid')] : ''; rows.push( await this.toCreateInput({ code: cols[idx('code')] ?? '', name: cols[idx('name')] ?? '', phone: cols[idx('phone')] ?? '', position: cols[idx('position')] ?? '', status: statusRaw || undefined, userId, assignedUserId: assignedRaw || undefined, }), ); } 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.employeesRepository.createMany(rows); return { imported: rows.length }; } toListItem(employee: Employee) { return { id: employee.id, code: employee.code, name: employee.name, phone: employee.phone.value, position: employee.position, status: employee.status.value, createdAt: employee.createdAt.value, updatedAt: employee.updatedAt.value, createdBy: employee.createdBy, updatedBy: employee.updatedBy, user: employee.user, }; } get visibleFields(): readonly string[] { return VISIBLE_FIELDS; } private async toCreateInput(input: { code: string; name: string; phone: string; position: string; status?: string; userId: string; assignedUserId?: string | null; }): Promise { return { code: this.assertCode(input.code), name: this.assertName(input.name), phone: this.assertPhone(input.phone), position: this.assertPosition(input.position), status: input.status ? Status.create(input.status) : Status.create(Status.DEFAULT), userId: input.userId, assignedUserId: await this.assertAssignedUserId(input.assignedUserId), }; } private async assertAssignedUserId( userId?: string | null, ): Promise { if (userId === undefined) { return undefined; } if (userId === null || userId === '') { return null; } const user = await this.usersService.findById(userId); if (!user) { throw new NotFoundException('User not found'); } return userId; } private assertName(raw: string): string { const name = raw.trim(); if (!isValidEmployeeName(name)) { throw new BadRequestException('Invalid employee name'); } return name; } private assertCode(raw: string): string { const code = raw.trim(); if (!isValidEmployeeCode(code)) { throw new BadRequestException('Invalid employee code'); } return code; } private assertPosition(raw: string): EmployeePosition { const position = raw.trim(); if (!isValidEmployeePosition(position)) { throw new BadRequestException('Invalid employee position'); } return position; } private assertPhone(raw: string): PhoneNumber { try { return PhoneNumber.create(raw); } catch (error) { if (error instanceof InvalidPhoneNumberError) { throw new BadRequestException('Invalid phone number'); } throw error; } } }