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:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user