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)
# 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
+30
View File
@@ -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');
});
});
+29
View File
@@ -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,
};
}
@@ -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<boolean>('SKIP_GPS_VALIDATION') === true,
};
}
private toPayload(
dto: AttendanceCheckInDto | AttendanceCheckOutDto,
): CheckInPayload {
@@ -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();
});
});
@@ -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,19 +110,24 @@ 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);
}
if (canMeasure) {
distanceMeters = haversineDistanceMeters(
payload.latitude,
payload.longitude,
target.latitude,
target.longitude,
targetLatitude,
targetLongitude,
);
if (distanceMeters > radiusMeters) {
if (!options?.skipGpsValidation && distanceMeters > radiusMeters) {
throw new BadRequestException(tooFarMessage);
}
}
}
return {
latitude: payload.latitude,
@@ -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<PaginationResponse<VisitDto>> {
@@ -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<boolean>('SKIP_GPS_VALIDATION') === true,
};
}
private toPayload(dto: VisitCheckInDto | VisitCheckOutDto): CheckInPayload {
if (!isCheckInMethod(dto.method)) {
throw new BadRequestException('Invalid check-in method');