From ae642d78b8bb515fdaf66223ab26894b53605092 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 4 Mar 2026 13:45:38 +0300 Subject: [PATCH] chore: add error utils and uuid7 helper Made-with: Cursor --- src/utils/errors.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++ src/utils/uuid.ts | 37 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/utils/errors.ts create mode 100644 src/utils/uuid.ts diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..ce53034 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,88 @@ +export const ERROR_CODES = { + BAD_REQUEST: 'BAD_REQUEST', + UNAUTHORIZED: 'UNAUTHORIZED', + FORBIDDEN: 'FORBIDDEN', + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + VALIDATION_ERROR: 'VALIDATION_ERROR', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', + INTERNAL_ERROR: 'INTERNAL_ERROR', + INVALID_CREDENTIALS: 'INVALID_CREDENTIALS', + ACCOUNT_LOCKED: 'ACCOUNT_LOCKED', + EMAIL_TAKEN: 'EMAIL_TAKEN', + INVALID_REFRESH_TOKEN: 'INVALID_REFRESH_TOKEN', + TOKEN_REUSE_DETECTED: 'TOKEN_REUSE_DETECTED', + INVALID_CODE: 'INVALID_CODE', + ALREADY_VERIFIED: 'ALREADY_VERIFIED', + INVALID_RESET_TOKEN: 'INVALID_RESET_TOKEN', + NICKNAME_TAKEN: 'NICKNAME_TAKEN', + DAILY_LIMIT_REACHED: 'DAILY_LIMIT_REACHED', + EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED', + QUESTIONS_UNAVAILABLE: 'QUESTIONS_UNAVAILABLE', + QUESTION_ALREADY_ANSWERED: 'QUESTION_ALREADY_ANSWERED', + WRONG_QUESTION: 'WRONG_QUESTION', + TEST_ALREADY_FINISHED: 'TEST_ALREADY_FINISHED', + NO_ANSWERS: 'NO_ANSWERS', + TEST_NOT_FINISHED: 'TEST_NOT_FINISHED', + USER_NOT_FOUND: 'USER_NOT_FOUND', +} as const; + +export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; + +export class AppError extends Error { + constructor( + public readonly code: ErrorCode, + public readonly message: string, + public readonly statusCode: number = 500, + public readonly details?: unknown + ) { + super(message); + this.name = 'AppError'; + Object.setPrototypeOf(this, AppError.prototype); + } + + toJSON() { + const err: { code: string; message: string; details?: unknown } = { + code: this.code, + message: this.message, + }; + if (this.details !== undefined) err.details = this.details; + return { error: err }; + } +} + +export function badRequest(message: string, details?: unknown): AppError { + return new AppError(ERROR_CODES.BAD_REQUEST, message, 400, details); +} + +export function unauthorized(message: string): AppError { + return new AppError(ERROR_CODES.UNAUTHORIZED, message, 401); +} + +export function forbidden(message: string): AppError { + return new AppError(ERROR_CODES.FORBIDDEN, message, 403); +} + +export function notFound(message: string): AppError { + return new AppError(ERROR_CODES.NOT_FOUND, message, 404); +} + +export function conflict(code: ErrorCode, message: string): AppError { + return new AppError(code, message, 409); +} + +export function validationError(message: string, details?: unknown): AppError { + return new AppError(ERROR_CODES.VALIDATION_ERROR, message, 422, details); +} + +export function rateLimitExceeded(message: string, retryAfter?: number): AppError { + const err = new AppError(ERROR_CODES.RATE_LIMIT_EXCEEDED, message, 429); + if (retryAfter !== undefined) { + (err as AppError & { retryAfter: number }).retryAfter = retryAfter; + } + return err; +} + +export function internalError(message: string, cause?: unknown): AppError { + return new AppError(ERROR_CODES.INTERNAL_ERROR, message, 500, cause); +} diff --git a/src/utils/uuid.ts b/src/utils/uuid.ts new file mode 100644 index 0000000..5028edb --- /dev/null +++ b/src/utils/uuid.ts @@ -0,0 +1,37 @@ +import { randomBytes } from 'node:crypto'; + +/** + * Generate UUID v7 (time-ordered, sortable). + * Simplified implementation: timestamp (48 bit) + random (74 bit). + */ +export function uuid7(): string { + const timestamp = Date.now(); + const random = randomBytes(10); + + const high = (timestamp / 0x100000000) >>> 0; + const low = timestamp >>> 0; + + const b = new Uint8Array(16); + b[0] = (high >> 24) & 0xff; + b[1] = (high >> 16) & 0xff; + b[2] = (high >> 8) & 0xff; + b[3] = high & 0xff; + b[4] = (low >> 24) & 0xff; + b[5] = (low >> 16) & 0xff; + b[6] = ((low >> 8) & 0x3f) | 0x70; + b[7] = low & 0xff; + b[8] = 0x80 | (random[0] & 0x3f); + b[9] = random[1]; + b[10] = random[2]; + b[11] = random[3]; + b[12] = random[4]; + b[13] = random[5]; + b[14] = random[6]; + b[15] = random[7]; + + const hex = Array.from(b) + .map((x) => x.toString(16).padStart(2, '0')) + .join(''); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +}