Files
trackgo-be/src/modules/field/attendances/attendances.repository.ts
T
shancheas 365a37b8d2 Add attendance and visit management features with database schema updates
- Introduced new `attendances` and `visits` tables to manage employee attendance and customer visits, including relevant fields for check-in and check-out details.
- Updated `company_settings` to include a `check_in_radius_meters` column for attendance validation.
- Implemented foreign key constraints to ensure data integrity between `attendances`, `visits`, `employees`, `branches`, and other related entities.
- Created new services and controllers for handling attendance and visit operations, including check-in, check-out, and bulk actions.
- Enhanced DTOs for attendance and visit data transfer, including validation for input data.
- Added unit and integration tests to validate the new functionalities and ensure proper handling of attendance and visit records.
- Created migration scripts to apply the necessary database schema changes for the new features.
2026-09-01 12:27:51 +07:00

345 lines
11 KiB
TypeScript

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<Attendance | null> {
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<Attendance | null> {
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<Attendance> {
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<Attendance> {
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<Attendance> {
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<number> {
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<void> {
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<number> {
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;
}
}