bugfix: appointment hours now use the client timezone on UTC servers.

Logical API errors throw stable codes so users see translated messages instead of a generic bad request.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 01:43:50 +03:30
parent d6958b2e48
commit 80167c622c
38 changed files with 833 additions and 392 deletions

View File

@@ -57,14 +57,121 @@ export const ErrorCode = {
VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
VALIDATION_LANGUAGE_INVALID: 'VALIDATION_LANGUAGE_INVALID',
VALIDATION_INVALID_REQUEST: 'VALIDATION_INVALID_REQUEST',
VALIDATION_TIMEZONE_INVALID: 'VALIDATION_TIMEZONE_INVALID',
// Appointments
APPOINTMENT_INVALID_TIME: 'APPOINTMENT_INVALID_TIME',
APPOINTMENT_END_BEFORE_START: 'APPOINTMENT_END_BEFORE_START',
APPOINTMENT_TOO_LONG: 'APPOINTMENT_TOO_LONG',
APPOINTMENT_IN_PAST: 'APPOINTMENT_IN_PAST',
APPOINTMENT_INVALID_RANGE: 'APPOINTMENT_INVALID_RANGE',
APPOINTMENT_INVALID_RANGE_ORDER: 'APPOINTMENT_INVALID_RANGE_ORDER',
APPOINTMENT_INVALID_DATE: 'APPOINTMENT_INVALID_DATE',
APPOINTMENT_PROVIDER_NOT_MEMBER: 'APPOINTMENT_PROVIDER_NOT_MEMBER',
APPOINTMENT_PROVIDER_INACTIVE: 'APPOINTMENT_PROVIDER_INACTIVE',
APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT: 'APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT',
APPOINTMENT_PROVIDER_NO_WORKING_HOURS: 'APPOINTMENT_PROVIDER_NO_WORKING_HOURS',
APPOINTMENT_PROVIDER_NOT_WORKING_DAY: 'APPOINTMENT_PROVIDER_NOT_WORKING_DAY',
APPOINTMENT_OUTSIDE_WORKING_HOURS: 'APPOINTMENT_OUTSIDE_WORKING_HOURS',
APPOINTMENT_NOT_FOUND: 'APPOINTMENT_NOT_FOUND',
APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED',
APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT',
APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER',
PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND',
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS: 'WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS',
STAFF_UNKNOWN_PERMISSIONS: 'STAFF_UNKNOWN_PERMISSIONS',
STAFF_NO_SUBSCRIPTION: 'STAFF_NO_SUBSCRIPTION',
STAFF_SEAT_LIMIT: 'STAFF_SEAT_LIMIT',
STAFF_INVITE_OWNER_EMAIL: 'STAFF_INVITE_OWNER_EMAIL',
STAFF_ALREADY_MEMBER: 'STAFF_ALREADY_MEMBER',
STAFF_OWNER_NO_INVITE_LINK: 'STAFF_OWNER_NO_INVITE_LINK',
STAFF_ALREADY_ACCEPTED: 'STAFF_ALREADY_ACCEPTED',
STAFF_INVITE_MISSING: 'STAFF_INVITE_MISSING',
STAFF_INVITE_ALREADY_ACCEPTED: 'STAFF_INVITE_ALREADY_ACCEPTED',
STAFF_INVITE_INVALID: 'STAFF_INVITE_INVALID',
STAFF_INVITE_REVOKED: 'STAFF_INVITE_REVOKED',
STAFF_INVITE_EXPIRED: 'STAFF_INVITE_EXPIRED',
STAFF_ALREADY_ACTIVE: 'STAFF_ALREADY_ACTIVE',
STAFF_ALREADY_DISABLED: 'STAFF_ALREADY_DISABLED',
STAFF_CANNOT_DISABLE_SELF: 'STAFF_CANNOT_DISABLE_SELF',
STAFF_MEMBER_NOT_FOUND: 'STAFF_MEMBER_NOT_FOUND',
STAFF_CANNOT_EDIT_OWNER: 'STAFF_CANNOT_EDIT_OWNER',
STAFF_CANNOT_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER',
STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER',
STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_OWNER',
ORG_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF',
ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE',
ORG_TARGET_NO_SUBSCRIPTION: 'ORG_TARGET_NO_SUBSCRIPTION',
ORG_LINK_EXISTS: 'ORG_LINK_EXISTS',
ORG_REQUEST_NOT_PENDING: 'ORG_REQUEST_NOT_PENDING',
ORG_CANNOT_RESPOND_OWN: 'ORG_CANNOT_RESPOND_OWN',
ORG_INVITE_ALREADY_ACCEPTED: 'ORG_INVITE_ALREADY_ACCEPTED',
ORG_INVITE_INVALID: 'ORG_INVITE_INVALID',
ORG_INVITE_REVOKED: 'ORG_INVITE_REVOKED',
ORG_INVITE_EXPIRED: 'ORG_INVITE_EXPIRED',
ORG_INVITE_TYPE_MISMATCH: 'ORG_INVITE_TYPE_MISMATCH',
ORG_INVITE_OWNER_HAS_ORG: 'ORG_INVITE_OWNER_HAS_ORG',
ORG_UNKNOWN_TYPE: 'ORG_UNKNOWN_TYPE',
ORG_COUNTERPART_NOT_LAB: 'ORG_COUNTERPART_NOT_LAB',
ORG_COUNTERPART_NOT_CLINIC: 'ORG_COUNTERPART_NOT_CLINIC',
ORG_CONNECTION_NOT_FOUND: 'ORG_CONNECTION_NOT_FOUND',
ORG_INVITE_NOT_FOUND: 'ORG_INVITE_NOT_FOUND',
ORG_ONLY_CLINIC_COMMENT: 'ORG_ONLY_CLINIC_COMMENT',
CATALOG_UNKNOWN_TREATMENT_TYPE: 'CATALOG_UNKNOWN_TREATMENT_TYPE',
CATALOG_UNKNOWN_PROSTHESIS_TYPE: 'CATALOG_UNKNOWN_PROSTHESIS_TYPE',
CATALOG_TREATMENT_NOT_LAB_DEPENDENT: 'CATALOG_TREATMENT_NOT_LAB_DEPENDENT',
TREATMENT_SAVE_DETAILS_BEFORE_LAB: 'TREATMENT_SAVE_DETAILS_BEFORE_LAB',
TREATMENT_DETAIL_ONE_CASE: 'TREATMENT_DETAIL_ONE_CASE',
TREATMENT_DETAILS_NOT_FOUND: 'TREATMENT_DETAILS_NOT_FOUND',
TREATMENT_DEST_NOT_LINKED: 'TREATMENT_DEST_NOT_LINKED',
TREATMENT_TEETH_REQUIRED_TO_SHIP: 'TREATMENT_TEETH_REQUIRED_TO_SHIP',
TREATMENT_TOOTH_UNKNOWN_DETAIL: 'TREATMENT_TOOTH_UNKNOWN_DETAIL',
TREATMENT_TOOTH_NOT_ON_DETAIL: 'TREATMENT_TOOTH_NOT_ON_DETAIL',
TREATMENT_CASE_NO_DEST: 'TREATMENT_CASE_NO_DEST',
TREATMENT_CASE_NEEDS_DETAIL: 'TREATMENT_CASE_NEEDS_DETAIL',
TREATMENT_CASE_ONE_DETAIL: 'TREATMENT_CASE_ONE_DETAIL',
TREATMENT_ONLY_PROVIDER_SEND: 'TREATMENT_ONLY_PROVIDER_SEND',
TREATMENT_CASE_ALREADY_SENT: 'TREATMENT_CASE_ALREADY_SENT',
TREATMENT_DUE_DATE_LOCKED: 'TREATMENT_DUE_DATE_LOCKED',
TREATMENT_INVALID_DUE_DATE: 'TREATMENT_INVALID_DUE_DATE',
TREATMENT_DETAIL_KEY_REQUIRED: 'TREATMENT_DETAIL_KEY_REQUIRED',
TREATMENT_FILE_REQUIRED: 'TREATMENT_FILE_REQUIRED',
TREATMENT_ATTACHMENT_NOT_FOUND: 'TREATMENT_ATTACHMENT_NOT_FOUND',
TREATMENT_FILE_UNAVAILABLE: 'TREATMENT_FILE_UNAVAILABLE',
TREATMENT_CASE_INVALID_ATTACHMENTS: 'TREATMENT_CASE_INVALID_ATTACHMENTS',
TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE: 'TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE',
TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT',
LAB_CASE_NOT_FOUND: 'LAB_CASE_NOT_FOUND',
TASK_ASSIGNEE_INVALID: 'TASK_ASSIGNEE_INVALID',
TASK_ASSIGNED_TO_OTHER: 'TASK_ASSIGNED_TO_OTHER',
TASK_PAGE_DATE_SORT_ONLY: 'TASK_PAGE_DATE_SORT_ONLY',
INVALID_SENT_FROM: 'INVALID_SENT_FROM',
INVALID_SENT_TO: 'INVALID_SENT_TO',
CASE_NOT_FOUND: 'CASE_NOT_FOUND',
CASE_ATTACHMENT_NOT_FOUND: 'CASE_ATTACHMENT_NOT_FOUND',
CASE_ATTACHMENT_MISSING_FILE: 'CASE_ATTACHMENT_MISSING_FILE',
CASE_TASK_NOT_FOUND: 'CASE_TASK_NOT_FOUND',
COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
COMMENT_VISIBILITY_LAB_ONLY: 'COMMENT_VISIBILITY_LAB_ONLY',
TODAY_INVALID_RANGE: 'TODAY_INVALID_RANGE',
TODAY_INVALID_RANGE_ORDER: 'TODAY_INVALID_RANGE_ORDER',
// Generic HTTP
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS',
APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED',
APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT',
TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT',
BAD_REQUEST: 'BAD_REQUEST',
INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const;

View File

@@ -28,58 +28,6 @@ const STATUS_FALLBACK_CODES: Partial<Record<number, ErrorCodeValue>> = {
[HttpStatus.INTERNAL_SERVER_ERROR]: ErrorCode.INTERNAL_ERROR,
};
/** Maps legacy English messages to stable codes during migration. */
const LEGACY_MESSAGE_CODES: Record<string, ErrorCodeValue> = {
'Invalid credentials': ErrorCode.AUTH_INVALID_CREDENTIALS,
Unauthorized: ErrorCode.AUTH_UNAUTHORIZED,
'Refresh token not found': ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND,
'Invalid or expired refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
'Invalid refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
'Refresh token failed': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
'Invalid token type': ErrorCode.AUTH_SESSION_EXPIRED,
'Invalid token': ErrorCode.AUTH_SESSION_EXPIRED,
'Session not found or expired': ErrorCode.AUTH_SESSION_EXPIRED,
'User not found': ErrorCode.AUTH_USER_NOT_FOUND,
'Organization is not selected': ErrorCode.AUTH_ORG_NOT_SELECTED,
'Access denied to this organization': ErrorCode.AUTH_ORG_ACCESS_DENIED,
'Your invitation is still pending activation': ErrorCode.AUTH_INVITATION_PENDING,
'Auto-login failed': ErrorCode.AUTH_REGISTRATION_FAILED,
'User already exists. Please login and create a new organization from your account.':
ErrorCode.AUTH_REGISTRATION_EMAIL_EXISTS,
'This mobile number is already registered.': ErrorCode.AUTH_REGISTRATION_MOBILE_EXISTS,
'Please enter a valid mobile number': ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID,
'Current password is incorrect': ErrorCode.AUTH_PASSWORD_INCORRECT,
'Password reset verification expired. Please verify your mobile again.':
ErrorCode.AUTH_PASSWORD_RESET_EXPIRED,
'Invalid or expired verification code': ErrorCode.AUTH_VERIFICATION_CODE_INVALID,
'Please wait before requesting another code': ErrorCode.AUTH_VERIFICATION_RATE_LIMIT,
'Current password is required': ErrorCode.AUTH_PASSWORD_CURRENT_REQUIRED,
'Select an organization before creating a new one.': ErrorCode.AUTH_SELECT_ORG_FIRST,
'Only owners of the current organization can create new organizations.':
ErrorCode.AUTH_CREATE_ORG_OWNER_ONLY,
'This action is only available for clinic organizations': ErrorCode.PERMISSION_CLINIC_ONLY,
'This action is only available for lab organizations': ErrorCode.PERMISSION_LAB_ONLY,
'Organization not found': ErrorCode.PERMISSION_ORG_NOT_FOUND,
'Unknown organization type': ErrorCode.PERMISSION_DENIED,
'You do not have permission to manage organizations': ErrorCode.PERMISSION_ORG_MANAGE,
'You are not a member of this organization': ErrorCode.PERMISSION_NOT_MEMBER,
'Only organization owners can manage participation': ErrorCode.PERMISSION_OWNER_ONLY,
'Only organization owners can manage their working hours': ErrorCode.PERMISSION_OWNER_ONLY,
'An active subscription is required to participate in treatments or tasks':
ErrorCode.PERMISSION_PARTICIPATION_SUBSCRIPTION,
'Enable treatment participation before setting working hours':
ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST,
'Working hours are only available for clinic organizations':
ErrorCode.PERMISSION_CLINIC_WORKING_HOURS,
'You do not have access to appointments': ErrorCode.PERMISSION_ACCESS_APPOINTMENTS,
'You cannot create or modify appointments': ErrorCode.PERMISSION_EDIT_APPOINTMENTS,
'You do not have access to treatments': ErrorCode.PERMISSION_ACCESS_TREATMENTS,
'You cannot edit treatments': ErrorCode.PERMISSION_EDIT_TREATMENTS,
'You do not have access to tasks': ErrorCode.PERMISSION_ACCESS_TASKS,
'You do not have access to staff management': ErrorCode.PERMISSION_ACCESS_STAFF,
'You cannot manage staff working hours': ErrorCode.PERMISSION_EDIT_STAFF,
};
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
@@ -121,7 +69,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
};
}
const code = this.resolveLegacyCode(rawResponse, statusCode);
const code = this.resolveFallbackCode(rawResponse, statusCode);
return {
statusCode,
body: this.buildBody(code, statusCode),
@@ -137,20 +85,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
);
}
private resolveLegacyCode(rawResponse: string | object, statusCode: number): ErrorCodeValue {
/** Nest/Passport leftovers: validation arrays, otherwise HTTP status. */
private resolveFallbackCode(rawResponse: string | object, statusCode: number): ErrorCodeValue {
const message = this.extractMessage(rawResponse);
if (typeof message === 'string') {
const mapped = LEGACY_MESSAGE_CODES[message.trim()];
if (mapped) {
return mapped;
}
}
if (Array.isArray(message)) {
return ErrorCode.VALIDATION_FAILED;
}
return STATUS_FALLBACK_CODES[statusCode] ?? ErrorCode.INTERNAL_ERROR;
}

View File

@@ -1,4 +1,4 @@
import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
import { HttpException, HttpStatus } from '@nestjs/common';
import type { ValidationError } from 'class-validator';
import { AppException } from './app.exception';
import { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes';
@@ -22,7 +22,12 @@ function constraintToCode(constraintKey: string, message: string): ErrorCodeValu
case 'isEnum':
return ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID;
case 'matches':
return ErrorCode.VALIDATION_MOBILE_INVALID;
case 'isDateString':
case 'isUuid':
case 'isInt':
case 'isBoolean':
case 'isArray':
return ErrorCode.VALIDATION_INVALID_REQUEST;
case 'isIn':
return ErrorCode.VALIDATION_LANGUAGE_INVALID;
case 'whitelistValidation':
@@ -66,8 +71,3 @@ export function validationExceptionFactory(errors: ValidationError[]): HttpExcep
return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST, details);
}
/** Handles forbidNonWhitelisted errors from ValidationPipe. */
export function isValidationPipeBadRequest(exception: unknown): exception is BadRequestException {
return exception instanceof BadRequestException;
}

View File

@@ -1,3 +1,5 @@
import { zonedWeekdayAndMinutes } from './zoned-civil-time';
export const MINUTES_PER_DAY = 24 * 60;
export type WorkingHoursBlockInput = {
@@ -82,17 +84,18 @@ export function isRangeWithinWorkingBlocks(
return true;
}
export function dateToLocalMinutes(date: Date): number {
return date.getHours() * 60 + date.getMinutes();
export function dateToZonedMinutes(date: Date, timeZone: string): number {
return zonedWeekdayAndMinutes(date, timeZone).minuteOfDay;
}
export function appointmentWithinWorkingHours(
startAt: Date,
endAt: Date,
dayBlocks: WorkingHoursDayBlock[],
timeZone: string,
): boolean {
const startMinute = dateToLocalMinutes(startAt);
const endMinute = dateToLocalMinutes(endAt);
const startMinute = dateToZonedMinutes(startAt, timeZone);
const endMinute = dateToZonedMinutes(endAt, timeZone);
return isRangeWithinWorkingBlocks(startMinute, endMinute, dayBlocks);
}

View File

@@ -0,0 +1,29 @@
import { appointmentWithinWorkingHours } from './working-hours';
import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from './zoned-civil-time';
describe('zoned civil time', () => {
it('accepts IANA zones and rejects garbage', () => {
expect(isValidIanaTimeZone('Asia/Tehran')).toBe(true);
expect(isValidIanaTimeZone('Europe/Amsterdam')).toBe(true);
expect(isValidIanaTimeZone('Not/AZone')).toBe(false);
});
it('maps a UTC instant to Tehran wall-clock (no DST)', () => {
// 2026-08-20 09:45 Asia/Tehran = 06:15 UTC (the production 400 case)
const instant = new Date('2026-08-20T06:15:00.000Z');
const zoned = zonedWeekdayAndMinutes(instant, 'Asia/Tehran');
expect(zoned.jsWeekday).toBe(4); // Thursday
expect(zoned.minuteOfDay).toBe(9 * 60 + 45);
});
it('treats morning Tehran slots as inside 08:0017:00 hours', () => {
const start = new Date('2026-08-20T06:15:00.000Z');
const end = new Date('2026-08-20T06:45:00.000Z');
expect(
appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'Asia/Tehran'),
).toBe(true);
expect(
appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'UTC'),
).toBe(false);
});
});

View File

@@ -0,0 +1,55 @@
/** Convert an absolute instant into weekday + minute-of-day in an IANA time zone. */
const JS_WEEKDAY: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
export function isValidIanaTimeZone(timeZone: string): boolean {
if (!timeZone || timeZone.length > 64) {
return false;
}
try {
Intl.DateTimeFormat('en-US', { timeZone }).format(new Date(0));
return true;
} catch {
return false;
}
}
export function zonedWeekdayAndMinutes(
date: Date,
timeZone: string,
): { jsWeekday: number; minuteOfDay: number } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date);
const weekdayToken = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun';
let hour = Number(parts.find((p) => p.type === 'hour')?.value ?? '0');
const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? '0');
if (hour === 24) {
hour = 0;
}
return {
jsWeekday: JS_WEEKDAY[weekdayToken] ?? 0,
minuteOfDay: hour * 60 + minute,
};
}
/** Weekday of a YYYY-MM-DD civil date (Gregorian; same worldwide). */
export function civilDateJsWeekday(isoDate: string): number {
const [y, m, d] = isoDate.split('-').map(Number);
const utcNoon = new Date(Date.UTC(y, m - 1, d, 12, 0, 0, 0));
return utcNoon.getUTCDay();
}