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 f8987cc..4d9f077 100644 --- a/client/src/dataProvider.ts +++ b/client/src/dataProvider.ts @@ -4,6 +4,33 @@ 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' }); @@ -15,23 +42,14 @@ const httpClient = async (url: string, options: fetchUtils.Options = {}) => { headers, }); } catch (error: unknown) { - const e = error as { - status?: number; - body?: { - message?: string | string[]; - details?: unknown; - }; - message?: string; - }; - const messageFromBody = e?.body?.message; - const normalizedMessage = Array.isArray(messageFromBody) - ? messageFromBody.join(', ') - : messageFromBody; + const fetchError = error as FetchJsonError; + const fromPayload = userMessageFromApiBody(fetchError.body, ''); + const fallbackMessage = fetchError.message || 'Request failed'; throw new HttpError( - normalizedMessage || e?.message || 'Request failed', - e?.status ?? 500, - e?.body, + fromPayload || fallbackMessage, + fetchError.status ?? 500, + fetchError.body, ); } }; 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/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 2e9159a..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`; @@ -296,7 +354,7 @@ function renderBackendModule(entityName, entity, resourceName, pk) { case 'integer': return 'number'; case 'decimal': - return 'string'; + return 'number'; case 'date': return 'string'; default: @@ -332,18 +390,31 @@ function renderBackendModule(entityName, entity, resourceName, pk) { needsTypeImport = true; break; case 'decimal': - decorators.push(`@IsNumberString({}, { message: '${field}: должно быть числом' })`); - imports.add('IsNumberString'); + 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: - // enum (kept as string in generated DTOs) - decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`); - imports.add('IsString'); + 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')) { @@ -730,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/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 index 3b2b0d5..d90a673 100644 --- a/server/src/common/filters/api-exception.filter.ts +++ b/server/src/common/filters/api-exception.filter.ts @@ -12,7 +12,7 @@ import { Request, Response } from 'express'; type ErrorResponseBody = { statusCode: number; - message: string; + message: string | string[]; code: string; details?: unknown; path: string; @@ -39,8 +39,10 @@ export class ApiExceptionFilter implements ExceptionFilter { }; if (mapped.statusCode >= 500) { + const logDetail = + exception instanceof Error ? exception.message : String(mapped.message); this.logger.error( - `Unhandled error on ${request.method} ${request.url}: ${mapped.message}`, + `Unhandled error on ${request.method} ${request.url}: ${logDetail}`, exception instanceof Error ? exception.stack : undefined, ); } @@ -50,7 +52,7 @@ export class ApiExceptionFilter implements ExceptionFilter { private mapException(exception: unknown): { statusCode: number; - message: string; + message: string | string[]; code: string; details?: unknown; } { @@ -74,9 +76,11 @@ export class ApiExceptionFilter implements ExceptionFilter { } const rawMessage = payload?.message ?? exception.message; - const message = Array.isArray(rawMessage) - ? rawMessage.join(', ') - : rawMessage || 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, @@ -118,21 +122,26 @@ export class ApiExceptionFilter implements ExceptionFilter { if (exception instanceof Error) { return { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, - message: exception.message || 'Internal server error', + message: 'Внутренняя ошибка сервера.', code: 'INTERNAL_ERROR', }; } return { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, - message: '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) diff --git a/server/src/main.ts b/server/src/main.ts index 2dc5318..def6788 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -8,34 +8,7 @@ import { import { AppModule } from './app.module'; import { RuntimeEnvironment } from './config/env.validation'; import { ApiExceptionFilter } from './common/filters/api-exception.filter'; - -const FIELD_LABELS: Record = { - code: 'Код', - name: 'Название', - manufacturer: 'Производитель', - maintenanceIntervalHours: 'Интервал ТО (часы)', - overhaulIntervalHours: 'Интервал капремонта (часы)', - inventoryNumber: 'Инвентарный номер', - serialNumber: 'Серийный номер', - equipmentTypeCode: 'Тип оборудования', - equipmentId: 'Оборудование', - status: 'Статус', - location: 'Местоположение', - commissionedAt: 'Дата ввода в эксплуатацию', - totalEngineHours: 'Наработка общая', - engineHoursSinceLastRepair: 'Наработка после ремонта', - lastRepairAt: 'Дата последнего ремонта', - notes: 'Примечание', - number: 'Номер', - repairKind: 'Вид ремонта', - plannedAt: 'Плановая дата', - startedAt: 'Дата начала', - completedAt: 'Дата завершения', - contractor: 'Подрядчик', - engineHoursAtRepair: 'Наработка на момент ремонта', - description: 'Описание', - id: 'Идентификатор', -}; +import { FIELD_LABELS } from './common/field-labels.generated'; function prettifyFieldName(field: string): string { if (FIELD_LABELS[field]) return FIELD_LABELS[field]; @@ -60,9 +33,12 @@ function constraintToRuMessage(field: string, constraint: string): string { return `Поле "${label}" должно быть UUID`; case 'isNumberString': return `Поле "${label}" должно быть числом`; + case 'isNumber': + return `Поле "${label}" должно быть числом`; case 'isIso8601': return `Поле "${label}" должно содержать корректную дату`; case 'isEnum': + case 'isIn': return `Поле "${label}" содержит недопустимое значение`; default: return `Поле "${label}" заполнено некорректно`; diff --git a/server/src/modules/equipment/dto/create-equipment.dto.ts b/server/src/modules/equipment/dto/create-equipment.dto.ts index 826e35a..8aa5251 100644 --- a/server/src/modules/equipment/dto/create-equipment.dto.ts +++ b/server/src/modules/equipment/dto/create-equipment.dto.ts @@ -1,4 +1,5 @@ -import { IsISO8601, IsNotEmpty, IsNumberString, IsString } from 'class-validator'; +import { IsISO8601, IsIn, IsNotEmpty, IsNumber, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; export class CreateEquipmentDto { @IsString({ message: 'inventoryNumber: должно быть строкой' }) @@ -12,17 +13,19 @@ export class CreateEquipmentDto { @IsString({ message: 'equipmentTypeCode: должно быть строкой' }) @IsNotEmpty({ message: 'equipmentTypeCode: обязательное поле' }) equipmentTypeCode!: string; - @IsString({ message: 'status: должно быть строкой' }) + @IsIn(['Active', 'Repair', 'Reserve', 'WriteOff'], { message: 'status: недопустимое значение' }) @IsNotEmpty({ message: 'status: обязательное поле' }) status!: string; @IsString({ message: 'location: должно быть строкой' }) location?: string; @IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' }) commissionedAt?: string; - @IsNumberString({}, { message: 'totalEngineHours: должно быть числом' }) - totalEngineHours?: string; - @IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' }) - engineHoursSinceLastRepair?: string; + @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: должно быть строкой' }) diff --git a/server/src/modules/equipment/dto/update-equipment.dto.ts b/server/src/modules/equipment/dto/update-equipment.dto.ts index 6c99b1c..6b29856 100644 --- a/server/src/modules/equipment/dto/update-equipment.dto.ts +++ b/server/src/modules/equipment/dto/update-equipment.dto.ts @@ -1,4 +1,5 @@ -import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsISO8601, IsIn, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; export class UpdateEquipmentDto { @IsOptional() @@ -17,7 +18,7 @@ export class UpdateEquipmentDto { @IsString({ message: 'equipmentTypeCode: должно быть строкой' }) equipmentTypeCode?: string; @IsOptional() - @IsString({ message: 'status: должно быть строкой' }) + @IsIn(['Active', 'Repair', 'Reserve', 'WriteOff'], { message: 'status: недопустимое значение' }) status?: string; @IsOptional() @IsString({ message: 'location: должно быть строкой' }) @@ -26,11 +27,13 @@ export class UpdateEquipmentDto { @IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' }) commissionedAt?: string; @IsOptional() - @IsNumberString({}, { message: 'totalEngineHours: должно быть числом' }) - totalEngineHours?: string; + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'totalEngineHours: должно быть числом' }) + totalEngineHours?: number; @IsOptional() - @IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' }) - engineHoursSinceLastRepair?: string; + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursSinceLastRepair: должно быть числом' }) + engineHoursSinceLastRepair?: number; @IsOptional() @IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' }) lastRepairAt?: 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 8521762..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,4 +1,5 @@ -import { IsISO8601, IsNotEmpty, IsNumberString, IsString, IsUUID } from 'class-validator'; +import { IsISO8601, IsIn, IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; export class CreateRepairOrderDto { @IsString({ message: 'number: должно быть строкой' }) @@ -7,10 +8,10 @@ export class CreateRepairOrderDto { @IsUUID(undefined, { message: 'equipmentId: должно быть UUID' }) @IsNotEmpty({ message: 'equipmentId: обязательное поле' }) equipmentId!: string; - @IsString({ message: 'repairKind: должно быть строкой' }) + @IsIn(['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'], { message: 'repairKind: недопустимое значение' }) @IsNotEmpty({ message: 'repairKind: обязательное поле' }) repairKind!: string; - @IsString({ message: 'status: должно быть строкой' }) + @IsIn(['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'], { message: 'status: недопустимое значение' }) @IsNotEmpty({ message: 'status: обязательное поле' }) status!: string; @IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' }) @@ -22,8 +23,9 @@ export class CreateRepairOrderDto { completedAt?: string; @IsString({ message: 'contractor: должно быть строкой' }) contractor?: string; - @IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' }) - engineHoursAtRepair?: string; + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursAtRepair: должно быть числом' }) + engineHoursAtRepair?: number; @IsString({ message: 'description: должно быть строкой' }) description?: string; @IsString({ message: 'notes: должно быть строкой' }) 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 c62de47..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,4 +1,5 @@ -import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsISO8601, IsIn, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; export class UpdateRepairOrderDto { @IsOptional() @@ -11,10 +12,10 @@ export class UpdateRepairOrderDto { @IsUUID(undefined, { message: 'equipmentId: должно быть UUID' }) equipmentId?: string; @IsOptional() - @IsString({ message: 'repairKind: должно быть строкой' }) + @IsIn(['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'], { message: 'repairKind: недопустимое значение' }) repairKind?: string; @IsOptional() - @IsString({ message: 'status: должно быть строкой' }) + @IsIn(['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'], { message: 'status: недопустимое значение' }) status?: string; @IsOptional() @IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' }) @@ -29,8 +30,9 @@ export class UpdateRepairOrderDto { @IsString({ message: 'contractor: должно быть строкой' }) contractor?: string; @IsOptional() - @IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' }) - engineHoursAtRepair?: string; + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursAtRepair: должно быть числом' }) + engineHoursAtRepair?: number; @IsOptional() @IsString({ message: 'description: должно быть строкой' }) description?: 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();