Files
trackgo-be/src/modules/configuration/employees/employees.service.ts
T
shancheas 8a61c94078 Add user and employee management enhancements with database schema updates
- Introduced new columns `status`, `created_by`, and `updated_by` in the `users` table to track user status and ownership.
- Updated the `employees` table to include a foreign key reference to the `users` table via `user_id`.
- Created migration script `0012_users_primary.sql` to apply these changes to the database schema.
- Enhanced the `EmployeesService` and `EmployeesRepository` to support user assignments and related data retrieval.
- Updated DTOs and service methods to reflect the new user and employee relationships.
- Added unit tests to validate the new functionality and ensure data integrity.
- Modified existing controllers to accommodate the new fields and relationships in user and employee management.
2026-08-26 15:29:18 +07:00

337 lines
9.3 KiB
TypeScript

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<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,
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<ReturnType<EmployeesService['toListItem']>> {
const employee = await this.employeesRepository.findById(id);
if (!employee) {
throw new NotFoundException('Employee not found');
}
return this.toListItem(employee);
}
async findByCode(
code: string,
): Promise<ReturnType<EmployeesService['toListItem']>> {
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<ReturnType<EmployeesService['toListItem']>> {
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<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,
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<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')] : '';
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<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,
assignedUserId: await this.assertAssignedUserId(input.assignedUserId),
};
}
private async assertAssignedUserId(
userId?: string | null,
): Promise<string | null | undefined> {
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;
}
}
}