From 9a1a700efa82fe02a974612716bc3ef1f3e85c4f Mon Sep 17 00:00:00 2001 From: time_ Date: Fri, 27 Mar 2026 11:07:01 +0300 Subject: [PATCH] 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. --- client/src/dataProvider.ts | 31 +++- generation/generate.mjs | 96 ++++++++-- server/package-lock.json | 40 ++++ server/package.json | 2 + .../common/filters/api-exception.filter.ts | 175 ++++++++++++++++++ server/src/main.ts | 100 ++++++++++ .../dto/create-equipment-type.dto.ts | 12 ++ .../dto/update-equipment-type.dto.ts | 17 ++ .../equipment/dto/create-equipment.dto.ts | 17 ++ .../equipment/dto/update-equipment.dto.ts | 26 +++ .../dto/create-repair-order.dto.ts | 18 ++ .../dto/update-repair-order.dto.ts | 26 +++ 12 files changed, 539 insertions(+), 21 deletions(-) create mode 100644 server/src/common/filters/api-exception.filter.ts diff --git a/client/src/dataProvider.ts b/client/src/dataProvider.ts index 07952bd..f8987cc 100644 --- a/client/src/dataProvider.ts +++ b/client/src/dataProvider.ts @@ -1,4 +1,4 @@ -import { DataProvider, fetchUtils } from 'react-admin'; +import { DataProvider, fetchUtils, HttpError } from 'react-admin'; import { getValidAccessToken } from './auth/keycloak'; import { env } from './config/env'; @@ -9,10 +9,31 @@ const httpClient = async (url: string, options: fetchUtils.Options = {}) => { const headers = new Headers(options.headers ?? { Accept: 'application/json' }); headers.set('Authorization', `Bearer ${token}`); - return fetchUtils.fetchJson(url, { - ...options, - headers, - }); + try { + return await fetchUtils.fetchJson(url, { + ...options, + headers, + }); + } catch (error: unknown) { + const e = error as { + status?: number; + body?: { + message?: string | string[]; + details?: unknown; + }; + message?: string; + }; + const messageFromBody = e?.body?.message; + const normalizedMessage = Array.isArray(messageFromBody) + ? messageFromBody.join(', ') + : messageFromBody; + + throw new HttpError( + normalizedMessage || e?.message || 'Request failed', + e?.status ?? 500, + e?.body, + ); + } }; function buildQueryString(query: Record) { diff --git a/generation/generate.mjs b/generation/generate.mjs index fa36c3f..2e9159a 100644 --- a/generation/generate.mjs +++ b/generation/generate.mjs @@ -287,15 +287,6 @@ function renderBackendModule(entityName, entity, resourceName, pk) { const folder = toKebab(entityName); // DTOs - const enumTypes = entity.attributes - .filter((a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) - .map((a) => a.type); - - const enumUnion = (typeName) => { - // Unknown labels in DSL aren't needed; keep as string union to avoid importing Prisma enums. - return `'${typeName}'`; - }; - const dtoType = (attr) => { switch (attr.type) { case 'uuid': @@ -314,23 +305,96 @@ function renderBackendModule(entityName, entity, resourceName, pk) { } }; + const getValidationDecorators = (attr, isUpdate) => { + const decorators = []; + const imports = new Set(); + let needsTypeImport = false; + const field = attr.name; + if (isUpdate) { + decorators.push('@IsOptional()'); + imports.add('IsOptional'); + } + + switch (attr.type) { + case 'uuid': + decorators.push(`@IsUUID(undefined, { message: '${field}: должно быть UUID' })`); + imports.add('IsUUID'); + break; + case 'string': + case 'text': + decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`); + imports.add('IsString'); + break; + case 'integer': + decorators.push('@Type(() => Number)'); + decorators.push(`@IsInt({ message: '${field}: должно быть целым числом' })`); + imports.add('IsInt'); + needsTypeImport = true; + break; + case 'decimal': + decorators.push(`@IsNumberString({}, { message: '${field}: должно быть числом' })`); + imports.add('IsNumberString'); + break; + case 'date': + decorators.push(`@IsISO8601({}, { message: '${field}: должно содержать корректную дату' })`); + imports.add('IsISO8601'); + break; + default: + // enum (kept as string in generated DTOs) + decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`); + imports.add('IsString'); + break; + } + + if (!isUpdate && attr.isRequired && !(attr.isPrimary && attr.type === 'uuid')) { + decorators.push(`@IsNotEmpty({ message: '${field}: обязательное поле' })`); + imports.add('IsNotEmpty'); + } + return { decorators, imports, needsTypeImport }; + }; + + const createDecorators = new Set(); + const updateDecorators = new Set(['IsOptional']); + let createNeedsTypeImport = false; + let updateNeedsTypeImport = false; + const createDtoLines = []; - createDtoLines.push(`export class Create${className}Dto {`); for (const a of entity.attributes) { if (a.isPrimary && a.type === 'uuid') continue; // generated + const { decorators, imports, needsTypeImport } = getValidationDecorators(a, false); + imports.forEach((i) => createDecorators.add(i)); + if (needsTypeImport) createNeedsTypeImport = true; + decorators.forEach((d) => createDtoLines.push(` ${d}`)); const opt = a.isRequired && !(a.isPrimary && a.type !== 'uuid') ? '!' : '?'; createDtoLines.push(` ${a.name}${opt}: ${dtoType(a)};`); } - createDtoLines.push('}'); const updateDtoLines = []; - updateDtoLines.push(`export class Update${className}Dto {`); - if (pk !== 'id') updateDtoLines.push(` id?: string;`); + if (pk !== 'id') { + updateDtoLines.push(' @IsOptional()'); + updateDtoLines.push(` @IsString({ message: 'id: должно быть строкой' })`); + updateDtoLines.push(' id?: string;'); + updateDecorators.add('IsString'); + } for (const a of entity.attributes) { if (pk !== 'id' && a.name === 'id') continue; + const { decorators, imports, needsTypeImport } = getValidationDecorators(a, true); + imports.forEach((i) => updateDecorators.add(i)); + if (needsTypeImport) updateNeedsTypeImport = true; + decorators.forEach((d) => updateDtoLines.push(` ${d}`)); updateDtoLines.push(` ${a.name}?: ${dtoType(a)};`); } - updateDtoLines.push('}'); + + const createImports = Array.from(createDecorators) + .filter(Boolean) + .sort() + .join(', '); + const updateImports = Array.from(updateDecorators) + .filter(Boolean) + .sort() + .join(', '); + const createDto = `import { ${createImports} } from 'class-validator';\n${createNeedsTypeImport ? "import { Type } from 'class-transformer';\n" : ''}\nexport class Create${className}Dto {\n${createDtoLines.join('\n')}\n}\n`; + const updateDto = `import { ${updateImports} } from 'class-validator';\n${updateNeedsTypeImport ? "import { Type } from 'class-transformer';\n" : ''}\nexport class Update${className}Dto {\n${updateDtoLines.join('\n')}\n}\n`; const controller = `import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';\nimport { Response } from 'express';\nimport { Roles } from '../../auth/decorators/roles.decorator';\nimport { RealmRole } from '../../auth/roles/realm-role.enum';\nimport { ${serviceName} } from './${folder}.service';\nimport { Create${className}Dto } from './dto/create-${folder}.dto';\nimport { Update${className}Dto } from './dto/update-${folder}.dto';\n\n@Controller('${resourceName}')\nexport class ${controllerName} {\n constructor(private readonly service: ${serviceName}) {}\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get()\n async findAll(@Query() query: any, @Res() res: Response) {\n const result = await this.service.findAll(query);\n res.set('Content-Range', \`${resourceName} \${query._start || 0}-\${query._end || result.total}/\${result.total}\`);\n res.set('Access-Control-Expose-Headers', 'Content-Range');\n return res.json(result.data);\n }\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get(':${pk}')\n findOne(@Param('${pk}') id: string) {\n return this.service.findOne(id);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Post()\n create(@Body() dto: Create${className}Dto) {\n return this.service.create(dto);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Patch(':${pk}')\n update(@Param('${pk}') id: string, @Body() dto: Update${className}Dto) {\n return this.service.update(id, dto);\n }\n\n @Roles(RealmRole.Admin)\n @Delete(':${pk}')\n remove(@Param('${pk}') id: string) {\n return this.service.remove(id);\n }\n}\n`; @@ -393,8 +457,8 @@ function renderBackendModule(entityName, entity, resourceName, pk) { [`server/src/modules/${folder}/${folder}.controller.ts`]: controller, [`server/src/modules/${folder}/${folder}.service.ts`]: serviceContent, [`server/src/modules/${folder}/${folder}.module.ts`]: mod, - [`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDtoLines.join('\n') + '\n', - [`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDtoLines.join('\n') + '\n', + [`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDto, + [`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDto, }, moduleName, importPath: `./modules/${folder}/${folder}.module`, diff --git a/server/package-lock.json b/server/package-lock.json index 16c0ade..5bf66a1 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index bbad221..73ac676 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/server/src/common/filters/api-exception.filter.ts b/server/src/common/filters/api-exception.filter.ts new file mode 100644 index 0000000..3b2b0d5 --- /dev/null +++ b/server/src/common/filters/api-exception.filter.ts @@ -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(); + const request = ctx.getRequest(); + + 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, + }; + } + } +} + diff --git a/server/src/main.ts b/server/src/main.ts index 2f858bb..2dc5318 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -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 = { + 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); } diff --git a/server/src/modules/equipment-type/dto/create-equipment-type.dto.ts b/server/src/modules/equipment-type/dto/create-equipment-type.dto.ts index 2f9d6ab..64d9abe 100644 --- a/server/src/modules/equipment-type/dto/create-equipment-type.dto.ts +++ b/server/src/modules/equipment-type/dto/create-equipment-type.dto.ts @@ -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; } diff --git a/server/src/modules/equipment-type/dto/update-equipment-type.dto.ts b/server/src/modules/equipment-type/dto/update-equipment-type.dto.ts index 7589324..e5e29da 100644 --- a/server/src/modules/equipment-type/dto/update-equipment-type.dto.ts +++ b/server/src/modules/equipment-type/dto/update-equipment-type.dto.ts @@ -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; } diff --git a/server/src/modules/equipment/dto/create-equipment.dto.ts b/server/src/modules/equipment/dto/create-equipment.dto.ts index a89cc8d..826e35a 100644 --- a/server/src/modules/equipment/dto/create-equipment.dto.ts +++ b/server/src/modules/equipment/dto/create-equipment.dto.ts @@ -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; } diff --git a/server/src/modules/equipment/dto/update-equipment.dto.ts b/server/src/modules/equipment/dto/update-equipment.dto.ts index a44c3c9..6c99b1c 100644 --- a/server/src/modules/equipment/dto/update-equipment.dto.ts +++ b/server/src/modules/equipment/dto/update-equipment.dto.ts @@ -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; } diff --git a/server/src/modules/repair-order/dto/create-repair-order.dto.ts b/server/src/modules/repair-order/dto/create-repair-order.dto.ts index 229f25f..8521762 100644 --- a/server/src/modules/repair-order/dto/create-repair-order.dto.ts +++ b/server/src/modules/repair-order/dto/create-repair-order.dto.ts @@ -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; } diff --git a/server/src/modules/repair-order/dto/update-repair-order.dto.ts b/server/src/modules/repair-order/dto/update-repair-order.dto.ts index e42bcb1..c62de47 100644 --- a/server/src/modules/repair-order/dto/update-repair-order.dto.ts +++ b/server/src/modules/repair-order/dto/update-repair-order.dto.ts @@ -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; }