Files
trackgo-be/src/modules/configuration/employees/employees.repository.ts
T
shancheas 627aeac4a0 Add API documentation for TrackGo HTTP API and enhance employee management features
- Created a new `api.md` file detailing the TrackGo HTTP API, including authentication, user management, and employee operations.
- Updated `Employee` type to simplify user relation handling by replacing `UserRelation` with a more concise structure.
- Enhanced filtering capabilities in employee queries to support an array of positions.
- Refactored employee-related services and repositories to accommodate the new position filtering logic.
- Added unit and e2e tests to validate the new API documentation and employee management functionalities.
2026-08-27 15:07:20 +07:00

388 lines
11 KiB
TypeScript

import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response/order-clause';
import { attachAuditUsers } from '../../../database/load-user-refs';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { employees, type EmployeeRow } from '../../../database/employees-table';
import { users } from '../../../database/schema';
import type { EmployeePosition } from './employee-fields';
import type {
CreateEmployeeInput,
Employee,
ListEmployeesFilters,
UpdateEmployeeInput,
} from './employee';
const EMPLOYEE_ORDER_COLUMNS = {
id: employees.id,
code: employees.code,
name: employees.name,
phone: employees.phone,
position: employees.position,
status: employees.status,
createdAt: employees.createdAt,
updatedAt: employees.updatedAt,
};
@Injectable()
export class EmployeesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListEmployeesFilters,
): Promise<{ data: Employee[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(employees)
.where(where);
const totalRow = totalRows[0];
let qb = this.db.select().from(employees).$dynamic();
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(
...toOrderClauses(EMPLOYEE_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
const mapped = await Promise.all(
rows.map(async (row) =>
this.hydrateOne(row, await this.loadAssignedUser(row.userId)),
),
);
return {
data: mapped,
total: Number(totalRow?.total ?? 0),
};
}
/**
* Hook for modules to add joins/extra predicates without forking list.
*/
extendListQuery<T>(qb: T, filters: ListEmployeesFilters): T {
void filters;
return qb;
}
async findById(id: string): Promise<Employee | null> {
const rows = await this.db
.select()
.from(employees)
.where(eq(employees.id, id))
.limit(1);
const row = rows[0];
return row
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
: null;
}
async findByCode(code: string): Promise<Employee | null> {
const rows = await this.db
.select()
.from(employees)
.where(eq(employees.code, code))
.limit(1);
const row = rows[0];
return row
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
: null;
}
async findByUserId(userId: string): Promise<Employee | null> {
const rows = await this.db
.select()
.from(employees)
.where(eq(employees.userId, userId))
.limit(1);
const row = rows[0];
return row
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
: null;
}
async create(input: CreateEmployeeInput): Promise<Employee> {
const now = DateTime.fromUnixMs(Date.now());
const status = input.status ?? Status.create(Status.DEFAULT);
try {
const inserted = await this.db
.insert(employees)
.values(this.toInsertValues(input, status, now, input.userId))
.returning();
const row = inserted[0];
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
} catch (error) {
this.rethrowUniqueViolation(error);
}
}
async createMany(inputs: CreateEmployeeInput[]): Promise<number> {
if (inputs.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
try {
await this.db.transaction(async (tx) => {
for (const input of inputs) {
const status = input.status ?? Status.create(Status.DEFAULT);
await tx
.insert(employees)
.values(this.toInsertValues(input, status, now, input.userId));
}
});
return inputs.length;
} catch (error) {
this.rethrowUniqueViolation(error);
}
}
async update(id: string, input: UpdateEmployeeInput): Promise<Employee> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Employee not found');
}
const now = DateTime.fromUnixMs(Date.now());
try {
const updated = await this.db
.update(employees)
.set({
code: input.code ?? existing.code,
name: input.name ?? existing.name,
phone: input.phone?.value ?? existing.phone.value,
position: input.position ?? existing.position,
updatedAt: now.value,
updatedBy: input.userId,
...(input.assignedUserId !== undefined
? { userId: input.assignedUserId }
: {}),
})
.where(eq(employees.id, id))
.returning();
const row = updated[0];
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
} catch (error) {
this.rethrowUniqueViolation(error);
}
}
async updateStatus(
id: string,
status: Status,
userId: string,
): Promise<Employee> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(employees)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(employees.id, id))
.returning();
const row = updated[0];
if (!row) {
throw new NotFoundException('Employee not found');
}
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
}
async bulkUpdateStatus(
ids: string[],
status: Status,
userId: string,
): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(employees)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(employees.id, ids))
.returning({ id: employees.id });
return rows.length;
}
async delete(id: string): Promise<void> {
const deleted = await this.db
.delete(employees)
.where(eq(employees.id, id))
.returning({ id: employees.id });
if (deleted.length === 0) {
throw new NotFoundException('Employee not found');
}
}
async bulkDelete(ids: string[]): Promise<number> {
if (ids.length === 0) {
return 0;
}
const deleted = await this.db
.delete(employees)
.where(inArray(employees.id, ids))
.returning({ id: employees.id });
return deleted.length;
}
private buildListWhere(filters: ListEmployeesFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.code) {
parts.push(ilike(employees.code, `%${filters.code}%`));
}
if (filters.name) {
parts.push(ilike(employees.name, `%${filters.name}%`));
}
if (filters.phone) {
parts.push(ilike(employees.phone, `%${filters.phone}%`));
}
if (filters.position && filters.position.length > 0) {
parts.push(inArray(employees.position, [...filters.position]));
}
if (filters.status) {
parts.push(eq(employees.status, filters.status));
}
if (filters.userId) {
parts.push(eq(employees.userId, filters.userId));
}
if (filters.search) {
const search = or(
ilike(employees.code, `%${filters.search}%`),
ilike(employees.name, `%${filters.search}%`),
);
if (search) {
parts.push(search);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toInsertValues(
input: CreateEmployeeInput,
status: Status,
now: DateTime,
userId: string,
) {
return {
code: input.code,
name: input.name,
phone: input.phone.value,
position: input.position,
status: status.value,
createdAt: now.value,
updatedAt: now.value,
createdBy: userId,
updatedBy: userId,
userId: input.assignedUserId ?? null,
};
}
private selectWithUser() {
return this.db
.select({
employee: employees,
user: {
id: users.id,
username: users.username,
},
})
.from(employees)
.leftJoin(users, eq(employees.userId, users.id));
}
private async loadAssignedUser(
userId: string | null,
): Promise<{ id: string; username: string } | null> {
if (!userId) {
return null;
}
const rows = await this.db
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return rows[0] ?? null;
}
private toDomain(
row: EmployeeRow,
user: { id: string; username: string } | null,
) {
return {
id: row.id,
code: row.code,
name: row.name,
phone: PhoneNumber.create(row.phone),
position: row.position as EmployeePosition,
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
userId: row.userId ?? null,
user: user?.id ? { id: user.id, username: user.username } : null,
};
}
private async hydrateOne(
row: EmployeeRow,
user: { id: string; username: string } | null,
): Promise<Employee> {
const [item] = await attachAuditUsers(this.db, [this.toDomain(row, user)]);
return item;
}
private rethrowUniqueViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23505') {
const constraint = err.constraint ?? '';
if (constraint.includes('user_id')) {
throw new ConflictException('User is already assigned to an employee');
}
throw new ConflictException('Employee code already exists');
}
throw error;
}
private unwrapDbError(error: unknown): {
code?: string;
constraint?: string;
} {
let current: unknown = error;
for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') {
break;
}
const obj = current as {
code?: string;
constraint?: string;
cause?: unknown;
};
if (obj.code === '23505' || obj.code === '23503') {
return { code: obj.code, constraint: obj.constraint };
}
current = obj.cause;
}
return error as { code?: string; constraint?: string };
}
}