Compare commits
2 Commits
79c9589658
...
5ef01c2282
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ef01c2282 | ||
|
|
51a5e1b5c1 |
@@ -1,6 +1,7 @@
|
|||||||
import { Admin, Resource } from 'react-admin';
|
import { Admin, Resource } from 'react-admin';
|
||||||
import dataProvider from './dataProvider';
|
import dataProvider from './dataProvider';
|
||||||
import authProvider from './auth/authProvider';
|
import authProvider from './auth/authProvider';
|
||||||
|
import { AppNotification } from './AppNotification';
|
||||||
|
|
||||||
import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList';
|
import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList';
|
||||||
import { EquipmentTypeCreate } from './resources/equipment-type/EquipmentTypeCreate';
|
import { EquipmentTypeCreate } from './resources/equipment-type/EquipmentTypeCreate';
|
||||||
@@ -18,7 +19,12 @@ import { RepairOrderEdit } from './resources/repair-order/RepairOrderEdit';
|
|||||||
import { RepairOrderShow } from './resources/repair-order/RepairOrderShow';
|
import { RepairOrderShow } from './resources/repair-order/RepairOrderShow';
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<Admin dataProvider={dataProvider} authProvider={authProvider} requireAuth>
|
<Admin
|
||||||
|
dataProvider={dataProvider}
|
||||||
|
authProvider={authProvider}
|
||||||
|
notification={AppNotification}
|
||||||
|
requireAuth
|
||||||
|
>
|
||||||
<Resource
|
<Resource
|
||||||
name="equipment-types"
|
name="equipment-types"
|
||||||
options={{ label: 'Виды оборудования' }}
|
options={{ label: 'Виды оборудования' }}
|
||||||
|
|||||||
16
client/src/AppNotification.tsx
Normal file
16
client/src/AppNotification.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Notification, NotificationProps } from 'react-admin';
|
||||||
|
|
||||||
|
export const AppNotification = (props: NotificationProps) => (
|
||||||
|
<Notification
|
||||||
|
{...props}
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
'& .MuiAlert-message': {
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
},
|
||||||
|
'& .MuiSnackbarContent-message': {
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DataProvider, fetchUtils } from 'react-admin';
|
import { DataProvider, fetchUtils, HttpError } from 'react-admin';
|
||||||
import { getValidAccessToken } from './auth/keycloak';
|
import { getValidAccessToken } from './auth/keycloak';
|
||||||
import { env } from './config/env';
|
import { env } from './config/env';
|
||||||
|
|
||||||
@@ -9,10 +9,33 @@ const httpClient = async (url: string, options: fetchUtils.Options = {}) => {
|
|||||||
const headers = new Headers(options.headers ?? { Accept: 'application/json' });
|
const headers = new Headers(options.headers ?? { Accept: 'application/json' });
|
||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
return fetchUtils.fetchJson(url, {
|
try {
|
||||||
...options,
|
return await fetchUtils.fetchJson(url, {
|
||||||
headers,
|
...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('\n')
|
||||||
|
: typeof messageFromBody === 'string'
|
||||||
|
? messageFromBody.split(', ').join('\n')
|
||||||
|
: messageFromBody;
|
||||||
|
|
||||||
|
throw new HttpError(
|
||||||
|
normalizedMessage || e?.message || 'Request failed',
|
||||||
|
e?.status ?? 500,
|
||||||
|
e?.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildQueryString(query: Record<string, unknown>) {
|
function buildQueryString(query: Record<string, unknown>) {
|
||||||
|
|||||||
@@ -287,15 +287,6 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
|||||||
const folder = toKebab(entityName);
|
const folder = toKebab(entityName);
|
||||||
|
|
||||||
// DTOs
|
// 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) => {
|
const dtoType = (attr) => {
|
||||||
switch (attr.type) {
|
switch (attr.type) {
|
||||||
case 'uuid':
|
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 = [];
|
const createDtoLines = [];
|
||||||
createDtoLines.push(`export class Create${className}Dto {`);
|
|
||||||
for (const a of entity.attributes) {
|
for (const a of entity.attributes) {
|
||||||
if (a.isPrimary && a.type === 'uuid') continue; // generated
|
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') ? '!' : '?';
|
const opt = a.isRequired && !(a.isPrimary && a.type !== 'uuid') ? '!' : '?';
|
||||||
createDtoLines.push(` ${a.name}${opt}: ${dtoType(a)};`);
|
createDtoLines.push(` ${a.name}${opt}: ${dtoType(a)};`);
|
||||||
}
|
}
|
||||||
createDtoLines.push('}');
|
|
||||||
|
|
||||||
const updateDtoLines = [];
|
const updateDtoLines = [];
|
||||||
updateDtoLines.push(`export class Update${className}Dto {`);
|
if (pk !== 'id') {
|
||||||
if (pk !== 'id') updateDtoLines.push(` id?: string;`);
|
updateDtoLines.push(' @IsOptional()');
|
||||||
|
updateDtoLines.push(` @IsString({ message: 'id: должно быть строкой' })`);
|
||||||
|
updateDtoLines.push(' id?: string;');
|
||||||
|
updateDecorators.add('IsString');
|
||||||
|
}
|
||||||
for (const a of entity.attributes) {
|
for (const a of entity.attributes) {
|
||||||
if (pk !== 'id' && a.name === 'id') continue;
|
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(` ${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`;
|
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}.controller.ts`]: controller,
|
||||||
[`server/src/modules/${folder}/${folder}.service.ts`]: serviceContent,
|
[`server/src/modules/${folder}/${folder}.service.ts`]: serviceContent,
|
||||||
[`server/src/modules/${folder}/${folder}.module.ts`]: mod,
|
[`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/create-${folder}.dto.ts`]: createDto,
|
||||||
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDtoLines.join('\n') + '\n',
|
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDto,
|
||||||
},
|
},
|
||||||
moduleName,
|
moduleName,
|
||||||
importPath: `./modules/${folder}/${folder}.module`,
|
importPath: `./modules/${folder}/${folder}.module`,
|
||||||
|
|||||||
40
server/package-lock.json
generated
40
server/package-lock.json
generated
@@ -15,6 +15,8 @@
|
|||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
@@ -2426,6 +2428,12 @@
|
|||||||
"@types/superagent": "^8.1.0"
|
"@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": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@@ -3655,6 +3663,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cli-cursor": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||||
@@ -6823,6 +6848,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -9462,6 +9493,15 @@
|
|||||||
"node": ">=10.12.0"
|
"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": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
|
|||||||
175
server/src/common/filters/api-exception.filter.ts
Normal file
175
server/src/common/filters/api-exception.filter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,96 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ValidationError,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { RuntimeEnvironment } from './config/env.validation';
|
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() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@@ -29,6 +118,17 @@ async function bootstrap() {
|
|||||||
credentials: false,
|
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);
|
const port = configService.get('PORT', 3000);
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
import { IsInt, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class CreateEquipmentTypeDto {
|
export class CreateEquipmentTypeDto {
|
||||||
|
@IsString({ message: 'code: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'code: обязательное поле' })
|
||||||
code?: string;
|
code?: string;
|
||||||
|
@IsString({ message: 'name: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'name: обязательное поле' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
@IsString({ message: 'manufacturer: должно быть строкой' })
|
||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'maintenanceIntervalHours: должно быть целым числом' })
|
||||||
maintenanceIntervalHours?: number;
|
maintenanceIntervalHours?: number;
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'overhaulIntervalHours: должно быть целым числом' })
|
||||||
overhaulIntervalHours?: number;
|
overhaulIntervalHours?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
|
import { IsInt, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class UpdateEquipmentTypeDto {
|
export class UpdateEquipmentTypeDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'id: должно быть строкой' })
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'code: должно быть строкой' })
|
||||||
code?: string;
|
code?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'name: должно быть строкой' })
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'manufacturer: должно быть строкой' })
|
||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'maintenanceIntervalHours: должно быть целым числом' })
|
||||||
maintenanceIntervalHours?: number;
|
maintenanceIntervalHours?: number;
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'overhaulIntervalHours: должно быть целым числом' })
|
||||||
overhaulIntervalHours?: number;
|
overhaulIntervalHours?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
|
import { IsISO8601, IsNotEmpty, IsNumberString, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateEquipmentDto {
|
export class CreateEquipmentDto {
|
||||||
|
@IsString({ message: 'inventoryNumber: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'inventoryNumber: обязательное поле' })
|
||||||
inventoryNumber!: string;
|
inventoryNumber!: string;
|
||||||
|
@IsString({ message: 'serialNumber: должно быть строкой' })
|
||||||
serialNumber?: string;
|
serialNumber?: string;
|
||||||
|
@IsString({ message: 'name: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'name: обязательное поле' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
@IsString({ message: 'equipmentTypeCode: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'equipmentTypeCode: обязательное поле' })
|
||||||
equipmentTypeCode!: string;
|
equipmentTypeCode!: string;
|
||||||
|
@IsString({ message: 'status: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'status: обязательное поле' })
|
||||||
status!: string;
|
status!: string;
|
||||||
|
@IsString({ message: 'location: должно быть строкой' })
|
||||||
location?: string;
|
location?: string;
|
||||||
|
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
|
||||||
commissionedAt?: string;
|
commissionedAt?: string;
|
||||||
|
@IsNumberString({}, { message: 'totalEngineHours: должно быть числом' })
|
||||||
totalEngineHours?: string;
|
totalEngineHours?: string;
|
||||||
|
@IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' })
|
||||||
engineHoursSinceLastRepair?: string;
|
engineHoursSinceLastRepair?: string;
|
||||||
|
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
|
||||||
lastRepairAt?: string;
|
lastRepairAt?: string;
|
||||||
|
@IsString({ message: 'notes: должно быть строкой' })
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
|
import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateEquipmentDto {
|
export class UpdateEquipmentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID(undefined, { message: 'id: должно быть UUID' })
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'inventoryNumber: должно быть строкой' })
|
||||||
inventoryNumber?: string;
|
inventoryNumber?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'serialNumber: должно быть строкой' })
|
||||||
serialNumber?: string;
|
serialNumber?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'name: должно быть строкой' })
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'equipmentTypeCode: должно быть строкой' })
|
||||||
equipmentTypeCode?: string;
|
equipmentTypeCode?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'status: должно быть строкой' })
|
||||||
status?: string;
|
status?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'location: должно быть строкой' })
|
||||||
location?: string;
|
location?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
|
||||||
commissionedAt?: string;
|
commissionedAt?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString({}, { message: 'totalEngineHours: должно быть числом' })
|
||||||
totalEngineHours?: string;
|
totalEngineHours?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' })
|
||||||
engineHoursSinceLastRepair?: string;
|
engineHoursSinceLastRepair?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
|
||||||
lastRepairAt?: string;
|
lastRepairAt?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'notes: должно быть строкой' })
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
|
import { IsISO8601, IsNotEmpty, IsNumberString, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class CreateRepairOrderDto {
|
export class CreateRepairOrderDto {
|
||||||
|
@IsString({ message: 'number: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'number: обязательное поле' })
|
||||||
number!: string;
|
number!: string;
|
||||||
|
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
|
||||||
|
@IsNotEmpty({ message: 'equipmentId: обязательное поле' })
|
||||||
equipmentId!: string;
|
equipmentId!: string;
|
||||||
|
@IsString({ message: 'repairKind: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'repairKind: обязательное поле' })
|
||||||
repairKind!: string;
|
repairKind!: string;
|
||||||
|
@IsString({ message: 'status: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'status: обязательное поле' })
|
||||||
status!: string;
|
status!: string;
|
||||||
|
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
|
||||||
|
@IsNotEmpty({ message: 'plannedAt: обязательное поле' })
|
||||||
plannedAt!: string;
|
plannedAt!: string;
|
||||||
|
@IsISO8601({}, { message: 'startedAt: должно содержать корректную дату' })
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
|
@IsISO8601({}, { message: 'completedAt: должно содержать корректную дату' })
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
|
@IsString({ message: 'contractor: должно быть строкой' })
|
||||||
contractor?: string;
|
contractor?: string;
|
||||||
|
@IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' })
|
||||||
engineHoursAtRepair?: string;
|
engineHoursAtRepair?: string;
|
||||||
|
@IsString({ message: 'description: должно быть строкой' })
|
||||||
description?: string;
|
description?: string;
|
||||||
|
@IsString({ message: 'notes: должно быть строкой' })
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
|
import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateRepairOrderDto {
|
export class UpdateRepairOrderDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID(undefined, { message: 'id: должно быть UUID' })
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'number: должно быть строкой' })
|
||||||
number?: string;
|
number?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
|
||||||
equipmentId?: string;
|
equipmentId?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'repairKind: должно быть строкой' })
|
||||||
repairKind?: string;
|
repairKind?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'status: должно быть строкой' })
|
||||||
status?: string;
|
status?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
|
||||||
plannedAt?: string;
|
plannedAt?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'startedAt: должно содержать корректную дату' })
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'completedAt: должно содержать корректную дату' })
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'contractor: должно быть строкой' })
|
||||||
contractor?: string;
|
contractor?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' })
|
||||||
engineHoursAtRepair?: string;
|
engineHoursAtRepair?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'description: должно быть строкой' })
|
||||||
description?: string;
|
description?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'notes: должно быть строкой' })
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user