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.
This commit is contained in:
shancheas
2026-09-01 22:01:25 +07:00
parent 4b48dbdf3c
commit ec5f012d6f
7 changed files with 142 additions and 9 deletions
+4
View File
@@ -23,3 +23,7 @@ BCRYPT_SALT_ROUNDS=10
# OpenAPI UI at /docs (default: on unless NODE_ENV=production) # OpenAPI UI at /docs (default: on unless NODE_ENV=production)
# SWAGGER_ENABLED=true # SWAGGER_ENABLED=true
# SWAGGER_ENABLED=false # 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
+30
View File
@@ -17,6 +17,7 @@ describe('loadEnv', () => {
expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(7 * 24 * 60 * 60 * 1000); expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(7 * 24 * 60 * 60 * 1000);
expect(env.BCRYPT_SALT_ROUNDS).toBe(10); expect(env.BCRYPT_SALT_ROUNDS).toBe(10);
expect(env.DEFAULT_TIMEZONE).toBe('GMT+7'); expect(env.DEFAULT_TIMEZONE).toBe('GMT+7');
expect(env.SKIP_GPS_VALIDATION).toBe(false);
}); });
it('throws when DATABASE_URL is missing', () => { it('throws when DATABASE_URL is missing', () => {
@@ -75,4 +76,33 @@ describe('loadEnv', () => {
}), }),
).toThrow('must match'); ).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');
});
}); });
+29
View File
@@ -7,6 +7,7 @@ export type AppEnv = {
REFRESH_TOKEN_EXPIRES_IN_MS: number; REFRESH_TOKEN_EXPIRES_IN_MS: number;
BCRYPT_SALT_ROUNDS: number; BCRYPT_SALT_ROUNDS: number;
DEFAULT_TIMEZONE: string; DEFAULT_TIMEZONE: string;
SKIP_GPS_VALIDATION: boolean;
}; };
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
@@ -37,6 +38,24 @@ function requireSecret(name: string, value: string | undefined): string {
return secret; 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( function parsePositiveInt(
name: string, name: string,
value: string | undefined, value: string | undefined,
@@ -88,6 +107,15 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
FIFTEEN_MINUTES_MS, 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 { return {
PORT: parsePositiveInt('PORT', source.PORT, 3000), PORT: parsePositiveInt('PORT', source.PORT, 3000),
DATABASE_URL: requireString('DATABASE_URL', source.DATABASE_URL), DATABASE_URL: requireString('DATABASE_URL', source.DATABASE_URL),
@@ -108,6 +136,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
10, 10,
), ),
DEFAULT_TIMEZONE: source.DEFAULT_TIMEZONE?.trim() || 'GMT+7', DEFAULT_TIMEZONE: source.DEFAULT_TIMEZONE?.trim() || 'GMT+7',
SKIP_GPS_VALIDATION: skipGpsValidation,
}; };
} }
@@ -4,6 +4,7 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { PaginationResponse } from '../../../common/http/response'; import type { PaginationResponse } from '../../../common/http/response';
import { import {
pickRelation, pickRelation,
@@ -21,6 +22,7 @@ import {
isCheckInMethod, isCheckInMethod,
verifyBranchCheckIn, verifyBranchCheckIn,
type CheckInPayload, type CheckInPayload,
type CheckInVerificationOptions,
} from '../shared/check-in-verification'; } from '../shared/check-in-verification';
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose'; import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
import { TimelineActivitiesService } from '../timeline/timeline-activities.service'; import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
@@ -41,6 +43,7 @@ export class AttendancesService {
private readonly employeesService: EmployeesService, private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService, private readonly companySettingsService: CompanySettingsService,
private readonly timelineActivitiesService: TimelineActivitiesService, private readonly timelineActivitiesService: TimelineActivitiesService,
private readonly config: ConfigService,
) {} ) {}
async list( async list(
@@ -106,6 +109,7 @@ export class AttendancesService {
}, },
payload, payload,
radiusMeters, radiusMeters,
this.gpsVerificationOptions(),
); );
const now = DateTime.fromUnixMs(Date.now()); const now = DateTime.fromUnixMs(Date.now());
@@ -164,6 +168,7 @@ export class AttendancesService {
}, },
payload, payload,
radiusMeters, radiusMeters,
this.gpsVerificationOptions(),
); );
const updated = await this.attendancesRepository.checkOut(id, { const updated = await this.attendancesRepository.checkOut(id, {
@@ -264,6 +269,13 @@ export class AttendancesService {
return FIELD_ATTENDANCE_PRIVILEGE_KEY; return FIELD_ATTENDANCE_PRIVILEGE_KEY;
} }
private gpsVerificationOptions(): CheckInVerificationOptions {
return {
skipGpsValidation:
this.config.get<boolean>('SKIP_GPS_VALIDATION') === true,
};
}
private toPayload( private toPayload(
dto: AttendanceCheckInDto | AttendanceCheckOutDto, dto: AttendanceCheckInDto | AttendanceCheckOutDto,
): CheckInPayload { ): CheckInPayload {
@@ -59,4 +59,36 @@ describe('check-in-verification', () => {
), ),
).toThrow('Too far from the customer location'); ).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();
});
}); });
@@ -25,6 +25,10 @@ export type CheckInVerificationResult = {
readonly photoUrl: string | null; readonly photoUrl: string | null;
}; };
export type CheckInVerificationOptions = {
readonly skipGpsValidation?: boolean;
};
export type BranchCheckInTarget = { export type BranchCheckInTarget = {
readonly code: string; readonly code: string;
readonly nfcId: string | null; readonly nfcId: string | null;
@@ -43,6 +47,7 @@ export function verifyBranchCheckIn(
target: BranchCheckInTarget, target: BranchCheckInTarget,
payload: CheckInPayload, payload: CheckInPayload,
radiusMeters: number, radiusMeters: number,
options?: CheckInVerificationOptions,
): CheckInVerificationResult { ): CheckInVerificationResult {
return verifyTargetCheckIn( return verifyTargetCheckIn(
target, target,
@@ -52,6 +57,7 @@ export function verifyBranchCheckIn(
'QR code does not match this branch', 'QR code does not match this branch',
'Branch location is not configured', 'Branch location is not configured',
'Too far from the branch location', 'Too far from the branch location',
options,
); );
} }
@@ -59,6 +65,7 @@ export function verifyCustomerCheckIn(
target: CustomerCheckInTarget, target: CustomerCheckInTarget,
payload: CheckInPayload, payload: CheckInPayload,
radiusMeters: number, radiusMeters: number,
options?: CheckInVerificationOptions,
): CheckInVerificationResult { ): CheckInVerificationResult {
return verifyTargetCheckIn( return verifyTargetCheckIn(
target, target,
@@ -68,6 +75,7 @@ export function verifyCustomerCheckIn(
'QR code does not match this customer', 'QR code does not match this customer',
'Customer location is not configured', 'Customer location is not configured',
'Too far from the customer location', 'Too far from the customer location',
options,
); );
} }
@@ -79,6 +87,7 @@ function verifyTargetCheckIn(
qrMismatchMessage: string, qrMismatchMessage: string,
locationMissingMessage: string, locationMissingMessage: string,
tooFarMessage: string, tooFarMessage: string,
options?: CheckInVerificationOptions,
): CheckInVerificationResult { ): CheckInVerificationResult {
if (!isCheckInMethod(payload.method)) { if (!isCheckInMethod(payload.method)) {
throw new BadRequestException('Invalid check-in method'); throw new BadRequestException('Invalid check-in method');
@@ -101,17 +110,22 @@ function verifyTargetCheckIn(
throw new BadRequestException(qrMismatchMessage); throw new BadRequestException(qrMismatchMessage);
} }
} else { } 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); throw new BadRequestException(locationMissingMessage);
} }
distanceMeters = haversineDistanceMeters( if (canMeasure) {
payload.latitude, distanceMeters = haversineDistanceMeters(
payload.longitude, payload.latitude,
target.latitude, payload.longitude,
target.longitude, targetLatitude,
); targetLongitude,
if (distanceMeters > radiusMeters) { );
throw new BadRequestException(tooFarMessage); if (!options?.skipGpsValidation && distanceMeters > radiusMeters) {
throw new BadRequestException(tooFarMessage);
}
} }
} }
@@ -4,6 +4,7 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { PaginationResponse } from '../../../common/http/response'; import type { PaginationResponse } from '../../../common/http/response';
import { import {
pickRelation, pickRelation,
@@ -21,6 +22,7 @@ import {
isCheckInMethod, isCheckInMethod,
verifyCustomerCheckIn, verifyCustomerCheckIn,
type CheckInPayload, type CheckInPayload,
type CheckInVerificationOptions,
} from '../shared/check-in-verification'; } from '../shared/check-in-verification';
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose'; import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
import { TimelineActivitiesService } from '../timeline/timeline-activities.service'; import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
@@ -42,6 +44,7 @@ export class VisitsService {
private readonly employeesService: EmployeesService, private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService, private readonly companySettingsService: CompanySettingsService,
private readonly timelineActivitiesService: TimelineActivitiesService, private readonly timelineActivitiesService: TimelineActivitiesService,
private readonly config: ConfigService,
) {} ) {}
async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> { async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> {
@@ -107,6 +110,7 @@ export class VisitsService {
}, },
payload, payload,
radiusMeters, radiusMeters,
this.gpsVerificationOptions(),
); );
let planDestinationId: string | null = null; let planDestinationId: string | null = null;
@@ -177,6 +181,7 @@ export class VisitsService {
}, },
payload, payload,
radiusMeters, radiusMeters,
this.gpsVerificationOptions(),
); );
const updated = await this.visitsRepository.checkOut(id, { const updated = await this.visitsRepository.checkOut(id, {
@@ -272,6 +277,13 @@ export class VisitsService {
}; };
} }
private gpsVerificationOptions(): CheckInVerificationOptions {
return {
skipGpsValidation:
this.config.get<boolean>('SKIP_GPS_VALIDATION') === true,
};
}
private toPayload(dto: VisitCheckInDto | VisitCheckOutDto): CheckInPayload { private toPayload(dto: VisitCheckInDto | VisitCheckOutDto): CheckInPayload {
if (!isCheckInMethod(dto.method)) { if (!isCheckInMethod(dto.method)) {
throw new BadRequestException('Invalid check-in method'); throw new BadRequestException('Invalid check-in method');