Add employees management module with database schema and validation
- Introduced `EmployeesModule` to manage employee data, including read and write controllers. - Created database migrations for the `employees` table, including constraints and unique indexes. - Implemented validation for employee fields such as name, code, and position with corresponding utility functions. - Developed service and repository layers for handling employee data operations. - Added unit tests for the employees service, repository, and controllers to ensure functionality and correctness. - Updated application module to include the new `EmployeesModule` for better organization.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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 { EmployeesRepository } from './employees.repository';
|
||||
|
||||
export type ListEmployeesQuery = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly phone?: string;
|
||||
readonly position?: string;
|
||||
readonly status?: 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',
|
||||
] as const;
|
||||
|
||||
const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class EmployeesService {
|
||||
constructor(private readonly employeesRepository: EmployeesRepository) {}
|
||||
|
||||
async list(
|
||||
query: ListEmployeesQuery,
|
||||
): Promise<PaginationResponse<ReturnType<EmployeesService['toListItem']>>> {
|
||||
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,
|
||||
search: query.search,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
const employee = await this.employeesRepository.findById(id);
|
||||
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;
|
||||
}): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
const created = await this.employeesRepository.create(
|
||||
this.toCreateInput(input),
|
||||
);
|
||||
return this.toListItem(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
code?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
position?: string;
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
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,
|
||||
};
|
||||
const updated = await this.employeesRepository.update(id, payload);
|
||||
return this.toListItem(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
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<void> {
|
||||
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')] : '';
|
||||
rows.push(
|
||||
this.toCreateInput({
|
||||
code: cols[idx('code')] ?? '',
|
||||
name: cols[idx('name')] ?? '',
|
||||
phone: cols[idx('phone')] ?? '',
|
||||
position: cols[idx('position')] ?? '',
|
||||
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.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,
|
||||
};
|
||||
}
|
||||
|
||||
get visibleFields(): readonly string[] {
|
||||
return VISIBLE_FIELDS;
|
||||
}
|
||||
|
||||
private toCreateInput(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
position: string;
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): CreateEmployeeInput {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user