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.
This commit is contained in:
@@ -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 = () => (
|
||||
<Admin dataProvider={dataProvider} authProvider={authProvider} requireAuth>
|
||||
<Admin
|
||||
dataProvider={dataProvider}
|
||||
authProvider={authProvider}
|
||||
notification={AppNotification}
|
||||
requireAuth
|
||||
>
|
||||
<Resource
|
||||
name="equipment-types"
|
||||
options={{ label: 'Виды оборудования' }}
|
||||
|
||||
16
client/src/AppNotification.tsx
Normal file
16
client/src/AppNotification.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Notification, NotificationProps } from 'react-admin';
|
||||
|
||||
export const AppNotification = (props: NotificationProps) => (
|
||||
<Notification
|
||||
{...props}
|
||||
sx={{
|
||||
whiteSpace: 'pre-line',
|
||||
'& .MuiAlert-message': {
|
||||
whiteSpace: 'pre-line',
|
||||
},
|
||||
'& .MuiSnackbarContent-message': {
|
||||
whiteSpace: 'pre-line',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ const statusChoices = [
|
||||
export const EquipmentEdit = () => (
|
||||
<Edit>
|
||||
<SimpleForm>
|
||||
<TextInput source="id" label="id" disabled />
|
||||
<TextInput source="id" label="Идентификатор" disabled />
|
||||
<TextInput source="inventoryNumber" label="Инвентарный номер" isRequired />
|
||||
<TextInput source="serialNumber" label="Заводской (серийный) номер" />
|
||||
<TextInput source="name" label="Наименование единицы оборудования" isRequired />
|
||||
|
||||
@@ -47,7 +47,7 @@ const EquipmentListActions = () => (
|
||||
export const EquipmentList = () => (
|
||||
<List actions={<EquipmentListActions />} filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="id" label="id" />
|
||||
<TextField source="id" label="Идентификатор" />
|
||||
<TextField source="inventoryNumber" label="Инвентарный номер" />
|
||||
<TextField source="serialNumber" label="Заводской (серийный) номер" />
|
||||
<TextField source="name" label="Наименование единицы оборудования" />
|
||||
|
||||
@@ -9,7 +9,7 @@ const statusChoices = [
|
||||
export const EquipmentShow = () => (
|
||||
<Show>
|
||||
<SimpleShowLayout>
|
||||
<TextField source="id" label="id" />
|
||||
<TextField source="id" label="Идентификатор" />
|
||||
<TextField source="inventoryNumber" label="Инвентарный номер" />
|
||||
<TextField source="serialNumber" label="Заводской (серийный) номер" />
|
||||
<TextField source="name" label="Наименование единицы оборудования" />
|
||||
|
||||
@@ -20,7 +20,7 @@ const statusChoices = [
|
||||
export const RepairOrderEdit = () => (
|
||||
<Edit>
|
||||
<SimpleForm>
|
||||
<TextInput source="id" label="id" disabled />
|
||||
<TextInput source="id" label="Идентификатор" disabled />
|
||||
<TextInput source="number" label="Номер заявки" isRequired />
|
||||
<ReferenceInput source="equipmentId" reference="equipment">
|
||||
<AutocompleteInput label="Оборудование" optionText={(record) => record.inventoryNumber ? `${record.inventoryNumber} — ${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
|
||||
|
||||
@@ -58,7 +58,7 @@ const RepairOrderListActions = () => (
|
||||
export const RepairOrderList = () => (
|
||||
<List actions={<RepairOrderListActions />} filters={repairOrderFilters} sort={{ field: 'number', order: 'ASC' }}>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="id" label="id" />
|
||||
<TextField source="id" label="Идентификатор" />
|
||||
<TextField source="number" label="Номер заявки" />
|
||||
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
|
||||
<TextField source="inventoryNumber" />
|
||||
|
||||
@@ -19,7 +19,7 @@ const statusChoices = [
|
||||
export const RepairOrderShow = () => (
|
||||
<Show>
|
||||
<SimpleShowLayout>
|
||||
<TextField source="id" label="id" />
|
||||
<TextField source="id" label="Идентификатор" />
|
||||
<TextField source="number" label="Номер заявки" />
|
||||
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
|
||||
<TextField source="inventoryNumber" />
|
||||
|
||||
@@ -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": { ... } }`
|
||||
|
||||
@@ -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<string, string> = {\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,19 +390,32 @@ 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)
|
||||
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}: обязательное поле' })`);
|
||||
@@ -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(
|
||||
/<Admin dataProvider=\{dataProvider\} authProvider=\{authProvider\} requireAuth>/,
|
||||
'<Admin\n dataProvider={dataProvider}\n authProvider={authProvider}\n notification={AppNotification}\n requireAuth\n >',
|
||||
);
|
||||
out = out.replace(
|
||||
/(<Admin\n\s*dataProvider=\{dataProvider\}\n\s*authProvider=\{authProvider\})(\n\s*requireAuth)/,
|
||||
'$1\n notification={AppNotification}$2',
|
||||
);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
@@ -741,13 +828,23 @@ function collectGeneratedBundle(parsed) {
|
||||
const pr = ensurePrismaSchema(parsed, prismaPath, false);
|
||||
files['server/prisma/schema.prisma'] = pr.content;
|
||||
|
||||
const fieldLabels = ensureFieldLabels(parsed, false);
|
||||
files[fieldLabels.rel] = fieldLabels.content;
|
||||
|
||||
const backendModules = [];
|
||||
const frontendResources = [];
|
||||
for (const [entityName, ent] of Object.entries(parsed.entities)) {
|
||||
const pk = ent.primaryKey;
|
||||
const resource = pluralize(toKebab(entityName));
|
||||
const be = renderBackendModule(entityName, ent, resource, pk);
|
||||
const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums);
|
||||
const be = renderBackendModule(entityName, ent, resource, pk, parsed.enums);
|
||||
const fe = renderFrontendResource(
|
||||
entityName,
|
||||
ent,
|
||||
resource,
|
||||
pk,
|
||||
parsed.enums,
|
||||
parsed.entities,
|
||||
);
|
||||
backendModules.push(be);
|
||||
frontendResources.push(fe);
|
||||
Object.assign(files, be.files, fe.files);
|
||||
@@ -758,6 +855,8 @@ function collectGeneratedBundle(parsed) {
|
||||
const clientApp = ensureClientApp(false, frontendResources);
|
||||
files['client/src/App.tsx'] = clientApp.content;
|
||||
|
||||
Object.assign(files, loadRuntimeTemplateFiles());
|
||||
|
||||
return {
|
||||
entityCount: Object.keys(parsed.entities).length,
|
||||
enumCount: Object.keys(parsed.enums).length,
|
||||
@@ -785,6 +884,7 @@ function main() {
|
||||
// Prisma schema
|
||||
const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma');
|
||||
ensurePrismaSchema(parsed, prismaPath, apply);
|
||||
ensureFieldLabels(parsed, apply);
|
||||
|
||||
// Backend modules + frontend resources
|
||||
const backendModules = [];
|
||||
@@ -792,7 +892,7 @@ function main() {
|
||||
for (const [entityName, ent] of Object.entries(parsed.entities)) {
|
||||
const pk = ent.primaryKey;
|
||||
const resource = pluralize(toKebab(entityName));
|
||||
const be = renderBackendModule(entityName, ent, resource, pk);
|
||||
const be = renderBackendModule(entityName, ent, resource, pk, parsed.enums);
|
||||
const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums, parsed.entities);
|
||||
backendModules.push(be);
|
||||
frontendResources.push(fe);
|
||||
@@ -805,6 +905,7 @@ function main() {
|
||||
|
||||
ensureAppModule(apply, backendModules);
|
||||
ensureClientApp(apply, frontendResources);
|
||||
applyRuntimeTemplateFiles(apply);
|
||||
|
||||
process.stdout.write(
|
||||
`${apply ? 'Generated' : 'Planned'} ${Object.keys(parsed.entities).length} entities from ${dslPath}\n`
|
||||
|
||||
16
generation/templates/runtime/AppNotification.tsx
Normal file
16
generation/templates/runtime/AppNotification.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Notification, NotificationProps } from 'react-admin';
|
||||
|
||||
export const AppNotification = (props: NotificationProps) => (
|
||||
<Notification
|
||||
{...props}
|
||||
sx={{
|
||||
whiteSpace: 'pre-line',
|
||||
'& .MuiAlert-message': {
|
||||
whiteSpace: 'pre-line',
|
||||
},
|
||||
'& .MuiSnackbarContent-message': {
|
||||
whiteSpace: 'pre-line',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
184
generation/templates/runtime/api-exception.filter.ts
Normal file
184
generation/templates/runtime/api-exception.filter.ts
Normal file
@@ -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<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const mapped = this.mapException(exception);
|
||||
const body: ErrorResponseBody = {
|
||||
statusCode: mapped.statusCode,
|
||||
message: mapped.message,
|
||||
code: mapped.code,
|
||||
...(mapped.details !== undefined ? { details: mapped.details } : {}),
|
||||
path: request.url,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (mapped.statusCode >= 500) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
185
generation/templates/runtime/dataProvider.ts
Normal file
185
generation/templates/runtime/dataProvider.ts
Normal file
@@ -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<string, unknown>) {
|
||||
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<string, unknown> = {
|
||||
_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<string, unknown> = {
|
||||
_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;
|
||||
111
generation/templates/runtime/main.ts
Normal file
111
generation/templates/runtime/main.ts
Normal file
@@ -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<RuntimeEnvironment, true>>(
|
||||
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();
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
28
server/src/common/field-labels.generated.ts
Normal file
28
server/src/common/field-labels.generated.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/** AUTO-GENERATED from domain DSL (generation/generate.mjs). Do not edit by hand. */
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
"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": "Общая наработка, моточасов",
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string> = {
|
||||
code: 'Код',
|
||||
name: 'Название',
|
||||
manufacturer: 'Производитель',
|
||||
maintenanceIntervalHours: 'Интервал ТО (часы)',
|
||||
overhaulIntervalHours: 'Интервал капремонта (часы)',
|
||||
inventoryNumber: 'Инвентарный номер',
|
||||
serialNumber: 'Серийный номер',
|
||||
equipmentTypeCode: 'Тип оборудования',
|
||||
equipmentId: 'Оборудование',
|
||||
status: 'Статус',
|
||||
location: 'Местоположение',
|
||||
commissionedAt: 'Дата ввода в эксплуатацию',
|
||||
totalEngineHours: 'Наработка общая',
|
||||
engineHoursSinceLastRepair: 'Наработка после ремонта',
|
||||
lastRepairAt: 'Дата последнего ремонта',
|
||||
notes: 'Примечание',
|
||||
number: 'Номер',
|
||||
repairKind: 'Вид ремонта',
|
||||
plannedAt: 'Плановая дата',
|
||||
startedAt: 'Дата начала',
|
||||
completedAt: 'Дата завершения',
|
||||
contractor: 'Подрядчик',
|
||||
engineHoursAtRepair: 'Наработка на момент ремонта',
|
||||
description: 'Описание',
|
||||
id: 'Идентификатор',
|
||||
};
|
||||
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}" заполнено некорректно`;
|
||||
|
||||
@@ -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: должно быть строкой' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: должно быть строкой' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user