- 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.
388 lines
11 KiB
TypeScript
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 };
|
|
}
|
|
}
|