Compare commits
2 Commits
chore/arch
...
npm-exact-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9352f15fd6 | ||
|
|
1cdd80f51b |
@@ -1,15 +0,0 @@
|
|||||||
POSTGRES_USER=postgres
|
|
||||||
POSTGRES_PASSWORD=change-me
|
|
||||||
POSTGRES_DB=toir
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
|
|
||||||
CORS_ALLOWED_ORIGINS=https://toir.example.ru
|
|
||||||
|
|
||||||
KEYCLOAK_ISSUER_URL=https://sso.example.ru/realms/toir
|
|
||||||
KEYCLOAK_AUDIENCE=toir-backend
|
|
||||||
KEYCLOAK_JWKS_URL=
|
|
||||||
|
|
||||||
VITE_API_URL=/api
|
|
||||||
VITE_KEYCLOAK_URL=https://sso.example.ru
|
|
||||||
VITE_KEYCLOAK_REALM=toir
|
|
||||||
VITE_KEYCLOAK_CLIENT_ID=toir-frontend
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
npm-debug.log*
|
|
||||||
3
client/.npmrc
Normal file
3
client/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
save-exact=true
|
||||||
|
fund=false
|
||||||
|
audit=false
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
FROM node:20-alpine AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ARG VITE_API_URL
|
|
||||||
ARG VITE_KEYCLOAK_URL
|
|
||||||
ARG VITE_KEYCLOAK_REALM
|
|
||||||
ARG VITE_KEYCLOAK_CLIENT_ID
|
|
||||||
|
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
|
||||||
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL
|
|
||||||
ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM
|
|
||||||
ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM nginx:1.27-alpine AS runtime
|
|
||||||
|
|
||||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://toir-server:3000/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /healthz {
|
|
||||||
access_log off;
|
|
||||||
add_header Content-Type text/plain;
|
|
||||||
return 200 'ok';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
client/package-lock.json
generated
36
client/package-lock.json
generated
@@ -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.2.0",
|
"react": "18.3.1",
|
||||||
"react-admin": "^5.14.4",
|
"react-admin": "5.14.4",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.55",
|
"@types/react": "18.3.28",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "18.3.7",
|
||||||
"@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.2.1",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "8.57.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "4.6.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "5.9.3",
|
||||||
"vite": "^5.1.0"
|
"vite": "5.4.21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
|
|||||||
@@ -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.2.0",
|
"react": "18.3.1",
|
||||||
"react-admin": "^5.14.4",
|
"react-admin": "5.14.4",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.55",
|
"@types/react": "18.3.28",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "18.3.7",
|
||||||
"@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.2.1",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "8.57.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "4.6.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "5.9.3",
|
||||||
"vite": "^5.1.0"
|
"vite": "5.4.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Admin, Resource } from 'react-admin';
|
import { Admin, Resource } from 'react-admin';
|
||||||
import dataProvider from './dataProvider';
|
import dataProvider from './dataProvider';
|
||||||
import authProvider from './auth/authProvider';
|
import authProvider from './auth/authProvider';
|
||||||
|
import { AppNotification } from './AppNotification';
|
||||||
|
|
||||||
import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList';
|
import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList';
|
||||||
import { EquipmentTypeCreate } from './resources/equipment-type/EquipmentTypeCreate';
|
import { EquipmentTypeCreate } from './resources/equipment-type/EquipmentTypeCreate';
|
||||||
@@ -18,7 +19,12 @@ import { RepairOrderEdit } from './resources/repair-order/RepairOrderEdit';
|
|||||||
import { RepairOrderShow } from './resources/repair-order/RepairOrderShow';
|
import { RepairOrderShow } from './resources/repair-order/RepairOrderShow';
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<Admin dataProvider={dataProvider} authProvider={authProvider} requireAuth>
|
<Admin
|
||||||
|
dataProvider={dataProvider}
|
||||||
|
authProvider={authProvider}
|
||||||
|
notification={AppNotification}
|
||||||
|
requireAuth
|
||||||
|
>
|
||||||
<Resource
|
<Resource
|
||||||
name="equipment-types"
|
name="equipment-types"
|
||||||
options={{ label: 'Виды оборудования' }}
|
options={{ label: 'Виды оборудования' }}
|
||||||
|
|||||||
16
client/src/AppNotification.tsx
Normal file
16
client/src/AppNotification.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Notification, NotificationProps } from 'react-admin';
|
||||||
|
|
||||||
|
export const AppNotification = (props: NotificationProps) => (
|
||||||
|
<Notification
|
||||||
|
{...props}
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
'& .MuiAlert-message': {
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
},
|
||||||
|
'& .MuiSnackbarContent-message': {
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -1,18 +1,57 @@
|
|||||||
import { DataProvider, fetchUtils } from 'react-admin';
|
import { DataProvider, fetchUtils, HttpError } from 'react-admin';
|
||||||
import { getValidAccessToken } from './auth/keycloak';
|
import { getValidAccessToken } from './auth/keycloak';
|
||||||
import { env } from './config/env';
|
import { env } from './config/env';
|
||||||
|
|
||||||
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' });
|
||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
return fetchUtils.fetchJson(url, {
|
try {
|
||||||
...options,
|
return await fetchUtils.fetchJson(url, {
|
||||||
headers,
|
...options,
|
||||||
});
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const 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>) {
|
function buildQueryString(query: Record<string, unknown>) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const statusChoices = [
|
|||||||
export const EquipmentEdit = () => (
|
export const EquipmentEdit = () => (
|
||||||
<Edit>
|
<Edit>
|
||||||
<SimpleForm>
|
<SimpleForm>
|
||||||
<TextInput source="id" label="id" disabled />
|
<TextInput source="id" label="Идентификатор" 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 />
|
||||||
|
|||||||
@@ -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="id" />
|
<TextField source="id" label="Идентификатор" />
|
||||||
<TextField source="inventoryNumber" label="Инвентарный номер" />
|
<TextField source="inventoryNumber" label="Инвентарный номер" />
|
||||||
<TextField source="serialNumber" label="Заводской (серийный) номер" />
|
<TextField source="serialNumber" label="Заводской (серийный) номер" />
|
||||||
<TextField source="name" label="Наименование единицы оборудования" />
|
<TextField source="name" label="Наименование единицы оборудования" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const statusChoices = [
|
|||||||
export const EquipmentShow = () => (
|
export const EquipmentShow = () => (
|
||||||
<Show>
|
<Show>
|
||||||
<SimpleShowLayout>
|
<SimpleShowLayout>
|
||||||
<TextField source="id" label="id" />
|
<TextField source="id" label="Идентификатор" />
|
||||||
<TextField source="inventoryNumber" label="Инвентарный номер" />
|
<TextField source="inventoryNumber" label="Инвентарный номер" />
|
||||||
<TextField source="serialNumber" label="Заводской (серийный) номер" />
|
<TextField source="serialNumber" label="Заводской (серийный) номер" />
|
||||||
<TextField source="name" label="Наименование единицы оборудования" />
|
<TextField source="name" label="Наименование единицы оборудования" />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const statusChoices = [
|
|||||||
export const RepairOrderEdit = () => (
|
export const RepairOrderEdit = () => (
|
||||||
<Edit>
|
<Edit>
|
||||||
<SimpleForm>
|
<SimpleForm>
|
||||||
<TextInput source="id" label="id" disabled />
|
<TextInput source="id" label="Идентификатор" 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 })} />
|
||||||
|
|||||||
@@ -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="id" />
|
<TextField source="id" label="Идентификатор" />
|
||||||
<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" />
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const statusChoices = [
|
|||||||
export const RepairOrderShow = () => (
|
export const RepairOrderShow = () => (
|
||||||
<Show>
|
<Show>
|
||||||
<SimpleShowLayout>
|
<SimpleShowLayout>
|
||||||
<TextField source="id" label="id" />
|
<TextField source="id" label="Идентификатор" />
|
||||||
<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" />
|
||||||
|
|||||||
@@ -4,93 +4,13 @@ services:
|
|||||||
container_name: toir-postgres
|
container_name: toir-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-toir}
|
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:
|
||||||
- "${POSTGRES_PORT:-5432}:5432"
|
- "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
|
|
||||||
|
|||||||
@@ -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`**.
|
- **`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": { ... } }`
|
||||||
|
|||||||
5
generation/context/dependency-pins.md
Normal file
5
generation/context/dependency-pins.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Зависимости 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`; агенты не должны ослаблять диапазоны версий при правках кода.
|
||||||
@@ -7,6 +7,14 @@ 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');
|
||||||
@@ -178,6 +186,7 @@ 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 'Вид оборудования';
|
||||||
@@ -260,6 +269,55 @@ 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);
|
||||||
@@ -279,7 +337,7 @@ function ensurePrismaSchema({ enums, entities }, prismaPath, apply) {
|
|||||||
return { changed: true, content: next };
|
return { changed: true, content: next };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBackendModule(entityName, entity, resourceName, pk) {
|
function renderBackendModule(entityName, entity, resourceName, pk, enums) {
|
||||||
const className = entityName;
|
const className = entityName;
|
||||||
const moduleName = `${className}Module`;
|
const moduleName = `${className}Module`;
|
||||||
const serviceName = `${className}Service`;
|
const serviceName = `${className}Service`;
|
||||||
@@ -287,15 +345,6 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
|||||||
const folder = toKebab(entityName);
|
const folder = toKebab(entityName);
|
||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
const enumTypes = entity.attributes
|
|
||||||
.filter((a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type))
|
|
||||||
.map((a) => a.type);
|
|
||||||
|
|
||||||
const enumUnion = (typeName) => {
|
|
||||||
// Unknown labels in DSL aren't needed; keep as string union to avoid importing Prisma enums.
|
|
||||||
return `'${typeName}'`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dtoType = (attr) => {
|
const dtoType = (attr) => {
|
||||||
switch (attr.type) {
|
switch (attr.type) {
|
||||||
case 'uuid':
|
case 'uuid':
|
||||||
@@ -305,7 +354,7 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
|||||||
case 'integer':
|
case 'integer':
|
||||||
return 'number';
|
return 'number';
|
||||||
case 'decimal':
|
case 'decimal':
|
||||||
return 'string';
|
return 'number';
|
||||||
case 'date':
|
case 'date':
|
||||||
return 'string';
|
return 'string';
|
||||||
default:
|
default:
|
||||||
@@ -314,23 +363,109 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getValidationDecorators = (attr, isUpdate) => {
|
||||||
|
const decorators = [];
|
||||||
|
const imports = new Set();
|
||||||
|
let needsTypeImport = false;
|
||||||
|
const field = attr.name;
|
||||||
|
if (isUpdate) {
|
||||||
|
decorators.push('@IsOptional()');
|
||||||
|
imports.add('IsOptional');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (attr.type) {
|
||||||
|
case 'uuid':
|
||||||
|
decorators.push(`@IsUUID(undefined, { message: '${field}: должно быть UUID' })`);
|
||||||
|
imports.add('IsUUID');
|
||||||
|
break;
|
||||||
|
case 'string':
|
||||||
|
case 'text':
|
||||||
|
decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`);
|
||||||
|
imports.add('IsString');
|
||||||
|
break;
|
||||||
|
case 'integer':
|
||||||
|
decorators.push('@Type(() => Number)');
|
||||||
|
decorators.push(`@IsInt({ message: '${field}: должно быть целым числом' })`);
|
||||||
|
imports.add('IsInt');
|
||||||
|
needsTypeImport = true;
|
||||||
|
break;
|
||||||
|
case 'decimal':
|
||||||
|
decorators.push('@Type(() => Number)');
|
||||||
|
decorators.push(
|
||||||
|
`@IsNumber({ allowNaN: false, allowInfinity: false }, { message: '${field}: должно быть числом' })`,
|
||||||
|
);
|
||||||
|
imports.add('IsNumber');
|
||||||
|
needsTypeImport = true;
|
||||||
|
break;
|
||||||
|
case 'date':
|
||||||
|
decorators.push(`@IsISO8601({}, { message: '${field}: должно содержать корректную дату' })`);
|
||||||
|
imports.add('IsISO8601');
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const vals = enums?.[attr.type]?.values;
|
||||||
|
if (vals && vals.length) {
|
||||||
|
const list = vals.map((v) => `'${v}'`).join(', ');
|
||||||
|
decorators.push(
|
||||||
|
`@IsIn([${list}], { message: '${field}: недопустимое значение' })`,
|
||||||
|
);
|
||||||
|
imports.add('IsIn');
|
||||||
|
} else {
|
||||||
|
decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`);
|
||||||
|
imports.add('IsString');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUpdate && attr.isRequired && !(attr.isPrimary && attr.type === 'uuid')) {
|
||||||
|
decorators.push(`@IsNotEmpty({ message: '${field}: обязательное поле' })`);
|
||||||
|
imports.add('IsNotEmpty');
|
||||||
|
}
|
||||||
|
return { decorators, imports, needsTypeImport };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDecorators = new Set();
|
||||||
|
const updateDecorators = new Set(['IsOptional']);
|
||||||
|
let createNeedsTypeImport = false;
|
||||||
|
let updateNeedsTypeImport = false;
|
||||||
|
|
||||||
const createDtoLines = [];
|
const createDtoLines = [];
|
||||||
createDtoLines.push(`export class Create${className}Dto {`);
|
|
||||||
for (const a of entity.attributes) {
|
for (const a of entity.attributes) {
|
||||||
if (a.isPrimary && a.type === 'uuid') continue; // generated
|
if (a.isPrimary && a.type === 'uuid') continue; // generated
|
||||||
|
const { decorators, imports, needsTypeImport } = getValidationDecorators(a, false);
|
||||||
|
imports.forEach((i) => createDecorators.add(i));
|
||||||
|
if (needsTypeImport) createNeedsTypeImport = true;
|
||||||
|
decorators.forEach((d) => createDtoLines.push(` ${d}`));
|
||||||
const opt = a.isRequired && !(a.isPrimary && a.type !== 'uuid') ? '!' : '?';
|
const opt = a.isRequired && !(a.isPrimary && a.type !== 'uuid') ? '!' : '?';
|
||||||
createDtoLines.push(` ${a.name}${opt}: ${dtoType(a)};`);
|
createDtoLines.push(` ${a.name}${opt}: ${dtoType(a)};`);
|
||||||
}
|
}
|
||||||
createDtoLines.push('}');
|
|
||||||
|
|
||||||
const updateDtoLines = [];
|
const updateDtoLines = [];
|
||||||
updateDtoLines.push(`export class Update${className}Dto {`);
|
if (pk !== 'id') {
|
||||||
if (pk !== 'id') updateDtoLines.push(` id?: string;`);
|
updateDtoLines.push(' @IsOptional()');
|
||||||
|
updateDtoLines.push(` @IsString({ message: 'id: должно быть строкой' })`);
|
||||||
|
updateDtoLines.push(' id?: string;');
|
||||||
|
updateDecorators.add('IsString');
|
||||||
|
}
|
||||||
for (const a of entity.attributes) {
|
for (const a of entity.attributes) {
|
||||||
if (pk !== 'id' && a.name === 'id') continue;
|
if (pk !== 'id' && a.name === 'id') continue;
|
||||||
|
const { decorators, imports, needsTypeImport } = getValidationDecorators(a, true);
|
||||||
|
imports.forEach((i) => updateDecorators.add(i));
|
||||||
|
if (needsTypeImport) updateNeedsTypeImport = true;
|
||||||
|
decorators.forEach((d) => updateDtoLines.push(` ${d}`));
|
||||||
updateDtoLines.push(` ${a.name}?: ${dtoType(a)};`);
|
updateDtoLines.push(` ${a.name}?: ${dtoType(a)};`);
|
||||||
}
|
}
|
||||||
updateDtoLines.push('}');
|
|
||||||
|
const createImports = Array.from(createDecorators)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.join(', ');
|
||||||
|
const updateImports = Array.from(updateDecorators)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.join(', ');
|
||||||
|
const createDto = `import { ${createImports} } from 'class-validator';\n${createNeedsTypeImport ? "import { Type } from 'class-transformer';\n" : ''}\nexport class Create${className}Dto {\n${createDtoLines.join('\n')}\n}\n`;
|
||||||
|
const updateDto = `import { ${updateImports} } from 'class-validator';\n${updateNeedsTypeImport ? "import { Type } from 'class-transformer';\n" : ''}\nexport class Update${className}Dto {\n${updateDtoLines.join('\n')}\n}\n`;
|
||||||
|
|
||||||
const controller = `import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';\nimport { Response } from 'express';\nimport { Roles } from '../../auth/decorators/roles.decorator';\nimport { RealmRole } from '../../auth/roles/realm-role.enum';\nimport { ${serviceName} } from './${folder}.service';\nimport { Create${className}Dto } from './dto/create-${folder}.dto';\nimport { Update${className}Dto } from './dto/update-${folder}.dto';\n\n@Controller('${resourceName}')\nexport class ${controllerName} {\n constructor(private readonly service: ${serviceName}) {}\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get()\n async findAll(@Query() query: any, @Res() res: Response) {\n const result = await this.service.findAll(query);\n res.set('Content-Range', \`${resourceName} \${query._start || 0}-\${query._end || result.total}/\${result.total}\`);\n res.set('Access-Control-Expose-Headers', 'Content-Range');\n return res.json(result.data);\n }\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get(':${pk}')\n findOne(@Param('${pk}') id: string) {\n return this.service.findOne(id);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Post()\n create(@Body() dto: Create${className}Dto) {\n return this.service.create(dto);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Patch(':${pk}')\n update(@Param('${pk}') id: string, @Body() dto: Update${className}Dto) {\n return this.service.update(id, dto);\n }\n\n @Roles(RealmRole.Admin)\n @Delete(':${pk}')\n remove(@Param('${pk}') id: string) {\n return this.service.remove(id);\n }\n}\n`;
|
const controller = `import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';\nimport { Response } from 'express';\nimport { Roles } from '../../auth/decorators/roles.decorator';\nimport { RealmRole } from '../../auth/roles/realm-role.enum';\nimport { ${serviceName} } from './${folder}.service';\nimport { Create${className}Dto } from './dto/create-${folder}.dto';\nimport { Update${className}Dto } from './dto/update-${folder}.dto';\n\n@Controller('${resourceName}')\nexport class ${controllerName} {\n constructor(private readonly service: ${serviceName}) {}\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get()\n async findAll(@Query() query: any, @Res() res: Response) {\n const result = await this.service.findAll(query);\n res.set('Content-Range', \`${resourceName} \${query._start || 0}-\${query._end || result.total}/\${result.total}\`);\n res.set('Access-Control-Expose-Headers', 'Content-Range');\n return res.json(result.data);\n }\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get(':${pk}')\n findOne(@Param('${pk}') id: string) {\n return this.service.findOne(id);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Post()\n create(@Body() dto: Create${className}Dto) {\n return this.service.create(dto);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Patch(':${pk}')\n update(@Param('${pk}') id: string, @Body() dto: Update${className}Dto) {\n return this.service.update(id, dto);\n }\n\n @Roles(RealmRole.Admin)\n @Delete(':${pk}')\n remove(@Param('${pk}') id: string) {\n return this.service.remove(id);\n }\n}\n`;
|
||||||
|
|
||||||
@@ -393,8 +528,8 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
|||||||
[`server/src/modules/${folder}/${folder}.controller.ts`]: controller,
|
[`server/src/modules/${folder}/${folder}.controller.ts`]: controller,
|
||||||
[`server/src/modules/${folder}/${folder}.service.ts`]: serviceContent,
|
[`server/src/modules/${folder}/${folder}.service.ts`]: serviceContent,
|
||||||
[`server/src/modules/${folder}/${folder}.module.ts`]: mod,
|
[`server/src/modules/${folder}/${folder}.module.ts`]: mod,
|
||||||
[`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDtoLines.join('\n') + '\n',
|
[`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDto,
|
||||||
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDtoLines.join('\n') + '\n',
|
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDto,
|
||||||
},
|
},
|
||||||
moduleName,
|
moduleName,
|
||||||
importPath: `./modules/${folder}/${folder}.module`,
|
importPath: `./modules/${folder}/${folder}.module`,
|
||||||
@@ -666,6 +801,22 @@ function ensureClientApp(apply, frontendResources) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!out.includes("from './AppNotification'")) {
|
||||||
|
out = out.replace(
|
||||||
|
/import authProvider from '\.\/auth\/authProvider';/,
|
||||||
|
`import authProvider from './auth/authProvider';\nimport { AppNotification } from './AppNotification';`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!out.includes('notification={AppNotification}')) {
|
||||||
|
out = out.replace(
|
||||||
|
/<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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -677,13 +828,23 @@ 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);
|
const be = renderBackendModule(entityName, ent, resource, pk, parsed.enums);
|
||||||
const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums);
|
const fe = renderFrontendResource(
|
||||||
|
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);
|
||||||
@@ -694,6 +855,8 @@ 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,
|
||||||
@@ -721,6 +884,7 @@ 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 = [];
|
||||||
@@ -728,7 +892,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);
|
const be = renderBackendModule(entityName, ent, resource, pk, parsed.enums);
|
||||||
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);
|
||||||
@@ -741,6 +905,7 @@ 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`
|
||||||
|
|||||||
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();
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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
|
||||||
|
|
||||||
@@ -87,6 +88,16 @@ 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
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
coverage
|
|
||||||
.git
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
npm-debug.log*
|
|
||||||
3
server/.npmrc
Normal file
3
server/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
save-exact=true
|
||||||
|
fund=false
|
||||||
|
audit=false
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
FROM node:20-bookworm-slim AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends openssl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
COPY prisma ./prisma
|
|
||||||
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY nest-cli.json tsconfig*.json ./
|
|
||||||
COPY src ./src
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:20-bookworm-slim AS runtime
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends openssl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=build /app/package*.json ./
|
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
|
||||||
COPY --from=build /app/prisma ./prisma
|
|
||||||
COPY --from=build /app/dist ./dist
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main.js"]
|
|
||||||
100
server/package-lock.json
generated
100
server/package-lock.json
generated
@@ -10,38 +10,40 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "10.4.22",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "4.0.3",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "10.4.22",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "10.4.22",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "5.22.0",
|
||||||
"jose": "^6.2.2",
|
"class-transformer": "0.5.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"class-validator": "0.14.4",
|
||||||
"rxjs": "^7.8.1"
|
"jose": "6.2.2",
|
||||||
|
"reflect-metadata": "0.2.2",
|
||||||
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "10.4.9",
|
||||||
"@nestjs/schematics": "^10.0.0",
|
"@nestjs/schematics": "10.2.3",
|
||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "10.4.22",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "5.0.6",
|
||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "29.5.14",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "20.19.37",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "6.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||||
"@typescript-eslint/parser": "^8.0.0",
|
"@typescript-eslint/parser": "8.57.0",
|
||||||
"eslint": "^8.0.0",
|
"eslint": "8.57.1",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "9.1.2",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "5.5.5",
|
||||||
"jest": "^29.5.0",
|
"jest": "29.7.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "3.8.1",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "5.22.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "7.2.2",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "29.4.6",
|
||||||
"ts-loader": "^9.4.3",
|
"ts-loader": "9.5.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "^5.1.3"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular-devkit/core": {
|
"node_modules/@angular-devkit/core": {
|
||||||
@@ -2426,6 +2428,12 @@
|
|||||||
"@types/superagent": "^8.1.0"
|
"@types/superagent": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/validator": {
|
||||||
|
"version": "13.15.10",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/validator/-/validator-13.15.10.tgz",
|
||||||
|
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@@ -3655,6 +3663,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/class-transformer": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/class-validator": {
|
||||||
|
"version": "0.14.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/class-validator/-/class-validator-0.14.4.tgz",
|
||||||
|
"integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/validator": "^13.15.3",
|
||||||
|
"libphonenumber-js": "^1.11.1",
|
||||||
|
"validator": "^13.15.22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cli-cursor": {
|
"node_modules/cli-cursor": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||||
@@ -6823,6 +6848,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libphonenumber-js": {
|
||||||
|
"version": "1.12.40",
|
||||||
|
"resolved": "https://registry.npmmirror.com/libphonenumber-js/-/libphonenumber-js-1.12.40.tgz",
|
||||||
|
"integrity": "sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -9462,6 +9493,15 @@
|
|||||||
"node": ">=10.12.0"
|
"node": ">=10.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/validator": {
|
||||||
|
"version": "13.15.26",
|
||||||
|
"resolved": "https://registry.npmmirror.com/validator/-/validator-13.15.26.tgz",
|
||||||
|
"integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|||||||
@@ -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/src/main.js",
|
"start:prod": "node dist/main",
|
||||||
"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,38 +26,40 @@
|
|||||||
"seed": "ts-node prisma/seed.ts"
|
"seed": "ts-node prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "10.4.22",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "4.0.3",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "10.4.22",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "10.4.22",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "5.22.0",
|
||||||
"jose": "^6.2.2",
|
"class-transformer": "0.5.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"class-validator": "0.14.4",
|
||||||
"rxjs": "^7.8.1"
|
"jose": "6.2.2",
|
||||||
|
"reflect-metadata": "0.2.2",
|
||||||
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "10.4.9",
|
||||||
"@nestjs/schematics": "^10.0.0",
|
"@nestjs/schematics": "10.2.3",
|
||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "10.4.22",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "5.0.6",
|
||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "29.5.14",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "20.19.37",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "6.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||||
"@typescript-eslint/parser": "^8.0.0",
|
"@typescript-eslint/parser": "8.57.0",
|
||||||
"eslint": "^8.0.0",
|
"eslint": "8.57.1",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "9.1.2",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "5.5.5",
|
||||||
"jest": "^29.5.0",
|
"jest": "29.7.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "3.8.1",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "5.22.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "7.2.2",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "29.4.6",
|
||||||
"ts-loader": "^9.4.3",
|
"ts-loader": "9.5.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "^5.1.3"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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": "Общая наработка, моточасов",
|
||||||
|
};
|
||||||
184
server/src/common/filters/api-exception.filter.ts
Normal file
184
server/src/common/filters/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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,72 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ValidationError,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { RuntimeEnvironment } from './config/env.validation';
|
import { RuntimeEnvironment } from './config/env.validation';
|
||||||
|
import { ApiExceptionFilter } from './common/filters/api-exception.filter';
|
||||||
|
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() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@@ -29,6 +94,17 @@ async function bootstrap() {
|
|||||||
credentials: false,
|
credentials: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidUnknownValues: false,
|
||||||
|
exceptionFactory: (errors) =>
|
||||||
|
new BadRequestException(buildValidationMessages(errors)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.useGlobalFilters(new ApiExceptionFilter());
|
||||||
|
|
||||||
const port = configService.get('PORT', 3000);
|
const port = configService.get('PORT', 3000);
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
import { IsInt, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class CreateEquipmentTypeDto {
|
export class CreateEquipmentTypeDto {
|
||||||
|
@IsString({ message: 'code: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'code: обязательное поле' })
|
||||||
code?: string;
|
code?: string;
|
||||||
|
@IsString({ message: 'name: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'name: обязательное поле' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
@IsString({ message: 'manufacturer: должно быть строкой' })
|
||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'maintenanceIntervalHours: должно быть целым числом' })
|
||||||
maintenanceIntervalHours?: number;
|
maintenanceIntervalHours?: number;
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'overhaulIntervalHours: должно быть целым числом' })
|
||||||
overhaulIntervalHours?: number;
|
overhaulIntervalHours?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
|
import { IsInt, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class UpdateEquipmentTypeDto {
|
export class UpdateEquipmentTypeDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'id: должно быть строкой' })
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'code: должно быть строкой' })
|
||||||
code?: string;
|
code?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'name: должно быть строкой' })
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'manufacturer: должно быть строкой' })
|
||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'maintenanceIntervalHours: должно быть целым числом' })
|
||||||
maintenanceIntervalHours?: number;
|
maintenanceIntervalHours?: number;
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'overhaulIntervalHours: должно быть целым числом' })
|
||||||
overhaulIntervalHours?: number;
|
overhaulIntervalHours?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
|
import { IsISO8601, IsIn, IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class CreateEquipmentDto {
|
export class CreateEquipmentDto {
|
||||||
|
@IsString({ message: 'inventoryNumber: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'inventoryNumber: обязательное поле' })
|
||||||
inventoryNumber!: string;
|
inventoryNumber!: string;
|
||||||
|
@IsString({ message: 'serialNumber: должно быть строкой' })
|
||||||
serialNumber?: string;
|
serialNumber?: string;
|
||||||
|
@IsString({ message: 'name: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'name: обязательное поле' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
@IsString({ message: 'equipmentTypeCode: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'equipmentTypeCode: обязательное поле' })
|
||||||
equipmentTypeCode!: string;
|
equipmentTypeCode!: string;
|
||||||
|
@IsIn(['Active', 'Repair', 'Reserve', 'WriteOff'], { message: 'status: недопустимое значение' })
|
||||||
|
@IsNotEmpty({ message: 'status: обязательное поле' })
|
||||||
status!: string;
|
status!: string;
|
||||||
|
@IsString({ message: 'location: должно быть строкой' })
|
||||||
location?: string;
|
location?: string;
|
||||||
|
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
|
||||||
commissionedAt?: string;
|
commissionedAt?: string;
|
||||||
totalEngineHours?: string;
|
@Type(() => Number)
|
||||||
engineHoursSinceLastRepair?: string;
|
@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;
|
lastRepairAt?: string;
|
||||||
|
@IsString({ message: 'notes: должно быть строкой' })
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
|
import { IsISO8601, IsIn, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class UpdateEquipmentDto {
|
export class UpdateEquipmentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID(undefined, { message: 'id: должно быть UUID' })
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'inventoryNumber: должно быть строкой' })
|
||||||
inventoryNumber?: string;
|
inventoryNumber?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'serialNumber: должно быть строкой' })
|
||||||
serialNumber?: string;
|
serialNumber?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'name: должно быть строкой' })
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'equipmentTypeCode: должно быть строкой' })
|
||||||
equipmentTypeCode?: string;
|
equipmentTypeCode?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['Active', 'Repair', 'Reserve', 'WriteOff'], { message: 'status: недопустимое значение' })
|
||||||
status?: string;
|
status?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'location: должно быть строкой' })
|
||||||
location?: string;
|
location?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
|
||||||
commissionedAt?: string;
|
commissionedAt?: string;
|
||||||
totalEngineHours?: string;
|
@IsOptional()
|
||||||
engineHoursSinceLastRepair?: string;
|
@Type(() => Number)
|
||||||
|
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'totalEngineHours: должно быть числом' })
|
||||||
|
totalEngineHours?: number;
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursSinceLastRepair: должно быть числом' })
|
||||||
|
engineHoursSinceLastRepair?: number;
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
|
||||||
lastRepairAt?: string;
|
lastRepairAt?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'notes: должно быть строкой' })
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
|
import { IsISO8601, IsIn, IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class CreateRepairOrderDto {
|
export class CreateRepairOrderDto {
|
||||||
|
@IsString({ message: 'number: должно быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'number: обязательное поле' })
|
||||||
number!: string;
|
number!: string;
|
||||||
|
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
|
||||||
|
@IsNotEmpty({ message: 'equipmentId: обязательное поле' })
|
||||||
equipmentId!: string;
|
equipmentId!: string;
|
||||||
|
@IsIn(['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'], { message: 'repairKind: недопустимое значение' })
|
||||||
|
@IsNotEmpty({ message: 'repairKind: обязательное поле' })
|
||||||
repairKind!: string;
|
repairKind!: string;
|
||||||
|
@IsIn(['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'], { message: 'status: недопустимое значение' })
|
||||||
|
@IsNotEmpty({ message: 'status: обязательное поле' })
|
||||||
status!: string;
|
status!: string;
|
||||||
|
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
|
||||||
|
@IsNotEmpty({ message: 'plannedAt: обязательное поле' })
|
||||||
plannedAt!: string;
|
plannedAt!: string;
|
||||||
|
@IsISO8601({}, { message: 'startedAt: должно содержать корректную дату' })
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
|
@IsISO8601({}, { message: 'completedAt: должно содержать корректную дату' })
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
|
@IsString({ message: 'contractor: должно быть строкой' })
|
||||||
contractor?: string;
|
contractor?: string;
|
||||||
engineHoursAtRepair?: string;
|
@Type(() => Number)
|
||||||
|
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursAtRepair: должно быть числом' })
|
||||||
|
engineHoursAtRepair?: number;
|
||||||
|
@IsString({ message: 'description: должно быть строкой' })
|
||||||
description?: string;
|
description?: string;
|
||||||
|
@IsString({ message: 'notes: должно быть строкой' })
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,42 @@
|
|||||||
|
import { IsISO8601, IsIn, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class UpdateRepairOrderDto {
|
export class UpdateRepairOrderDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID(undefined, { message: 'id: должно быть UUID' })
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'number: должно быть строкой' })
|
||||||
number?: string;
|
number?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
|
||||||
equipmentId?: string;
|
equipmentId?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'], { message: 'repairKind: недопустимое значение' })
|
||||||
repairKind?: string;
|
repairKind?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'], { message: 'status: недопустимое значение' })
|
||||||
status?: string;
|
status?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
|
||||||
plannedAt?: string;
|
plannedAt?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'startedAt: должно содержать корректную дату' })
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'completedAt: должно содержать корректную дату' })
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'contractor: должно быть строкой' })
|
||||||
contractor?: string;
|
contractor?: string;
|
||||||
engineHoursAtRepair?: string;
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursAtRepair: должно быть числом' })
|
||||||
|
engineHoursAtRepair?: number;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'description: должно быть строкой' })
|
||||||
description?: string;
|
description?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'notes: должно быть строкой' })
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
44
tools/pin-package-versions.mjs
Normal file
44
tools/pin-package-versions.mjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* ╨Ч╨░╨┐╨╕╤Б╤Л╨▓╨░╨╡╤В ╨▓ 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`);
|
||||||
@@ -135,6 +135,10 @@ 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('\\', '/'));
|
||||||
@@ -177,6 +181,26 @@ 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() {
|
||||||
@@ -240,6 +264,38 @@ 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) {
|
||||||
@@ -484,6 +540,7 @@ function validateRuntimeExecutionChecks() {
|
|||||||
|
|
||||||
validateBuildChecks();
|
validateBuildChecks();
|
||||||
validateAuthChecks();
|
validateAuthChecks();
|
||||||
|
validateApiErrorContractChecks();
|
||||||
validateNaturalKeyChecks();
|
validateNaturalKeyChecks();
|
||||||
validateRealmChecks();
|
validateRealmChecks();
|
||||||
validateRuntimeContractChecks();
|
validateRuntimeContractChecks();
|
||||||
|
|||||||
Reference in New Issue
Block a user