From ec5f012d6f08bf79a98cf09cd66a9b27d5b9aa1e Mon Sep 17 00:00:00 2001 From: shancheas Date: Tue, 1 Sep 2026 22:01:25 +0700 Subject: [PATCH] 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. --- .env.example | 4 +++ src/config/env.spec.ts | 30 +++++++++++++++++ src/config/env.ts | 29 +++++++++++++++++ .../field/attendances/attendances.service.ts | 12 +++++++ .../shared/check-in-verification.spec.ts | 32 +++++++++++++++++++ .../field/shared/check-in-verification.ts | 32 +++++++++++++------ src/modules/field/visits/visits.service.ts | 12 +++++++ 7 files changed, 142 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 8c70a50..1bc5b6c 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,7 @@ BCRYPT_SALT_ROUNDS=10 # OpenAPI UI at /docs (default: on unless NODE_ENV=production) # SWAGGER_ENABLED=true # SWAGGER_ENABLED=false + +# Debug only. Skip GPS radius/location checks on check-in and check-out. +# Rejected when NODE_ENV=production. +# SKIP_GPS_VALIDATION=true diff --git a/src/config/env.spec.ts b/src/config/env.spec.ts index c27b7ed..0639367 100644 --- a/src/config/env.spec.ts +++ b/src/config/env.spec.ts @@ -17,6 +17,7 @@ describe('loadEnv', () => { expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(7 * 24 * 60 * 60 * 1000); expect(env.BCRYPT_SALT_ROUNDS).toBe(10); expect(env.DEFAULT_TIMEZONE).toBe('GMT+7'); + expect(env.SKIP_GPS_VALIDATION).toBe(false); }); it('throws when DATABASE_URL is missing', () => { @@ -75,4 +76,33 @@ describe('loadEnv', () => { }), ).toThrow('must match'); }); + + it('enables SKIP_GPS_VALIDATION outside production', () => { + const env = loadEnv({ + ...valid, + SKIP_GPS_VALIDATION: 'true', + NODE_ENV: 'development', + }); + + expect(env.SKIP_GPS_VALIDATION).toBe(true); + }); + + it('rejects SKIP_GPS_VALIDATION in production', () => { + expect(() => + loadEnv({ + ...valid, + SKIP_GPS_VALIDATION: 'true', + NODE_ENV: 'production', + }), + ).toThrow('cannot be enabled in production'); + }); + + it('rejects invalid SKIP_GPS_VALIDATION values', () => { + expect(() => + loadEnv({ + ...valid, + SKIP_GPS_VALIDATION: 'yes', + }), + ).toThrow('must be true or false'); + }); }); diff --git a/src/config/env.ts b/src/config/env.ts index 5f1ef0b..e395d6d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -7,6 +7,7 @@ export type AppEnv = { REFRESH_TOKEN_EXPIRES_IN_MS: number; BCRYPT_SALT_ROUNDS: number; DEFAULT_TIMEZONE: string; + SKIP_GPS_VALIDATION: boolean; }; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; @@ -37,6 +38,24 @@ function requireSecret(name: string, value: string | undefined): string { return secret; } +function parseBoolean( + name: string, + value: string | undefined, + fallback: boolean, +): boolean { + if (value === undefined || value.trim() === '') { + return fallback; + } + const normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1') { + return true; + } + if (normalized === 'false' || normalized === '0') { + return false; + } + throw new Error(`${name} must be true or false`); +} + function parsePositiveInt( name: string, value: string | undefined, @@ -88,6 +107,15 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv { FIFTEEN_MINUTES_MS, ); + const skipGpsValidation = parseBoolean( + 'SKIP_GPS_VALIDATION', + source.SKIP_GPS_VALIDATION, + false, + ); + if (source.NODE_ENV === 'production' && skipGpsValidation) { + throw new Error('SKIP_GPS_VALIDATION cannot be enabled in production'); + } + return { PORT: parsePositiveInt('PORT', source.PORT, 3000), DATABASE_URL: requireString('DATABASE_URL', source.DATABASE_URL), @@ -108,6 +136,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv { 10, ), DEFAULT_TIMEZONE: source.DEFAULT_TIMEZONE?.trim() || 'GMT+7', + SKIP_GPS_VALIDATION: skipGpsValidation, }; } diff --git a/src/modules/field/attendances/attendances.service.ts b/src/modules/field/attendances/attendances.service.ts index 3e4ca6d..9fe8f67 100644 --- a/src/modules/field/attendances/attendances.service.ts +++ b/src/modules/field/attendances/attendances.service.ts @@ -4,6 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import type { PaginationResponse } from '../../../common/http/response'; import { pickRelation, @@ -21,6 +22,7 @@ import { isCheckInMethod, verifyBranchCheckIn, type CheckInPayload, + type CheckInVerificationOptions, } from '../shared/check-in-verification'; import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose'; import { TimelineActivitiesService } from '../timeline/timeline-activities.service'; @@ -41,6 +43,7 @@ export class AttendancesService { private readonly employeesService: EmployeesService, private readonly companySettingsService: CompanySettingsService, private readonly timelineActivitiesService: TimelineActivitiesService, + private readonly config: ConfigService, ) {} async list( @@ -106,6 +109,7 @@ export class AttendancesService { }, payload, radiusMeters, + this.gpsVerificationOptions(), ); const now = DateTime.fromUnixMs(Date.now()); @@ -164,6 +168,7 @@ export class AttendancesService { }, payload, radiusMeters, + this.gpsVerificationOptions(), ); const updated = await this.attendancesRepository.checkOut(id, { @@ -264,6 +269,13 @@ export class AttendancesService { return FIELD_ATTENDANCE_PRIVILEGE_KEY; } + private gpsVerificationOptions(): CheckInVerificationOptions { + return { + skipGpsValidation: + this.config.get('SKIP_GPS_VALIDATION') === true, + }; + } + private toPayload( dto: AttendanceCheckInDto | AttendanceCheckOutDto, ): CheckInPayload { diff --git a/src/modules/field/shared/check-in-verification.spec.ts b/src/modules/field/shared/check-in-verification.spec.ts index 5edda9a..ddf1b32 100644 --- a/src/modules/field/shared/check-in-verification.spec.ts +++ b/src/modules/field/shared/check-in-verification.spec.ts @@ -59,4 +59,36 @@ describe('check-in-verification', () => { ), ).toThrow('Too far from the customer location'); }); + + it('accepts GPS when too far if skipGpsValidation is set', () => { + const result = verifyBranchCheckIn( + target, + { + method: 'gps', + latitude: -7, + longitude: 107.5, + }, + 100, + { skipGpsValidation: true }, + ); + + expect(result.method).toBe('gps'); + expect(result.distanceMeters).toBeGreaterThan(100); + }); + + it('accepts GPS when the target has no location if skipGpsValidation is set', () => { + const result = verifyCustomerCheckIn( + { ...target, latitude: null, longitude: null }, + { + method: 'gps', + latitude: -7, + longitude: 107.5, + }, + 100, + { skipGpsValidation: true }, + ); + + expect(result.method).toBe('gps'); + expect(result.distanceMeters).toBeNull(); + }); }); diff --git a/src/modules/field/shared/check-in-verification.ts b/src/modules/field/shared/check-in-verification.ts index ea3da68..4efc272 100644 --- a/src/modules/field/shared/check-in-verification.ts +++ b/src/modules/field/shared/check-in-verification.ts @@ -25,6 +25,10 @@ export type CheckInVerificationResult = { readonly photoUrl: string | null; }; +export type CheckInVerificationOptions = { + readonly skipGpsValidation?: boolean; +}; + export type BranchCheckInTarget = { readonly code: string; readonly nfcId: string | null; @@ -43,6 +47,7 @@ export function verifyBranchCheckIn( target: BranchCheckInTarget, payload: CheckInPayload, radiusMeters: number, + options?: CheckInVerificationOptions, ): CheckInVerificationResult { return verifyTargetCheckIn( target, @@ -52,6 +57,7 @@ export function verifyBranchCheckIn( 'QR code does not match this branch', 'Branch location is not configured', 'Too far from the branch location', + options, ); } @@ -59,6 +65,7 @@ export function verifyCustomerCheckIn( target: CustomerCheckInTarget, payload: CheckInPayload, radiusMeters: number, + options?: CheckInVerificationOptions, ): CheckInVerificationResult { return verifyTargetCheckIn( target, @@ -68,6 +75,7 @@ export function verifyCustomerCheckIn( 'QR code does not match this customer', 'Customer location is not configured', 'Too far from the customer location', + options, ); } @@ -79,6 +87,7 @@ function verifyTargetCheckIn( qrMismatchMessage: string, locationMissingMessage: string, tooFarMessage: string, + options?: CheckInVerificationOptions, ): CheckInVerificationResult { if (!isCheckInMethod(payload.method)) { throw new BadRequestException('Invalid check-in method'); @@ -101,17 +110,22 @@ function verifyTargetCheckIn( throw new BadRequestException(qrMismatchMessage); } } else { - if (target.latitude == null || target.longitude == null) { + const targetLatitude = target.latitude; + const targetLongitude = target.longitude; + const canMeasure = targetLatitude != null && targetLongitude != null; + if (!canMeasure && !options?.skipGpsValidation) { throw new BadRequestException(locationMissingMessage); } - distanceMeters = haversineDistanceMeters( - payload.latitude, - payload.longitude, - target.latitude, - target.longitude, - ); - if (distanceMeters > radiusMeters) { - throw new BadRequestException(tooFarMessage); + if (canMeasure) { + distanceMeters = haversineDistanceMeters( + payload.latitude, + payload.longitude, + targetLatitude, + targetLongitude, + ); + if (!options?.skipGpsValidation && distanceMeters > radiusMeters) { + throw new BadRequestException(tooFarMessage); + } } } diff --git a/src/modules/field/visits/visits.service.ts b/src/modules/field/visits/visits.service.ts index d980474..9a561d7 100644 --- a/src/modules/field/visits/visits.service.ts +++ b/src/modules/field/visits/visits.service.ts @@ -4,6 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import type { PaginationResponse } from '../../../common/http/response'; import { pickRelation, @@ -21,6 +22,7 @@ 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'; @@ -42,6 +44,7 @@ export class VisitsService { private readonly employeesService: EmployeesService, private readonly companySettingsService: CompanySettingsService, private readonly timelineActivitiesService: TimelineActivitiesService, + private readonly config: ConfigService, ) {} async list(query: ListVisitsQueryDto): Promise> { @@ -107,6 +110,7 @@ export class VisitsService { }, payload, radiusMeters, + this.gpsVerificationOptions(), ); let planDestinationId: string | null = null; @@ -177,6 +181,7 @@ export class VisitsService { }, payload, radiusMeters, + this.gpsVerificationOptions(), ); const updated = await this.visitsRepository.checkOut(id, { @@ -272,6 +277,13 @@ export class VisitsService { }; } + 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');