update generated validation UX and AID integration flow

Improve generated validation behavior and backend error mapping so UI shows user-friendly Russian messages, while keeping filtering/sorting and exporter updates aligned with current app generation flow.
This commit is contained in:
time_
2026-03-27 11:07:01 +03:00
parent 420ca0348c
commit 9a1a700efa
12 changed files with 539 additions and 21 deletions

View File

@@ -15,6 +15,8 @@
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.22.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"jose": "^6.2.2",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
@@ -2426,6 +2428,12 @@
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmmirror.com/@types/validator/-/validator-13.15.10.tgz",
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -3655,6 +3663,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmmirror.com/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.4",
"resolved": "https://registry.npmmirror.com/class-validator/-/class-validator-0.14.4.tgz",
"integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==",
"license": "MIT",
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
"validator": "^13.15.22"
}
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -6823,6 +6848,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/libphonenumber-js": {
"version": "1.12.40",
"resolved": "https://registry.npmmirror.com/libphonenumber-js/-/libphonenumber-js-1.12.40.tgz",
"integrity": "sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==",
"license": "MIT"
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -9462,6 +9493,15 @@
"node": ">=10.12.0"
}
},
"node_modules/validator": {
"version": "13.15.26",
"resolved": "https://registry.npmmirror.com/validator/-/validator-13.15.26.tgz",
"integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -31,6 +31,8 @@
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.22.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"jose": "^6.2.2",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"

View File

@@ -0,0 +1,175 @@
import {
ArgumentsHost,
BadRequestException,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Request, Response } from 'express';
type ErrorResponseBody = {
statusCode: number;
message: string;
code: string;
details?: unknown;
path: string;
timestamp: string;
};
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ApiExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const mapped = this.mapException(exception);
const body: ErrorResponseBody = {
statusCode: mapped.statusCode,
message: mapped.message,
code: mapped.code,
...(mapped.details !== undefined ? { details: mapped.details } : {}),
path: request.url,
timestamp: new Date().toISOString(),
};
if (mapped.statusCode >= 500) {
this.logger.error(
`Unhandled error on ${request.method} ${request.url}: ${mapped.message}`,
exception instanceof Error ? exception.stack : undefined,
);
}
response.status(mapped.statusCode).json(body);
}
private mapException(exception: unknown): {
statusCode: number;
message: string;
code: string;
details?: unknown;
} {
if (exception instanceof HttpException) {
const statusCode = exception.getStatus();
const payload = exception.getResponse() as
| string
| {
message?: string | string[];
error?: string;
code?: string;
details?: unknown;
};
if (typeof payload === 'string') {
return {
statusCode,
message: payload,
code: `HTTP_${statusCode}`,
};
}
const rawMessage = payload?.message ?? exception.message;
const message = Array.isArray(rawMessage)
? rawMessage.join(', ')
: rawMessage || exception.message;
return {
statusCode,
message,
code: payload?.code ?? payload?.error ?? `HTTP_${statusCode}`,
details: payload?.details,
};
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
return this.mapPrismaKnownRequestError(exception);
}
if (exception instanceof Prisma.PrismaClientValidationError) {
return {
statusCode: HttpStatus.BAD_REQUEST,
message:
'Некорректные данные запроса. Проверьте обязательные поля и форматы значений.',
code: 'PRISMA_VALIDATION_ERROR',
};
}
if (exception instanceof Prisma.PrismaClientInitializationError) {
return {
statusCode: HttpStatus.SERVICE_UNAVAILABLE,
message: 'Сервис базы данных временно недоступен.',
code: 'DATABASE_UNAVAILABLE',
};
}
if (exception instanceof Prisma.PrismaClientRustPanicError) {
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Внутренняя ошибка сервера.',
code: 'DATABASE_ENGINE_PANIC',
};
}
if (exception instanceof Error) {
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: exception.message || 'Internal server error',
code: 'INTERNAL_ERROR',
};
}
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
code: 'INTERNAL_ERROR',
};
}
private mapPrismaKnownRequestError(
exception: Prisma.PrismaClientKnownRequestError,
) {
switch (exception.code) {
case 'P2002': {
const target = Array.isArray(exception.meta?.target)
? exception.meta?.target.join(', ')
: String(exception.meta?.target ?? '');
return {
statusCode: HttpStatus.CONFLICT,
message: target
? `Запись с таким значением уже существует (${target}).`
: 'Запись с таким значением уже существует.',
code: 'UNIQUE_CONSTRAINT_VIOLATION',
details: exception.meta,
};
}
case 'P2003':
return {
statusCode: HttpStatus.CONFLICT,
message:
'Операцию нельзя выполнить из-за связанных данных или некорректной ссылки.',
code: 'FOREIGN_KEY_CONSTRAINT_VIOLATION',
details: exception.meta,
};
case 'P2025':
return {
statusCode: HttpStatus.NOT_FOUND,
message: 'Запись не найдена.',
code: 'RECORD_NOT_FOUND',
details: exception.meta,
};
default:
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Ошибка при обработке данных в базе.',
code: exception.code,
details: exception.meta,
};
}
}
}

View File

@@ -1,7 +1,96 @@
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import {
BadRequestException,
ValidationError,
ValidationPipe,
} from '@nestjs/common';
import { AppModule } from './app.module';
import { RuntimeEnvironment } from './config/env.validation';
import { ApiExceptionFilter } from './common/filters/api-exception.filter';
const FIELD_LABELS: Record<string, string> = {
code: 'Код',
name: 'Название',
manufacturer: 'Производитель',
maintenanceIntervalHours: 'Интервал ТО (часы)',
overhaulIntervalHours: 'Интервал капремонта (часы)',
inventoryNumber: 'Инвентарный номер',
serialNumber: 'Серийный номер',
equipmentTypeCode: 'Тип оборудования',
equipmentId: 'Оборудование',
status: 'Статус',
location: 'Местоположение',
commissionedAt: 'Дата ввода в эксплуатацию',
totalEngineHours: 'Наработка общая',
engineHoursSinceLastRepair: 'Наработка после ремонта',
lastRepairAt: 'Дата последнего ремонта',
notes: 'Примечание',
number: 'Номер',
repairKind: 'Вид ремонта',
plannedAt: 'Плановая дата',
startedAt: 'Дата начала',
completedAt: 'Дата завершения',
contractor: 'Подрядчик',
engineHoursAtRepair: 'Наработка на момент ремонта',
description: 'Описание',
id: 'Идентификатор',
};
function prettifyFieldName(field: string): string {
if (FIELD_LABELS[field]) return FIELD_LABELS[field];
const withSpaces = field
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.trim();
if (!withSpaces) return field;
return withSpaces[0].toUpperCase() + withSpaces.slice(1);
}
function constraintToRuMessage(field: string, constraint: string): string {
const label = prettifyFieldName(field);
switch (constraint) {
case 'isNotEmpty':
return `Поле "${label}" обязательно`;
case 'isString':
return `Поле "${label}" должно быть строкой`;
case 'isInt':
return `Поле "${label}" должно быть целым числом`;
case 'isUUID':
return `Поле "${label}" должно быть UUID`;
case 'isNumberString':
return `Поле "${label}" должно быть числом`;
case 'isIso8601':
return `Поле "${label}" должно содержать корректную дату`;
case 'isEnum':
return `Поле "${label}" содержит недопустимое значение`;
default:
return `Поле "${label}" заполнено некорректно`;
}
}
function buildValidationMessages(errors: ValidationError[]): string[] {
const messages: string[] = [];
const walk = (errorList: ValidationError[]) => {
for (const error of errorList) {
if (error.constraints) {
const constraints = Object.keys(error.constraints);
// If field is empty, "required" is enough; skip type noise.
const filtered = constraints.includes('isNotEmpty')
? ['isNotEmpty']
: constraints;
filtered.forEach((constraint) =>
messages.push(constraintToRuMessage(error.property, constraint)),
);
}
if (error.children?.length) walk(error.children);
}
};
walk(errors);
return Array.from(new Set(messages));
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@@ -29,6 +118,17 @@ async function bootstrap() {
credentials: false,
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidUnknownValues: false,
exceptionFactory: (errors) =>
new BadRequestException(buildValidationMessages(errors)),
}),
);
app.useGlobalFilters(new ApiExceptionFilter());
const port = configService.get('PORT', 3000);
await app.listen(port);
}

View File

@@ -1,7 +1,19 @@
import { IsInt, IsNotEmpty, IsString } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateEquipmentTypeDto {
@IsString({ message: 'code: должно быть строкой' })
@IsNotEmpty({ message: 'code: обязательное поле' })
code?: string;
@IsString({ message: 'name: должно быть строкой' })
@IsNotEmpty({ message: 'name: обязательное поле' })
name!: string;
@IsString({ message: 'manufacturer: должно быть строкой' })
manufacturer?: string;
@Type(() => Number)
@IsInt({ message: 'maintenanceIntervalHours: должно быть целым числом' })
maintenanceIntervalHours?: number;
@Type(() => Number)
@IsInt({ message: 'overhaulIntervalHours: должно быть целым числом' })
overhaulIntervalHours?: number;
}

View File

@@ -1,8 +1,25 @@
import { IsInt, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
export class UpdateEquipmentTypeDto {
@IsOptional()
@IsString({ message: 'id: должно быть строкой' })
id?: string;
@IsOptional()
@IsString({ message: 'code: должно быть строкой' })
code?: string;
@IsOptional()
@IsString({ message: 'name: должно быть строкой' })
name?: string;
@IsOptional()
@IsString({ message: 'manufacturer: должно быть строкой' })
manufacturer?: string;
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'maintenanceIntervalHours: должно быть целым числом' })
maintenanceIntervalHours?: number;
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'overhaulIntervalHours: должно быть целым числом' })
overhaulIntervalHours?: number;
}

View File

@@ -1,13 +1,30 @@
import { IsISO8601, IsNotEmpty, IsNumberString, IsString } from 'class-validator';
export class CreateEquipmentDto {
@IsString({ message: 'inventoryNumber: должно быть строкой' })
@IsNotEmpty({ message: 'inventoryNumber: обязательное поле' })
inventoryNumber!: string;
@IsString({ message: 'serialNumber: должно быть строкой' })
serialNumber?: string;
@IsString({ message: 'name: должно быть строкой' })
@IsNotEmpty({ message: 'name: обязательное поле' })
name!: string;
@IsString({ message: 'equipmentTypeCode: должно быть строкой' })
@IsNotEmpty({ message: 'equipmentTypeCode: обязательное поле' })
equipmentTypeCode!: string;
@IsString({ message: 'status: должно быть строкой' })
@IsNotEmpty({ message: 'status: обязательное поле' })
status!: string;
@IsString({ message: 'location: должно быть строкой' })
location?: string;
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
commissionedAt?: string;
@IsNumberString({}, { message: 'totalEngineHours: должно быть числом' })
totalEngineHours?: string;
@IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' })
engineHoursSinceLastRepair?: string;
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
lastRepairAt?: string;
@IsString({ message: 'notes: должно быть строкой' })
notes?: string;
}

View File

@@ -1,14 +1,40 @@
import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateEquipmentDto {
@IsOptional()
@IsUUID(undefined, { message: 'id: должно быть UUID' })
id?: string;
@IsOptional()
@IsString({ message: 'inventoryNumber: должно быть строкой' })
inventoryNumber?: string;
@IsOptional()
@IsString({ message: 'serialNumber: должно быть строкой' })
serialNumber?: string;
@IsOptional()
@IsString({ message: 'name: должно быть строкой' })
name?: string;
@IsOptional()
@IsString({ message: 'equipmentTypeCode: должно быть строкой' })
equipmentTypeCode?: string;
@IsOptional()
@IsString({ message: 'status: должно быть строкой' })
status?: string;
@IsOptional()
@IsString({ message: 'location: должно быть строкой' })
location?: string;
@IsOptional()
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
commissionedAt?: string;
@IsOptional()
@IsNumberString({}, { message: 'totalEngineHours: должно быть числом' })
totalEngineHours?: string;
@IsOptional()
@IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' })
engineHoursSinceLastRepair?: string;
@IsOptional()
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
lastRepairAt?: string;
@IsOptional()
@IsString({ message: 'notes: должно быть строкой' })
notes?: string;
}

View File

@@ -1,13 +1,31 @@
import { IsISO8601, IsNotEmpty, IsNumberString, IsString, IsUUID } from 'class-validator';
export class CreateRepairOrderDto {
@IsString({ message: 'number: должно быть строкой' })
@IsNotEmpty({ message: 'number: обязательное поле' })
number!: string;
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
@IsNotEmpty({ message: 'equipmentId: обязательное поле' })
equipmentId!: string;
@IsString({ message: 'repairKind: должно быть строкой' })
@IsNotEmpty({ message: 'repairKind: обязательное поле' })
repairKind!: string;
@IsString({ message: 'status: должно быть строкой' })
@IsNotEmpty({ message: 'status: обязательное поле' })
status!: string;
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
@IsNotEmpty({ message: 'plannedAt: обязательное поле' })
plannedAt!: string;
@IsISO8601({}, { message: 'startedAt: должно содержать корректную дату' })
startedAt?: string;
@IsISO8601({}, { message: 'completedAt: должно содержать корректную дату' })
completedAt?: string;
@IsString({ message: 'contractor: должно быть строкой' })
contractor?: string;
@IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' })
engineHoursAtRepair?: string;
@IsString({ message: 'description: должно быть строкой' })
description?: string;
@IsString({ message: 'notes: должно быть строкой' })
notes?: string;
}

View File

@@ -1,14 +1,40 @@
import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateRepairOrderDto {
@IsOptional()
@IsUUID(undefined, { message: 'id: должно быть UUID' })
id?: string;
@IsOptional()
@IsString({ message: 'number: должно быть строкой' })
number?: string;
@IsOptional()
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
equipmentId?: string;
@IsOptional()
@IsString({ message: 'repairKind: должно быть строкой' })
repairKind?: string;
@IsOptional()
@IsString({ message: 'status: должно быть строкой' })
status?: string;
@IsOptional()
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
plannedAt?: string;
@IsOptional()
@IsISO8601({}, { message: 'startedAt: должно содержать корректную дату' })
startedAt?: string;
@IsOptional()
@IsISO8601({}, { message: 'completedAt: должно содержать корректную дату' })
completedAt?: string;
@IsOptional()
@IsString({ message: 'contractor: должно быть строкой' })
contractor?: string;
@IsOptional()
@IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' })
engineHoursAtRepair?: string;
@IsOptional()
@IsString({ message: 'description: должно быть строкой' })
description?: string;
@IsOptional()
@IsString({ message: 'notes: должно быть строкой' })
notes?: string;
}