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.
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user