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> { 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 { const visit = await this.visitsRepository.findById(id); if (!visit) { throw new NotFoundException('Visit not found'); } return this.toItem(visit); } async findCurrent(userId: string): Promise { 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 { 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 { 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 { 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 { 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('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;