From 1cdd80f51bd5ec76db629d414fad4dc0e74a1197 Mon Sep 17 00:00:00 2001 From: time_ Date: Sun, 29 Mar 2026 14:36:10 +0300 Subject: [PATCH] feat: align RU validation, error contract, and generator runtime templates Wire DSL-derived field labels, safe API error JSON (string|string[]), decimal/enum DTO fixes, and client dataProvider without comma-splitting. Add generation/templates/runtime as canonical source copied on generate; extend AID bundle, prompts, validation gate, and docs. --- .env.portainer.example | 15 -- client/.dockerignore | 7 - client/Dockerfile | 27 --- client/nginx/default.conf | 27 --- client/src/App.tsx | 8 +- client/src/AppNotification.tsx | 16 ++ client/src/dataProvider.ts | 49 ++++- .../src/resources/equipment/EquipmentEdit.tsx | 2 +- .../src/resources/equipment/EquipmentList.tsx | 2 +- .../src/resources/equipment/EquipmentShow.tsx | 2 +- .../repair-order/RepairOrderEdit.tsx | 2 +- .../repair-order/RepairOrderList.tsx | 2 +- .../repair-order/RepairOrderShow.tsx | 2 +- docker-compose.yml | 88 +------- docs/AID_EXPORT_README.md | 2 +- generation/generate.mjs | 207 ++++++++++++++++-- .../templates/runtime/AppNotification.tsx | 16 ++ .../templates/runtime/api-exception.filter.ts | 184 ++++++++++++++++ generation/templates/runtime/dataProvider.ts | 185 ++++++++++++++++ generation/templates/runtime/main.ts | 111 ++++++++++ prompts/general-prompt.md | 1 + prompts/validation-rules.md | 10 + server/.dockerignore | 8 - server/Dockerfile | 37 ---- server/package-lock.json | 40 ++++ server/package.json | 4 +- .../20260322114233_baseline/migration.sql | 2 +- server/src/common/field-labels.generated.ts | 28 +++ .../common/filters/api-exception.filter.ts | 184 ++++++++++++++++ server/src/main.ts | 76 +++++++ .../dto/create-equipment-type.dto.ts | 12 + .../dto/update-equipment-type.dto.ts | 17 ++ .../equipment/dto/create-equipment.dto.ts | 24 +- .../equipment/dto/update-equipment.dto.ts | 33 ++- .../dto/create-repair-order.dto.ts | 22 +- .../dto/update-repair-order.dto.ts | 30 ++- tools/validate-generation.mjs | 37 ++++ 37 files changed, 1272 insertions(+), 247 deletions(-) delete mode 100644 .env.portainer.example delete mode 100644 client/.dockerignore delete mode 100644 client/Dockerfile delete mode 100644 client/nginx/default.conf create mode 100644 client/src/AppNotification.tsx create mode 100644 generation/templates/runtime/AppNotification.tsx create mode 100644 generation/templates/runtime/api-exception.filter.ts create mode 100644 generation/templates/runtime/dataProvider.ts create mode 100644 generation/templates/runtime/main.ts delete mode 100644 server/.dockerignore delete mode 100644 server/Dockerfile create mode 100644 server/src/common/field-labels.generated.ts create mode 100644 server/src/common/filters/api-exception.filter.ts diff --git a/.env.portainer.example b/.env.portainer.example deleted file mode 100644 index 5dda750..0000000 --- a/.env.portainer.example +++ /dev/null @@ -1,15 +0,0 @@ -POSTGRES_USER=postgres -POSTGRES_PASSWORD=change-me -POSTGRES_DB=toir -POSTGRES_PORT=5432 - -CORS_ALLOWED_ORIGINS=https://toir.example.ru - -KEYCLOAK_ISSUER_URL=https://sso.example.ru/realms/toir -KEYCLOAK_AUDIENCE=toir-backend -KEYCLOAK_JWKS_URL= - -VITE_API_URL=/api -VITE_KEYCLOAK_URL=https://sso.example.ru -VITE_KEYCLOAK_REALM=toir -VITE_KEYCLOAK_CLIENT_ID=toir-frontend diff --git a/client/.dockerignore b/client/.dockerignore deleted file mode 100644 index 0e48e48..0000000 --- a/client/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -dist -.git -.env -.env.local -.env.*.local -npm-debug.log* diff --git a/client/Dockerfile b/client/Dockerfile deleted file mode 100644 index ff96fd3..0000000 --- a/client/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -FROM node:20-alpine AS build - -WORKDIR /app - -COPY package*.json ./ -RUN npm ci - -COPY . . - -ARG VITE_API_URL -ARG VITE_KEYCLOAK_URL -ARG VITE_KEYCLOAK_REALM -ARG VITE_KEYCLOAK_CLIENT_ID - -ENV VITE_API_URL=$VITE_API_URL -ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL -ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM -ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID - -RUN npm run build - -FROM nginx:1.27-alpine AS runtime - -COPY nginx/default.conf /etc/nginx/conf.d/default.conf -COPY --from=build /app/dist /usr/share/nginx/html - -EXPOSE 80 diff --git a/client/nginx/default.conf b/client/nginx/default.conf deleted file mode 100644 index d11675a..0000000 --- a/client/nginx/default.conf +++ /dev/null @@ -1,27 +0,0 @@ -server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - location /api/ { - proxy_pass http://toir-server:3000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - } - - location / { - try_files $uri $uri/ /index.html; - } - - location = /healthz { - access_log off; - add_header Content-Type text/plain; - return 200 'ok'; - } -} diff --git a/client/src/App.tsx b/client/src/App.tsx index 91a825f..6479c97 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,7 @@ import { Admin, Resource } from 'react-admin'; import dataProvider from './dataProvider'; import authProvider from './auth/authProvider'; +import { AppNotification } from './AppNotification'; import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList'; 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'; const App = () => ( - + ( + +); diff --git a/client/src/dataProvider.ts b/client/src/dataProvider.ts index 07952bd..4d9f077 100644 --- a/client/src/dataProvider.ts +++ b/client/src/dataProvider.ts @@ -1,18 +1,57 @@ -import { DataProvider, fetchUtils } from 'react-admin'; +import { DataProvider, fetchUtils, HttpError } from 'react-admin'; import { getValidAccessToken } from './auth/keycloak'; import { env } from './config/env'; const apiUrl = env.apiUrl; +/** HTTP status from fetch / react-admin error objects (avoid coupling the client to Nest). */ +type HttpStatusCode = number; + +/** JSON body shape returned by ApiExceptionFilter (and compatible Nest errors). */ +type ApiErrorBody = { + message?: string | string[]; + code?: string; + details?: unknown; +}; + +/** Shape thrown by fetchUtils.fetchJson on non-2xx responses. */ +type FetchJsonError = { + status?: HttpStatusCode; + body?: ApiErrorBody; + message?: string; +}; + +function userMessageFromApiBody( + body: ApiErrorBody | undefined, + fallback: string, +): string { + const raw = body?.message; + if (Array.isArray(raw)) return raw.join('\n'); + if (typeof raw === 'string') return raw; + return fallback; +} + const httpClient = async (url: string, options: fetchUtils.Options = {}) => { const token = await getValidAccessToken(); 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 fetchError = error as FetchJsonError; + const fromPayload = userMessageFromApiBody(fetchError.body, ''); + const fallbackMessage = fetchError.message || 'Request failed'; + + throw new HttpError( + fromPayload || fallbackMessage, + fetchError.status ?? 500, + fetchError.body, + ); + } }; function buildQueryString(query: Record) { diff --git a/client/src/resources/equipment/EquipmentEdit.tsx b/client/src/resources/equipment/EquipmentEdit.tsx index 98a3a1e..2824e4a 100644 --- a/client/src/resources/equipment/EquipmentEdit.tsx +++ b/client/src/resources/equipment/EquipmentEdit.tsx @@ -10,7 +10,7 @@ const statusChoices = [ export const EquipmentEdit = () => ( - + diff --git a/client/src/resources/equipment/EquipmentList.tsx b/client/src/resources/equipment/EquipmentList.tsx index 62be3e7..dfbe333 100644 --- a/client/src/resources/equipment/EquipmentList.tsx +++ b/client/src/resources/equipment/EquipmentList.tsx @@ -47,7 +47,7 @@ const EquipmentListActions = () => ( export const EquipmentList = () => ( } filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}> - + diff --git a/client/src/resources/equipment/EquipmentShow.tsx b/client/src/resources/equipment/EquipmentShow.tsx index fc099e4..a3c0e4d 100644 --- a/client/src/resources/equipment/EquipmentShow.tsx +++ b/client/src/resources/equipment/EquipmentShow.tsx @@ -9,7 +9,7 @@ const statusChoices = [ export const EquipmentShow = () => ( - + diff --git a/client/src/resources/repair-order/RepairOrderEdit.tsx b/client/src/resources/repair-order/RepairOrderEdit.tsx index 043730d..75b46e5 100644 --- a/client/src/resources/repair-order/RepairOrderEdit.tsx +++ b/client/src/resources/repair-order/RepairOrderEdit.tsx @@ -20,7 +20,7 @@ const statusChoices = [ export const RepairOrderEdit = () => ( - + record.inventoryNumber ? `${record.inventoryNumber} — ${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} /> diff --git a/client/src/resources/repair-order/RepairOrderList.tsx b/client/src/resources/repair-order/RepairOrderList.tsx index 0e1e9f3..ecccbcb 100644 --- a/client/src/resources/repair-order/RepairOrderList.tsx +++ b/client/src/resources/repair-order/RepairOrderList.tsx @@ -58,7 +58,7 @@ const RepairOrderListActions = () => ( export const RepairOrderList = () => ( } filters={repairOrderFilters} sort={{ field: 'number', order: 'ASC' }}> - + diff --git a/client/src/resources/repair-order/RepairOrderShow.tsx b/client/src/resources/repair-order/RepairOrderShow.tsx index 78f2a79..3d4beae 100644 --- a/client/src/resources/repair-order/RepairOrderShow.tsx +++ b/client/src/resources/repair-order/RepairOrderShow.tsx @@ -19,7 +19,7 @@ const statusChoices = [ export const RepairOrderShow = () => ( - + diff --git a/docker-compose.yml b/docker-compose.yml index 156d278..61fcc40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,93 +4,13 @@ services: container_name: toir-postgres restart: unless-stopped environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} - POSTGRES_DB: ${POSTGRES_DB:-toir} - healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-toir}", - ] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: toir ports: - - "${POSTGRES_PORT:-5432}:5432" + - "5432:5432" volumes: - postgres-data:/var/lib/postgresql/data - networks: - - app - - server: - build: - context: ./server - dockerfile: Dockerfile - container_name: toir-server - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - environment: - PORT: 3000 - DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-toir} - CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8080,https://toir.greact.ru} - KEYCLOAK_ISSUER_URL: ${KEYCLOAK_ISSUER_URL:-https://sso.greact.ru/realms/toir} - KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-toir-backend} - KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL:-} - healthcheck: - test: - [ - "CMD", - "node", - "-e", - "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", - ] - interval: 15s - timeout: 5s - retries: 5 - start_period: 20s - expose: - - "3000" - networks: - - app - - proxy - - client: - build: - context: ./client - dockerfile: Dockerfile - args: - VITE_API_URL: ${VITE_API_URL:-/api} - VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL:-https://sso.greact.ru} - VITE_KEYCLOAK_REALM: ${VITE_KEYCLOAK_REALM:-toir} - VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID:-toir-frontend} - container_name: toir-client - restart: unless-stopped - depends_on: - server: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1 || exit 1"] - interval: 15s - timeout: 5s - retries: 5 - start_period: 10s - ports: - - "${CLIENT_PORT:-8080}:80" - expose: - - "80" - networks: - - app - - proxy volumes: postgres-data: - -networks: - app: - driver: bridge - proxy: - external: true diff --git a/docs/AID_EXPORT_README.md b/docs/AID_EXPORT_README.md index c10a546..5c5cf2f 100644 --- a/docs/AID_EXPORT_README.md +++ b/docs/AID_EXPORT_README.md @@ -80,7 +80,7 @@ X-AID-Export-Key: <если задан AID_EXPORT_API_KEY> } ``` -- **`apply: false`** (рекомендуется для AID): в ответе **`files`** — объект «путь от корня репо → текст файла». Диск на сервере не меняется. +- **`apply: false`** (рекомендуется для AID): в ответе **`files`** — объект «путь от корня репо → текст файла». Диск на сервере не меняется. В бандл входят сгенерированные модули, `App.tsx`, **`server/src/common/field-labels.generated.ts`** (из DSL) и **канонические шаблоны рантайма** из **`generation/templates/runtime/`** (копируются в `server/src/main.ts`, `ApiExceptionFilter`, `dataProvider`, `AppNotification` при `npm run generate:from-dsl`). - **`apply: true`**: выполняется запись файлов как у `npm run generate:from-dsl` с `--apply`; нужен **`AID_GENERATOR_ALLOW_APPLY=1`**. **Ответ (бандл):** `{ "applied": false, "entityCount": N, "enumCount": M, "files": { ... } }` diff --git a/generation/generate.mjs b/generation/generate.mjs index fa36c3f..f3b4e66 100644 --- a/generation/generate.mjs +++ b/generation/generate.mjs @@ -7,6 +7,14 @@ import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT = path.resolve(__dirname, '..'); +/** Canonical runtime files for API errors + RA dataProvider; copied into server/ and client/ on each generate --apply. */ +const RUNTIME_TEMPLATE_DIR = path.join(__dirname, 'templates', 'runtime'); +const RUNTIME_TEMPLATE_FILES = [ + ['api-exception.filter.ts', 'server/src/common/filters/api-exception.filter.ts'], + ['dataProvider.ts', 'client/src/dataProvider.ts'], + ['AppNotification.tsx', 'client/src/AppNotification.tsx'], + ['main.ts', 'server/src/main.ts'], +]; function readFile(p) { return fs.readFileSync(p, 'utf8'); @@ -178,6 +186,7 @@ function getReferenceDisplayExpr(foreignEntity) { function getAttributeLabel(attr, allEntities) { if (attr.label && attr.label !== attr.name) return attr.label; + if (attr.name === 'id' && attr.type === 'uuid') return 'Идентификатор'; if (attr.name === 'status') return 'Статус'; if (attr.name === 'equipmentId') return 'Оборудование'; if (attr.name === 'equipmentTypeCode') return 'Вид оборудования'; @@ -260,6 +269,55 @@ function generatePrismaModel(name, entity, allEntities) { return `${lines.join('\n')}\n`; } +/** Human-readable field labels for ValidationPipe messages (derived from DSL descriptions). */ +function renderFieldLabelsGenerated(parsed) { + const { entities } = parsed; + const map = new Map(); + for (const ent of Object.values(entities)) { + for (const a of ent.attributes) { + const label = getAttributeLabel(a, entities); + const prev = map.get(a.name); + if (prev === undefined) { + map.set(a.name, label); + } else if (String(label).length > String(prev).length) { + map.set(a.name, label); + } + } + } + const keys = [...map.keys()].sort(); + const lines = keys.map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(map.get(k))},`); + return `/** AUTO-GENERATED from domain DSL (generation/generate.mjs). Do not edit by hand. */\nexport const FIELD_LABELS: Record = {\n${lines.join('\n')}\n};\n`; +} + +function ensureFieldLabels(parsed, apply) { + const rel = 'server/src/common/field-labels.generated.ts'; + const content = renderFieldLabelsGenerated(parsed); + if (apply) writeFile(path.join(ROOT, rel), content); + return { rel, content }; +} + +function loadRuntimeTemplateFiles() { + const out = {}; + for (const [name, rel] of RUNTIME_TEMPLATE_FILES) { + const abs = path.join(RUNTIME_TEMPLATE_DIR, name); + if (!fs.existsSync(abs)) { + throw new Error(`Missing generator runtime template: ${abs}`); + } + out[rel] = readFile(abs); + } + return out; +} + +function applyRuntimeTemplateFiles(apply) { + const files = loadRuntimeTemplateFiles(); + if (apply) { + for (const [rel, content] of Object.entries(files)) { + writeFile(path.join(ROOT, rel), content); + } + } + return files; +} + function ensurePrismaSchema({ enums, entities }, prismaPath, apply) { const existing = fs.existsSync(prismaPath) ? readFile(prismaPath) : ''; const hasGenerator = /generator\s+client\s*\{/m.test(existing); @@ -279,7 +337,7 @@ function ensurePrismaSchema({ enums, entities }, prismaPath, apply) { return { changed: true, content: next }; } -function renderBackendModule(entityName, entity, resourceName, pk) { +function renderBackendModule(entityName, entity, resourceName, pk, enums) { const className = entityName; const moduleName = `${className}Module`; const serviceName = `${className}Service`; @@ -287,15 +345,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': @@ -305,7 +354,7 @@ function renderBackendModule(entityName, entity, resourceName, pk) { case 'integer': return 'number'; case 'decimal': - return 'string'; + return 'number'; case 'date': return 'string'; default: @@ -314,23 +363,109 @@ 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('@Type(() => Number)'); + decorators.push( + `@IsNumber({ allowNaN: false, allowInfinity: false }, { message: '${field}: должно быть числом' })`, + ); + imports.add('IsNumber'); + needsTypeImport = true; + break; + case 'date': + decorators.push(`@IsISO8601({}, { message: '${field}: должно содержать корректную дату' })`); + imports.add('IsISO8601'); + break; + default: { + const vals = enums?.[attr.type]?.values; + if (vals && vals.length) { + const list = vals.map((v) => `'${v}'`).join(', '); + decorators.push( + `@IsIn([${list}], { message: '${field}: недопустимое значение' })`, + ); + imports.add('IsIn'); + } else { + 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 +528,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`, @@ -666,6 +801,22 @@ function ensureClientApp(apply, frontendResources) { ); } } + if (!out.includes("from './AppNotification'")) { + out = out.replace( + /import authProvider from '\.\/auth\/authProvider';/, + `import authProvider from './auth/authProvider';\nimport { AppNotification } from './AppNotification';`, + ); + } + if (!out.includes('notification={AppNotification}')) { + out = out.replace( + //, + '', + ); + out = out.replace( + /( ( + +); diff --git a/generation/templates/runtime/api-exception.filter.ts b/generation/templates/runtime/api-exception.filter.ts new file mode 100644 index 0000000..d90a673 --- /dev/null +++ b/generation/templates/runtime/api-exception.filter.ts @@ -0,0 +1,184 @@ +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 | 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) { + const logDetail = + exception instanceof Error ? exception.message : String(mapped.message); + this.logger.error( + `Unhandled error on ${request.method} ${request.url}: ${logDetail}`, + exception instanceof Error ? exception.stack : undefined, + ); + } + + response.status(mapped.statusCode).json(body); + } + + private mapException(exception: unknown): { + statusCode: number; + message: string | 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: string | string[] = Array.isArray(rawMessage) + ? rawMessage.map((m) => String(m)) + : typeof rawMessage === 'string' && rawMessage.length > 0 + ? rawMessage + : String(exception.message ?? 'Bad Request'); + + 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: 'Внутренняя ошибка сервера.', + code: 'INTERNAL_ERROR', + }; + } + + return { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Внутренняя ошибка сервера.', + code: 'INTERNAL_ERROR', + }; + } + + private mapPrismaKnownRequestError( + exception: Prisma.PrismaClientKnownRequestError, + ): { + statusCode: number; + message: string; + code: string; + details?: unknown; + } { + 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/generation/templates/runtime/dataProvider.ts b/generation/templates/runtime/dataProvider.ts new file mode 100644 index 0000000..4d9f077 --- /dev/null +++ b/generation/templates/runtime/dataProvider.ts @@ -0,0 +1,185 @@ +import { DataProvider, fetchUtils, HttpError } from 'react-admin'; +import { getValidAccessToken } from './auth/keycloak'; +import { env } from './config/env'; + +const apiUrl = env.apiUrl; + +/** HTTP status from fetch / react-admin error objects (avoid coupling the client to Nest). */ +type HttpStatusCode = number; + +/** JSON body shape returned by ApiExceptionFilter (and compatible Nest errors). */ +type ApiErrorBody = { + message?: string | string[]; + code?: string; + details?: unknown; +}; + +/** Shape thrown by fetchUtils.fetchJson on non-2xx responses. */ +type FetchJsonError = { + status?: HttpStatusCode; + body?: ApiErrorBody; + message?: string; +}; + +function userMessageFromApiBody( + body: ApiErrorBody | undefined, + fallback: string, +): string { + const raw = body?.message; + if (Array.isArray(raw)) return raw.join('\n'); + if (typeof raw === 'string') return raw; + return fallback; +} + +const httpClient = async (url: string, options: fetchUtils.Options = {}) => { + const token = await getValidAccessToken(); + const headers = new Headers(options.headers ?? { Accept: 'application/json' }); + headers.set('Authorization', `Bearer ${token}`); + + try { + return await fetchUtils.fetchJson(url, { + ...options, + headers, + }); + } catch (error: unknown) { + const fetchError = error as FetchJsonError; + const fromPayload = userMessageFromApiBody(fetchError.body, ''); + const fallbackMessage = fetchError.message || 'Request failed'; + + throw new HttpError( + fromPayload || fallbackMessage, + fetchError.status ?? 500, + fetchError.body, + ); + } +}; + +function buildQueryString(query: Record) { + const search = new URLSearchParams(); + Object.entries(query).forEach(([key, val]) => { + if (val === undefined || val === null || val === '') return; + if (Array.isArray(val)) { + val.forEach((v) => { + if (v === undefined || v === null || v === '') return; + search.append(key, String(v)); + }); + return; + } + search.set(key, String(val)); + }); + return search.toString(); +} + +const dataProvider: DataProvider = { + getList: async (resource, params) => { + const { page, perPage } = params.pagination!; + const { field, order } = params.sort!; + const start = (page - 1) * perPage; + const end = page * perPage; + + const query: Record = { + _start: start, + _end: end, + _sort: field, + _order: order, + ...(params.filter ?? {}), + }; + + const queryString = buildQueryString(query); + const url = `${apiUrl}/${resource}?${queryString}`; + const { json, headers } = await httpClient(url); + + const contentRange = headers.get('Content-Range'); + const total = contentRange + ? parseInt(contentRange.split('/').pop() || '0', 10) + : json.length; + + return { data: json, total }; + }, + + getOne: async (resource, params) => { + const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`); + return { data: json }; + }, + + getMany: async (resource, params) => { + const query = params.ids.map((id) => `id=${id}`).join('&'); + const { json } = await httpClient(`${apiUrl}/${resource}?${query}`); + return { data: json }; + }, + + getManyReference: async (resource, params) => { + const { page, perPage } = params.pagination!; + const { field, order } = params.sort!; + const start = (page - 1) * perPage; + const end = page * perPage; + + const query: Record = { + _start: start, + _end: end, + _sort: field, + _order: order, + [params.target]: params.id, + ...(params.filter ?? {}), + }; + + const queryString = buildQueryString(query); + const url = `${apiUrl}/${resource}?${queryString}`; + const { json, headers } = await httpClient(url); + + const contentRange = headers.get('Content-Range'); + const total = contentRange + ? parseInt(contentRange.split('/').pop() || '0', 10) + : json.length; + + return { data: json, total }; + }, + + create: async (resource, params) => { + const { json } = await httpClient(`${apiUrl}/${resource}`, { + method: 'POST', + body: JSON.stringify(params.data), + }); + return { data: json }; + }, + + update: async (resource, params) => { + const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`, { + method: 'PATCH', + body: JSON.stringify(params.data), + }); + return { data: json }; + }, + + updateMany: async (resource, params) => { + const responses = await Promise.all( + params.ids.map((id) => + httpClient(`${apiUrl}/${resource}/${id}`, { + method: 'PATCH', + body: JSON.stringify(params.data), + }) + ) + ); + return { data: responses.map(({ json }) => json.id) }; + }, + + delete: async (resource, params) => { + const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`, { + method: 'DELETE', + }); + return { data: json }; + }, + + deleteMany: async (resource, params) => { + const responses = await Promise.all( + params.ids.map((id) => + httpClient(`${apiUrl}/${resource}/${id}`, { + method: 'DELETE', + }) + ) + ); + return { data: responses.map(({ json }) => json.id) }; + }, +}; + +export default dataProvider; diff --git a/generation/templates/runtime/main.ts b/generation/templates/runtime/main.ts new file mode 100644 index 0000000..def6788 --- /dev/null +++ b/generation/templates/runtime/main.ts @@ -0,0 +1,111 @@ +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'; +import { FIELD_LABELS } from './common/field-labels.generated'; + +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 'isNumber': + return `Поле "${label}" должно быть числом`; + case 'isIso8601': + return `Поле "${label}" должно содержать корректную дату`; + case 'isEnum': + case 'isIn': + 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); + const configService = app.get>( + ConfigService, + ); + + const allowedOrigins = configService + .getOrThrow('CORS_ALLOWED_ORIGINS') + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + + app.enableCors({ + origin: (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + return; + } + callback(new Error(`Origin ${origin} is not allowed by CORS`), false); + }, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Authorization', 'Content-Type'], + exposedHeaders: ['Content-Range'], + 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); +} +bootstrap(); diff --git a/prompts/general-prompt.md b/prompts/general-prompt.md index 7c6be55..5506d25 100644 --- a/prompts/general-prompt.md +++ b/prompts/general-prompt.md @@ -140,6 +140,7 @@ COMPLETION INVARIANTS - Generation is incomplete if auth rules, runtime rules, and validation rules describe different truth paths. - Generation is incomplete if buildability is broken. - If buildability cannot be checked because dependencies are missing, report that state explicitly; do not report a green result for buildability. +- Generation is incomplete if the **API error contract** (ValidationPipe + `ApiExceptionFilter` + client `dataProvider` + DSL-derived `field-labels.generated.ts`) drifts from `prompts/validation-rules.md` without an explicit exception. VALIDATION diff --git a/prompts/validation-rules.md b/prompts/validation-rules.md index a071be5..5ea158e 100644 --- a/prompts/validation-rules.md +++ b/prompts/validation-rules.md @@ -87,6 +87,16 @@ Validation is now a lightweight automated gate instead of a prose-only checklist - `npx prisma migrate dev` - `npx prisma db seed` +### API error contract (backend ↔ frontend) + +- Backend errors use the shared `ApiExceptionFilter` shape: `statusCode`, `message` (**`string` or `string[]`** — для списков ошибок валидации), `code`, optional `details`, `path`, `timestamp`. +- Список ошибок валидации отдаётся в JSON как **`message: string[]`**, без склейки через `", "` на сервере (клиент сам склеивает для UI, например через `\n`). +- Клиентский `dataProvider` не должен резать одну строку `message` по запятым — только обрабатывать массив или целую строку. +- Подписи полей для русских сообщений `ValidationPipe` генерируются из DSL в **`server/src/common/field-labels.generated.ts`** (не дублировать огромный словарь вручную в `main.ts`). +- Поля DSL `decimal` в DTO принимают **число** (как шлёт React Admin `NumberInput`); в сервисе конвертация в `Prisma.Decimal` сохраняется. +- Атрибуты-enum в DTO валидируются **`@IsIn([...])`** по значениям enum из DSL, а не только `@IsString`. +- Бандл AID (`collectGeneratedBundle`) включает копии **`generation/templates/runtime/*`** (`main.ts`, `api-exception.filter.ts`, `dataProvider.ts`, `AppNotification.tsx`) — это канонический источник для шва ошибок; при `--apply` они перезаписываются в `server/` и `client/`. + ### Scaffold checks - backend initialization starts from official Nest CLI scaffolding diff --git a/server/.dockerignore b/server/.dockerignore deleted file mode 100644 index 661172a..0000000 --- a/server/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -dist -coverage -.git -.env -.env.local -.env.*.local -npm-debug.log* diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index 80780c9..0000000 --- a/server/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -FROM node:20-bookworm-slim AS build - -WORKDIR /app - -RUN apt-get update \ - && apt-get install -y --no-install-recommends openssl \ - && rm -rf /var/lib/apt/lists/* - -COPY package*.json ./ - -COPY prisma ./prisma - -RUN npm ci - -COPY nest-cli.json tsconfig*.json ./ -COPY src ./src - -RUN npm run build - -FROM node:20-bookworm-slim AS runtime - -WORKDIR /app - -ENV NODE_ENV=production - -RUN apt-get update \ - && apt-get install -y --no-install-recommends openssl \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=build /app/package*.json ./ -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/prisma ./prisma -COPY --from=build /app/dist ./dist - -EXPOSE 3000 - -CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main.js"] 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 b7206a3..73ac676 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,7 @@ "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", - "start:prod": "node dist/src/main.js", + "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", @@ -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/prisma/migrations/20260322114233_baseline/migration.sql b/server/prisma/migrations/20260322114233_baseline/migration.sql index 20fec42..098be2f 100644 --- a/server/prisma/migrations/20260322114233_baseline/migration.sql +++ b/server/prisma/migrations/20260322114233_baseline/migration.sql @@ -1,4 +1,4 @@ --- CreateEnum +-- CreateEnum CREATE TYPE "EquipmentStatus" AS ENUM ('Active', 'Repair', 'Reserve', 'WriteOff'); -- CreateEnum diff --git a/server/src/common/field-labels.generated.ts b/server/src/common/field-labels.generated.ts new file mode 100644 index 0000000..4c33138 --- /dev/null +++ b/server/src/common/field-labels.generated.ts @@ -0,0 +1,28 @@ +/** AUTO-GENERATED from domain DSL (generation/generate.mjs). Do not edit by hand. */ +export const FIELD_LABELS: Record = { + "code": "Код вида оборудования", + "commissionedAt": "Дата ввода в эксплуатацию", + "completedAt": "Фактическая дата завершения", + "contractor": "Подрядная организация (если внешний ремонт)", + "description": "Описание работ / дефекта", + "engineHoursAtRepair": "Наработка на момент ремонта, моточасов", + "engineHoursSinceLastRepair": "Наработка с последнего ремонта, моточасов", + "equipmentId": "Оборудование", + "equipmentTypeCode": "Вид оборудования", + "id": "Идентификатор", + "inventoryNumber": "Инвентарный номер", + "lastRepairAt": "Дата последнего ремонта", + "location": "Место эксплуатации / скважина / куст", + "maintenanceIntervalHours": "Периодичность ТО, моточасов", + "manufacturer": "Производитель", + "name": "Наименование единицы оборудования", + "notes": "Примечания", + "number": "Номер заявки", + "overhaulIntervalHours": "Периодичность КР, моточасов", + "plannedAt": "Плановая дата начала", + "repairKind": "Вид ремонта", + "serialNumber": "Заводской (серийный) номер", + "startedAt": "Фактическая дата начала", + "status": "Текущий статус", + "totalEngineHours": "Общая наработка, моточасов", +}; 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..d90a673 --- /dev/null +++ b/server/src/common/filters/api-exception.filter.ts @@ -0,0 +1,184 @@ +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 | 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) { + const logDetail = + exception instanceof Error ? exception.message : String(mapped.message); + this.logger.error( + `Unhandled error on ${request.method} ${request.url}: ${logDetail}`, + exception instanceof Error ? exception.stack : undefined, + ); + } + + response.status(mapped.statusCode).json(body); + } + + private mapException(exception: unknown): { + statusCode: number; + message: string | 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: string | string[] = Array.isArray(rawMessage) + ? rawMessage.map((m) => String(m)) + : typeof rawMessage === 'string' && rawMessage.length > 0 + ? rawMessage + : String(exception.message ?? 'Bad Request'); + + 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: 'Внутренняя ошибка сервера.', + code: 'INTERNAL_ERROR', + }; + } + + return { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Внутренняя ошибка сервера.', + code: 'INTERNAL_ERROR', + }; + } + + private mapPrismaKnownRequestError( + exception: Prisma.PrismaClientKnownRequestError, + ): { + statusCode: number; + message: string; + code: string; + details?: unknown; + } { + 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..def6788 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,7 +1,72 @@ 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'; +import { FIELD_LABELS } from './common/field-labels.generated'; + +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 'isNumber': + return `Поле "${label}" должно быть числом`; + case 'isIso8601': + return `Поле "${label}" должно содержать корректную дату`; + case 'isEnum': + case 'isIn': + 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 +94,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..8aa5251 100644 --- a/server/src/modules/equipment/dto/create-equipment.dto.ts +++ b/server/src/modules/equipment/dto/create-equipment.dto.ts @@ -1,13 +1,33 @@ +import { IsISO8601, IsIn, IsNotEmpty, IsNumber, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; + 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; + @IsIn(['Active', 'Repair', 'Reserve', 'WriteOff'], { message: 'status: недопустимое значение' }) + @IsNotEmpty({ message: 'status: обязательное поле' }) status!: string; + @IsString({ message: 'location: должно быть строкой' }) location?: string; + @IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' }) commissionedAt?: string; - totalEngineHours?: string; - engineHoursSinceLastRepair?: string; + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'totalEngineHours: должно быть числом' }) + totalEngineHours?: number; + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursSinceLastRepair: должно быть числом' }) + engineHoursSinceLastRepair?: number; + @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..6b29856 100644 --- a/server/src/modules/equipment/dto/update-equipment.dto.ts +++ b/server/src/modules/equipment/dto/update-equipment.dto.ts @@ -1,14 +1,43 @@ +import { IsISO8601, IsIn, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; + 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() + @IsIn(['Active', 'Repair', 'Reserve', 'WriteOff'], { message: 'status: недопустимое значение' }) status?: string; + @IsOptional() + @IsString({ message: 'location: должно быть строкой' }) location?: string; + @IsOptional() + @IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' }) commissionedAt?: string; - totalEngineHours?: string; - engineHoursSinceLastRepair?: string; + @IsOptional() + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'totalEngineHours: должно быть числом' }) + totalEngineHours?: number; + @IsOptional() + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursSinceLastRepair: должно быть числом' }) + engineHoursSinceLastRepair?: number; + @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..bb1b830 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,33 @@ +import { IsISO8601, IsIn, IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; + export class CreateRepairOrderDto { + @IsString({ message: 'number: должно быть строкой' }) + @IsNotEmpty({ message: 'number: обязательное поле' }) number!: string; + @IsUUID(undefined, { message: 'equipmentId: должно быть UUID' }) + @IsNotEmpty({ message: 'equipmentId: обязательное поле' }) equipmentId!: string; + @IsIn(['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'], { message: 'repairKind: недопустимое значение' }) + @IsNotEmpty({ message: 'repairKind: обязательное поле' }) repairKind!: string; + @IsIn(['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'], { 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; - engineHoursAtRepair?: string; + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursAtRepair: должно быть числом' }) + engineHoursAtRepair?: number; + @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..b78459e 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,42 @@ +import { IsISO8601, IsIn, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; + 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() + @IsIn(['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'], { message: 'repairKind: недопустимое значение' }) repairKind?: string; + @IsOptional() + @IsIn(['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'], { 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; - engineHoursAtRepair?: string; + @IsOptional() + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursAtRepair: должно быть числом' }) + engineHoursAtRepair?: number; + @IsOptional() + @IsString({ message: 'description: должно быть строкой' }) description?: string; + @IsOptional() + @IsString({ message: 'notes: должно быть строкой' }) notes?: string; } diff --git a/tools/validate-generation.mjs b/tools/validate-generation.mjs index e7209b0..17fde66 100644 --- a/tools/validate-generation.mjs +++ b/tools/validate-generation.mjs @@ -135,6 +135,10 @@ function validateBuildChecks() { 'prompts/frontend-rules.md', 'prompts/runtime-rules.md', 'prompts/validation-rules.md', + 'generation/templates/runtime/main.ts', + 'generation/templates/runtime/api-exception.filter.ts', + 'generation/templates/runtime/dataProvider.ts', + 'generation/templates/runtime/AppNotification.tsx', ]); const dslFiles = getDslFiles(rootDir).map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')); @@ -240,6 +244,38 @@ function validateAuthChecks() { assertCondition(/protocol\/openid-connect\/certs/.test(authService), 'Backend auth must keep Keycloak certs fallback resolution'); } +function validateApiErrorContractChecks() { + requireFiles([ + 'server/src/common/field-labels.generated.ts', + 'server/src/common/filters/api-exception.filter.ts', + ]); + requireContent( + 'server/src/main.ts', + /field-labels\.generated/, + 'main.ts must import DSL-generated FIELD_LABELS', + ); + requireContent( + 'server/src/common/filters/api-exception.filter.ts', + /message:\s*string\s*\|\s*string\[\]/, + 'Error JSON must allow message: string | string[]', + ); + requireContent( + 'server/src/common/filters/api-exception.filter.ts', + /Внутренняя ошибка сервера/, + 'Unexpected server errors must use a generic user-facing message', + ); + requireContent( + 'client/src/dataProvider.ts', + /ApiErrorBody/, + 'dataProvider must document API error payload shape (ApiErrorBody)', + ); + const dataProviderSource = read('client/src/dataProvider.ts'); + assertCondition( + !dataProviderSource.includes(".split(', ')"), + 'dataProvider must not split API error strings on comma+space (breaks messages that contain commas)', + ); +} + function validateNaturalKeyChecks() { const summary = parseJson('domain-summary.json'); if (!summary) { @@ -484,6 +520,7 @@ function validateRuntimeExecutionChecks() { validateBuildChecks(); validateAuthChecks(); +validateApiErrorContractChecks(); validateNaturalKeyChecks(); validateRealmChecks(); validateRuntimeContractChecks();