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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
29
backend/src/common/zoned-civil-time.spec.ts
Normal file
29
backend/src/common/zoned-civil-time.spec.ts
Normal 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:00–17: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);
|
||||
});
|
||||
});
|
||||
55
backend/src/common/zoned-civil-time.ts
Normal file
55
backend/src/common/zoned-civil-time.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user