import { ConflictException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { and, count, eq, ilike, inArray, isNull, or, SQL } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { toOrderClauses } from '../../../common/http/response/order-clause'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Status } from '../../../common/value-objects/status/status'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; import { attendances, type AttendanceRow, } from '../../../database/attendances-table'; import { branches } from '../../../database/branches-table'; import { divisions, employees, users } from '../../../database/schema'; import type { Attendance, CheckOutAttendanceInput, CreateAttendanceInput, ListAttendancesFilters, } from './attendance'; import type { CheckInMethod } from '../shared/check-in-verification'; const ATTENDANCE_ORDER_COLUMNS = { id: attendances.id, date: attendances.date, status: attendances.status, createdAt: attendances.createdAt, updatedAt: attendances.updatedAt, }; const createdByUsers = alias(users, 'attendance_created_by_users'); const updatedByUsers = alias(users, 'attendance_updated_by_users'); type AttendanceJoinedRow = { attendance: AttendanceRow; employee: typeof employees.$inferSelect; branch: typeof branches.$inferSelect; division: typeof divisions.$inferSelect | null; createdByUser: typeof users.$inferSelect | null; updatedByUser: typeof users.$inferSelect | null; }; @Injectable() export class AttendancesRepository { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} async list( filters: ListAttendancesFilters, ): Promise<{ data: Attendance[]; total: number }> { const where = this.buildListWhere(filters); const totalRows = await this.db .select({ total: count() }) .from(attendances) .where(where); const totalRow = totalRows[0]; const rows = await this.selectWithRelations() .where(where) .orderBy( ...toOrderClauses(ATTENDANCE_ORDER_COLUMNS, filters, [ { column: 'date', type: 'DESC' }, ]), ) .limit(filters.limit) .offset(filters.offset); return { data: rows.map((row) => this.toDomain(row)), total: Number(totalRow?.total ?? 0), }; } async findById(id: string): Promise { const rows = await this.selectWithRelations() .where(eq(attendances.id, id)) .limit(1); const row = rows[0]; return row ? this.toDomain(row) : null; } async findOpenByEmployeeId(employeeId: string): Promise { const rows = await this.selectWithRelations() .where( and( eq(attendances.employeeId, employeeId), isNull(attendances.checkOutAt), ), ) .limit(1); const row = rows[0]; return row ? this.toDomain(row) : null; } async create(input: CreateAttendanceInput): Promise { const now = DateTime.fromUnixMs(Date.now()); try { const inserted = await this.db .insert(attendances) .values({ employeeId: input.employeeId, branchId: input.branchId, date: input.date.value, checkInAt: input.checkInAt.value, checkInMethod: input.checkInMethod, checkInLatitude: input.checkInLatitude, checkInLongitude: input.checkInLongitude, checkInPhotoUrl: input.checkInPhotoUrl, checkInDistanceMeters: input.checkInDistanceMeters, status: Status.create('active').value, createdAt: now.value, updatedAt: now.value, createdBy: input.userId, updatedBy: input.userId, }) .returning(); const created = await this.findById(inserted[0].id); if (!created) { throw new NotFoundException('Attendance not found'); } return created; } catch (error) { this.rethrowUniqueViolation(error); } } async checkOut( id: string, input: CheckOutAttendanceInput, ): Promise { const existing = await this.findById(id); if (!existing) { throw new NotFoundException('Attendance not found'); } if (existing.checkOutAt) { throw new ConflictException('Attendance is already checked out'); } const now = DateTime.fromUnixMs(Date.now()); await this.db .update(attendances) .set({ checkOutAt: input.checkOutAt.value, checkOutMethod: input.checkOutMethod, checkOutLatitude: input.checkOutLatitude, checkOutLongitude: input.checkOutLongitude, checkOutPhotoUrl: input.checkOutPhotoUrl, checkOutDistanceMeters: input.checkOutDistanceMeters, updatedAt: now.value, updatedBy: input.userId, }) .where(eq(attendances.id, id)); const updated = await this.findById(id); if (!updated) { throw new NotFoundException('Attendance not found'); } return updated; } async updateStatus( id: string, status: Status, userId: string, ): Promise { const now = DateTime.fromUnixMs(Date.now()); const updated = await this.db .update(attendances) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(eq(attendances.id, id)) .returning({ id: attendances.id }); if (updated.length === 0) { throw new NotFoundException('Attendance not found'); } const row = await this.findById(id); if (!row) { throw new NotFoundException('Attendance not found'); } return row; } 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(attendances) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(inArray(attendances.id, ids)) .returning({ id: attendances.id }); return rows.length; } async delete(id: string): Promise { const deleted = await this.db .delete(attendances) .where(eq(attendances.id, id)) .returning({ id: attendances.id }); if (deleted.length === 0) { throw new NotFoundException('Attendance not found'); } } async bulkDelete(ids: string[]): Promise { if (ids.length === 0) { return 0; } const deleted = await this.db .delete(attendances) .where(inArray(attendances.id, ids)) .returning({ id: attendances.id }); return deleted.length; } private selectWithRelations() { return this.db .select({ attendance: attendances, employee: employees, branch: branches, division: divisions, createdByUser: createdByUsers, updatedByUser: updatedByUsers, }) .from(attendances) .innerJoin(employees, eq(attendances.employeeId, employees.id)) .innerJoin(branches, eq(attendances.branchId, branches.id)) .leftJoin(divisions, eq(branches.divisionId, divisions.id)) .leftJoin(createdByUsers, eq(attendances.createdBy, createdByUsers.id)) .leftJoin(updatedByUsers, eq(attendances.updatedBy, updatedByUsers.id)); } private buildListWhere(filters: ListAttendancesFilters): SQL | undefined { const parts: SQL[] = []; if (filters.employeeId) { parts.push(eq(attendances.employeeId, filters.employeeId)); } if (filters.branchId) { parts.push(eq(attendances.branchId, filters.branchId)); } if (filters.date !== undefined) { parts.push(eq(attendances.date, filters.date)); } if (filters.status) { parts.push(eq(attendances.status, filters.status)); } if (filters.search) { const search = or( ilike(employees.code, `%${filters.search}%`), ilike(employees.name, `%${filters.search}%`), ilike(branches.code, `%${filters.search}%`), ilike(branches.name, `%${filters.search}%`), ); if (search) { parts.push(search); } } if (parts.length === 0) { return undefined; } return parts.length === 1 ? parts[0] : and(...parts); } private toDomain(row: AttendanceJoinedRow): Attendance { const attendance = row.attendance; return { id: attendance.id, employeeId: attendance.employeeId, branchId: attendance.branchId, date: DateTime.fromUnixMs(attendance.date), checkInAt: DateTime.fromUnixMs(attendance.checkInAt), checkInMethod: attendance.checkInMethod as CheckInMethod, checkInLatitude: attendance.checkInLatitude, checkInLongitude: attendance.checkInLongitude, checkInPhotoUrl: attendance.checkInPhotoUrl, checkInDistanceMeters: attendance.checkInDistanceMeters, checkOutAt: attendance.checkOutAt ? DateTime.fromUnixMs(attendance.checkOutAt) : null, checkOutMethod: attendance.checkOutMethod as CheckInMethod | null, checkOutLatitude: attendance.checkOutLatitude, checkOutLongitude: attendance.checkOutLongitude, checkOutPhotoUrl: attendance.checkOutPhotoUrl, checkOutDistanceMeters: attendance.checkOutDistanceMeters, status: Status.create(attendance.status), createdAt: DateTime.fromUnixMs(attendance.createdAt), updatedAt: DateTime.fromUnixMs(attendance.updatedAt), createdBy: attendance.createdBy, updatedBy: attendance.updatedBy, employee: { id: row.employee.id, code: row.employee.code, name: row.employee.name, }, branch: { id: row.branch.id, code: row.branch.code, name: row.branch.name, division: row.division ? { id: row.division.id, code: row.division.code, name: row.division.name, } : null, }, createdByUser: row.createdByUser ? { id: row.createdByUser.id, username: row.createdByUser.username } : null, updatedByUser: row.updatedByUser ? { id: row.updatedByUser.id, username: row.updatedByUser.username } : null, }; } private rethrowUniqueViolation(error: unknown): never { let current: unknown = error; for (let i = 0; i < 5; i++) { if (!current || typeof current !== 'object') { break; } const obj = current as { code?: string; cause?: unknown }; if (obj.code === '23505') { throw new ConflictException('Attendance already exists for this shift'); } current = obj.cause; } throw error; } }