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(qb: T, filters: ListEmployeesFilters): T { void filters; return qb; } async findById(id: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 }; } }