- 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.
337 lines
9.3 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|