2 Commits

Author SHA1 Message Date
vadim
5ef01c2282 format UI validation alerts as multiline
Render backend validation messages line-by-line in react-admin notifications so each error appears on a separate line.

Made-with: Cursor
2026-03-27 12:22:28 +03:00
time_
51a5e1b5c1 update generated validation UX and AID integration flow
Improve generated validation behavior and backend error mapping so UI shows user-friendly Russian messages, while keeping filtering/sorting and exporter updates aligned with current app generation flow.
2026-03-27 12:21:35 +03:00
40 changed files with 401 additions and 961 deletions

15
.env.portainer.example Normal file
View File

@@ -0,0 +1,15 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change-me
POSTGRES_DB=toir
POSTGRES_PORT=5432
CORS_ALLOWED_ORIGINS=https://toir.example.ru
KEYCLOAK_ISSUER_URL=https://sso.example.ru/realms/toir
KEYCLOAK_AUDIENCE=toir-backend
KEYCLOAK_JWKS_URL=
VITE_API_URL=/api
VITE_KEYCLOAK_URL=https://sso.example.ru
VITE_KEYCLOAK_REALM=toir
VITE_KEYCLOAK_CLIENT_ID=toir-frontend

7
client/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.git
.env
.env.local
.env.*.local
npm-debug.log*

View File

@@ -1,3 +0,0 @@
save-exact=true
fund=false
audit=false

27
client/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL
ARG VITE_KEYCLOAK_URL
ARG VITE_KEYCLOAK_REALM
ARG VITE_KEYCLOAK_CLIENT_ID
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL
ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM
ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

27
client/nginx/default.conf Normal file
View File

@@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://toir-server:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 'ok';
}
}

View File

@@ -8,26 +8,26 @@
"name": "client", "name": "client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@emotion/react": "11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "11.14.1", "@emotion/styled": "^11.14.1",
"@mui/material": "7.3.9", "@mui/material": "^7.3.9",
"keycloak-js": "26.2.3", "keycloak-js": "^26.2.3",
"ra-data-simple-rest": "5.14.4", "ra-data-simple-rest": "^5.14.4",
"react": "18.3.1", "react": "^18.2.0",
"react-admin": "5.14.4", "react-admin": "^5.14.4",
"react-dom": "18.3.1" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "18.3.28", "@types/react": "^18.2.55",
"@types/react-dom": "18.3.7", "@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "6.21.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "6.21.0", "@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "4.7.0", "@vitejs/plugin-react": "^4.2.1",
"eslint": "8.57.1", "eslint": "^8.56.0",
"eslint-plugin-react-hooks": "4.6.2", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-react-refresh": "^0.4.5",
"typescript": "5.9.3", "typescript": "^5.2.2",
"vite": "5.4.21" "vite": "^5.1.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {

View File

@@ -10,25 +10,25 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "11.14.1", "@emotion/styled": "^11.14.1",
"@mui/material": "7.3.9", "@mui/material": "^7.3.9",
"keycloak-js": "26.2.3", "keycloak-js": "^26.2.3",
"ra-data-simple-rest": "5.14.4", "ra-data-simple-rest": "^5.14.4",
"react": "18.3.1", "react": "^18.2.0",
"react-admin": "5.14.4", "react-admin": "^5.14.4",
"react-dom": "18.3.1" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "18.3.28", "@types/react": "^18.2.55",
"@types/react-dom": "18.3.7", "@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "6.21.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "6.21.0", "@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "4.7.0", "@vitejs/plugin-react": "^4.2.1",
"eslint": "8.57.1", "eslint": "^8.56.0",
"eslint-plugin-react-hooks": "4.6.2", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-react-refresh": "^0.4.5",
"typescript": "5.9.3", "typescript": "^5.2.2",
"vite": "5.4.21" "vite": "^5.1.0"
} }
} }

View File

@@ -4,33 +4,6 @@ import { env } from './config/env';
const apiUrl = env.apiUrl; 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 httpClient = async (url: string, options: fetchUtils.Options = {}) => {
const token = await getValidAccessToken(); const token = await getValidAccessToken();
const headers = new Headers(options.headers ?? { Accept: 'application/json' }); const headers = new Headers(options.headers ?? { Accept: 'application/json' });
@@ -42,14 +15,25 @@ const httpClient = async (url: string, options: fetchUtils.Options = {}) => {
headers, headers,
}); });
} catch (error: unknown) { } catch (error: unknown) {
const fetchError = error as FetchJsonError; const e = error as {
const fromPayload = userMessageFromApiBody(fetchError.body, ''); status?: number;
const fallbackMessage = fetchError.message || 'Request failed'; body?: {
message?: string | string[];
details?: unknown;
};
message?: string;
};
const messageFromBody = e?.body?.message;
const normalizedMessage = Array.isArray(messageFromBody)
? messageFromBody.join('\n')
: typeof messageFromBody === 'string'
? messageFromBody.split(', ').join('\n')
: messageFromBody;
throw new HttpError( throw new HttpError(
fromPayload || fallbackMessage, normalizedMessage || e?.message || 'Request failed',
fetchError.status ?? 500, e?.status ?? 500,
fetchError.body, e?.body,
); );
} }
}; };

View File

@@ -10,7 +10,7 @@ const statusChoices = [
export const EquipmentEdit = () => ( export const EquipmentEdit = () => (
<Edit> <Edit>
<SimpleForm> <SimpleForm>
<TextInput source="id" label="Идентификатор" disabled /> <TextInput source="id" label="id" disabled />
<TextInput source="inventoryNumber" label="Инвентарный номер" isRequired /> <TextInput source="inventoryNumber" label="Инвентарный номер" isRequired />
<TextInput source="serialNumber" label="Заводской (серийный) номер" /> <TextInput source="serialNumber" label="Заводской (серийный) номер" />
<TextInput source="name" label="Наименование единицы оборудования" isRequired /> <TextInput source="name" label="Наименование единицы оборудования" isRequired />

View File

@@ -47,7 +47,7 @@ const EquipmentListActions = () => (
export const EquipmentList = () => ( export const EquipmentList = () => (
<List actions={<EquipmentListActions />} filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}> <List actions={<EquipmentListActions />} filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}>
<Datagrid rowClick="show"> <Datagrid rowClick="show">
<TextField source="id" label="Идентификатор" /> <TextField source="id" label="id" />
<TextField source="inventoryNumber" label="Инвентарный номер" /> <TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="serialNumber" label="Заводской (серийный) номер" /> <TextField source="serialNumber" label="Заводской (серийный) номер" />
<TextField source="name" label="Наименование единицы оборудования" /> <TextField source="name" label="Наименование единицы оборудования" />

View File

@@ -9,7 +9,7 @@ const statusChoices = [
export const EquipmentShow = () => ( export const EquipmentShow = () => (
<Show> <Show>
<SimpleShowLayout> <SimpleShowLayout>
<TextField source="id" label="Идентификатор" /> <TextField source="id" label="id" />
<TextField source="inventoryNumber" label="Инвентарный номер" /> <TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="serialNumber" label="Заводской (серийный) номер" /> <TextField source="serialNumber" label="Заводской (серийный) номер" />
<TextField source="name" label="Наименование единицы оборудования" /> <TextField source="name" label="Наименование единицы оборудования" />

View File

@@ -20,7 +20,7 @@ const statusChoices = [
export const RepairOrderEdit = () => ( export const RepairOrderEdit = () => (
<Edit> <Edit>
<SimpleForm> <SimpleForm>
<TextInput source="id" label="Идентификатор" disabled /> <TextInput source="id" label="id" disabled />
<TextInput source="number" label="Номер заявки" isRequired /> <TextInput source="number" label="Номер заявки" isRequired />
<ReferenceInput source="equipmentId" reference="equipment"> <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 })} /> <AutocompleteInput label="Оборудование" optionText={(record) => record.inventoryNumber ? `${record.inventoryNumber}${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />

View File

@@ -58,7 +58,7 @@ const RepairOrderListActions = () => (
export const RepairOrderList = () => ( export const RepairOrderList = () => (
<List actions={<RepairOrderListActions />} filters={repairOrderFilters} sort={{ field: 'number', order: 'ASC' }}> <List actions={<RepairOrderListActions />} filters={repairOrderFilters} sort={{ field: 'number', order: 'ASC' }}>
<Datagrid rowClick="show"> <Datagrid rowClick="show">
<TextField source="id" label="Идентификатор" /> <TextField source="id" label="id" />
<TextField source="number" label="Номер заявки" /> <TextField source="number" label="Номер заявки" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show"> <ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
<TextField source="inventoryNumber" /> <TextField source="inventoryNumber" />

View File

@@ -19,7 +19,7 @@ const statusChoices = [
export const RepairOrderShow = () => ( export const RepairOrderShow = () => (
<Show> <Show>
<SimpleShowLayout> <SimpleShowLayout>
<TextField source="id" label="Идентификатор" /> <TextField source="id" label="id" />
<TextField source="number" label="Номер заявки" /> <TextField source="number" label="Номер заявки" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show"> <ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
<TextField source="inventoryNumber" /> <TextField source="inventoryNumber" />

View File

@@ -4,13 +4,93 @@ services:
container_name: toir-postgres container_name: toir-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
POSTGRES_DB: toir POSTGRES_DB: ${POSTGRES_DB:-toir}
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-toir}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
ports: ports:
- "5432:5432" - "${POSTGRES_PORT:-5432}:5432"
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
networks:
- app
server:
build:
context: ./server
dockerfile: Dockerfile
container_name: toir-server
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
PORT: 3000
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-toir}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8080,https://toir.greact.ru}
KEYCLOAK_ISSUER_URL: ${KEYCLOAK_ISSUER_URL:-https://sso.greact.ru/realms/toir}
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-toir-backend}
KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL:-}
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
expose:
- "3000"
networks:
- app
- proxy
client:
build:
context: ./client
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-/api}
VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL:-https://sso.greact.ru}
VITE_KEYCLOAK_REALM: ${VITE_KEYCLOAK_REALM:-toir}
VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID:-toir-frontend}
container_name: toir-client
restart: unless-stopped
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
ports:
- "${CLIENT_PORT:-8080}:80"
expose:
- "80"
networks:
- app
- proxy
volumes: volumes:
postgres-data: postgres-data:
networks:
app:
driver: bridge
proxy:
external: true

View File

@@ -80,7 +80,7 @@ X-AID-Export-Key: <если задан AID_EXPORT_API_KEY>
} }
``` ```
- **`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: false`** (рекомендуется для AID): в ответе **`files`** — объект «путь от корня репо → текст файла». Диск на сервере не меняется.
- **`apply: true`**: выполняется запись файлов как у `npm run generate:from-dsl` с `--apply`; нужен **`AID_GENERATOR_ALLOW_APPLY=1`**. - **`apply: true`**: выполняется запись файлов как у `npm run generate:from-dsl` с `--apply`; нужен **`AID_GENERATOR_ALLOW_APPLY=1`**.
**Ответ (бандл):** `{ "applied": false, "entityCount": N, "enumCount": M, "files": { ... } }` **Ответ (бандл):** `{ "applied": false, "entityCount": N, "enumCount": M, "files": { ... } }`

View File

@@ -1,5 +0,0 @@
# Зависимости workspace (зафиксированные версии)
- Рабочие манифесты: **`server/package.json`**, **`client/package.json`** — прямые зависимости **без** префиксов `^` / `~`; точные версии синхронизированы с **`package-lock.json`** (скрипт `tools/pin-package-versions.mjs`).
- После добавления пакета: `npm install <pkg>` в `server/` или `client/` (с `save-exact` в `.npmrc`), затем при необходимости снова **`node tools/pin-package-versions.mjs server|client`** и коммит обоих файлов.
- Генератор **`generation/generate.mjs`** не перезаписывает `package.json`; агенты не должны ослаблять диапазоны версий при правках кода.

View File

@@ -7,14 +7,6 @@ import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '..'); 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) { function readFile(p) {
return fs.readFileSync(p, 'utf8'); return fs.readFileSync(p, 'utf8');
@@ -186,7 +178,6 @@ function getReferenceDisplayExpr(foreignEntity) {
function getAttributeLabel(attr, allEntities) { function getAttributeLabel(attr, allEntities) {
if (attr.label && attr.label !== attr.name) return attr.label; 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 === 'status') return 'Статус';
if (attr.name === 'equipmentId') return 'Оборудование'; if (attr.name === 'equipmentId') return 'Оборудование';
if (attr.name === 'equipmentTypeCode') return 'Вид оборудования'; if (attr.name === 'equipmentTypeCode') return 'Вид оборудования';
@@ -269,55 +260,6 @@ function generatePrismaModel(name, entity, allEntities) {
return `${lines.join('\n')}\n`; 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) { function ensurePrismaSchema({ enums, entities }, prismaPath, apply) {
const existing = fs.existsSync(prismaPath) ? readFile(prismaPath) : ''; const existing = fs.existsSync(prismaPath) ? readFile(prismaPath) : '';
const hasGenerator = /generator\s+client\s*\{/m.test(existing); const hasGenerator = /generator\s+client\s*\{/m.test(existing);
@@ -337,7 +279,7 @@ function ensurePrismaSchema({ enums, entities }, prismaPath, apply) {
return { changed: true, content: next }; return { changed: true, content: next };
} }
function renderBackendModule(entityName, entity, resourceName, pk, enums) { function renderBackendModule(entityName, entity, resourceName, pk) {
const className = entityName; const className = entityName;
const moduleName = `${className}Module`; const moduleName = `${className}Module`;
const serviceName = `${className}Service`; const serviceName = `${className}Service`;
@@ -354,7 +296,7 @@ function renderBackendModule(entityName, entity, resourceName, pk, enums) {
case 'integer': case 'integer':
return 'number'; return 'number';
case 'decimal': case 'decimal':
return 'number'; return 'string';
case 'date': case 'date':
return 'string'; return 'string';
default: default:
@@ -390,31 +332,18 @@ function renderBackendModule(entityName, entity, resourceName, pk, enums) {
needsTypeImport = true; needsTypeImport = true;
break; break;
case 'decimal': case 'decimal':
decorators.push('@Type(() => Number)'); decorators.push(`@IsNumberString({}, { message: '${field}: должно быть числом' })`);
decorators.push( imports.add('IsNumberString');
`@IsNumber({ allowNaN: false, allowInfinity: false }, { message: '${field}: должно быть числом' })`,
);
imports.add('IsNumber');
needsTypeImport = true;
break; break;
case 'date': case 'date':
decorators.push(`@IsISO8601({}, { message: '${field}: должно содержать корректную дату' })`); decorators.push(`@IsISO8601({}, { message: '${field}: должно содержать корректную дату' })`);
imports.add('IsISO8601'); imports.add('IsISO8601');
break; break;
default: { default:
const vals = enums?.[attr.type]?.values; // enum (kept as string in generated DTOs)
if (vals && vals.length) { decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`);
const list = vals.map((v) => `'${v}'`).join(', '); imports.add('IsString');
decorators.push(
`@IsIn([${list}], { message: '${field}: недопустимое значение' })`,
);
imports.add('IsIn');
} else {
decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`);
imports.add('IsString');
}
break; break;
}
} }
if (!isUpdate && attr.isRequired && !(attr.isPrimary && attr.type === 'uuid')) { if (!isUpdate && attr.isRequired && !(attr.isPrimary && attr.type === 'uuid')) {
@@ -801,22 +730,6 @@ 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; return out;
}); });
} }
@@ -828,23 +741,13 @@ function collectGeneratedBundle(parsed) {
const pr = ensurePrismaSchema(parsed, prismaPath, false); const pr = ensurePrismaSchema(parsed, prismaPath, false);
files['server/prisma/schema.prisma'] = pr.content; files['server/prisma/schema.prisma'] = pr.content;
const fieldLabels = ensureFieldLabels(parsed, false);
files[fieldLabels.rel] = fieldLabels.content;
const backendModules = []; const backendModules = [];
const frontendResources = []; const frontendResources = [];
for (const [entityName, ent] of Object.entries(parsed.entities)) { for (const [entityName, ent] of Object.entries(parsed.entities)) {
const pk = ent.primaryKey; const pk = ent.primaryKey;
const resource = pluralize(toKebab(entityName)); const resource = pluralize(toKebab(entityName));
const be = renderBackendModule(entityName, ent, resource, pk, parsed.enums); const be = renderBackendModule(entityName, ent, resource, pk);
const fe = renderFrontendResource( const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums);
entityName,
ent,
resource,
pk,
parsed.enums,
parsed.entities,
);
backendModules.push(be); backendModules.push(be);
frontendResources.push(fe); frontendResources.push(fe);
Object.assign(files, be.files, fe.files); Object.assign(files, be.files, fe.files);
@@ -855,8 +758,6 @@ function collectGeneratedBundle(parsed) {
const clientApp = ensureClientApp(false, frontendResources); const clientApp = ensureClientApp(false, frontendResources);
files['client/src/App.tsx'] = clientApp.content; files['client/src/App.tsx'] = clientApp.content;
Object.assign(files, loadRuntimeTemplateFiles());
return { return {
entityCount: Object.keys(parsed.entities).length, entityCount: Object.keys(parsed.entities).length,
enumCount: Object.keys(parsed.enums).length, enumCount: Object.keys(parsed.enums).length,
@@ -884,7 +785,6 @@ function main() {
// Prisma schema // Prisma schema
const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma'); const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma');
ensurePrismaSchema(parsed, prismaPath, apply); ensurePrismaSchema(parsed, prismaPath, apply);
ensureFieldLabels(parsed, apply);
// Backend modules + frontend resources // Backend modules + frontend resources
const backendModules = []; const backendModules = [];
@@ -892,7 +792,7 @@ function main() {
for (const [entityName, ent] of Object.entries(parsed.entities)) { for (const [entityName, ent] of Object.entries(parsed.entities)) {
const pk = ent.primaryKey; const pk = ent.primaryKey;
const resource = pluralize(toKebab(entityName)); const resource = pluralize(toKebab(entityName));
const be = renderBackendModule(entityName, ent, resource, pk, parsed.enums); const be = renderBackendModule(entityName, ent, resource, pk);
const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums, parsed.entities); const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums, parsed.entities);
backendModules.push(be); backendModules.push(be);
frontendResources.push(fe); frontendResources.push(fe);
@@ -905,7 +805,6 @@ function main() {
ensureAppModule(apply, backendModules); ensureAppModule(apply, backendModules);
ensureClientApp(apply, frontendResources); ensureClientApp(apply, frontendResources);
applyRuntimeTemplateFiles(apply);
process.stdout.write( process.stdout.write(
`${apply ? 'Generated' : 'Planned'} ${Object.keys(parsed.entities).length} entities from ${dslPath}\n` `${apply ? 'Generated' : 'Planned'} ${Object.keys(parsed.entities).length} entities from ${dslPath}\n`

View File

@@ -1,16 +0,0 @@
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',
},
}}
/>
);

View File

@@ -1,184 +0,0 @@
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,
};
}
}
}

View File

@@ -1,185 +0,0 @@
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;

View File

@@ -1,111 +0,0 @@
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();

View File

@@ -2,7 +2,6 @@
"name": "toir-generation-context", "name": "toir-generation-context",
"private": true, "private": true,
"scripts": { "scripts": {
"pin:deps": "node tools/pin-package-versions.mjs server && node tools/pin-package-versions.mjs client",
"generate:domain-summary": "node tools/generate-domain-summary.mjs", "generate:domain-summary": "node tools/generate-domain-summary.mjs",
"validate:generation": "node tools/validate-generation.mjs", "validate:generation": "node tools/validate-generation.mjs",
"validate:generation:runtime": "node tools/validate-generation.mjs --run-runtime", "validate:generation:runtime": "node tools/validate-generation.mjs --run-runtime",

View File

@@ -140,7 +140,6 @@ COMPLETION INVARIANTS
- Generation is incomplete if auth rules, runtime rules, and validation rules describe different truth paths. - Generation is incomplete if auth rules, runtime rules, and validation rules describe different truth paths.
- Generation is incomplete if buildability is broken. - 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. - 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 VALIDATION

View File

@@ -30,7 +30,6 @@ Validation is now a lightweight automated gate instead of a prose-only checklist
- generation must not pass validation if framework scaffolding files were deleted and replaced by a hand-written minimal skeleton - generation must not pass validation if framework scaffolding files were deleted and replaced by a hand-written minimal skeleton
- if dependencies are installed, build verification runs for `server/` and `client/` - if dependencies are installed, build verification runs for `server/` and `client/`
- if dependencies are missing, build verification is reported as skipped with reason instead of green - if dependencies are missing, build verification is reported as skipped with reason instead of green
- `server/package.json` and `client/package.json`: every entry in `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies` must use an exact version string (no `^` or `~` prefix)
### Auth checks ### Auth checks
@@ -88,16 +87,6 @@ Validation is now a lightweight automated gate instead of a prose-only checklist
- `npx prisma migrate dev` - `npx prisma migrate dev`
- `npx prisma db seed` - `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 ### Scaffold checks
- backend initialization starts from official Nest CLI scaffolding - backend initialization starts from official Nest CLI scaffolding

8
server/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
coverage
.git
.env
.env.local
.env.*.local
npm-debug.log*

View File

@@ -1,3 +0,0 @@
save-exact=true
fund=false
audit=false

37
server/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM node:20-bookworm-slim AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci
COPY nest-cli.json tsconfig*.json ./
COPY src ./src
RUN npm run build
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main.js"]

View File

@@ -10,40 +10,40 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "10.4.22", "@nestjs/common": "^10.0.0",
"@nestjs/config": "4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "10.4.22", "@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "10.4.22", "@nestjs/platform-express": "^10.0.0",
"@prisma/client": "5.22.0", "@prisma/client": "^5.22.0",
"class-transformer": "0.5.1", "class-transformer": "^0.5.1",
"class-validator": "0.14.4", "class-validator": "^0.14.1",
"jose": "6.2.2", "jose": "^6.2.2",
"reflect-metadata": "0.2.2", "reflect-metadata": "^0.2.0",
"rxjs": "7.8.2" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "10.4.9", "@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "10.2.3", "@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "10.4.22", "@nestjs/testing": "^10.0.0",
"@types/express": "5.0.6", "@types/express": "^5.0.0",
"@types/jest": "29.5.14", "@types/jest": "^29.5.2",
"@types/node": "20.19.37", "@types/node": "^20.3.1",
"@types/supertest": "6.0.3", "@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "8.57.0", "@typescript-eslint/parser": "^8.0.0",
"eslint": "8.57.1", "eslint": "^8.0.0",
"eslint-config-prettier": "9.1.2", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "5.5.5", "eslint-plugin-prettier": "^5.0.0",
"jest": "29.7.0", "jest": "^29.5.0",
"prettier": "3.8.1", "prettier": "^3.0.0",
"prisma": "5.22.0", "prisma": "^5.22.0",
"source-map-support": "0.5.21", "source-map-support": "^0.5.21",
"supertest": "7.2.2", "supertest": "^7.0.0",
"ts-jest": "29.4.6", "ts-jest": "^29.1.0",
"ts-loader": "9.5.4", "ts-loader": "^9.4.3",
"ts-node": "10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "5.9.3" "typescript": "^5.1.3"
} }
}, },
"node_modules/@angular-devkit/core": { "node_modules/@angular-devkit/core": {

View File

@@ -11,7 +11,7 @@
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/src/main.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
@@ -26,40 +26,40 @@
"seed": "ts-node prisma/seed.ts" "seed": "ts-node prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "10.4.22", "@nestjs/common": "^10.0.0",
"@nestjs/config": "4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "10.4.22", "@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "10.4.22", "@nestjs/platform-express": "^10.0.0",
"@prisma/client": "5.22.0", "@prisma/client": "^5.22.0",
"class-transformer": "0.5.1", "class-transformer": "^0.5.1",
"class-validator": "0.14.4", "class-validator": "^0.14.1",
"jose": "6.2.2", "jose": "^6.2.2",
"reflect-metadata": "0.2.2", "reflect-metadata": "^0.2.0",
"rxjs": "7.8.2" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "10.4.9", "@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "10.2.3", "@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "10.4.22", "@nestjs/testing": "^10.0.0",
"@types/express": "5.0.6", "@types/express": "^5.0.0",
"@types/jest": "29.5.14", "@types/jest": "^29.5.2",
"@types/node": "20.19.37", "@types/node": "^20.3.1",
"@types/supertest": "6.0.3", "@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "8.57.0", "@typescript-eslint/parser": "^8.0.0",
"eslint": "8.57.1", "eslint": "^8.0.0",
"eslint-config-prettier": "9.1.2", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "5.5.5", "eslint-plugin-prettier": "^5.0.0",
"jest": "29.7.0", "jest": "^29.5.0",
"prettier": "3.8.1", "prettier": "^3.0.0",
"prisma": "5.22.0", "prisma": "^5.22.0",
"source-map-support": "0.5.21", "source-map-support": "^0.5.21",
"supertest": "7.2.2", "supertest": "^7.0.0",
"ts-jest": "29.4.6", "ts-jest": "^29.1.0",
"ts-loader": "9.5.4", "ts-loader": "^9.4.3",
"ts-node": "10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "5.9.3" "typescript": "^5.1.3"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [

View File

@@ -1,4 +1,4 @@
-- CreateEnum -- CreateEnum
CREATE TYPE "EquipmentStatus" AS ENUM ('Active', 'Repair', 'Reserve', 'WriteOff'); CREATE TYPE "EquipmentStatus" AS ENUM ('Active', 'Repair', 'Reserve', 'WriteOff');
-- CreateEnum -- CreateEnum

View File

@@ -1,28 +0,0 @@
/** 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": "Общая наработка, моточасов",
};

View File

@@ -12,7 +12,7 @@ import { Request, Response } from 'express';
type ErrorResponseBody = { type ErrorResponseBody = {
statusCode: number; statusCode: number;
message: string | string[]; message: string;
code: string; code: string;
details?: unknown; details?: unknown;
path: string; path: string;
@@ -39,10 +39,8 @@ export class ApiExceptionFilter implements ExceptionFilter {
}; };
if (mapped.statusCode >= 500) { if (mapped.statusCode >= 500) {
const logDetail =
exception instanceof Error ? exception.message : String(mapped.message);
this.logger.error( this.logger.error(
`Unhandled error on ${request.method} ${request.url}: ${logDetail}`, `Unhandled error on ${request.method} ${request.url}: ${mapped.message}`,
exception instanceof Error ? exception.stack : undefined, exception instanceof Error ? exception.stack : undefined,
); );
} }
@@ -52,7 +50,7 @@ export class ApiExceptionFilter implements ExceptionFilter {
private mapException(exception: unknown): { private mapException(exception: unknown): {
statusCode: number; statusCode: number;
message: string | string[]; message: string;
code: string; code: string;
details?: unknown; details?: unknown;
} { } {
@@ -76,11 +74,9 @@ export class ApiExceptionFilter implements ExceptionFilter {
} }
const rawMessage = payload?.message ?? exception.message; const rawMessage = payload?.message ?? exception.message;
const message: string | string[] = Array.isArray(rawMessage) const message = Array.isArray(rawMessage)
? rawMessage.map((m) => String(m)) ? rawMessage.join(', ')
: typeof rawMessage === 'string' && rawMessage.length > 0 : rawMessage || exception.message;
? rawMessage
: String(exception.message ?? 'Bad Request');
return { return {
statusCode, statusCode,
@@ -122,26 +118,21 @@ export class ApiExceptionFilter implements ExceptionFilter {
if (exception instanceof Error) { if (exception instanceof Error) {
return { return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR, statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Внутренняя ошибка сервера.', message: exception.message || 'Internal server error',
code: 'INTERNAL_ERROR', code: 'INTERNAL_ERROR',
}; };
} }
return { return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR, statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Внутренняя ошибка сервера.', message: 'Internal server error',
code: 'INTERNAL_ERROR', code: 'INTERNAL_ERROR',
}; };
} }
private mapPrismaKnownRequestError( private mapPrismaKnownRequestError(
exception: Prisma.PrismaClientKnownRequestError, exception: Prisma.PrismaClientKnownRequestError,
): { ) {
statusCode: number;
message: string;
code: string;
details?: unknown;
} {
switch (exception.code) { switch (exception.code) {
case 'P2002': { case 'P2002': {
const target = Array.isArray(exception.meta?.target) const target = Array.isArray(exception.meta?.target)

View File

@@ -8,7 +8,34 @@ import {
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { RuntimeEnvironment } from './config/env.validation'; import { RuntimeEnvironment } from './config/env.validation';
import { ApiExceptionFilter } from './common/filters/api-exception.filter'; import { ApiExceptionFilter } from './common/filters/api-exception.filter';
import { FIELD_LABELS } from './common/field-labels.generated';
const FIELD_LABELS: Record<string, string> = {
code: 'Код',
name: 'Название',
manufacturer: 'Производитель',
maintenanceIntervalHours: 'Интервал ТО (часы)',
overhaulIntervalHours: 'Интервал капремонта (часы)',
inventoryNumber: 'Инвентарный номер',
serialNumber: 'Серийный номер',
equipmentTypeCode: 'Тип оборудования',
equipmentId: 'Оборудование',
status: 'Статус',
location: 'Местоположение',
commissionedAt: 'Дата ввода в эксплуатацию',
totalEngineHours: 'Наработка общая',
engineHoursSinceLastRepair: 'Наработка после ремонта',
lastRepairAt: 'Дата последнего ремонта',
notes: 'Примечание',
number: 'Номер',
repairKind: 'Вид ремонта',
plannedAt: 'Плановая дата',
startedAt: 'Дата начала',
completedAt: 'Дата завершения',
contractor: 'Подрядчик',
engineHoursAtRepair: 'Наработка на момент ремонта',
description: 'Описание',
id: 'Идентификатор',
};
function prettifyFieldName(field: string): string { function prettifyFieldName(field: string): string {
if (FIELD_LABELS[field]) return FIELD_LABELS[field]; if (FIELD_LABELS[field]) return FIELD_LABELS[field];
@@ -33,12 +60,9 @@ function constraintToRuMessage(field: string, constraint: string): string {
return `Поле "${label}" должно быть UUID`; return `Поле "${label}" должно быть UUID`;
case 'isNumberString': case 'isNumberString':
return `Поле "${label}" должно быть числом`; return `Поле "${label}" должно быть числом`;
case 'isNumber':
return `Поле "${label}" должно быть числом`;
case 'isIso8601': case 'isIso8601':
return `Поле "${label}" должно содержать корректную дату`; return `Поле "${label}" должно содержать корректную дату`;
case 'isEnum': case 'isEnum':
case 'isIn':
return `Поле "${label}" содержит недопустимое значение`; return `Поле "${label}" содержит недопустимое значение`;
default: default:
return `Поле "${label}" заполнено некорректно`; return `Поле "${label}" заполнено некорректно`;

View File

@@ -1,5 +1,4 @@
import { IsISO8601, IsIn, IsNotEmpty, IsNumber, IsString } from 'class-validator'; import { IsISO8601, IsNotEmpty, IsNumberString, IsString } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateEquipmentDto { export class CreateEquipmentDto {
@IsString({ message: 'inventoryNumber: должно быть строкой' }) @IsString({ message: 'inventoryNumber: должно быть строкой' })
@@ -13,19 +12,17 @@ export class CreateEquipmentDto {
@IsString({ message: 'equipmentTypeCode: должно быть строкой' }) @IsString({ message: 'equipmentTypeCode: должно быть строкой' })
@IsNotEmpty({ message: 'equipmentTypeCode: обязательное поле' }) @IsNotEmpty({ message: 'equipmentTypeCode: обязательное поле' })
equipmentTypeCode!: string; equipmentTypeCode!: string;
@IsIn(['Active', 'Repair', 'Reserve', 'WriteOff'], { message: 'status: недопустимое значение' }) @IsString({ message: 'status: должно быть строкой' })
@IsNotEmpty({ message: 'status: обязательное поле' }) @IsNotEmpty({ message: 'status: обязательное поле' })
status!: string; status!: string;
@IsString({ message: 'location: должно быть строкой' }) @IsString({ message: 'location: должно быть строкой' })
location?: string; location?: string;
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' }) @IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
commissionedAt?: string; commissionedAt?: string;
@Type(() => Number) @IsNumberString({}, { message: 'totalEngineHours: должно быть числом' })
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'totalEngineHours: должно быть числом' }) totalEngineHours?: string;
totalEngineHours?: number; @IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' })
@Type(() => Number) engineHoursSinceLastRepair?: string;
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursSinceLastRepair: должно быть числом' })
engineHoursSinceLastRepair?: number;
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' }) @IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
lastRepairAt?: string; lastRepairAt?: string;
@IsString({ message: 'notes: должно быть строкой' }) @IsString({ message: 'notes: должно быть строкой' })

View File

@@ -1,5 +1,4 @@
import { IsISO8601, IsIn, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
export class UpdateEquipmentDto { export class UpdateEquipmentDto {
@IsOptional() @IsOptional()
@@ -18,7 +17,7 @@ export class UpdateEquipmentDto {
@IsString({ message: 'equipmentTypeCode: должно быть строкой' }) @IsString({ message: 'equipmentTypeCode: должно быть строкой' })
equipmentTypeCode?: string; equipmentTypeCode?: string;
@IsOptional() @IsOptional()
@IsIn(['Active', 'Repair', 'Reserve', 'WriteOff'], { message: 'status: недопустимое значение' }) @IsString({ message: 'status: должно быть строкой' })
status?: string; status?: string;
@IsOptional() @IsOptional()
@IsString({ message: 'location: должно быть строкой' }) @IsString({ message: 'location: должно быть строкой' })
@@ -27,13 +26,11 @@ export class UpdateEquipmentDto {
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' }) @IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
commissionedAt?: string; commissionedAt?: string;
@IsOptional() @IsOptional()
@Type(() => Number) @IsNumberString({}, { message: 'totalEngineHours: должно быть числом' })
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'totalEngineHours: должно быть числом' }) totalEngineHours?: string;
totalEngineHours?: number;
@IsOptional() @IsOptional()
@Type(() => Number) @IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' })
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursSinceLastRepair: должно быть числом' }) engineHoursSinceLastRepair?: string;
engineHoursSinceLastRepair?: number;
@IsOptional() @IsOptional()
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' }) @IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
lastRepairAt?: string; lastRepairAt?: string;

View File

@@ -1,5 +1,4 @@
import { IsISO8601, IsIn, IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator'; import { IsISO8601, IsNotEmpty, IsNumberString, IsString, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateRepairOrderDto { export class CreateRepairOrderDto {
@IsString({ message: 'number: должно быть строкой' }) @IsString({ message: 'number: должно быть строкой' })
@@ -8,10 +7,10 @@ export class CreateRepairOrderDto {
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' }) @IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
@IsNotEmpty({ message: 'equipmentId: обязательное поле' }) @IsNotEmpty({ message: 'equipmentId: обязательное поле' })
equipmentId!: string; equipmentId!: string;
@IsIn(['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'], { message: 'repairKind: недопустимое значение' }) @IsString({ message: 'repairKind: должно быть строкой' })
@IsNotEmpty({ message: 'repairKind: обязательное поле' }) @IsNotEmpty({ message: 'repairKind: обязательное поле' })
repairKind!: string; repairKind!: string;
@IsIn(['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'], { message: 'status: недопустимое значение' }) @IsString({ message: 'status: должно быть строкой' })
@IsNotEmpty({ message: 'status: обязательное поле' }) @IsNotEmpty({ message: 'status: обязательное поле' })
status!: string; status!: string;
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' }) @IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
@@ -23,9 +22,8 @@ export class CreateRepairOrderDto {
completedAt?: string; completedAt?: string;
@IsString({ message: 'contractor: должно быть строкой' }) @IsString({ message: 'contractor: должно быть строкой' })
contractor?: string; contractor?: string;
@Type(() => Number) @IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' })
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursAtRepair: должно быть числом' }) engineHoursAtRepair?: string;
engineHoursAtRepair?: number;
@IsString({ message: 'description: должно быть строкой' }) @IsString({ message: 'description: должно быть строкой' })
description?: string; description?: string;
@IsString({ message: 'notes: должно быть строкой' }) @IsString({ message: 'notes: должно быть строкой' })

View File

@@ -1,5 +1,4 @@
import { IsISO8601, IsIn, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
export class UpdateRepairOrderDto { export class UpdateRepairOrderDto {
@IsOptional() @IsOptional()
@@ -12,10 +11,10 @@ export class UpdateRepairOrderDto {
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' }) @IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
equipmentId?: string; equipmentId?: string;
@IsOptional() @IsOptional()
@IsIn(['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'], { message: 'repairKind: недопустимое значение' }) @IsString({ message: 'repairKind: должно быть строкой' })
repairKind?: string; repairKind?: string;
@IsOptional() @IsOptional()
@IsIn(['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'], { message: 'status: недопустимое значение' }) @IsString({ message: 'status: должно быть строкой' })
status?: string; status?: string;
@IsOptional() @IsOptional()
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' }) @IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
@@ -30,9 +29,8 @@ export class UpdateRepairOrderDto {
@IsString({ message: 'contractor: должно быть строкой' }) @IsString({ message: 'contractor: должно быть строкой' })
contractor?: string; contractor?: string;
@IsOptional() @IsOptional()
@Type(() => Number) @IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' })
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursAtRepair: должно быть числом' }) engineHoursAtRepair?: string;
engineHoursAtRepair?: number;
@IsOptional() @IsOptional()
@IsString({ message: 'description: должно быть строкой' }) @IsString({ message: 'description: должно быть строкой' })
description?: string; description?: string;

View File

@@ -1,44 +0,0 @@
/**
* ╨Ч╨░╨┐╨╕╤Б╤Л╨▓╨░╨╡╤В ╨▓ package.json ╤В╨╛╤З╨╜╤Л╨╡ ╨▓╨╡╤А╤Б╨╕╨╕ ╨┐╤А╤П╨╝╤Л╤Е ╨╖╨░╨▓╨╕╤Б╨╕╨╝╨╛╤Б╤В╨╡╨╣ ╨╕╨╖ package-lock.json (lockfile v3).
* ╨Ш╤Б╨┐╨╛╨╗╤М╨╖╨╛╨▓╨░╨╜╨╕╨╡: node tools/pin-package-versions.mjs server
* node tools/pin-package-versions.mjs client
*/
import fs from 'node:fs';
import path from 'node:path';
const dir = process.argv[2];
if (!dir || !['server', 'client'].includes(dir)) {
console.error('Usage: node tools/pin-package-versions.mjs <server|client>');
process.exit(1);
}
const root = path.resolve(process.cwd(), dir);
const lockPath = path.join(root, 'package-lock.json');
const pkgPath = path.join(root, 'package.json');
if (!fs.existsSync(lockPath)) {
console.error(`Missing ${lockPath}. Run npm install in ${dir}/ first.`);
process.exit(1);
}
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
function pinSection(section) {
if (!section) return;
for (const name of Object.keys(section)) {
const lockKey = `node_modules/${name}`;
const entry = lock.packages?.[lockKey];
if (!entry?.version) {
console.error(`Missing lock entry for ${lockKey}`);
process.exit(1);
}
section[name] = entry.version;
}
}
pinSection(pkg.dependencies);
pinSection(pkg.devDependencies);
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
console.log(`Pinned ${dir}/package.json from package-lock.json`);

View File

@@ -135,10 +135,6 @@ function validateBuildChecks() {
'prompts/frontend-rules.md', 'prompts/frontend-rules.md',
'prompts/runtime-rules.md', 'prompts/runtime-rules.md',
'prompts/validation-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('\\', '/')); const dslFiles = getDslFiles(rootDir).map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/'));
@@ -181,26 +177,6 @@ function validateBuildChecks() {
assertCondition(Boolean(clientPackage.devDependencies?.vite), 'client/package.json must keep Vite as a dev dependency'); assertCondition(Boolean(clientPackage.devDependencies?.vite), 'client/package.json must keep Vite as a dev dependency');
assertCondition(Boolean(clientPackage.devDependencies?.['@vitejs/plugin-react']), 'client/package.json must keep @vitejs/plugin-react as a dev dependency'); assertCondition(Boolean(clientPackage.devDependencies?.['@vitejs/plugin-react']), 'client/package.json must keep @vitejs/plugin-react as a dev dependency');
} }
validatePinnedPackageJsonVersions();
}
function validatePinnedPackageJsonVersions() {
for (const rel of ['server/package.json', 'client/package.json']) {
const pkg = parseJson(rel);
if (!pkg) continue;
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
const deps = pkg[section];
if (!deps || typeof deps !== 'object') continue;
for (const [name, ver] of Object.entries(deps)) {
if (typeof ver !== 'string') continue;
assertCondition(
!/^[\^~]/.test(ver),
`${rel} ${section}.${name} must be pinned (no ^ or ~): got ${ver}`,
);
}
}
}
} }
function validateAuthChecks() { function validateAuthChecks() {
@@ -264,38 +240,6 @@ function validateAuthChecks() {
assertCondition(/protocol\/openid-connect\/certs/.test(authService), 'Backend auth must keep Keycloak certs fallback resolution'); 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() { function validateNaturalKeyChecks() {
const summary = parseJson('domain-summary.json'); const summary = parseJson('domain-summary.json');
if (!summary) { if (!summary) {
@@ -540,7 +484,6 @@ function validateRuntimeExecutionChecks() {
validateBuildChecks(); validateBuildChecks();
validateAuthChecks(); validateAuthChecks();
validateApiErrorContractChecks();
validateNaturalKeyChecks(); validateNaturalKeyChecks();
validateRealmChecks(); validateRealmChecks();
validateRuntimeContractChecks(); validateRuntimeContractChecks();