Files
trackgo-be/src/modules/field/visits/visits.service.ts
T
shancheas ec5f012d6f Add SKIP_GPS_VALIDATION feature for attendance and visit services
- Introduced SKIP_GPS_VALIDATION environment variable to control GPS validation during check-ins and check-outs.
- Updated loadEnv function to parse SKIP_GPS_VALIDATION and enforce its rules based on NODE_ENV.
- Enhanced attendance and visit services to utilize the new configuration, allowing GPS validation to be skipped in development environments.
- Added unit tests to verify the behavior of SKIP_GPS_VALIDATION in various scenarios, ensuring proper handling in both production and development contexts.
- Updated check-in verification logic to respect the SKIP_GPS_VALIDATION option, improving flexibility in location checks.
2026-09-01 22:01:25 +07:00

303 lines
9.4 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { PaginationResponse } from '../../../common/http/response';
import {
pickRelation,
pickUserRelation,
DEFAULT_RELATION_FIELDS,
toListPage,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { CustomersService } from '../../configuration/customers/customers.service';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { AttendancesRepository } from '../attendances/attendances.repository';
import { CompanySettingsService } from '../settings/company-settings.service';
import {
isCheckInMethod,
verifyCustomerCheckIn,
type CheckInPayload,
type CheckInVerificationOptions,
} from '../shared/check-in-verification';
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
import type { Visit } from './visit';
import type {
ListVisitsQueryDto,
VisitCheckInDto,
VisitCheckOutDto,
VisitDto,
} from './dto/visit.dto';
import { VisitsRepository } from './visits.repository';
@Injectable()
export class VisitsService {
constructor(
private readonly visitsRepository: VisitsRepository,
private readonly attendancesRepository: AttendancesRepository,
private readonly customersService: CustomersService,
private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService,
private readonly timelineActivitiesService: TimelineActivitiesService,
private readonly config: ConfigService,
) {}
async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> {
const page = toListPage(query);
const { data, total } = await this.visitsRepository.list({
employeeId: query.employeeId,
customerId: query.customerId,
date: query.date,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
return {
data: data.map((item) => this.toItem(item)),
total,
};
}
async findById(id: string): Promise<VisitDto> {
const visit = await this.visitsRepository.findById(id);
if (!visit) {
throw new NotFoundException('Visit not found');
}
return this.toItem(visit);
}
async findCurrent(userId: string): Promise<VisitDto | null> {
const employee = await this.employeesService.requireByUserId(userId);
const visit = await this.visitsRepository.findOpenByEmployeeId(employee.id);
return visit ? this.toItem(visit) : null;
}
async checkIn(dto: VisitCheckInDto, userId: string): Promise<VisitDto> {
const employee = await this.employeesService.requireByUserId(userId);
const openVisit = await this.visitsRepository.findOpenByEmployeeId(
employee.id,
);
if (openVisit) {
throw new ConflictException('A customer visit is already open');
}
const openAttendance =
await this.attendancesRepository.findOpenByEmployeeId(employee.id);
if (!openAttendance) {
throw new BadRequestException(
'Branch check-in is required before visiting a customer',
);
}
const customer = await this.customersService.findById(dto.customerId);
const radiusMeters =
await this.companySettingsService.requireCheckInRadiusMeters();
const payload = this.toPayload(dto);
const verified = verifyCustomerCheckIn(
{
code: customer.code,
nfcId: customer.nfcId,
latitude: customer.latitude,
longitude: customer.longitude,
},
payload,
radiusMeters,
this.gpsVerificationOptions(),
);
let planDestinationId: string | null = null;
if (dto.planId) {
planDestinationId = await this.visitsRepository.findPlanDestinationId(
dto.planId,
dto.customerId,
);
}
const now = DateTime.fromUnixMs(Date.now());
const created = await this.visitsRepository.create({
employeeId: employee.id,
customerId: dto.customerId,
attendanceId: openAttendance.id,
planId: dto.planId ?? null,
planDestinationId,
date: now.startOfDay(),
checkInAt: now,
checkInMethod: verified.method,
checkInLatitude: verified.latitude,
checkInLongitude: verified.longitude,
checkInPhotoUrl: verified.photoUrl,
checkInDistanceMeters: verified.distanceMeters,
userId,
});
await this.timelineActivitiesService.recordIfLocated({
employeeId: employee.id,
type: 'customer_check_in',
sourceType: 'visit',
sourceId: created.id,
latitude: verified.latitude,
longitude: verified.longitude,
recordedAt: now.value,
customerId: dto.customerId,
visitId: created.id,
});
return this.toItem(created);
}
async checkOut(
id: string,
dto: VisitCheckOutDto,
userId: string,
): Promise<VisitDto> {
const employee = await this.employeesService.requireByUserId(userId);
const visit = await this.visitsRepository.findById(id);
if (!visit) {
throw new NotFoundException('Visit not found');
}
if (visit.employeeId !== employee.id) {
throw new BadRequestException('Visit does not belong to this user');
}
if (visit.checkOutAt) {
throw new ConflictException('Visit is already checked out');
}
const customer = await this.customersService.findById(visit.customerId);
const radiusMeters =
await this.companySettingsService.requireCheckInRadiusMeters();
const payload = this.toPayload(dto);
const verified = verifyCustomerCheckIn(
{
code: customer.code,
nfcId: customer.nfcId,
latitude: customer.latitude,
longitude: customer.longitude,
},
payload,
radiusMeters,
this.gpsVerificationOptions(),
);
const updated = await this.visitsRepository.checkOut(id, {
checkOutAt: DateTime.fromUnixMs(Date.now()),
checkOutMethod: verified.method,
checkOutLatitude: verified.latitude,
checkOutLongitude: verified.longitude,
checkOutPhotoUrl: verified.photoUrl,
checkOutDistanceMeters: verified.distanceMeters,
userId,
});
await this.timelineActivitiesService.recordIfLocated({
employeeId: employee.id,
type: 'customer_check_out',
sourceType: 'visit',
sourceId: updated.id,
latitude: verified.latitude,
longitude: verified.longitude,
recordedAt: updated.checkOutAt?.value,
customerId: visit.customerId,
visitId: updated.id,
});
return this.toItem(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
): Promise<VisitDto> {
const status = Status.create(statusRaw);
const updated = await this.visitsRepository.updateStatus(
id,
status,
userId,
);
return this.toItem(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
): Promise<{ updated: number }> {
const status = Status.create(statusRaw);
const updated = await this.visitsRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string): Promise<void> {
await this.visitsRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.visitsRepository.bulkDelete(ids);
return { deleted };
}
toItem(visit: Visit): VisitDto {
return {
id: visit.id,
employee: pickRelation(visit.employee, DEFAULT_RELATION_FIELDS)!,
customer: pickRelation(visit.customer, DEFAULT_RELATION_FIELDS)!,
attendanceId: visit.attendanceId,
planId: visit.planId,
planDestinationId: visit.planDestinationId,
date: visit.date.value,
checkInAt: visit.checkInAt.value,
checkInMethod: visit.checkInMethod,
checkInLatitude: visit.checkInLatitude,
checkInLongitude: visit.checkInLongitude,
checkInPhotoUrl: visit.checkInPhotoUrl,
checkInDistanceMeters: visit.checkInDistanceMeters,
checkOutAt: visit.checkOutAt?.value ?? null,
checkOutMethod: visit.checkOutMethod,
checkOutLatitude: visit.checkOutLatitude,
checkOutLongitude: visit.checkOutLongitude,
checkOutPhotoUrl: visit.checkOutPhotoUrl,
checkOutDistanceMeters: visit.checkOutDistanceMeters,
status: visit.status.value,
createdAt: visit.createdAt.value,
updatedAt: visit.updatedAt.value,
createdBy: pickUserRelation(
visit.createdByUser ?? { id: visit.createdBy, username: '' },
),
updatedBy: pickUserRelation(
visit.updatedByUser ?? { id: visit.updatedBy, username: '' },
),
};
}
private gpsVerificationOptions(): CheckInVerificationOptions {
return {
skipGpsValidation:
this.config.get<boolean>('SKIP_GPS_VALIDATION') === true,
};
}
private toPayload(dto: VisitCheckInDto | VisitCheckOutDto): CheckInPayload {
if (!isCheckInMethod(dto.method)) {
throw new BadRequestException('Invalid check-in method');
}
return {
method: dto.method,
nfcId: dto.nfcId,
qrCode: dto.qrCode,
latitude: dto.latitude,
longitude: dto.longitude,
photoUrl: dto.photoUrl,
};
}
}
void FIELD_VISIT_PRIVILEGE_KEY;