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