- 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.
303 lines
9.4 KiB
TypeScript
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;
|