13 Commits

Author SHA1 Message Date
time_
9a1a700efa update generated validation UX and AID integration flow
Improve generated validation behavior and backend error mapping so UI shows user-friendly Russian messages, while keeping filtering/sorting and exporter updates aligned with current app generation flow.
2026-03-27 11:07:01 +03:00
time_
420ca0348c Merge add_aid_exporters into feat/keycloak (local) 2026-03-25 13:26:11 +03:00
time_
86692caddb chore: add step-by-step OpenAPI conversion demo
Add tools/api-format-to-openapi/demo-steps.mjs and npm scripts demo/demo:pause; ignore demo-output and document usage.
2026-03-25 11:01:20 +03:00
0acc47f9dd Merge pull request #2 from ma1andes/add_filters
Generate filtering/sorting and searchable dropdowns
2026-03-24 14:46:52 +03:00
MaKarin
f08b862b15 fix hyper-links 2026-03-24 14:43:01 +03:00
MaKarin
dd8678cb11 commit migration 2026-03-24 14:17:53 +03:00
MaKarin
68dc33f6be Merge feat/keycloak into add_filters with manual conflict resolution.
Preserve Keycloak auth/RBAC contracts while retaining filter behavior and generator consistency for future regenerations.

Made-with: Cursor
2026-03-24 13:52:20 +03:00
MaKarin
b9b936c9ea fix after review 2026-03-24 12:49:23 +03:00
MaKarin
d1ea297dfc chore: harden generation context baseline 2026-03-22 18:34:22 +03:00
MaKarin
7e6b76cef2 use only TOiR.domain.dsl like single source of truth for generation, update context for pinned .gitignore 2026-03-21 17:14:37 +03:00
time_
f46acf3bc8 docs: AID export README (usage and overview) 2026-03-19 16:59:30 +03:00
time_
2bc1aea56a Add AID export: OpenAPI from api-format and app generator bundle
Nest: POST /aid/export/openapi, POST /aid/export/app. Tools: api-format-to-openapi CLI. Generator: --print-bundle-json. Optional env: AID_EXPORT_API_KEY, AID_GENERATOR_ALLOW_APPLY.
2026-03-19 16:49:27 +03:00
time_
5b8d8a85c4 Generate filtering/sorting and searchable dropdowns
Includes backend q search + generated list UX from DSL.
2026-03-18 19:49:07 +03:00
92 changed files with 5202 additions and 5383 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
**/node_modules/
# Build outputs
**/dist/
**/dist-ssr/
**/coverage/
**/.cache/
**/*.tsbuildinfo
# Environment files
**/.env
**/.env.local
**/.env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# OS files
.DS_Store
Thumbs.db
# Editor / IDE
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Generated OpenAPI (local runs; commit only if you want to publish the spec)
openapi.generated.json
openapi.llm.json
tools/api-format-to-openapi/demo-output/

View File

@@ -1,60 +0,0 @@
# Runtime Keycloak Auth
This repository now uses Keycloak-based authentication for the runtime application only (`client/` and `server/`).
## Required environment variables
### Frontend (`client/.env`)
- `VITE_API_URL` (example: `http://localhost:3000`)
- `VITE_KEYCLOAK_URL` (example: `https://sso.greact.ru`)
- `VITE_KEYCLOAK_REALM` (example: `toir`)
- `VITE_KEYCLOAK_CLIENT_ID` (example: `toir-frontend`)
The frontend fails fast at startup if any required auth/env variable is missing.
### Backend (`server/.env`)
- `PORT` (example: `3000`)
- `DATABASE_URL`
- `CORS_ALLOWED_ORIGINS` (comma-separated list)
- `KEYCLOAK_ISSUER_URL` (example: `https://sso.greact.ru/realms/toir`)
- `KEYCLOAK_AUDIENCE` (example: `toir-backend`)
- `KEYCLOAK_JWKS_URL` (optional)
Backend validates required env vars at startup and fails fast if missing.
## Frontend auth flow
- The SPA initializes Keycloak before rendering.
- Authentication is redirect-based Keycloak login only (no custom username/password form).
- Authorization Code + PKCE (`S256`) is used.
- Access token is attached to every API request via `Authorization: Bearer <token>`.
- `checkError` behavior:
- `401`: force re-authentication.
- `403`: keep session and surface access denied to React Admin.
- Token refresh is concurrency-safe: parallel requests share one in-flight refresh operation.
## Backend JWT validation and RBAC
- JWTs are verified against:
- issuer: `KEYCLOAK_ISSUER_URL`
- audience: `KEYCLOAK_AUDIENCE`
- JWKS resolution priority:
1. explicit `KEYCLOAK_JWKS_URL`
2. OIDC discovery (`/.well-known/openid-configuration`)
3. fallback `${issuer}/protocol/openid-connect/certs`
- RBAC source is only `realm_access.roles`.
Role policy applied to CRUD endpoints:
- `GET`: `viewer`, `editor`, `admin`
- `POST`, `PATCH` (and `PUT` if added): `editor`, `admin`
- `DELETE`: `admin`
Public route:
- `GET /health`
All other existing API routes require authentication.

View File

@@ -1,3 +1,68 @@
This directory defines the AI generation context. This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline.
All code generation must follow the rules described in these documents. It is not a new generator engine and it is not a compiler platform. The repository remains:
- an AI generation context
- an active generated and maintained fullstack CRUD project
- `server/` as the active backend target output path
- `client/` as the active frontend target output path
- an LLM-first orchestration baseline with CLI-first framework bootstrap
- a compact rule set that strengthens the existing pipeline with:
- `domain-summary.json`
- a physical root-level realm artifact
- a lightweight automated validation gate
## Active knowledge blocks
The active prompt corpus is intentionally normalized to six stable blocks:
1. [prompts/general-prompt.md](prompts/general-prompt.md)
2. [prompts/auth-rules.md](prompts/auth-rules.md)
3. [prompts/backend-rules.md](prompts/backend-rules.md)
4. [prompts/frontend-rules.md](prompts/frontend-rules.md)
5. [prompts/runtime-rules.md](prompts/runtime-rules.md)
6. [prompts/validation-rules.md](prompts/validation-rules.md)
## Baseline contracts
- `domain/*.dsl` is the source of truth for the domain model.
- [domain-summary.json](domain-summary.json) is a derived artifact for LLM stabilization and validation.
- [toir-realm.json](toir-realm.json) is the physical Keycloak bootstrap artifact baseline.
- `server/` and `client/` are the active target output paths for this repository.
- `server/` must remain a valid NestJS workspace baseline.
- `client/` must remain a valid Vite React TypeScript workspace baseline.
## Scaffold baseline
- Generation remains LLM-first for orchestration and domain-derived feature code.
- Framework bootstrap is CLI-first:
- backend baseline starts from official Nest CLI conventions
- frontend baseline starts from official Vite React TypeScript conventions
- If `server/` or `client/` drift away from a valid workspace, repair the workspace baseline before generating more feature code.
- Do not replace the framework workspace with a hand-written minimal skeleton.
## Anti-regression contract
- The active prompts define forbidden generation patterns, required invariants, and recovery rules for future agents.
- Buildability is part of the baseline contract, not an optional follow-up.
- Validation targets `domain/*.dsl` as reusable source inputs, while TOiR names remain project defaults/examples.
## Repository layout
- [docs/repository-structure.md](docs/repository-structure.md) explains the normalized folder structure.
- Active prompts live in `prompts/`.
- Helper scripts live in `tools/`.
## Commands
```bash
npm run generate:domain-summary
npm run validate:generation
npm run validate:generation:runtime
```
`npm run validate:generation` now checks both contract shape and workspace validity. When dependencies are installed, it also verifies `npm run build` in `server/` and `client/`. If dependencies are missing, it reports build verification as skipped instead of pretending the baseline is fully green.
## AID export (OpenAPI + app generator)
HTTP-экспортёры для интеграции с AID: **`POST /aid/export/openapi`** (api-format → OpenAPI 3.0) и **`POST /aid/export/app`** (DSL → бандл файлов или `--apply`). Подробно: **[docs/AID_EXPORT_README.md](docs/AID_EXPORT_README.md)**.

View File

@@ -1,120 +0,0 @@
# Backend Auth Rules
This document defines mandatory backend authentication and authorization behavior for generated applications.
---
# Generated Backend Auth Surface
The generator must create explicit auth infrastructure in the generated NestJS backend.
At minimum generate:
- `server/src/auth/auth.module.ts`
- JWT auth guard
- roles guard
- `@Public()`
- `@Roles()`
- typed authenticated principal interface
- typed environment validation in `server/src/config/`
The generator must not describe backend auth as an external manual integration step.
---
# Standards-Based JWT Verification
The generated backend must validate JWTs against Keycloak using standards-based libraries.
Rules:
1. Verify **issuer**
2. Verify **audience**
3. Verify signature via **JWKS**
4. Do **not** use deprecated Keycloak-specific Node adapters such as `keycloak-connect`
The default library rule for this repository is:
- **`jose`** for JWT and JWKS verification
---
# JWT Verification Contract
The generated backend must verify tokens with:
- `KEYCLOAK_ISSUER_URL`
- `KEYCLOAK_AUDIENCE`
JWKS resolution priority must be exactly:
1. explicit `KEYCLOAK_JWKS_URL`
2. OIDC discovery
3. fallback `${issuer}/protocol/openid-connect/certs`
The generator must encode this priority explicitly.
---
# Role Extraction
Authorization roles must be extracted **only** from:
- `realm_access.roles`
The generator must not mix in:
- resource roles
- custom frontend-only permissions
- undocumented claim fallbacks
`realm_access.roles` is the single RBAC source for this repository.
---
# Default RBAC Policy
Apply these RBAC defaults to generated CRUD controllers:
- `GET`: `viewer`, `editor`, `admin`
- `POST`: `editor`, `admin`
- `PATCH`: `editor`, `admin`
- `PUT`: `editor`, `admin`
- `DELETE`: `admin`
`GET /health` must remain public and must use the generated `@Public()` mechanism.
All other generated CRUD routes must be protected by default.
---
# Typed Principal
The generated backend must attach a typed authenticated principal to the request context.
At minimum, the generated principal type must be able to represent:
- `sub`
- user identity fields when present
- `roles`
- raw claims payload
This principal type is required so guards, controllers, and tests share one consistent contract.
---
# Backend Environment Contract
The generated backend env contract must include:
- `PORT`
- `DATABASE_URL`
- `CORS_ALLOWED_ORIGINS`
- `KEYCLOAK_ISSUER_URL`
- `KEYCLOAK_AUDIENCE`
- `KEYCLOAK_JWKS_URL` (optional)
The generated backend config must **fail fast** if required auth variables are missing.
The generated backend must not silently fall back to production auth settings in code.

View File

@@ -1,134 +0,0 @@
# Frontend Auth Rules
This document defines mandatory frontend authentication behavior for generated applications.
---
# Generated Files
The generator must create at minimum:
- `client/src/config/env.ts`
- `client/src/auth/keycloak.ts`
- `client/src/auth/authProvider.ts`
- `client/src/App.tsx`
- `client/src/main.tsx`
- `client/src/dataProvider.ts`
- `client/.env.example`
`App.tsx`, `main.tsx`, and `dataProvider.ts` must be generated in an auth-aware form. Auth must not be bolted on later.
---
# Login Model
The generated frontend must use:
- **Keycloak JS**
- **Authorization Code Flow + PKCE**
- **PKCE method `S256`**
- **redirect-based login only**
The generator must **not** create a custom in-app username/password login form.
The generated SPA must initialize Keycloak **before rendering** the application. The app must not operate anonymously once auth is enabled.
---
# React Admin Integration
The generated frontend must use a React Admin **`authProvider`** and connect it at the `Admin` root.
Rules:
1. `authProvider` is mandatory.
2. The generated `Admin` root must enforce authenticated operation.
3. Auth bootstrap must happen before rendering `Admin`.
The generated frontend must not rely on anonymous access with later lazy auth attachment.
---
# Shared Request Seam
The generated frontend must use the shared request seam in `client/src/dataProvider.ts` as the single place where access tokens are attached.
Rules:
1. All backend requests must carry `Authorization: Bearer <access_token>`.
2. This must cover all React Admin calls, including:
- list
- get one
- get many
- get many reference
- create
- update
- delete
3. Reference/resource lookups must flow through the same authenticated request seam.
The generator must not scatter token attachment across resource components.
---
# Error Semantics
The generated `authProvider.checkError` must distinguish authentication failures from authorization failures:
- `401` -> force logout / re-authentication
- `403` -> do **not** re-authenticate; surface access denied / permission error to React Admin
The generator must not treat `401` and `403` as equivalent.
---
# Token Refresh
The generated frontend must refresh tokens before protected API calls when needed.
Refresh behavior must be **concurrency-safe**:
- use one shared in-flight refresh operation
- parallel requests must wait for the same refresh promise
- do not trigger multiple parallel refresh requests for the same expiry window
The generator must explicitly describe or implement the shared in-flight refresh pattern.
---
# Browser Storage Rules
The generated frontend must **not** store access tokens or refresh tokens in:
- `localStorage`
- `sessionStorage`
In-memory handling via Keycloak JS behavior is the default rule for this repository.
---
# Frontend Environment Contract
The generated frontend env contract must include:
- `VITE_API_URL`
- `VITE_KEYCLOAK_URL`
- `VITE_KEYCLOAK_REALM`
- `VITE_KEYCLOAK_CLIENT_ID`
The generated frontend config module must **fail fast** if required auth variables are missing.
The generated frontend must not silently fall back to production auth settings in code.
---
# Default Values for Examples
`client/.env.example` must use these repository defaults as examples:
```env
VITE_API_URL=http://localhost:3000
VITE_KEYCLOAK_URL=https://sso.greact.ru
VITE_KEYCLOAK_REALM=toir
VITE_KEYCLOAK_CLIENT_ID=toir-frontend
```

View File

@@ -1,89 +0,0 @@
# Keycloak Architecture
This repository generates a fullstack CRUD application with **default Keycloak authentication and authorization**. Authentication is not an optional add-on and must not be described as a manual post-generation task.
---
# Default Auth Model
The generated application must use this runtime topology:
- **Frontend:** SPA served by Vite + React Admin
- **Backend:** NestJS API
- **Auth transport:** `Authorization: Bearer <access_token>`
- **Identity provider:** external Keycloak server
The generated application must **not** use:
- a BFF layer
- cookie/session authentication
- a custom in-app username/password login form
The generated application must use **redirect-based Keycloak login** only.
---
# Auth Is Part of Default Generation
The generator must produce:
- authenticated frontend bootstrap and request flow
- authenticated backend bootstrap and request guards
- auth-aware env examples
- auth-aware validation rules
- a root-level Keycloak realm import artifact that describes the Keycloak realm/client bootstrap
Repository defaults may use names such as `toir`, `toir-frontend`, `toir-backend`, and `toir-realm.json`, but future generations must parameterize realm name, client IDs, production URLs, and artifact filename from the generated project or explicit auth configuration.
The generated application must not require a separate prompt step such as "now add auth manually".
---
# Public and Protected Routes
The generated backend must keep:
- `GET /health` — public
All generated CRUD routes must be protected by default.
Authorization must be role-based and must use **only** Keycloak realm roles from `realm_access.roles`.
---
# Default Role Policy
Apply these RBAC defaults to generated CRUD controllers:
- `GET` list/detail: `viewer`, `editor`, `admin`
- `POST`, `PATCH`, `PUT`: `editor`, `admin`
- `DELETE`: `admin`
The frontend may use roles for display and permission awareness, but the backend remains the enforcement point.
---
# Token Contract
Generated auth context must guarantee that access tokens used by the SPA and API reliably contain:
- `sub`
- `aud`
- `realm_access.roles`
The frontend client and backend audience/client must be documented explicitly. The generator must not rely on undocumented Keycloak defaults for these claims.
---
# Runtime Boundaries
The repository's local runtime topology remains unchanged:
- `docker-compose.yml` provisions PostgreSQL only
- Keycloak remains external to this repo's Docker runtime
The generator must therefore produce:
- runtime documentation for importing/configuring the generated realm import artifact
- env contracts for frontend and backend
- validation rules that assume an external but reachable Keycloak server

View File

@@ -1,152 +0,0 @@
# Keycloak Realm Template Rules
This document defines the required Keycloak realm/bootstrap artifact that the generator must produce.
---
# Required Artifact
The generator must create a root-level Keycloak import artifact:
- project-specific realm import artifact
- repository default example filename: `toir-realm.json`
This artifact is part of the normal generated result and must be documented in the runtime bootstrap flow.
The generator must parameterize at minimum:
- realm name
- frontend SPA client ID
- backend audience/resource client ID
- production frontend URLs
- realm-artifact filename
Repository defaults may use `toir`, `toir-frontend`, `toir-backend`, `toir-realm.json`, and `https://toir-frontend.greact.ru` as examples, but those names must not be treated as universal requirements for all future generated applications.
---
# Strategy Choice
This repository uses the **robust bootstrap strategy** by default.
That means the generated realm/client setup must be:
- self-contained
- reproducible after import
- explicit about audience delivery
- explicit about role delivery
- explicit about required claims
The generator must **not** rely on built-in client scopes magically existing and attaching correctly after realm import.
---
# Required Realm Structure
The generated realm import artifact must define:
- realm name derived from generated project auth config
- realm roles:
- `admin`
- `editor`
- `viewer`
- frontend SPA client ID derived from generated project auth config
- backend audience/resource client ID derived from generated project auth config
- dedicated audience client scope for audience delivery
- repository default example: `api-audience`
The audience client scope must explicitly add the generated backend audience/client ID to SPA access tokens.
---
# Required Frontend Client Rules
The generated SPA client must be configured as a public SPA client with:
- `publicClient: true`
- `standardFlowEnabled: true`
- `implicitFlowEnabled: false`
- `directAccessGrantsEnabled: false`
- `serviceAccountsEnabled: false`
- `pkce.code.challenge.method: S256`
Default rule:
- keep `fullScopeAllowed: true`
Reason:
- this repository uses realm-role RBAC
- earlier failures came from fragile or implicit scope behavior
- the robust default is explicit audience delivery plus explicit claim mappers, not a partially documented scoped-role setup
If a future prompt chooses `fullScopeAllowed: false`, it must also fully document role scope mappings and validation rules. The generator must not switch to that strategy silently.
---
# Required Claim Delivery
The generated realm/client configuration must reliably produce access tokens containing:
- `sub`
- `aud`
- `realm_access.roles`
The generator must explicitly address claim delivery and must not leave it implicit.
The generated realm import artifact must therefore define explicit protocol mappers for:
- `sub`
- user identity claims needed by the frontend
- `realm_access.roles`
The generator must not assume these claims will appear through undeclared built-in scopes.
Post-generation validation must fail if the generated realm contract leaves `sub`, `aud`, or `realm_access.roles` implicit instead of explicitly delivered.
---
# Required Role Delivery
The generated realm/client configuration must explicitly deliver realm roles into the access token.
Rules:
1. Role delivery must be configured intentionally.
2. `realm_access.roles` must be present in the access token after import.
3. The generator must validate that realm role delivery is part of the generated realm artifact.
---
# Redirect URIs and Web Origins
The generated realm template guidance must document local and production frontend URLs derived from the generated project configuration.
Repository default production examples may include:
- `http://localhost:5173`
- `https://toir-frontend.greact.ru`
The generated SPA client must include matching redirect URIs and web origins for the generated local and production frontend URLs. These values must be explicit in the generated realm artifact or in a generated artifact template with explicit placeholders and validation instructions.
---
# Anti-Regression Rule
The earlier broken realm-template behavior must not be reproduced.
The generator context must explicitly prevent:
- missing audience in SPA access tokens
- missing `sub`
- missing `realm_access.roles`
- realm imports that depend on undeclared built-in scopes being present
- ambiguous role-delivery behavior
The generated realm/bootstrap guidance must be self-contained enough that another engineer can import the artifact and predict the access-token shape without relying on hidden Keycloak defaults.
The generator must guarantee through the generated realm artifact plus post-generation validation that imported access tokens reliably contain:
- `sub`
- `aud`
- `realm_access.roles`

View File

@@ -1,301 +0,0 @@
# Backend Architecture
Backend stack:
- Node.js
- TypeScript
- NestJS
- Prisma ORM
- PostgreSQL
- jose
The backend is generated from the DSL specification.
Each DSL entity becomes:
- Prisma model
- NestJS module
- CRUD controller
- Service
- DTO definitions
---
# Project Structure
server/
package.json
prisma/
schema.prisma
src/
main.ts
app.module.ts
auth/
auth.module.ts
guards/
decorators/
interfaces/
config/
env.validation.ts
modules/
{entity}/
{entity}.module.ts
{entity}.controller.ts
{entity}.service.ts
dto/
create-{entity}.dto.ts
update-{entity}.dto.ts
{entity}.response.dto.ts
---
# Module Rules
Each entity generates exactly one NestJS module.
Example:
Entity:
Equipment
Module:
modules/
equipment/
equipment.module.ts
equipment.controller.ts
equipment.service.ts
---
# Controller Rules
Each entity controller must expose these endpoints:
- GET /{resource} — list
- GET /{resource}/:pk — get one
- POST /{resource} — create
- PATCH /{resource}/:pk — update
- DELETE /{resource}/:pk — delete
The path parameter **:pk** must use the **primary key attribute name** from the DSL, not always `:id`.
## Path parameter by primary key
| Entity | Primary key (DSL) | Path parameter | Example routes |
| ------------- | ----------------- | -------------- | -------------------------------------------------------- |
| Equipment | id | :id | GET /equipment/:id, PATCH /equipment/:id |
| EquipmentType | code | :code | GET /equipment-types/:code, PATCH /equipment-types/:code |
| RepairOrder | id | :id | GET /repair-orders/:id, PATCH /repair-orders/:id |
**Rule:** Use the actual primary key name. For example, **EquipmentType** has PK `code`, so routes must be:
- GET /equipment-types/:code
- PATCH /equipment-types/:code
- DELETE /equipment-types/:code
**Do not** use `/equipment-types/:id` when the entity primary key is `code`.
---
# Health Endpoint
Every generated backend must expose a **health endpoint** so that runtime and orchestration can verify the API is up.
- **Path:** `GET /health`
- **Response:** JSON with a status indicator (e.g. `{ "status": "ok" }`).
## Controller example
```typescript
@Public()
@Controller("health")
export class HealthController {
@Get()
getHealth() {
return { status: "ok" };
}
}
```
Register the health controller in the root app module (or a dedicated health module). No authentication required for this endpoint.
---
# Authentication and Authorization
The generated backend must include explicit auth infrastructure by default.
Generate:
- `AuthModule`
- JWT guard
- roles guard
- `@Public()`
- `@Roles()`
- typed authenticated principal
- typed env validation for auth/runtime variables
Rules:
1. `/health` must remain public.
2. Generated CRUD routes must be protected by default.
3. JWT verification must use issuer + audience + JWKS.
4. Authorization roles must be extracted only from `realm_access.roles`.
5. The generated backend must not use deprecated Keycloak-specific Node adapters.
## CRUD RBAC defaults
Apply these defaults to generated CRUD controllers:
- `GET` -> `viewer`, `editor`, `admin`
- `POST` -> `editor`, `admin`
- `PATCH` -> `editor`, `admin`
- `PUT` -> `editor`, `admin`
- `DELETE` -> `admin`
These defaults must be encoded in generated guards/decorators, not left as informal guidance.
---
# List Endpoint
List endpoint must support pagination and filters via query parameters.
Example:
GET /equipment?page=0&size=10
Response format must follow React Admin requirements:
{
"data": [],
"total": number
}
---
# Service Layer
Services implement CRUD operations using Prisma.
Example:
findAll()
findOne(id)
create(data)
update(id, data)
remove(id)
---
# DTO Rules
Create DTO:
- contains required fields
- does NOT contain generated primary keys
Update DTO:
- all fields optional
Response DTO:
- mirrors domain entity attributes
---
# Naming Conventions
## Entity naming
DSL entities use PascalCase. Generated backend artifacts use the same base name in lowercase for folders and file prefixes.
- **Equipment** → equipment module
- **EquipmentType** → equipment-type module (or equipment-type as path segment)
- **RepairOrder** → repair-order module
## Module naming
One entity = one module folder. Folder name = entity name in kebab-case (lowercase, hyphen-separated).
- Equipment → `modules/equipment/`
- EquipmentType → `modules/equipment-type/`
- RepairOrder → `modules/repair-order/`
## Controller naming
- File: `{entity-kebab}.controller.ts`
- Class: `EquipmentController`, `EquipmentTypeController`, `RepairOrderController`
Examples:
- `equipment.controller.ts`
- `equipment-type.controller.ts`
- `repair-order.controller.ts`
## Service naming
- File: `{entity-kebab}.service.ts`
- Class: `EquipmentService`, `EquipmentTypeService`, `RepairOrderService`
Examples:
- `equipment.service.ts`
- `equipment-type.service.ts`
- `repair-order.service.ts`
## DTO naming
- Create: `create-{entity-kebab}.dto.ts` (e.g. `create-equipment.dto.ts`, `create-repair-order.dto.ts`)
- Update: `update-{entity-kebab}.dto.ts` (e.g. `update-equipment.dto.ts`)
- Response: `{entity-kebab}.response.dto.ts` or use entity name for list/detail response types
---
# Resource Naming Rules
API resource paths are derived from the entity name:
1. **PascalCase → kebab-case:** Replace camelCase with lowercase hyphenated segments.
2. **Pluralize:** Use plural form for the resource path (list endpoint represents a collection).
| Entity (DSL) | API path (resource) |
| ------------- | ------------------- |
| Equipment | /equipment |
| EquipmentType | /equipment-types |
| RepairOrder | /repair-orders |
Rules:
- **Equipment** → `equipment` (already singular-looking; path is still `/equipment` for consistency with REST resource naming).
- **EquipmentType** → `equipment-types` (camelCase "EquipmentType" → "equipment-type", then plural → "equipment-types").
- **RepairOrder** → `repair-orders` ("RepairOrder" → "repair-order" → "repair-orders").
Standard endpoints per resource:
- GET /{resource} — list
- GET /{resource}/:pk — get one (pk = primary key name, e.g. :id or :code)
- POST /{resource} — create
- PATCH /{resource}/:pk — update
- DELETE /{resource}/:pk — delete
See **Controller Rules** above for the rule that :pk must match the entity's primary key attribute name.
---
# Environment and runtime
- **Environment variables:** Backend requires runtime and auth variables. See **backend/runtime-rules.md**.
- **.env:** Generated project must include a `.env` (and `.env.example`) with database, auth, and CORS variables so the app starts without runtime errors.
- **PrismaService:** Must follow **backend/prisma-service.md** (OnModuleInit, $connect; no beforeExit).
- **Prisma client:** Add `"postinstall": "prisma generate"` (or equivalent) to package.json so the client is generated after install.
- **Migrations:** Document or run `npx prisma migrate dev` after schema generation. See **backend/runtime-rules.md** and **generation/backend-generation.md**.

View File

@@ -1,70 +0,0 @@
# Database Runtime
The generated project must include a **PostgreSQL development database** so the application can run immediately after generation without manual database setup.
Use **Docker** to provision the database.
---
# docker-compose.yml
The generator must create a `docker-compose.yml` file at the **project root** (same level as `server/` and `client/` directories).
## Example
```yaml
version: "3.9"
services:
postgres:
image: postgres:16
container_name: toir-postgres
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: toir
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
```
---
# Rules
- **Location:** Project root (e.g. `TOiR-generation/docker-compose.yml` or monorepo root).
- **Service name:** Can be `postgres` or project-specific (e.g. `toir-postgres` as container_name).
- **Credentials and DB name** must match the `DATABASE_URL` in `server/.env`:
- User: `postgres`
- Password: `postgres`
- Database: `toir`
- Host: `localhost`
- Port: `5432`
- **Volume:** Use a named volume so data persists across container restarts.
---
# Usage
Start the database before running the backend:
```bash
docker compose up -d
```
Stop:
```bash
docker compose down
```
Without this file and a running database, the backend will fail at runtime with errors such as:
```
PrismaClientInitializationError P1001: Can't reach database server at localhost:5432
```

View File

@@ -1,166 +0,0 @@
# DSL → Prisma Mapping Rules
The domain DSL defines the database schema.
Each DSL entity becomes a Prisma model.
---
# Type Mapping (Prisma schema)
| DSL Type | Prisma Type |
|--------|-------------|
| string | String |
| uuid | String @id @default(uuid()) |
| integer | Int |
| decimal | Decimal |
| date | DateTime |
| text | String |
---
# DTO Type Mapping (API / serialization)
For DTOs and API responses, use types that serialize to JSON without errors. Prisma's `Decimal` and `DateTime` do not always serialize predictably; map them as follows in generated DTOs:
| DSL Type | Prisma Type | DTO / API type | Notes |
|----------|-------------|----------------|-------|
| string | String | string | — |
| uuid | String | string | — |
| integer | Int | number | — |
| decimal | Decimal | **string** | Avoid Prisma Decimal in DTO; use string to prevent serialization issues. |
| date | DateTime | **string** | ISO 8601 date string (e.g. `"2025-03-08T00:00:00.000Z"`). |
| text | String | string | — |
| enum | enum | string | Enum value as string. |
**Rules:**
- **decimal** → In create/update/response DTOs, use `string` (or a type that serializes to string). Convert to/from Prisma `Decimal` only in the service layer.
- **date** → In DTOs, use `string` (ISO date). Convert to/from Prisma `DateTime` in the service layer.
---
# Enum Mapping
DSL enum becomes Prisma enum.
Example:
DSL
enum EquipmentStatus {
Active
Repair
}
Prisma
enum EquipmentStatus {
Active
Repair
}
---
# Foreign Keys
DSL foreign keys become Prisma relations.
Example:
DSL
attribute equipmentTypeCode {
type string;
key foreign {
relates EquipmentType.code;
}
}
Prisma
equipmentTypeCode String
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
---
# Primary Keys
Primary keys defined in DSL must become Prisma model identifiers.
Example:
attribute id {
type uuid;
key primary;
}
Prisma
id String @id @default(uuid())
---
# Prisma Service Rules
Use Prisma v5 compatible service. See **backend/prisma-service.md** for full specification.
- PrismaService MUST implement `OnModuleInit` and call `await this.$connect()` in `onModuleInit()`.
- PrismaService MUST NOT use the deprecated `this.$on('beforeExit', ...)` hook (not supported in Prisma 5).
---
# Prisma Lifecycle Rules
These lifecycle rules must be applied in generated projects so Prisma runtime is reliable after generation and after dependency installation.
## 1) Generate Prisma client after schema changes
Whenever `prisma/schema.prisma` is generated or updated, run:
```bash
npx prisma generate
```
## 2) postinstall lifecycle
Generated `server/package.json` must include:
```json
{
"scripts": {
"postinstall": "prisma generate"
}
}
```
This ensures Prisma client generation after `npm install`.
## 3) Migration lifecycle
Use development migration workflow:
```bash
npx prisma migrate dev
```
Run this after database startup and before starting the backend server.
## 4) Seed lifecycle
If seed is configured (see `backend/seed-rules.md`), run:
```bash
npx prisma db seed
```
Generated `server/package.json` should include:
```json
{
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}
```
Use `tsx prisma/seed.ts` if the project standard uses `tsx` instead of `ts-node`.

View File

@@ -1,80 +0,0 @@
# PrismaService Implementation
The generated backend must provide a NestJS injectable service that wraps PrismaClient. This document defines the **only** supported implementation for Prisma 5+.
---
# Correct Implementation
```typescript
import { Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
```
## Why this pattern
- **OnModuleInit:** NestJS lifecycle hook; `onModuleInit()` runs when the module is initialized, ensuring the database connection is established before handling requests.
- **$connect():** Explicitly connects the Prisma client. Required for reliable connection handling in serverless or long-running apps.
---
# Deprecated: Do NOT Use
The following pattern is **deprecated** in Prisma 5 and must **not** be generated:
```typescript
// WRONG — do not generate
this.$on("beforeExit", async () => {
await this.$disconnect();
});
```
- `beforeExit` is not supported in Prisma 5.
- Using it will cause runtime errors or warnings.
## Rule for generators
Generated `PrismaService` (or equivalent) must:
1. Extend `PrismaClient`.
2. Implement `OnModuleInit`.
3. Call `await this.$connect()` in `onModuleInit()`.
4. **Not** use `this.$on('beforeExit', ...)` or any `beforeExit` hook.
---
# Module Registration
Register the service in a dedicated Prisma module (or in `AppModule`) so it can be injected into other services:
```typescript
// prisma.module.ts (or app.module.ts)
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
```
Using `@Global()` allows injecting `PrismaService` in any module without re-importing.
---
# File location
Generated file:
- `src/prisma.prisma.service.ts` or `src/prisma.service.ts`
Class name: `PrismaService`.

View File

@@ -1,152 +0,0 @@
# Backend Runtime Rules
This document defines runtime configuration requirements for the generated backend. Generators must produce a project that runs without errors when these rules are followed.
---
# Environment Variables
The backend **must** have a `.env` file at the project root (e.g. `server/.env` or backend package root). This file must **not** be committed with real credentials; provide an example instead (e.g. `.env.example`).
## Required variables
| Variable | Required | Description |
| -------------------- | -------- | ----------- |
| PORT | Yes | Backend listen port |
| DATABASE_URL | Yes | PostgreSQL connection string for Prisma |
| CORS_ALLOWED_ORIGINS | Yes | Comma-separated SPA origins allowed to call the API |
| KEYCLOAK_ISSUER_URL | Yes | Keycloak issuer URL used for JWT verification |
| KEYCLOAK_AUDIENCE | Yes | Backend audience/client expected in access tokens |
| KEYCLOAK_JWKS_URL | No | Optional explicit JWKS URL |
## Example
```env
PORT=3000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"
CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"
KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"
KEYCLOAK_AUDIENCE="toir-backend"
# KEYCLOAK_JWKS_URL="https://sso.greact.ru/realms/toir/protocol/openid-connect/certs"
```
## Generation requirement
When generating the backend, **always** create:
1. **`.env.example`** — with placeholder DATABASE_URL and instructions.
2. **`.env`** — with local development example values so the app can start.
If the generator does not create `.env`, the first run will fail with:
```
Environment variable not found: DATABASE_URL
```
## Fail-fast requirement
The generated backend must validate required runtime and auth variables at startup.
Rules:
1. Missing required auth variables must stop startup immediately.
2. The generated backend must not silently fall back to production auth settings in code.
3. Auth env validation must be implemented explicitly in generated config/bootstrap code.
---
# Authentication Runtime Rules
The generated backend must verify JWTs against Keycloak using:
- issuer
- audience
- JWKS
JWKS resolution priority must be exactly:
1. explicit `KEYCLOAK_JWKS_URL`
2. OIDC discovery
3. `${issuer}/protocol/openid-connect/certs`
The generated backend must extract authorization roles only from `realm_access.roles`.
---
# Prisma Client Generation
After the Prisma schema is generated or modified, the Prisma client must be generated.
## Command
```bash
npx prisma generate
```
## Lifecycle rule
Add to generated `package.json` so that `prisma generate` runs after every `npm install`:
```json
{
"scripts": {
"postinstall": "prisma generate"
}
}
```
This ensures that after cloning or installing dependencies, the Prisma client is available without a manual step.
**Note:** Path may need to be adjusted if Prisma schema lives in a subfolder (e.g. `prisma generate` is typically run from the package root where `prisma/schema.prisma` exists).
---
# Database Migration
The generation process must document and support database migration.
## Command
```bash
npx prisma migrate dev
```
Run after:
1. Prisma schema has been generated or updated.
2. `npx prisma generate` has been run.
## Generation requirement
1. Include migration in the backend generation pipeline (see `generation/backend-generation.md`).
2. Document in README or post-generation validation that the user must run `npx prisma migrate dev` before first run (or provide a setup script that runs it).
---
# CORS Rules
The generated backend must support the SPA -> API bearer-token model explicitly.
Rules:
1. Use `CORS_ALLOWED_ORIGINS` as the allowed origin list.
2. Allow at minimum these example origins in `.env.example`:
- `http://localhost:5173`
- `https://toir-frontend.greact.ru`
3. Allow `Authorization` and JSON content headers.
4. Expose `Content-Range` because React Admin depends on it.
5. Do not enable credentials by default.
---
# Summary
| Requirement | Action |
| ----------------------- | ------ |
| Runtime env | Create `.env` and `.env.example` with DB, auth, and CORS variables |
| Fail-fast config | Validate required auth/runtime variables at startup |
| Prisma client | Run `npx prisma generate`; add `postinstall` script |
| Database schema | Document/run `npx prisma migrate dev` after schema generation |
| JWT verification | Use issuer + audience + JWKS with explicit resolution priority |
| Role extraction | Use only `realm_access.roles` |
| CORS | Support SPA -> API bearer flow and expose `Content-Range` |

View File

@@ -1,82 +0,0 @@
# Seed Data Rules
The generator must create a **Prisma seed script** so the development database contains minimal sample data. This allows the frontend and API to be used immediately after migration.
---
# Seed Script Location
**File:** `server/prisma/seed.ts`
(Or `server/prisma/seed.js` if the project uses JavaScript; TypeScript is preferred and may require `ts-node` or `tsx` to run.)
---
# Seed Data Requirements
The seed script must create **minimal sample data** for at least one record per main entity, so that:
- List views show data.
- Reference fields (e.g. equipment type, equipment) have valid options.
- The app is demo-ready without manual data entry.
## Example scope (TOiR-style domain)
- **One EquipmentType** (e.g. code `"pump"`, name `"Pump"`).
- **One Equipment** (e.g. linked to that type, with required fields filled).
- **One RepairOrder** (e.g. linked to that equipment, with required fields filled).
Order matters: create EquipmentType first, then Equipment (references type), then RepairOrder (references equipment). Use Prisma `create` with the generated client; respect unique constraints and foreign keys.
---
# Prisma Seed Configuration
The generator must add the following to **`server/package.json`**:
```json
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
```
If the project uses a different runner (e.g. `tsx`), use that instead:
```json
"prisma": {
"seed": "tsx prisma/seed.ts"
}
```
Ensure the seed runner is installed (e.g. `ts-node` as dev dependency) so that:
```bash
npx prisma db seed
```
runs successfully.
---
# Running the Seed
After migrations:
```bash
cd server
npx prisma migrate dev
npx prisma db seed
```
Or document that the user can run `npx prisma db seed` once after the first migration to populate sample data.
---
# Summary
| Requirement | Action |
|--------------------|--------|
| Seed script | Create `server/prisma/seed.ts` (or equivalent). |
| Sample data | At least one EquipmentType, one Equipment, one RepairOrder (or equivalent for the DSL). |
| package.json | Add `"prisma": { "seed": "ts-node prisma/seed.ts" }` (or tsx). |
| Seed runner | Ensure ts-node (or tsx) is available so `prisma db seed` works. |

View File

@@ -1,97 +0,0 @@
# Service Layer Rules
Generated NestJS services must follow these rules so that update operations work correctly with React Admin and Prisma.
---
# Update Payload Sanitization
React Admin (and many REST clients) **always send `id`** in PATCH/PUT request bodies.
Example payload from React Admin:
```json
{
"id": "003",
"name": "Pump"
}
```
Some entities use a **different primary key** (e.g. `code`). The API response includes `id` (mapped from the PK) for React Admin compatibility, but the Prisma model may have no `id` field—only `code`. If the service passes the incoming DTO directly to Prisma:
```typescript
// WRONG — causes runtime error
prisma.equipmentType.update({
where: { code },
data: dto // dto contains "id", which is not a Prisma field
});
```
Prisma throws because `id` (and possibly other non-updatable fields) are not on the model or must not be written in `data`.
---
# Rules
1. **Update payload must be sanitized** before passing to the ORM. Do not pass the raw request body as `data` to `prisma.*.update()`.
2. **Remove `id`** from the update payload. React Admin sends `id` for identity; it must not be written to the database as a column (unless the entity actually has an `id` column and it is intended to be immutable on update).
3. **Remove the primary key** field from the update payload. The primary key is used in `where`; it must not appear in `data`. For example, remove `code` from `data` when updating by `code`.
4. **Remove readonly attributes** (e.g. created timestamps, server-generated fields) if they are present in the DTO, so they are not passed to Prisma `data`.
---
# Example Implementation
Destructure identity and primary key (and any other non-updatable fields) out of the DTO, then pass only the rest as `data`:
**Entity with primary key `code` (e.g. EquipmentType):**
```typescript
update(code: string, dto: UpdateEquipmentTypeDto) {
const { id, code: _pk, ...data } = dto as any;
return this.prisma.equipmentType.update({
where: { code },
data,
});
}
```
Or, if the DTO type does not include `id` or `code`, explicitly omit only the fields that must not be written:
```typescript
update(code: string, dto: UpdateEquipmentTypeDto) {
const { id, code: _pk, ...data } = dto as Record<string, unknown>;
return this.prisma.equipmentType.update({
where: { code },
data,
});
}
```
**Entity with primary key `id` (e.g. Equipment):**
```typescript
update(id: string, dto: UpdateEquipmentDto) {
const { id: _pk, ...data } = dto as any;
return this.prisma.equipment.update({
where: { id },
data,
});
}
```
This way, `id` (and the PK) are never passed into `data`, and Prisma does not receive unknown or read-only fields.
---
# Summary
| Rule | Action |
|------|--------|
| Sanitize update payload | Before `prisma.*.update()`, strip non-data fields from the DTO. |
| Remove `id` | Do not pass `id` in `data` unless the entity has an updatable `id` (rare). |
| Remove primary key | Use PK only in `where`; omit from `data`. |
| Remove readonly fields | Omit created_at, server-only fields, etc. from `data`. |

View File

@@ -1449,9 +1449,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1466,9 +1463,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1483,9 +1477,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1500,9 +1491,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1517,9 +1505,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1534,9 +1519,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1551,9 +1533,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1568,9 +1547,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1585,9 +1561,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1602,9 +1575,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1619,9 +1589,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1636,9 +1603,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1653,9 +1617,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },

View File

@@ -1,4 +1,4 @@
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';
@@ -9,12 +9,49 @@ const httpClient = async (url: string, options: fetchUtils.Options = {}) => {
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 {
return await fetchUtils.fetchJson(url, {
...options, ...options,
headers, headers,
}); });
} catch (error: unknown) {
const e = error as {
status?: number;
body?: {
message?: string | string[];
details?: unknown;
};
message?: string;
};
const messageFromBody = e?.body?.message;
const normalizedMessage = Array.isArray(messageFromBody)
? messageFromBody.join(', ')
: messageFromBody;
throw new HttpError(
normalizedMessage || e?.message || 'Request failed',
e?.status ?? 500,
e?.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 = { const dataProvider: DataProvider = {
getList: async (resource, params) => { getList: async (resource, params) => {
const { page, perPage } = params.pagination!; const { page, perPage } = params.pagination!;
@@ -22,23 +59,15 @@ const dataProvider: DataProvider = {
const start = (page - 1) * perPage; const start = (page - 1) * perPage;
const end = page * perPage; const end = page * perPage;
const query: Record<string, string> = { const query: Record<string, unknown> = {
_start: String(start), _start: start,
_end: String(end), _end: end,
_sort: field, _sort: field,
_order: order, _order: order,
...(params.filter ?? {}),
}; };
if (params.filter) { const queryString = buildQueryString(query);
Object.keys(params.filter).forEach((key) => {
const val = params.filter[key];
if (val !== undefined && val !== null && val !== '') {
query[key] = String(val);
}
});
}
const queryString = new URLSearchParams(query).toString();
const url = `${apiUrl}/${resource}?${queryString}`; const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url); const { json, headers } = await httpClient(url);
@@ -67,15 +96,16 @@ const dataProvider: DataProvider = {
const start = (page - 1) * perPage; const start = (page - 1) * perPage;
const end = page * perPage; const end = page * perPage;
const query: Record<string, string> = { const query: Record<string, unknown> = {
_start: String(start), _start: start,
_end: String(end), _end: end,
_sort: field, _sort: field,
_order: order, _order: order,
[params.target]: String(params.id), [params.target]: params.id,
...(params.filter ?? {}),
}; };
const queryString = new URLSearchParams(query).toString(); const queryString = buildQueryString(query);
const url = `${apiUrl}/${resource}?${queryString}`; const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url); const { json, headers } = await httpClient(url);

View File

@@ -1,13 +1,14 @@
import { Create, SimpleForm, TextInput, NumberInput } from 'react-admin'; import { Create, SimpleForm, TextInput, NumberInput } from 'react-admin';
export const EquipmentTypeCreate = () => ( export const EquipmentTypeCreate = () => (
<Create> <Create>
<SimpleForm> <SimpleForm>
<TextInput source="code" label="Код" isRequired /> <TextInput source="code" label="Код вида оборудования" isRequired />
<TextInput source="name" label="Наименование" isRequired /> <TextInput source="name" label="Наименование вида" isRequired />
<TextInput source="manufacturer" label="Производитель" /> <TextInput source="manufacturer" label="Производитель" />
<NumberInput source="maintenanceIntervalHours" label="Периодичность ТО (ч)" /> <NumberInput source="maintenanceIntervalHours" label="Периодичность ТО, моточасов" />
<NumberInput source="overhaulIntervalHours" label="Периодичность КР (ч)" /> <NumberInput source="overhaulIntervalHours" label="Периодичность КР, моточасов" />
</SimpleForm> </SimpleForm>
</Create> </Create>
); );

View File

@@ -1,13 +1,14 @@
import { Edit, SimpleForm, TextInput, NumberInput } from 'react-admin'; import { Edit, SimpleForm, TextInput, NumberInput } from 'react-admin';
export const EquipmentTypeEdit = () => ( export const EquipmentTypeEdit = () => (
<Edit> <Edit>
<SimpleForm> <SimpleForm>
<TextInput source="code" label="Код" disabled /> <TextInput source="code" label="Код вида оборудования" disabled />
<TextInput source="name" label="Наименование" isRequired /> <TextInput source="name" label="Наименование вида" isRequired />
<TextInput source="manufacturer" label="Производитель" /> <TextInput source="manufacturer" label="Производитель" />
<NumberInput source="maintenanceIntervalHours" label="Периодичность ТО (ч)" /> <NumberInput source="maintenanceIntervalHours" label="Периодичность ТО, моточасов" />
<NumberInput source="overhaulIntervalHours" label="Периодичность КР (ч)" /> <NumberInput source="overhaulIntervalHours" label="Периодичность КР, моточасов" />
</SimpleForm> </SimpleForm>
</Edit> </Edit>
); );

View File

@@ -1,13 +1,38 @@
import { List, Datagrid, TextField, NumberField } from 'react-admin'; import {
List,
Datagrid,
TextField,
TextInput,
TopToolbar,
FilterButton,
CreateButton,
ExportButton,
NumberField
} from 'react-admin';
const equipmentTypeFilters = [
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
<TextInput key="name" source="name" label="Наименование вида" />,
<TextInput key="manufacturer" source="manufacturer" label="Производитель" />
];
const EquipmentTypeListActions = () => (
<TopToolbar>
<FilterButton filters={equipmentTypeFilters} />
<CreateButton />
<ExportButton />
</TopToolbar>
);
export const EquipmentTypeList = () => ( export const EquipmentTypeList = () => (
<List> <List actions={<EquipmentTypeListActions />} filters={equipmentTypeFilters} sort={{ field: 'code', order: 'ASC' }}>
<Datagrid rowClick="show"> <Datagrid rowClick="show">
<TextField source="code" label="Код" /> <TextField source="code" label="Код вида оборудования" />
<TextField source="name" label="Наименование" /> <TextField source="name" label="Наименование вида" />
<TextField source="manufacturer" label="Производитель" /> <TextField source="manufacturer" label="Производитель" />
<NumberField source="maintenanceIntervalHours" label="Периодичность ТО (ч)" /> <NumberField source="maintenanceIntervalHours" label="Периодичность ТО, моточасов" />
<NumberField source="overhaulIntervalHours" label="Периодичность КР (ч)" /> <NumberField source="overhaulIntervalHours" label="Периодичность КР, моточасов" />
</Datagrid> </Datagrid>
</List> </List>
); );

View File

@@ -3,11 +3,11 @@ import { Show, SimpleShowLayout, TextField, NumberField } from 'react-admin';
export const EquipmentTypeShow = () => ( export const EquipmentTypeShow = () => (
<Show> <Show>
<SimpleShowLayout> <SimpleShowLayout>
<TextField source="code" label="Код" /> <TextField source="code" label="Код вида оборудования" />
<TextField source="name" label="Наименование" /> <TextField source="name" label="Наименование вида" />
<TextField source="manufacturer" label="Производитель" /> <TextField source="manufacturer" label="Производитель" />
<NumberField source="maintenanceIntervalHours" label="Периодичность ТО (ч)" /> <NumberField source="maintenanceIntervalHours" label="Периодичность ТО, моточасов" />
<NumberField source="overhaulIntervalHours" label="Периодичность КР (ч)" /> <NumberField source="overhaulIntervalHours" label="Периодичность КР, моточасов" />
</SimpleShowLayout> </SimpleShowLayout>
</Show> </Show>
); );

View File

@@ -1,12 +1,4 @@
import { import { Create, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
Create,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
const statusChoices = [ const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' }, { id: 'Active', name: 'В эксплуатации' },
@@ -19,18 +11,18 @@ export const EquipmentCreate = () => (
<Create> <Create>
<SimpleForm> <SimpleForm>
<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 />
<ReferenceInput source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования"> <ReferenceInput source="equipmentTypeCode" reference="equipment-types">
<SelectInput optionText="name" optionValue="code" isRequired /> <AutocompleteInput label="Вид оборудования" optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput> </ReferenceInput>
<SelectInput source="status" label="Статус" choices={statusChoices} defaultValue="Active" /> <SelectInput source="status" label="Текущий статус" choices={statusChoices} emptyText="Не выбрано" />
<TextInput source="location" label="Место эксплуатации" /> <TextInput source="location" label="Место эксплуатации / скважина / куст" />
<DateInput source="commissionedAt" label="Дата ввода в эксплуатацию" /> <DateInput source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberInput source="totalEngineHours" label="Общая наработка (ч)" /> <NumberInput source="totalEngineHours" label="Общая наработка, моточасов" />
<NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта (ч)" /> <NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<DateInput source="lastRepairAt" label="Дата последнего ремонта" /> <DateInput source="lastRepairAt" label="Дата последнего ремонта" />
<TextInput source="notes" label="Примечания" multiline /> <TextInput source="notes" label="Примечания" />
</SimpleForm> </SimpleForm>
</Create> </Create>
); );

View File

@@ -1,12 +1,4 @@
import { import { Edit, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
Edit,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
const statusChoices = [ const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' }, { id: 'Active', name: 'В эксплуатации' },
@@ -18,19 +10,20 @@ const statusChoices = [
export const EquipmentEdit = () => ( export const EquipmentEdit = () => (
<Edit> <Edit>
<SimpleForm> <SimpleForm>
<TextInput source="id" label="id" disabled />
<TextInput source="inventoryNumber" label="Инвентарный номер" isRequired /> <TextInput source="inventoryNumber" label="Инвентарный номер" isRequired />
<TextInput source="serialNumber" label="Заводской номер" /> <TextInput source="serialNumber" label="Заводской (серийный) номер" />
<TextInput source="name" label="Наименование" isRequired /> <TextInput source="name" label="Наименование единицы оборудования" isRequired />
<ReferenceInput source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования"> <ReferenceInput source="equipmentTypeCode" reference="equipment-types">
<SelectInput optionText="name" optionValue="code" isRequired /> <AutocompleteInput label="Вид оборудования" optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput> </ReferenceInput>
<SelectInput source="status" label="Статус" choices={statusChoices} /> <SelectInput source="status" label="Текущий статус" choices={statusChoices} emptyText="Не выбрано" />
<TextInput source="location" label="Место эксплуатации" /> <TextInput source="location" label="Место эксплуатации / скважина / куст" />
<DateInput source="commissionedAt" label="Дата ввода в эксплуатацию" /> <DateInput source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberInput source="totalEngineHours" label="Общая наработка (ч)" /> <NumberInput source="totalEngineHours" label="Общая наработка, моточасов" />
<NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта (ч)" /> <NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<DateInput source="lastRepairAt" label="Дата последнего ремонта" /> <DateInput source="lastRepairAt" label="Дата последнего ремонта" />
<TextInput source="notes" label="Примечания" multiline /> <TextInput source="notes" label="Примечания" />
</SimpleForm> </SimpleForm>
</Edit> </Edit>
); );

View File

@@ -2,9 +2,18 @@ import {
List, List,
Datagrid, Datagrid,
TextField, TextField,
TextInput,
TopToolbar,
FilterButton,
CreateButton,
ExportButton,
NumberField, NumberField,
DateField,
SelectField, SelectField,
ReferenceField, ReferenceField,
SelectArrayInput,
ReferenceInput,
AutocompleteInput
} from 'react-admin'; } from 'react-admin';
const statusChoices = [ const statusChoices = [
@@ -14,17 +23,44 @@ const statusChoices = [
{ id: 'WriteOff', name: 'Списано' }, { id: 'WriteOff', name: 'Списано' },
]; ];
const equipmentFilters = [
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
<TextInput key="inventoryNumber" source="inventoryNumber" label="Инвентарный номер" />,
<TextInput key="serialNumber" source="serialNumber" label="Заводской (серийный) номер" />,
<TextInput key="name" source="name" label="Наименование единицы оборудования" />,
<ReferenceInput key="equipmentTypeCode" source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования">
<AutocompleteInput optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>,
<SelectArrayInput key="status" source="status" label="Текущий статус" choices={statusChoices} />,
<TextInput key="location" source="location" label="Место эксплуатации / скважина / куст" />,
<TextInput key="notes" source="notes" label="Примечания" />
];
const EquipmentListActions = () => (
<TopToolbar>
<FilterButton filters={equipmentFilters} />
<CreateButton />
<ExportButton />
</TopToolbar>
);
export const EquipmentList = () => ( export const EquipmentList = () => (
<List> <List actions={<EquipmentListActions />} filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}>
<Datagrid rowClick="show"> <Datagrid rowClick="show">
<TextField source="id" label="id" />
<TextField source="inventoryNumber" label="Инвентарный номер" /> <TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="name" label="Наименование" /> <TextField source="serialNumber" label="Заводской (серийный) номер" />
<TextField source="name" label="Наименование единицы оборудования" />
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show"> <ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<TextField source="name" /> <TextField source="code" />
</ReferenceField> </ReferenceField>
<SelectField source="status" label="Статус" choices={statusChoices} /> <SelectField source="status" label="Текущий статус" choices={statusChoices} />
<TextField source="location" label="Место эксплуатации" /> <TextField source="location" label="Место эксплуатации / скважина / куст" />
<NumberField source="totalEngineHours" label="Наработка (ч)" /> <DateField source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberField source="totalEngineHours" label="Общая наработка, моточасов" />
<NumberField source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<DateField source="lastRepairAt" label="Дата последнего ремонта" />
<TextField source="notes" label="Примечания" />
</Datagrid> </Datagrid>
</List> </List>
); );

View File

@@ -1,12 +1,4 @@
import { import { Show, SimpleShowLayout, TextField, NumberField, DateField, SelectField, ReferenceField } from 'react-admin';
Show,
SimpleShowLayout,
TextField,
NumberField,
DateField,
SelectField,
ReferenceField,
} from 'react-admin';
const statusChoices = [ const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' }, { id: 'Active', name: 'В эксплуатации' },
@@ -14,21 +6,21 @@ const statusChoices = [
{ id: 'Reserve', name: 'В резерве' }, { id: 'Reserve', name: 'В резерве' },
{ id: 'WriteOff', name: 'Списано' }, { id: 'WriteOff', name: 'Списано' },
]; ];
export const EquipmentShow = () => ( export const EquipmentShow = () => (
<Show> <Show>
<SimpleShowLayout> <SimpleShowLayout>
<TextField source="id" label="id" />
<TextField source="inventoryNumber" label="Инвентарный номер" /> <TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="serialNumber" label="Заводской номер" /> <TextField source="serialNumber" label="Заводской (серийный) номер" />
<TextField source="name" label="Наименование" /> <TextField source="name" label="Наименование единицы оборудования" />
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show"> <ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<TextField source="name" /> <TextField source="code" />
</ReferenceField> </ReferenceField>
<SelectField source="status" label="Статус" choices={statusChoices} /> <SelectField source="status" label="Текущий статус" choices={statusChoices} />
<TextField source="location" label="Место эксплуатации" /> <TextField source="location" label="Место эксплуатации / скважина / куст" />
<DateField source="commissionedAt" label="Дата ввода в эксплуатацию" /> <DateField source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberField source="totalEngineHours" label="Общая наработка (ч)" /> <NumberField source="totalEngineHours" label="Общая наработка, моточасов" />
<NumberField source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта (ч)" /> <NumberField source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<DateField source="lastRepairAt" label="Дата последнего ремонта" /> <DateField source="lastRepairAt" label="Дата последнего ремонта" />
<TextField source="notes" label="Примечания" /> <TextField source="notes" label="Примечания" />
</SimpleShowLayout> </SimpleShowLayout>

View File

@@ -1,12 +1,4 @@
import { import { Create, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
Create,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
const repairKindChoices = [ const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' }, { id: 'TO', name: 'Техническое обслуживание' },
@@ -29,18 +21,18 @@ export const RepairOrderCreate = () => (
<Create> <Create>
<SimpleForm> <SimpleForm>
<TextInput source="number" label="Номер заявки" isRequired /> <TextInput source="number" label="Номер заявки" isRequired />
<ReferenceInput source="equipmentId" reference="equipment" label="Оборудование"> <ReferenceInput source="equipmentId" reference="equipment">
<SelectInput optionText="name" isRequired /> <AutocompleteInput label="Оборудование" optionText={(record) => record.inventoryNumber ? `${record.inventoryNumber}${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput> </ReferenceInput>
<SelectInput source="repairKind" label="Вид ремонта" choices={repairKindChoices} isRequired /> <SelectInput source="repairKind" label="Вид ремонта" choices={repairKindChoices} emptyText="Не выбрано" />
<SelectInput source="status" label="Статус" choices={statusChoices} defaultValue="Draft" /> <SelectInput source="status" label="Статус" choices={statusChoices} emptyText="Не выбрано" />
<DateInput source="plannedAt" label="Плановая дата начала" isRequired /> <DateInput source="plannedAt" label="Плановая дата начала" />
<DateInput source="startedAt" label="Фактическая дата начала" /> <DateInput source="startedAt" label="Фактическая дата начала" />
<DateInput source="completedAt" label="Фактическая дата завершения" /> <DateInput source="completedAt" label="Фактическая дата завершения" />
<TextInput source="contractor" label="Подрядная организация" /> <TextInput source="contractor" label="Подрядная организация (если внешний ремонт)" />
<NumberInput source="engineHoursAtRepair" label="Наработка на момент ремонта (ч)" /> <NumberInput source="engineHoursAtRepair" label="Наработка на момент ремонта, моточасов" />
<TextInput source="description" label="Описание работ / дефекта" multiline /> <TextInput source="description" label="Описание работ / дефекта" />
<TextInput source="notes" label="Примечания" multiline /> <TextInput source="notes" label="Примечания" />
</SimpleForm> </SimpleForm>
</Create> </Create>
); );

View File

@@ -1,12 +1,4 @@
import { import { Edit, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
Edit,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
const repairKindChoices = [ const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' }, { id: 'TO', name: 'Техническое обслуживание' },
@@ -28,19 +20,20 @@ const statusChoices = [
export const RepairOrderEdit = () => ( export const RepairOrderEdit = () => (
<Edit> <Edit>
<SimpleForm> <SimpleForm>
<TextInput source="id" label="id" disabled />
<TextInput source="number" label="Номер заявки" isRequired /> <TextInput source="number" label="Номер заявки" isRequired />
<ReferenceInput source="equipmentId" reference="equipment" label="Оборудование"> <ReferenceInput source="equipmentId" reference="equipment">
<SelectInput optionText="name" isRequired /> <AutocompleteInput label="Оборудование" optionText={(record) => record.inventoryNumber ? `${record.inventoryNumber}${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput> </ReferenceInput>
<SelectInput source="repairKind" label="Вид ремонта" choices={repairKindChoices} isRequired /> <SelectInput source="repairKind" label="Вид ремонта" choices={repairKindChoices} emptyText="Не выбрано" />
<SelectInput source="status" label="Статус" choices={statusChoices} /> <SelectInput source="status" label="Статус" choices={statusChoices} emptyText="Не выбрано" />
<DateInput source="plannedAt" label="Плановая дата начала" isRequired /> <DateInput source="plannedAt" label="Плановая дата начала" />
<DateInput source="startedAt" label="Фактическая дата начала" /> <DateInput source="startedAt" label="Фактическая дата начала" />
<DateInput source="completedAt" label="Фактическая дата завершения" /> <DateInput source="completedAt" label="Фактическая дата завершения" />
<TextInput source="contractor" label="Подрядная организация" /> <TextInput source="contractor" label="Подрядная организация (если внешний ремонт)" />
<NumberInput source="engineHoursAtRepair" label="Наработка на момент ремонта (ч)" /> <NumberInput source="engineHoursAtRepair" label="Наработка на момент ремонта, моточасов" />
<TextInput source="description" label="Описание работ / дефекта" multiline /> <TextInput source="description" label="Описание работ / дефекта" />
<TextInput source="notes" label="Примечания" multiline /> <TextInput source="notes" label="Примечания" />
</SimpleForm> </SimpleForm>
</Edit> </Edit>
); );

View File

@@ -2,9 +2,19 @@ import {
List, List,
Datagrid, Datagrid,
TextField, TextField,
TextInput,
TopToolbar,
FilterButton,
CreateButton,
ExportButton,
NumberField,
DateField, DateField,
SelectField, SelectField,
ReferenceField, ReferenceField,
SelectArrayInput,
SelectInput,
ReferenceInput,
AutocompleteInput
} from 'react-admin'; } from 'react-admin';
const repairKindChoices = [ const repairKindChoices = [
@@ -24,17 +34,44 @@ const statusChoices = [
{ id: 'Cancelled', name: 'Отменена' }, { id: 'Cancelled', name: 'Отменена' },
]; ];
const repairOrderFilters = [
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
<TextInput key="number" source="number" label="Номер заявки" />,
<ReferenceInput key="equipmentId" source="equipmentId" reference="equipment" label="Оборудование">
<AutocompleteInput optionText={(record) => record.inventoryNumber ? `${record.inventoryNumber}${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>,
<SelectInput key="repairKind" source="repairKind" label="Вид ремонта" choices={repairKindChoices} emptyText="Все" />,
<SelectArrayInput key="status" source="status" label="Статус" choices={statusChoices} />,
<TextInput key="contractor" source="contractor" label="Подрядная организация (если внешний ремонт)" />,
<TextInput key="description" source="description" label="Описание работ / дефекта" />,
<TextInput key="notes" source="notes" label="Примечания" />
];
const RepairOrderListActions = () => (
<TopToolbar>
<FilterButton filters={repairOrderFilters} />
<CreateButton />
<ExportButton />
</TopToolbar>
);
export const RepairOrderList = () => ( export const RepairOrderList = () => (
<List> <List actions={<RepairOrderListActions />} filters={repairOrderFilters} sort={{ field: 'number', order: 'ASC' }}>
<Datagrid rowClick="show"> <Datagrid rowClick="show">
<TextField source="number" label="Номер" /> <TextField source="id" label="id" />
<TextField source="number" label="Номер заявки" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show"> <ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
<TextField source="name" /> <TextField source="inventoryNumber" />
</ReferenceField> </ReferenceField>
<SelectField source="repairKind" label="Вид ремонта" choices={repairKindChoices} /> <SelectField source="repairKind" label="Вид ремонта" choices={repairKindChoices} />
<SelectField source="status" label="Статус" choices={statusChoices} /> <SelectField source="status" label="Статус" choices={statusChoices} />
<DateField source="plannedAt" label="Плановая дата" /> <DateField source="plannedAt" label="Плановая дата начала" />
<TextField source="contractor" label="Подрядчик" /> <DateField source="startedAt" label="Фактическая дата начала" />
<DateField source="completedAt" label="Фактическая дата завершения" />
<TextField source="contractor" label="Подрядная организация (если внешний ремонт)" />
<NumberField source="engineHoursAtRepair" label="Наработка на момент ремонта, моточасов" />
<TextField source="description" label="Описание работ / дефекта" />
<TextField source="notes" label="Примечания" />
</Datagrid> </Datagrid>
</List> </List>
); );

View File

@@ -1,12 +1,4 @@
import { import { Show, SimpleShowLayout, TextField, NumberField, DateField, SelectField, ReferenceField } from 'react-admin';
Show,
SimpleShowLayout,
TextField,
NumberField,
DateField,
SelectField,
ReferenceField,
} from 'react-admin';
const repairKindChoices = [ const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' }, { id: 'TO', name: 'Техническое обслуживание' },
@@ -24,21 +16,21 @@ const statusChoices = [
{ id: 'Done', name: 'Выполнена' }, { id: 'Done', name: 'Выполнена' },
{ id: 'Cancelled', name: 'Отменена' }, { id: 'Cancelled', name: 'Отменена' },
]; ];
export const RepairOrderShow = () => ( export const RepairOrderShow = () => (
<Show> <Show>
<SimpleShowLayout> <SimpleShowLayout>
<TextField source="id" label="id" />
<TextField source="number" label="Номер заявки" /> <TextField source="number" label="Номер заявки" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show"> <ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
<TextField source="name" /> <TextField source="inventoryNumber" />
</ReferenceField> </ReferenceField>
<SelectField source="repairKind" label="Вид ремонта" choices={repairKindChoices} /> <SelectField source="repairKind" label="Вид ремонта" choices={repairKindChoices} />
<SelectField source="status" label="Статус" choices={statusChoices} /> <SelectField source="status" label="Статус" choices={statusChoices} />
<DateField source="plannedAt" label="Плановая дата начала" /> <DateField source="plannedAt" label="Плановая дата начала" />
<DateField source="startedAt" label="Фактическая дата начала" /> <DateField source="startedAt" label="Фактическая дата начала" />
<DateField source="completedAt" label="Фактическая дата завершения" /> <DateField source="completedAt" label="Фактическая дата завершения" />
<TextField source="contractor" label="Подрядная организация" /> <TextField source="contractor" label="Подрядная организация (если внешний ремонт)" />
<NumberField source="engineHoursAtRepair" label="Наработка на момент ремонта (ч)" /> <NumberField source="engineHoursAtRepair" label="Наработка на момент ремонта, моточасов" />
<TextField source="description" label="Описание работ / дефекта" /> <TextField source="description" label="Описание работ / дефекта" />
<TextField source="notes" label="Примечания" /> <TextField source="notes" label="Примечания" />
</SimpleShowLayout> </SimpleShowLayout>

View File

@@ -2,7 +2,7 @@ services:
postgres: postgres:
image: postgres:16 image: postgres:16
container_name: toir-postgres container_name: toir-postgres
restart: always restart: unless-stopped
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
@@ -10,7 +10,7 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
volumes: volumes:
postgres_data: postgres-data:

164
docs/AID_EXPORT_README.md Normal file
View File

@@ -0,0 +1,164 @@
# AID: экспорт OpenAPI и генератор приложения
В репозитории добавлены **сервисы-экспортёры** для интеграции с **AID** (или любым другим клиентом по HTTP): автоматическое получение **OpenAPI 3.0** из доменного **api-format** и выдача **сгенерированного fullstack-приложения** из **DSL** без ручного копирования файлов.
---
## Что сделано
| Компонент | Назначение |
|-----------|------------|
| **`POST /aid/export/openapi`** (NestJS) | На вход JSON **api-format** → на выход документ **OpenAPI 3.0** в поле `openapi`. |
| **`POST /aid/export/app`** (NestJS) | На вход текст **DSL** → либо JSON-бандл всех сгенерированных файлов (`files`), либо запись в рабочую копию репозитория (`apply: true`, опционально). |
| **`tools/api-format-to-openapi/`** | CLI и промпт для LLM: тот же конвертер, что вызывает Nest. |
| **`generation/generate.mjs`** | Новый флаг **`--print-bundle-json`**: вывод в stdout JSON с `entityCount`, `enumCount`, `files` — без записи на диск (аналог «сухого» экспорта для AID). |
| **`server/src/aid-export/`** | Модуль Nest: контроллер, сервис, краткая справка в `README.md` рядом с кодом. |
Ветка с этими изменениями: **`add_aid_exporters`**.
---
## Требования к запуску
1. Репозиторий клонирован целиком (есть `generation/`, `tools/`, `server/`, `client/`).
2. Backend запускается из каталога **`server/`** (`npm run start` / `start:dev`), чтобы относительные пути `../generation/generate.mjs` и `../tools/api-format-to-openapi/convert.mjs` были корректны.
3. Для режима OpenAPI через LLM на сервере нужны **`OPENAI_API_KEY`** (и при необходимости `OPENAI_MODEL`, `OPENAI_BASE_URL`).
---
## Переменные окружения (`server/.env`)
| Переменная | Зачем |
|------------|--------|
| `AID_EXPORT_API_KEY` | Если задана, к **`/aid/export/*`** нужен заголовок **`X-AID-Export-Key`** с тем же значением. |
| `AID_GENERATOR_ALLOW_APPLY` | Должна быть **`1`** или **`true`**, иначе **`POST /aid/export/app`** с **`apply: true`** вернёт **403** (защита от случайной перезаписи репозитория на сервере). |
| `OPENAI_API_KEY` | Для `POST /aid/export/openapi` с **`"mode": "llm"`**. |
Остальное как для обычного бэкенда (`DATABASE_URL`, `PORT` и т.д.).
---
## HTTP API (интеграция с AID)
Базовый URL: `http://<host>:<port>` (например `http://localhost:3000`).
### 1. OpenAPI из api-format
**`POST /aid/export/openapi`**
```http
Content-Type: application/json
X-AID-Export-Key: <если задан AID_EXPORT_API_KEY>
```
```json
{
"apiFormat": {
"apiFormatVersion": "1",
"info": { "title": "API", "version": "1.0.0" },
"server": { "basePath": "/api" },
"resources": []
},
"mode": "deterministic"
}
```
- **`mode`**: `deterministic` (по умолчанию) — маппинг в коде для схемы версии `1`; **`llm`** — вызов OpenAI по промпту из `tools/api-format-to-openapi/prompts/llm-system.md`.
**Ответ:** `{ "openapi": { "openapi": "3.0.3", ... } }`
Пример входа для теста: `tools/api-format-to-openapi/examples/api-format.example.json` (подставьте как значение `apiFormat`).
### 2. Генератор приложения из DSL
**`POST /aid/export/app`**
```json
{
"dsl": "domain TOiR {\n ...\n}\n",
"apply": false
}
```
- **`apply: false`** (рекомендуется для AID): в ответе **`files`** — объект «путь от корня репо → текст файла». Диск на сервере не меняется.
- **`apply: true`**: выполняется запись файлов как у `npm run generate:from-dsl` с `--apply`; нужен **`AID_GENERATOR_ALLOW_APPLY=1`**.
**Ответ (бандл):** `{ "applied": false, "entityCount": N, "enumCount": M, "files": { ... } }`
**Ответ (apply):** `{ "applied": true, "message": "Generated ..." }`
Эталон DSL: `examples/TOiR.domain.dsl`.
---
## CLI (без Nest)
### Пошаговая демонстрация в терминале
```bash
cd tools/api-format-to-openapi
npm run demo
# или с паузой после каждого шага (Enter):
npm run demo:pause
```
Показывает входной **api-format**, логику маппинга, запуск конвертера и структуру **OpenAPI**; результат — `demo-output/openapi.json`.
### api-format → OpenAPI
```bash
cd tools/api-format-to-openapi
node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
```
LLM:
```bash
set OPENAI_API_KEY=sk-...
node convert.mjs --mode llm --in your-api-format.json --out ../../openapi.llm.json
```
Подробнее: **`tools/api-format-to-openapi/README.md`**.
### DSL → JSON-бандл
Из **корня репозитория**:
```bash
node generation/generate.mjs --print-bundle-json --dsl examples/TOiR.domain.dsl > bundle.json
```
Из **`server/`**:
```bash
npm run generate:bundle-json > ../bundle.json
```
Применить генератор к файлам на диске (как раньше):
```bash
cd server
npm run generate:from-dsl
```
---
## Типичный сценарий для AID
1. AID уже сформировал **api-format** (как у вас принято после DTO).
2. AID вызывает **`POST /aid/export/openapi`** → получает **OpenAPI 3.0** → сохраняет в проект / отдаёт в Swagger / в реестр.
3. Для кода: AID передаёт **DSL** в **`POST /aid/export/app`** с **`apply: false`** → забирает **`files`** → применяет у себя (git apply, распаковка, PR).
4. Запись **`apply: true`** на общем сервере используйте только в доверенной среде и с **`AID_GENERATOR_ALLOW_APPLY`**.
---
## Где смотреть код и короткую справку
- Полное описание эндпоинтов рядом с реализацией: **`server/src/aid-export/README.md`**
- Общий dev-workflow (в т.ч. упоминание AID): **`generation/dev-workflow.md`**
---
## Ограничения и дальнейшие шаги
- Пример **api-format** в репозитории — **учебный**; под ваш продакшен-формат может понадобиться расширить маппинг в `convert.mjs` или отточить промпт **`llm-system.md`**.
- Ответ **`/aid/export/app`** с большим числом сущностей может быть объёмным; при необходимости добавьте сжатие, отдельное хранилище артефактов или пагинацию по файлам — контракт с AID лучше зафиксировать отдельно.

View File

@@ -0,0 +1,35 @@
# Repository Structure
`KIS-TOiR` keeps the existing LLM-first generation philosophy and organizes the repository by meaning:
- `domain/`
- canonical DSL inputs
- DSL specification
- `prompts/`
- active prompt corpus used to drive generation
- `docs/`
- overview and repository-level architecture notes
- `tools/`
- helper scripts for summary generation and validation
- `server/`
- active backend target output
- `client/`
- active frontend target output
The repository keeps LLM-first generation orchestration, but framework bootstrap is CLI-first:
- `server/` must remain a valid NestJS workspace baseline
- `client/` must remain a valid Vite React TypeScript workspace baseline
- repair a broken workspace before applying more domain-derived generation changes
- future agents must treat forbidden generation patterns in `prompts/` as contract violations, not suggestions
Root-level files stay limited to repository-level artifacts such as:
- `README.md`
- `package.json`
- `docker-compose.yml`
- `domain-summary.json`
- `toir-realm.json`
- `.gitignore`
The repository does not introduce a new generator engine or compiler platform. It keeps the current LLM-first pipeline and makes it cleaner, more explicit, and easier to navigate.

324
domain-summary.json Normal file
View File

@@ -0,0 +1,324 @@
{
"sourceFiles": [
"domain/TOiR.domain.dsl"
],
"entities": [
{
"name": "EquipmentType",
"primaryKey": "code",
"fields": [
{
"name": "code",
"type": "string",
"required": true,
"unique": true,
"default": null
},
{
"name": "name",
"type": "string",
"required": true,
"unique": false,
"default": null
},
{
"name": "manufacturer",
"type": "string",
"required": false,
"unique": false,
"default": null
},
{
"name": "maintenanceIntervalHours",
"type": "integer",
"required": false,
"unique": false,
"default": null
},
{
"name": "overhaulIntervalHours",
"type": "integer",
"required": false,
"unique": false,
"default": null
}
],
"foreignKeys": []
},
{
"name": "Equipment",
"primaryKey": "id",
"fields": [
{
"name": "id",
"type": "uuid",
"required": false,
"unique": false,
"default": null
},
{
"name": "inventoryNumber",
"type": "string",
"required": true,
"unique": true,
"default": null
},
{
"name": "serialNumber",
"type": "string",
"required": false,
"unique": false,
"default": null
},
{
"name": "name",
"type": "string",
"required": true,
"unique": false,
"default": null
},
{
"name": "equipmentTypeCode",
"type": "string",
"required": true,
"unique": false,
"default": null
},
{
"name": "status",
"type": "EquipmentStatus",
"required": true,
"unique": false,
"default": "Active"
},
{
"name": "location",
"type": "string",
"required": false,
"unique": false,
"default": null
},
{
"name": "commissionedAt",
"type": "date",
"required": false,
"unique": false,
"default": null
},
{
"name": "totalEngineHours",
"type": "decimal",
"required": false,
"unique": false,
"default": null
},
{
"name": "engineHoursSinceLastRepair",
"type": "decimal",
"required": false,
"unique": false,
"default": null
},
{
"name": "lastRepairAt",
"type": "date",
"required": false,
"unique": false,
"default": null
},
{
"name": "notes",
"type": "text",
"required": false,
"unique": false,
"default": null
}
],
"foreignKeys": [
{
"field": "equipmentTypeCode",
"references": {
"entity": "EquipmentType",
"field": "code"
}
}
]
},
{
"name": "RepairOrder",
"primaryKey": "id",
"fields": [
{
"name": "id",
"type": "uuid",
"required": false,
"unique": false,
"default": null
},
{
"name": "number",
"type": "string",
"required": true,
"unique": true,
"default": null
},
{
"name": "equipmentId",
"type": "uuid",
"required": true,
"unique": false,
"default": null
},
{
"name": "repairKind",
"type": "RepairKind",
"required": true,
"unique": false,
"default": null
},
{
"name": "status",
"type": "RepairOrderStatus",
"required": true,
"unique": false,
"default": "Draft"
},
{
"name": "plannedAt",
"type": "date",
"required": true,
"unique": false,
"default": null
},
{
"name": "startedAt",
"type": "date",
"required": false,
"unique": false,
"default": null
},
{
"name": "completedAt",
"type": "date",
"required": false,
"unique": false,
"default": null
},
{
"name": "contractor",
"type": "string",
"required": false,
"unique": false,
"default": null
},
{
"name": "engineHoursAtRepair",
"type": "decimal",
"required": false,
"unique": false,
"default": null
},
{
"name": "description",
"type": "text",
"required": false,
"unique": false,
"default": null
},
{
"name": "notes",
"type": "text",
"required": false,
"unique": false,
"default": null
}
],
"foreignKeys": [
{
"field": "equipmentId",
"references": {
"entity": "Equipment",
"field": "id"
}
}
]
}
],
"enums": [
{
"name": "EquipmentStatus",
"values": [
{
"name": "Active",
"label": "В эксплуатации"
},
{
"name": "Repair",
"label": "В ремонте"
},
{
"name": "Reserve",
"label": "В резерве"
},
{
"name": "WriteOff",
"label": "Списано"
}
]
},
{
"name": "RepairKind",
"values": [
{
"name": "TO",
"label": "Техническое обслуживание"
},
{
"name": "TR",
"label": "Текущий ремонт"
},
{
"name": "TRE",
"label": "Текущий расширенный ремонт"
},
{
"name": "KR",
"label": "Капитальный ремонт"
},
{
"name": "AR",
"label": "Аварийный ремонт"
},
{
"name": "MP",
"label": "Метрологическая поверка"
}
]
},
{
"name": "RepairOrderStatus",
"values": [
{
"name": "Draft",
"label": "Черновик"
},
{
"name": "Approved",
"label": "Утверждена"
},
{
"name": "InWork",
"label": "В работе"
},
{
"name": "Done",
"label": "Выполнена"
},
{
"name": "Cancelled",
"label": "Отменена"
}
]
}
]
}

View File

@@ -3,10 +3,6 @@
Сущности: Equipment (Оборудование), EquipmentType (Вид оборудования), RepairOrder (Заявка на ремонт) Сущности: Equipment (Оборудование), EquipmentType (Вид оборудования), RepairOrder (Заявка на ремонт)
*/ */
// ─────────────────────────────────────────────
// Перечисления
// ─────────────────────────────────────────────
enum EquipmentStatus { enum EquipmentStatus {
value Active { value Active {
label "В эксплуатации"; label "В эксплуатации";
@@ -61,11 +57,6 @@ enum RepairOrderStatus {
} }
} }
// ─────────────────────────────────────────────
// Справочник: Вид оборудования
// ─────────────────────────────────────────────
entity EquipmentType { entity EquipmentType {
description "Вид (марка) оборудования — нормативный справочник НСИ"; description "Вид (марка) оборудования — нормативный справочник НСИ";
@@ -88,7 +79,6 @@ entity EquipmentType {
type string; type string;
} }
// Нормативный межремонтный ресурс (моточасы)
attribute maintenanceIntervalHours { attribute maintenanceIntervalHours {
description "Периодичность ТО, моточасов"; description "Периодичность ТО, моточасов";
type integer; type integer;
@@ -100,11 +90,6 @@ entity EquipmentType {
} }
} }
// ─────────────────────────────────────────────
// Основная сущность: Оборудование
// ─────────────────────────────────────────────
entity Equipment { entity Equipment {
description "Единица оборудования — объект ремонта и технического обслуживания"; description "Единица оборудования — объект ремонта и технического обслуживания";
@@ -131,7 +116,6 @@ entity Equipment {
is required; is required;
} }
// Связь с видом оборудования (справочник НСИ)
attribute equipmentTypeCode { attribute equipmentTypeCode {
type string; type string;
key foreign { key foreign {
@@ -157,7 +141,6 @@ entity Equipment {
type date; type date;
} }
// Наработка фиксируется вручную или из производственной программы
attribute totalEngineHours { attribute totalEngineHours {
description "Общая наработка, моточасов"; description "Общая наработка, моточасов";
type decimal; type decimal;
@@ -179,11 +162,6 @@ entity Equipment {
} }
} }
// ─────────────────────────────────────────────
// Заявка на ремонт
// ─────────────────────────────────────────────
entity RepairOrder { entity RepairOrder {
description "Заявка на ремонт — формируется по ППР или по факту обнаруженного дефекта"; description "Заявка на ремонт — формируется по ППР или по факту обнаруженного дефекта";
@@ -255,3 +233,4 @@ entity RepairOrder {
type text; type text;
} }
} }

View File

@@ -1,6 +1,46 @@
# DSL Language Specification # DSL Language Specification
This document describes the DSL (Domain Specific Language) used to specify fullstack CRUD applications. The DSL has four layers: Domain, DTO, API, and UI. This document describes the single DSL (Domain Specific Language) used to specify fullstack CRUD applications. The only required DSL input is `domain/*.dsl`.
`domain-summary.json` is a derived artifact generated from this DSL to stabilize LLM-first generation and feed the lightweight validation gate. It must never replace the DSL as the source of truth. The active prompt corpus that consumes this contract lives in `prompts/`.
---
# DSL Responsibility
The domain DSL defines only:
- domain model
- relations
- enums
The domain DSL is the single source of truth for:
- entities
- attributes
- primary keys
- foreign keys
- enums
The following layers are always derived from the domain DSL and must not be authored as standalone authoritative DSL inputs:
- DTO
- API
- UI
Optional extension mechanism:
```text
overrides/
api-overrides.dsl
ui-overrides.dsl
```
Override rules:
- Overrides are optional.
- The generator must work without them.
- Overrides may refine derived API or UI behavior, but they must not duplicate or redefine entities, attributes, primary keys, foreign keys, relations, or enums.
--- ---
@@ -108,7 +148,7 @@ attribute equipmentTypeCode {
## required ## required
- **is required** — attribute is non-nullable in domain and (unless overridden) in DTOs. - **is required** — attribute is non-nullable in the domain model and drives requiredness in derived DTO/API/UI artifacts.
- Absence of `is required` means the attribute is optional (nullable). - Absence of `is required` means the attribute is optional (nullable).
--- ---
@@ -120,33 +160,6 @@ attribute equipmentTypeCode {
--- ---
## map
Used in **DTO** and **UI** layers to bind a DTO/UI field to a domain entity attribute.
**In DTO:**
```
attribute name {
type string;
map Equipment.name;
}
```
- Ensures DTO attribute corresponds to an existing `Entity.attribute` and that types align.
**In UI:**
```
attribute Наименование {
map Equipment.name;
}
```
- UI label (e.g. "Наименование") maps to domain field `Equipment.name` for correct data binding and generation.
---
# DSL → System Component Mapping # DSL → System Component Mapping
## DSL → Prisma ## DSL → Prisma
@@ -174,9 +187,9 @@ attribute Наименование {
| entity | One module (e.g. equipment.module.ts) | | entity | One module (e.g. equipment.module.ts) |
| entity | Controller with CRUD endpoints | | entity | Controller with CRUD endpoints |
| entity | Service with Prisma CRUD | | entity | Service with Prisma CRUD |
| DTO (Create) | create-{entity}.dto.ts | | entity + attribute metadata | create-{entity}.dto.ts |
| DTO (Update) | update-{entity}.dto.ts | | entity + attribute metadata | update-{entity}.dto.ts |
| DTO (Response) | Used for GET response shape | | entity + attribute metadata | Response DTO / API shape |
API paths are derived from entity name: PascalCase → kebab-case, pluralized (e.g. `Equipment``/equipment`, `RepairOrder``/repair-orders`). API paths are derived from entity name: PascalCase → kebab-case, pluralized (e.g. `Equipment``/equipment`, `RepairOrder``/repair-orders`).
@@ -193,21 +206,12 @@ API paths are derived from entity name: PascalCase → kebab-case, pluralized (e
| type date | DateInput, DateField | | type date | DateInput, DateField |
| enum | SelectInput with choices | | enum | SelectInput with choices |
| foreign key | ReferenceInput, ReferenceField | | foreign key | ReferenceInput, ReferenceField |
| UI attribute with map | Field with correct source |
--- ---
# DTO Mapping # Derived Layer Mapping
- **map Entity.attribute** — DTO attribute corresponds to domain attribute; types must match. - **Create DTO** — derived from domain attributes and must not include generated primary keys (for example no `id` for uuid PKs).
- **Create DTO** — must not include generated primary keys (e.g. no `id` for uuid PK). - **Update DTO** — derived from the same domain attributes with all fields optional for partial updates.
- **Update DTO** — all fields optional (nullable) for partial updates. - **API response shape** — derived from domain attributes and must expose React Admin-compatible identifiers when needed.
- **List response DTO** — must expose `data` (array) and `total` (integer) for React Admin compatibility. - **UI field mapping** — derived from attribute types, descriptions, enums, and foreign keys without a separate UI DSL.
---
# UI Mapping
- Each UI attribute should have **map Entity.attribute** so it binds to a real domain field.
- UI attribute name is the label (e.g. "Наименование"); **source** in generated components is the domain attribute name (e.g. `name`).
- Enums → SelectInput; foreign keys → ReferenceInput/ReferenceField.

View File

@@ -1,57 +0,0 @@
import ./TOiR;
ui UI.Equipment {
offset 600;
description "Единица оборудования — объект ремонта и технического обслуживания";
attribute Код {
map Equipment.id;
}
attribute ИнвентарныйНомер {
map Equipment.inventoryNumber;
}
attribute СерийныйНомер {
map Equipment.serialNumber;
}
attribute Наименование {
map Equipment.name;
}
// Связь с видом оборудования (справочник НСИ)
attribute Тип {
map Equipment.equipmentTypeCode;
}
attribute Статус {
map Equipment.status;
description "Текущий статус";
}
attribute МестоТекущее {
map Equipment.location;
}
attribute ДатаВвода {
map Equipment.commissionedAt;
}
attribute НаработкаВсего {
map Equipment.totalEngineHours;
}
attribute НаработкаТекущая {
map Equipment.engineHoursSinceLastRepair;
}
attribute Ремонт {
map Equipment.lastRepairAt;
}
attribute Примечания {
map Equipment.notes;
}
}

View File

@@ -1,811 +0,0 @@
// ─────────────────────────────────────────────
// DTO: Вид оборудования (EquipmentType)
// ─────────────────────────────────────────────
dto DTO.EquipmentType {
description "Вид оборудования — полный объект ответа";
attribute code {
type string;
description "Код вида оборудования";
map EquipmentType.code;
}
attribute name {
type string;
description "Наименование вида";
map EquipmentType.name;
}
attribute manufacturer {
type string;
description "Производитель";
is nullable;
map EquipmentType.manufacturer;
}
attribute maintenanceIntervalHours {
type integer;
description "Периодичность ТО, моточасов";
is nullable;
map EquipmentType.maintenanceIntervalHours;
}
attribute overhaulIntervalHours {
type integer;
description "Периодичность КР, моточасов";
is nullable;
map EquipmentType.overhaulIntervalHours;
}
}
dto DTO.EquipmentTypeCreate {
description "Вид оборудования — тело запроса на создание";
attribute code {
type string;
description "Код вида оборудования";
is required;
map EquipmentType.code;
}
attribute name {
type string;
description "Наименование вида";
is required;
map EquipmentType.name;
}
attribute manufacturer {
type string;
description "Производитель";
is nullable;
map EquipmentType.manufacturer;
}
attribute maintenanceIntervalHours {
type integer;
description "Периодичность ТО, моточасов";
is nullable;
map EquipmentType.maintenanceIntervalHours;
}
attribute overhaulIntervalHours {
type integer;
description "Периодичность КР, моточасов";
is nullable;
map EquipmentType.overhaulIntervalHours;
}
}
dto DTO.EquipmentTypeUpdate {
description "Вид оборудования — тело запроса на обновление (частичное)";
attribute code {
type string;
description "Код вида оборудования";
is nullable;
map EquipmentType.code;
}
attribute name {
type string;
description "Наименование вида";
is nullable;
map EquipmentType.name;
}
attribute manufacturer {
type string;
description "Производитель";
is nullable;
map EquipmentType.manufacturer;
}
attribute maintenanceIntervalHours {
type integer;
description "Периодичность ТО, моточасов";
is nullable;
map EquipmentType.maintenanceIntervalHours;
}
attribute overhaulIntervalHours {
type integer;
description "Периодичность КР, моточасов";
is nullable;
map EquipmentType.overhaulIntervalHours;
}
}
dto DTO.EquipmentTypeListResponse {
description "Список видов оборудования (формат React Admin)";
attribute data {
type DTO.EquipmentType[];
}
attribute total {
type integer;
}
}
// ─────────────────────────────────────────────
// DTO: Оборудование (Equipment)
// ─────────────────────────────────────────────
dto DTO.Equipment {
description "Единица оборудования — полный объект ответа";
attribute id {
type uuid;
map Equipment.id;
}
attribute inventoryNumber {
type string;
description "Инвентарный номер";
map Equipment.inventoryNumber;
}
attribute serialNumber {
type string;
description "Заводской (серийный) номер";
is nullable;
map Equipment.serialNumber;
}
attribute name {
type string;
description "Наименование единицы оборудования";
map Equipment.name;
}
attribute equipmentTypeCode {
type string;
description "Код вида оборудования";
map Equipment.equipmentTypeCode;
}
attribute status {
type EquipmentStatus;
description "Текущий статус";
map Equipment.status;
}
attribute location {
type string;
description "Место эксплуатации / скважина / куст";
is nullable;
map Equipment.location;
}
attribute commissionedAt {
type date;
description "Дата ввода в эксплуатацию";
is nullable;
map Equipment.commissionedAt;
}
attribute totalEngineHours {
type decimal;
description "Общая наработка, моточасов";
is nullable;
map Equipment.totalEngineHours;
}
attribute engineHoursSinceLastRepair {
type decimal;
description "Наработка с последнего ремонта, моточасов";
is nullable;
map Equipment.engineHoursSinceLastRepair;
}
attribute lastRepairAt {
type date;
description "Дата последнего ремонта";
is nullable;
map Equipment.lastRepairAt;
}
attribute notes {
type text;
description "Примечания";
is nullable;
map Equipment.notes;
}
}
dto DTO.EquipmentCreate {
description "Единица оборудования — тело запроса на создание";
attribute inventoryNumber {
type string;
description "Инвентарный номер";
is required;
map Equipment.inventoryNumber;
}
attribute serialNumber {
type string;
description "Заводской (серийный) номер";
is nullable;
map Equipment.serialNumber;
}
attribute name {
type string;
description "Наименование единицы оборудования";
is required;
map Equipment.name;
}
attribute equipmentTypeCode {
type string;
description "Код вида оборудования";
is required;
map Equipment.equipmentTypeCode;
}
attribute status {
type EquipmentStatus;
description "Текущий статус";
is nullable;
map Equipment.status;
}
attribute location {
type string;
description "Место эксплуатации / скважина / куст";
is nullable;
map Equipment.location;
}
attribute commissionedAt {
type date;
description "Дата ввода в эксплуатацию";
is nullable;
map Equipment.commissionedAt;
}
attribute totalEngineHours {
type decimal;
description "Общая наработка, моточасов";
is nullable;
map Equipment.totalEngineHours;
}
attribute engineHoursSinceLastRepair {
type decimal;
description "Наработка с последнего ремонта, моточасов";
is nullable;
map Equipment.engineHoursSinceLastRepair;
}
attribute lastRepairAt {
type date;
description "Дата последнего ремонта";
is nullable;
map Equipment.lastRepairAt;
}
attribute notes {
type text;
description "Примечания";
is nullable;
map Equipment.notes;
}
}
dto DTO.EquipmentUpdate {
description "Единица оборудования — тело запроса на обновление (частичное)";
attribute inventoryNumber {
type string;
description "Инвентарный номер";
is nullable;
map Equipment.inventoryNumber;
}
attribute serialNumber {
type string;
description "Заводской (серийный) номер";
is nullable;
map Equipment.serialNumber;
}
attribute name {
type string;
description "Наименование единицы оборудования";
is nullable;
map Equipment.name;
}
attribute equipmentTypeCode {
type string;
description "Код вида оборудования";
is nullable;
map Equipment.equipmentTypeCode;
}
attribute status {
type EquipmentStatus;
description "Текущий статус";
is nullable;
map Equipment.status;
}
attribute location {
type string;
description "Место эксплуатации / скважина / куст";
is nullable;
map Equipment.location;
}
attribute commissionedAt {
type date;
description "Дата ввода в эксплуатацию";
is nullable;
map Equipment.commissionedAt;
}
attribute totalEngineHours {
type decimal;
description "Общая наработка, моточасов";
is nullable;
map Equipment.totalEngineHours;
}
attribute engineHoursSinceLastRepair {
type decimal;
description "Наработка с последнего ремонта, моточасов";
is nullable;
map Equipment.engineHoursSinceLastRepair;
}
attribute lastRepairAt {
type date;
description "Дата последнего ремонта";
is nullable;
map Equipment.lastRepairAt;
}
attribute notes {
type text;
description "Примечания";
is nullable;
map Equipment.notes;
}
}
dto DTO.EquipmentListResponse {
description "Список оборудования (формат React Admin)";
attribute data {
type DTO.Equipment[];
}
attribute total {
type integer;
}
}
// ─────────────────────────────────────────────
// DTO: Заявка на ремонт (RepairOrder)
// ─────────────────────────────────────────────
dto DTO.RepairOrder {
description "Заявка на ремонт — полный объект ответа";
attribute id {
type uuid;
map RepairOrder.id;
}
attribute number {
type string;
description "Номер заявки";
map RepairOrder.number;
}
attribute equipmentId {
type uuid;
description "Идентификатор единицы оборудования";
map RepairOrder.equipmentId;
}
attribute repairKind {
type RepairKind;
description "Вид ремонта";
map RepairOrder.repairKind;
}
attribute status {
type RepairOrderStatus;
description "Статус заявки";
map RepairOrder.status;
}
attribute plannedAt {
type date;
description "Плановая дата начала";
map RepairOrder.plannedAt;
}
attribute startedAt {
type date;
description "Фактическая дата начала";
is nullable;
map RepairOrder.startedAt;
}
attribute completedAt {
type date;
description "Фактическая дата завершения";
is nullable;
map RepairOrder.completedAt;
}
attribute contractor {
type string;
description "Подрядная организация (если внешний ремонт)";
is nullable;
map RepairOrder.contractor;
}
attribute engineHoursAtRepair {
type decimal;
description "Наработка на момент ремонта, моточасов";
is nullable;
map RepairOrder.engineHoursAtRepair;
}
attribute description {
type text;
description "Описание работ / дефекта";
is nullable;
map RepairOrder.description;
}
attribute notes {
type text;
description "Примечания";
is nullable;
map RepairOrder.notes;
}
}
dto DTO.RepairOrderCreate {
description "Заявка на ремонт — тело запроса на создание";
attribute number {
type string;
description "Номер заявки";
is required;
map RepairOrder.number;
}
attribute equipmentId {
type uuid;
description "Идентификатор единицы оборудования";
is required;
map RepairOrder.equipmentId;
}
attribute repairKind {
type RepairKind;
description "Вид ремонта";
is required;
map RepairOrder.repairKind;
}
attribute status {
type RepairOrderStatus;
description "Статус заявки";
is nullable;
map RepairOrder.status;
}
attribute plannedAt {
type date;
description "Плановая дата начала";
is required;
map RepairOrder.plannedAt;
}
attribute startedAt {
type date;
description "Фактическая дата начала";
is nullable;
map RepairOrder.startedAt;
}
attribute completedAt {
type date;
description "Фактическая дата завершения";
is nullable;
map RepairOrder.completedAt;
}
attribute contractor {
type string;
description "Подрядная организация (если внешний ремонт)";
is nullable;
map RepairOrder.contractor;
}
attribute engineHoursAtRepair {
type decimal;
description "Наработка на момент ремонта, моточасов";
is nullable;
map RepairOrder.engineHoursAtRepair;
}
attribute description {
type text;
description "Описание работ / дефекта";
is nullable;
map RepairOrder.description;
}
attribute notes {
type text;
description "Примечания";
is nullable;
map RepairOrder.notes;
}
}
dto DTO.RepairOrderUpdate {
description "Заявка на ремонт — тело запроса на обновление (частичное)";
attribute number {
type string;
description "Номер заявки";
is nullable;
map RepairOrder.number;
}
attribute equipmentId {
type uuid;
description "Идентификатор единицы оборудования";
is nullable;
map RepairOrder.equipmentId;
}
attribute repairKind {
type RepairKind;
description "Вид ремонта";
is nullable;
map RepairOrder.repairKind;
}
attribute status {
type RepairOrderStatus;
description "Статус заявки";
is nullable;
map RepairOrder.status;
}
attribute plannedAt {
type date;
description "Плановая дата начала";
is nullable;
map RepairOrder.plannedAt;
}
attribute startedAt {
type date;
description "Фактическая дата начала";
is nullable;
map RepairOrder.startedAt;
}
attribute completedAt {
type date;
description "Фактическая дата завершения";
is nullable;
map RepairOrder.completedAt;
}
attribute contractor {
type string;
description "Подрядная организация (если внешний ремонт)";
is nullable;
map RepairOrder.contractor;
}
attribute engineHoursAtRepair {
type decimal;
description "Наработка на момент ремонта, моточасов";
is nullable;
map RepairOrder.engineHoursAtRepair;
}
attribute description {
type text;
description "Описание работ / дефекта";
is nullable;
map RepairOrder.description;
}
attribute notes {
type text;
description "Примечания";
is nullable;
map RepairOrder.notes;
}
}
dto DTO.RepairOrderListResponse {
description "Список заявок на ремонт (формат React Admin)";
attribute data {
type DTO.RepairOrder[];
}
attribute total {
type integer;
}
}
// ─────────────────────────────────────────────
// API: Виды оборудования
// ─────────────────────────────────────────────
api API.EquipmentTypes {
description "API управления справочником видов оборудования";
endpoint listEquipmentTypes {
label "GET /equipment-types";
description "Список видов оборудования (фильтры и пагинация — query-параметры)";
attribute response {
type DTO.EquipmentTypeListResponse;
}
}
endpoint getEquipmentType {
label "GET /equipment-types/{code}";
description "Получить вид оборудования по коду";
attribute code {
type string;
}
attribute response {
type DTO.EquipmentType;
}
}
endpoint createEquipmentType {
label "POST /equipment-types";
description "Создать вид оборудования";
attribute request {
type DTO.EquipmentTypeCreate;
}
}
endpoint updateEquipmentType {
label "PATCH /equipment-types/{code}";
description "Обновить вид оборудования";
attribute code {
type string;
}
attribute request {
type DTO.EquipmentTypeUpdate;
}
}
endpoint deleteEquipmentType {
label "DELETE /equipment-types/{code}";
description "Удалить вид оборудования";
attribute code {
type string;
}
}
}
// ─────────────────────────────────────────────
// API: Оборудование
// ─────────────────────────────────────────────
api API.Equipment {
description "API управления оборудованием";
endpoint listEquipment {
label "GET /equipment";
description "Список оборудования (фильтры и пагинация — query-параметры)";
attribute response {
type DTO.EquipmentListResponse;
}
}
endpoint getEquipment {
label "GET /equipment/{id}";
description "Получить единицу оборудования по идентификатору";
attribute id {
type uuid;
}
attribute response {
type DTO.Equipment;
}
}
endpoint createEquipment {
label "POST /equipment";
description "Создать единицу оборудования";
attribute request {
type DTO.EquipmentCreate;
}
}
endpoint updateEquipment {
label "PATCH /equipment/{id}";
description "Обновить единицу оборудования";
attribute id {
type uuid;
}
attribute request {
type DTO.EquipmentUpdate;
}
}
endpoint deleteEquipment {
label "DELETE /equipment/{id}";
description "Удалить единицу оборудования";
attribute id {
type uuid;
}
}
}
// ─────────────────────────────────────────────
// API: Заявки на ремонт
// ─────────────────────────────────────────────
api API.RepairOrders {
description "API управления заявками на ремонт";
endpoint listRepairOrders {
label "GET /repair-orders";
description "Список заявок на ремонт (фильтры и пагинация — query-параметры)";
attribute response {
type DTO.RepairOrderListResponse;
}
}
endpoint getRepairOrder {
label "GET /repair-orders/{id}";
description "Получить заявку на ремонт по идентификатору";
attribute id {
type uuid;
}
attribute response {
type DTO.RepairOrder;
}
}
endpoint createRepairOrder {
label "POST /repair-orders";
description "Создать заявку на ремонт";
attribute request {
type DTO.RepairOrderCreate;
}
}
endpoint updateRepairOrder {
label "PATCH /repair-orders/{id}";
description "Обновить заявку на ремонт";
attribute id {
type uuid;
}
attribute request {
type DTO.RepairOrderUpdate;
}
}
endpoint deleteRepairOrder {
label "DELETE /repair-orders/{id}";
description "Удалить заявку на ремонт";
attribute id {
type uuid;
}
}
}

View File

@@ -1,753 +0,0 @@
/*
КИС ТОиР — DTO
Структуры данных для обмена через API
*/
//import ./TOiR;
//only external;
// ─────────────────────────────────────────────
// Общие
// ─────────────────────────────────────────────
dto DTO.PageRequest {
description "Параметры постраничной выдачи";
attribute page {
description "Номер страницы (начиная с 0)";
type integer;
}
attribute size {
description "Размер страницы";
type integer;
}
}
dto DTO.Filter {
description "Элемент фильтра для запросов списка";
attribute field { type string; }
attribute operator { type string; }
attribute value { type string; }
}
dto DTO.PageInfo {
description "Метаданные постраничной выдачи";
attribute size { type integer; }
attribute number { type integer; }
attribute totalElements { type integer; }
attribute totalPages { type integer; }
}
// ─────────────────────────────────────────────
// Вид оборудования (EquipmentType)
// ─────────────────────────────────────────────
dto DTO.EquipmentType {
description "Вид оборудования — response";
attribute code {
type string;
map EquipmentType.code;
}
attribute name {
type string;
map EquipmentType.name;
}
attribute manufacturer {
type string;
is nullable;
map EquipmentType.manufacturer;
}
attribute maintenanceIntervalHours {
type integer;
is nullable;
map EquipmentType.maintenanceIntervalHours;
}
attribute overhaulIntervalHours {
type integer;
is nullable;
map EquipmentType.overhaulIntervalHours;
}
}
dto DTO.EquipmentTypeFilter {
description "Фильтры для списка видов оборудования";
attribute code {
description "Частичное совпадение по коду";
type string;
is nullable;
}
attribute name {
description "Частичное совпадение по наименованию";
type string;
is nullable;
}
attribute manufacturer {
description "Частичное совпадение по производителю";
type string;
is nullable;
}
}
dto DTO.EquipmentTypeCreate {
description "Тело запроса на создание вида оборудования";
attribute code {
description "Код вида оборудования";
type string;
is required;
}
attribute name {
description "Наименование вида";
type string;
is required;
}
attribute manufacturer {
description "Производитель";
type string;
is nullable;
}
attribute maintenanceIntervalHours {
description "Периодичность ТО, моточасов";
type integer;
is nullable;
}
attribute overhaulIntervalHours {
description "Периодичность КР, моточасов";
type integer;
is nullable;
}
}
dto DTO.EquipmentTypeUpdate {
description "Тело запроса на обновление вида оборудования";
attribute name {
description "Наименование вида";
type string;
is nullable;
}
attribute manufacturer {
description "Производитель";
type string;
is nullable;
}
attribute maintenanceIntervalHours {
description "Периодичность ТО, моточасов";
type integer;
is nullable;
}
attribute overhaulIntervalHours {
description "Периодичность КР, моточасов";
type integer;
is nullable;
}
}
// ─────────────────────────────────────────────
// Оборудование (Equipment)
// ─────────────────────────────────────────────
dto DTO.EquipmentListItem {
description "Строка списка оборудования";
attribute id {
type uuid;
map Equipment.id;
}
attribute inventoryNumber {
type string;
map Equipment.inventoryNumber;
}
attribute name {
type string;
map Equipment.name;
}
attribute equipmentTypeCode {
type string;
map Equipment.equipmentTypeCode;
}
attribute status {
type EquipmentStatus;
map Equipment.status;
}
attribute location {
type string;
is nullable;
map Equipment.location;
}
}
dto DTO.EquipmentDetail {
description "Полная информация об оборудовании";
attribute id {
type uuid;
map Equipment.id;
}
attribute inventoryNumber {
type string;
map Equipment.inventoryNumber;
}
attribute serialNumber {
type string;
is nullable;
map Equipment.serialNumber;
}
attribute name {
type string;
map Equipment.name;
}
attribute equipmentTypeCode {
type string;
map Equipment.equipmentTypeCode;
}
attribute status {
type EquipmentStatus;
map Equipment.status;
}
attribute location {
type string;
is nullable;
map Equipment.location;
}
attribute commissionedAt {
type date;
is nullable;
map Equipment.commissionedAt;
}
attribute totalEngineHours {
type decimal;
is nullable;
map Equipment.totalEngineHours;
}
attribute engineHoursSinceLastRepair {
type decimal;
is nullable;
map Equipment.engineHoursSinceLastRepair;
}
attribute lastRepairAt {
type date;
is nullable;
map Equipment.lastRepairAt;
}
attribute notes {
type text;
is nullable;
map Equipment.notes;
}
}
dto DTO.EquipmentFilter {
description "Фильтры для списка оборудования";
attribute inventoryNumber {
description "Частичное совпадение по инвентарному номеру";
type string;
is nullable;
}
attribute serialNumber {
description "Частичное совпадение по заводскому номеру";
type string;
is nullable;
}
attribute name {
description "Частичное совпадение по наименованию";
type string;
is nullable;
}
attribute equipmentTypeCode {
description "Точное совпадение по коду вида оборудования";
type string;
is nullable;
}
attribute status {
description "Фильтр по статусу";
type EquipmentStatus;
is nullable;
}
attribute location {
description "Частичное совпадение по месту эксплуатации";
type string;
is nullable;
}
}
dto DTO.EquipmentCreate {
description "Тело запроса на создание оборудования";
attribute inventoryNumber {
description "Инвентарный номер";
type string;
is required;
}
attribute serialNumber {
description "Заводской (серийный) номер";
type string;
is nullable;
}
attribute name {
description "Наименование единицы оборудования";
type string;
is required;
}
attribute equipmentTypeCode {
description "Код вида оборудования";
type string;
is required;
}
attribute status {
description "Текущий статус";
type EquipmentStatus;
is nullable;
}
attribute location {
description "Место эксплуатации / скважина / куст";
type string;
is nullable;
}
attribute commissionedAt {
description "Дата ввода в эксплуатацию";
type date;
is nullable;
}
attribute totalEngineHours {
description "Общая наработка, моточасов";
type decimal;
is nullable;
}
attribute engineHoursSinceLastRepair {
description "Наработка с последнего ремонта, моточасов";
type decimal;
is nullable;
}
attribute lastRepairAt {
description "Дата последнего ремонта";
type date;
is nullable;
}
attribute notes {
description "Примечания";
type text;
is nullable;
}
}
dto DTO.EquipmentUpdate {
description "Тело запроса на обновление оборудования";
attribute inventoryNumber {
description "Инвентарный номер";
type string;
is nullable;
}
attribute serialNumber {
description "Заводской (серийный) номер";
type string;
is nullable;
}
attribute name {
description "Наименование единицы оборудования";
type string;
is nullable;
}
attribute equipmentTypeCode {
description "Код вида оборудования";
type string;
is nullable;
}
attribute status {
description "Текущий статус";
type EquipmentStatus;
is nullable;
}
attribute location {
description "Место эксплуатации / скважина / куст";
type string;
is nullable;
}
attribute commissionedAt {
description "Дата ввода в эксплуатацию";
type date;
is nullable;
}
attribute totalEngineHours {
description "Общая наработка, моточасов";
type decimal;
is nullable;
}
attribute engineHoursSinceLastRepair {
description "Наработка с последнего ремонта, моточасов";
type decimal;
is nullable;
}
attribute lastRepairAt {
description "Дата последнего ремонта";
type date;
is nullable;
}
attribute notes {
description "Примечания";
type text;
is nullable;
}
}
// ─────────────────────────────────────────────
// Заявка на ремонт (RepairOrder)
// ─────────────────────────────────────────────
dto DTO.RepairOrderListItem {
description "Строка списка заявок на ремонт";
attribute id {
type uuid;
map RepairOrder.id;
}
attribute number {
type string;
map RepairOrder.number;
}
attribute equipmentId {
type uuid;
map RepairOrder.equipmentId;
}
attribute repairKind {
type RepairKind;
map RepairOrder.repairKind;
}
attribute status {
type RepairOrderStatus;
map RepairOrder.status;
}
attribute plannedAt {
type date;
map RepairOrder.plannedAt;
}
attribute contractor {
type string;
is nullable;
map RepairOrder.contractor;
}
}
dto DTO.RepairOrderDetail {
description "Полная информация о заявке на ремонт";
attribute id {
type uuid;
map RepairOrder.id;
}
attribute number {
type string;
map RepairOrder.number;
}
attribute equipmentId {
type uuid;
map RepairOrder.equipmentId;
}
attribute repairKind {
type RepairKind;
map RepairOrder.repairKind;
}
attribute status {
type RepairOrderStatus;
map RepairOrder.status;
}
attribute plannedAt {
type date;
map RepairOrder.plannedAt;
}
attribute startedAt {
type date;
is nullable;
map RepairOrder.startedAt;
}
attribute completedAt {
type date;
is nullable;
map RepairOrder.completedAt;
}
attribute contractor {
type string;
is nullable;
map RepairOrder.contractor;
}
attribute engineHoursAtRepair {
type decimal;
is nullable;
map RepairOrder.engineHoursAtRepair;
}
attribute description {
type text;
is nullable;
map RepairOrder.description;
}
attribute notes {
type text;
is nullable;
map RepairOrder.notes;
}
}
dto DTO.RepairOrderFilter {
description "Фильтры для списка заявок на ремонт";
attribute number {
description "Частичное совпадение по номеру заявки";
type string;
is nullable;
}
attribute equipmentId {
description "Точное совпадение по идентификатору оборудования";
type uuid;
is nullable;
}
attribute repairKind {
description "Фильтр по виду ремонта";
type RepairKind;
is nullable;
}
attribute status {
description "Фильтр по статусу заявки";
type RepairOrderStatus;
is nullable;
}
attribute plannedAtFrom {
description "Плановая дата начала ОТ";
type date;
is nullable;
}
attribute plannedAtTo {
description "Плановая дата начала ДО";
type date;
is nullable;
}
attribute contractor {
description "Частичное совпадение по подрядчику";
type string;
is nullable;
}
}
dto DTO.RepairOrderCreate {
description "Тело запроса на создание заявки на ремонт";
attribute number {
description "Номер заявки";
type string;
is required;
}
attribute equipmentId {
description "Идентификатор оборудования";
type uuid;
is required;
}
attribute repairKind {
description "Вид ремонта";
type RepairKind;
is required;
}
attribute status {
description "Статус заявки";
type RepairOrderStatus;
is nullable;
}
attribute plannedAt {
description "Плановая дата начала";
type date;
is required;
}
attribute startedAt {
description "Фактическая дата начала";
type date;
is nullable;
}
attribute completedAt {
description "Фактическая дата завершения";
type date;
is nullable;
}
attribute contractor {
description "Подрядная организация";
type string;
is nullable;
}
attribute engineHoursAtRepair {
description "Наработка на момент ремонта, моточасов";
type decimal;
is nullable;
}
attribute description {
description "Описание работ / дефекта";
type text;
is nullable;
}
attribute notes {
description "Примечания";
type text;
is nullable;
}
}
dto DTO.RepairOrderUpdate {
description "Тело запроса на обновление заявки на ремонт";
attribute number {
description "Номер заявки";
type string;
is nullable;
}
attribute equipmentId {
description "Идентификатор оборудования";
type uuid;
is nullable;
}
attribute repairKind {
description "Вид ремонта";
type RepairKind;
is nullable;
}
attribute status {
description "Статус заявки";
type RepairOrderStatus;
is nullable;
}
attribute plannedAt {
description "Плановая дата начала";
type date;
is nullable;
}
attribute startedAt {
description "Фактическая дата начала";
type date;
is nullable;
}
attribute completedAt {
description "Фактическая дата завершения";
type date;
is nullable;
}
attribute contractor {
description "Подрядная организация";
type string;
is nullable;
}
attribute engineHoursAtRepair {
description "Наработка на момент ремонта, моточасов";
type decimal;
is nullable;
}
attribute description {
description "Описание работ / дефекта";
type text;
is nullable;
}
attribute notes {
description "Примечания";
type text;
is nullable;
}
}

View File

@@ -1,181 +0,0 @@
# Frontend Architecture
Frontend stack:
- React
- TypeScript
- Vite
- React Admin
- shadcn/ui
- Keycloak JS
The frontend is generated from the DSL and API specification.
Each entity becomes a React Admin resource.
The generated frontend must also include Keycloak authentication by default.
---
# Project Structure
client/
src/
App.tsx
main.tsx
dataProvider.ts
auth/
keycloak.ts
authProvider.ts
config/
env.ts
resources/
{entity}/
{entity}List.tsx
{entity}Create.tsx
{entity}Edit.tsx
{entity}Show.tsx
---
# Resource Registration
Each resource must be registered in App.tsx.
The generated `App.tsx` must register:
- `dataProvider`
- `authProvider`
The generated `Admin` root must enforce authenticated operation. The generated frontend must not operate anonymously once auth is enabled.
Example:
<Resource
name="equipment"
list={EquipmentList}
create={EquipmentCreate}
edit={EquipmentEdit}
show={EquipmentShow}
/>
---
# Data Provider
React Admin uses a generated shared REST-compatible data provider.
API format must follow:
GET /resource
GET /resource/:id
POST /resource
PATCH /resource/:id
DELETE /resource/:id
List response format:
{
data: [],
total: number
}
The generated `dataProvider.ts` must remain the **single shared request seam** for backend API calls.
Rules:
1. Use an env-driven API base URL.
2. Attach `Authorization: Bearer <access_token>` in this shared seam.
3. Cover all React Admin operations, including references and bulk fetches.
4. Do not scatter auth headers across resource components.
---
# Application Bootstrap
The generated `main.tsx` must initialize Keycloak before rendering the SPA.
Rules:
1. Use redirect-based Keycloak login only.
2. Use Authorization Code + PKCE (`S256`).
3. Do not generate a custom in-app username/password login form.
4. Do not render the authenticated admin app before Keycloak initialization completes.
---
# Config
The generated frontend must include a dedicated config module in `src/config/`.
Required env variables:
- `VITE_API_URL`
- `VITE_KEYCLOAK_URL`
- `VITE_KEYCLOAK_REALM`
- `VITE_KEYCLOAK_CLIENT_ID`
The generated frontend config must fail fast if required auth variables are missing. The generated frontend must not silently fall back to production auth settings in code.
---
# Foreign Keys
Foreign keys must use ReferenceInput and ReferenceField.
Example:
<ReferenceInput source="equipmentTypeCode" reference="equipment-types" />
---
# Naming Conventions
## React component naming
Components are named after the entity in PascalCase. One entity = one resource with four main views.
- **List:** `{Entity}List.tsx` (e.g. `EquipmentList.tsx`, `RepairOrderList.tsx`)
- **Create:** `{Entity}Create.tsx` (e.g. `EquipmentCreate.tsx`)
- **Edit:** `{Entity}Edit.tsx` (e.g. `EquipmentEdit.tsx`)
- **Show:** `{Entity}Show.tsx` (e.g. `EquipmentShow.tsx`)
Examples:
- Equipment → `EquipmentList.tsx`, `EquipmentCreate.tsx`, `EquipmentEdit.tsx`, `EquipmentShow.tsx`
- EquipmentType → `EquipmentTypeList.tsx`, `EquipmentTypeCreate.tsx`, `EquipmentTypeEdit.tsx`, `EquipmentTypeShow.tsx`
- RepairOrder → `RepairOrderList.tsx`, `RepairOrderCreate.tsx`, `RepairOrderEdit.tsx`, `RepairOrderShow.tsx`
## Resource folder naming
Folder under `resources/` uses kebab-case, matching the React Admin resource name:
- `resources/equipment/`
- `resources/equipment-type/`
- `resources/repair-order/`
---
# Resource Naming Rules
React Admin resource name (used in `<Resource name="..." />` and in `reference` for ReferenceInput) must match the API resource path (no leading slash, same segment string).
1. **Entity → resource name:** PascalCase entity name is converted to kebab-case and pluralized.
2. **Consistency with API:** The resource name must be the same as the backend path segment so that the data provider calls the correct URL.
| Entity (DSL) | Resource name (React Admin) | API path |
|----------------|-----------------------------|------------|
| Equipment | equipment | /equipment |
| EquipmentType | equipment-types | /equipment-types |
| RepairOrder | repair-orders | /repair-orders |
Examples in App.tsx:
- `<Resource name="equipment" list={EquipmentList} create={EquipmentCreate} edit={EquipmentEdit} show={EquipmentShow} />`
- `<Resource name="equipment-types" list={EquipmentTypeList} ... />`
- `<Resource name="repair-orders" list={RepairOrderList} ... />`

View File

@@ -1,147 +0,0 @@
# DSL → React Admin Mapping
Entity attributes determine UI fields.
---
# Authentication
Generated React Admin applications in this repository must include an `authProvider`.
Rules:
1. `authProvider` is mandatory.
2. The generated app must use redirect-based Keycloak login only.
3. The generator must not create a custom in-app username/password form.
4. The generated app must initialize authentication before rendering the admin UI.
---
# Shared Authenticated Request Layer
The generated frontend must attach bearer tokens through the shared request seam in `client/src/dataProvider.ts`.
Rules:
1. All resource calls must use the same authenticated request layer.
2. Reference lookups must use the same authenticated request layer.
3. The generated frontend must not attach auth headers directly inside resource components.
---
# Error Handling
The generated `authProvider.checkError` must distinguish authentication failures from authorization failures:
- `401` -> force logout / re-authentication
- `403` -> do not re-authenticate; surface access denied / permission error
The generator must not treat `401` and `403` as the same outcome.
---
# Token Handling
The generated frontend must use Keycloak JS token handling with these rules:
1. Use Authorization Code + PKCE (`S256`).
2. Refresh tokens before protected API calls when needed.
3. Token refresh must be concurrency-safe:
- one shared in-flight refresh operation
- no parallel refresh stampede
4. Do not store access tokens or refresh tokens in `localStorage` or `sessionStorage`.
---
# Type Mapping
| DSL Type | React Admin Component |
|---------|-----------------------|
| string | TextInput / TextField |
| integer | NumberInput |
| decimal | NumberInput |
| date | DateInput |
| enum | SelectInput |
| foreign key | ReferenceInput |
---
# Example
DSL
attribute name {
type string;
}
React Admin
<TextInput source="name" />
---
# Enum Example
DSL
attribute status {
type EquipmentStatus;
}
React Admin
<SelectInput source="status" choices={statusChoices} />
---
# Foreign Key Example
DSL
attribute equipmentTypeCode {
type string;
}
React Admin
<ReferenceInput source="equipmentTypeCode" reference="equipment-types" />
---
# React Admin ID Field Requirement
React Admin requires every record in list and detail responses to contain a field named **`id`**. It uses this field for resource identity, cache keys, and references.
**Rules:**
1. Every record returned by the API must contain an **`id`** field.
2. If the DSL primary key is not named `id`, the generator must **map** the primary key value to an `id` field in the API response (backend) or in a frontend adapter.
3. The `id` field must contain the **value of the primary key** (e.g. uuid string, or `code` value for EquipmentType).
**Example:**
DSL entity with primary key `code`:
```
entity EquipmentType {
attribute code {
key primary;
type string;
}
attribute name { type string; }
}
```
API response must include `id` so React Admin can identify the record:
```json
{
"id": "pump",
"code": "pump",
"name": "Pump"
}
```
If the response only had `{ "code": "pump", "name": "Pump" }`, React Admin would not work correctly because it expects `id`. The backend or frontend adapter must therefore set `id: record.code` (or equivalent) when the primary key is not `id`.
This rule ensures compatibility with React Admin resource identity handling.

View File

@@ -1,450 +0,0 @@
ROLE
You are a Staff-level Fullstack Platform Engineer.
Your task is to generate a fully runnable fullstack CRUD application from the DSL context of this repository.
Use context7.
Follow official best practices from:
NestJS documentation
Prisma documentation
React Admin documentation
Vite documentation
Keycloak documentation
Docker documentation
The generated application must run without manual fixes.
PROJECT CONTEXT
You must read the project documentation in the following strict order:
domain/dsl-spec.md
examples/*.dsl
backend/architecture.md
backend/prisma-rules.md
backend/prisma-service.md
backend/service-rules.md
backend/runtime-rules.md
backend/database-runtime.md
backend/seed-rules.md
frontend/architecture.md
frontend/react-admin-rules.md
auth/keycloak-architecture.md
auth/frontend-auth-rules.md
auth/backend-auth-rules.md
auth/keycloak-realm-template-rules.md
generation/scaffolding-rules.md
generation/backend-generation.md
generation/frontend-generation.md
generation/runtime-bootstrap.md
generation/post-generation-validation.md
Do not ignore any rules defined in these documents.
GOAL
Generate a DSL-driven fullstack CRUD system with default Keycloak authentication and authorization.
Repository-specific defaults and examples may use names such as `toir`, `toir-frontend`, `toir-backend`, `toir-realm.json`, and `*.greact.ru`, but the generator must parameterize realm name, client IDs, production URLs, and realm-artifact filename for other generated projects.
Stack:
Backend
Node.js
NestJS
Prisma ORM
PostgreSQL
jose
Frontend
React
Vite
React Admin
MUI
shadcn/ui
Keycloak JS
PROJECT STRUCTURE
Root
docker-compose.yml
root-level Keycloak realm import artifact (default example filename: `toir-realm.json`)
server/
client/
Backend
server/
src/
auth/
config/
modules/{entity}/
prisma/schema.prisma
prisma/seed.ts
.env
.env.example
Frontend
client/
src/
auth/
config/
resources/{entity}/
App.tsx
main.tsx
dataProvider.ts
.env.example
STEP 1 — Parse DSL
Parse all DSL files and extract:
Entities
Attributes
Primary keys
Foreign keys
Enums
Respect the DSL specification.
STEP 2 — CLI scaffolding
Use official CLIs.
Backend
npx @nestjs/cli@10.3.2 new server --package-manager npm --skip-git
Frontend
npm create vite@5.2.0 client -- --template react-ts
STEP 3 — Install dependencies
Backend
@prisma/client
prisma
@nestjs/config
jose
Frontend
react-admin
ra-data-simple-rest
@mui/material
@emotion/react
@emotion/styled
keycloak-js
STEP 4 — Generate Prisma schema
From DSL domain generate:
models
enums
relations
primary keys
Type mapping
decimal → Decimal
date → DateTime
DTO mapping
decimal → string
date → ISO string
STEP 5 — Generate NestJS CRUD modules
Per entity generate:
module
controller
service
dto
Controller routes
GET /resource
GET /resource/:pk
POST /resource
PATCH /resource/:pk
DELETE /resource/:pk
Path parameter must match the DSL primary key name.
Examples
/equipment/:id
/equipment-types/:code
/repair-orders/:id
STEP 6 — Generate backend auth infrastructure
Generate:
AuthModule
JWT guard
roles guard
@Public()
@Roles()
typed authenticated principal
typed env validation
Rules:
- `/health` must remain public
- CRUD routes must be protected by default
- RBAC source must be `realm_access.roles`
- JWT verification must use issuer + audience + JWKS
- JWKS resolution priority must be:
1. explicit `KEYCLOAK_JWKS_URL`
2. OIDC discovery
3. `${issuer}/protocol/openid-connect/certs`
- Do not use deprecated Keycloak-specific Node adapters
STEP 7 — Generate Service Layer
Service layer must follow backend/service-rules.md.
Important rule:
React Admin sends the id field in update payloads even when the primary key is not named id.
Therefore update payload must be sanitized before passing data to Prisma.
Services MUST NOT pass raw request DTO directly into Prisma.
Incorrect:
prisma.entity.update({
where,
data: dto
})
Correct pattern:
const { id, <primaryKey>, ...data } = dto
return prisma.entity.update({
where,
data
})
Example (PK = code)
const { id, code, ...data } = dto
return prisma.equipmentType.update({
where: { code },
data
})
Example (PK = id)
const { id: _pk, ...data } = dto
return prisma.entity.update({
where: { id },
data
})
Rules
Update payload passed to Prisma must not contain:
id
primary key attribute
readonly attributes
STEP 8 — Generate frontend auth integration
Generate:
client/src/config/env.ts
client/src/auth/keycloak.ts
client/src/auth/authProvider.ts
Rules:
- Keycloak login must be redirect-based only
- Use Authorization Code + PKCE (`S256`)
- Initialize Keycloak before rendering the SPA
- Attach `Authorization: Bearer <access_token>` through the shared request seam in `client/src/dataProvider.ts`
- `401` must force re-authentication
- `403` must surface access denied without forcing re-authentication
- Token refresh must be concurrency-safe
- Do not store tokens in `localStorage` or `sessionStorage`
- Frontend auth config must fail fast if required auth vars are missing
STEP 9 — Generate runtime infrastructure
Create:
server/.env
server/.env.example
client/.env.example
root-level Keycloak realm import artifact (default example filename: `toir-realm.json`)
Backend env examples must include:
PORT
DATABASE_URL
CORS_ALLOWED_ORIGINS
KEYCLOAK_ISSUER_URL
KEYCLOAK_AUDIENCE
KEYCLOAK_JWKS_URL (optional)
Frontend env examples must include:
VITE_API_URL
VITE_KEYCLOAK_URL
VITE_KEYCLOAK_REALM
VITE_KEYCLOAK_CLIENT_ID
Add to package.json:
postinstall: prisma generate
STEP 10 — Database runtime
Generate root:
docker-compose.yml
PostgreSQL container
postgres:16
port 5432
STEP 11 — Generate seed
Create:
server/prisma/seed.ts
Seed minimal data for:
EquipmentType
Equipment
RepairOrder
Add to package.json:
prisma.seed
STEP 12 — Generate React Admin resources
For each entity generate:
Field mapping
string → TextInput
number → NumberInput
date → DateInput
enum → SelectInput
FK → ReferenceInput
API responses MUST contain:
If PK ≠ id, map primary key to id.
Example
{
id: record.code,
code: record.code
}
STEP 13 — Validation
Verify:
docker-compose.yml exists
database container starts
prisma migrate dev works
prisma db seed works
API responds /health
React Admin receives id
update services sanitize payload before Prisma
frontend auth files exist
backend auth files exist
auth env examples exist
public /health is preserved
unauthenticated protected route returns 401
insufficient role returns 403
generated realm import artifact is self-contained and guarantees `sub`, `aud`, and `realm_access.roles`
OUTPUT
Provide:
FULLSTACK GENERATION REPORT
Include:
1 Parsed DSL
2 Prisma models
3 Backend modules
4 API endpoints
5 React Admin resources
6 Authentication and authorization
7 Runtime configuration
8 Validation results
RUN INSTRUCTIONS
The generated application must run successfully with:
Import the generated root-level Keycloak realm artifact (for example `toir-realm.json`) into the external Keycloak server
docker compose up -d
cd server
npm install
npx prisma migrate dev
npm run start
cd client
npm install
npm run dev

View File

@@ -1,225 +0,0 @@
# Backend Generation Process
Backend generation follows a pipeline aligned with runtime, auth, and validation docs:
DSL
CLI scaffolding
backend code generation
auth generation
runtime infrastructure
database runtime
migration
seed
validation
Follow:
- `backend/architecture.md`
- `backend/runtime-rules.md`
- `backend/prisma-rules.md`
- `backend/prisma-service.md`
- `backend/database-runtime.md`
- `backend/seed-rules.md`
- `backend/service-rules.md`
- `auth/keycloak-architecture.md`
- `auth/backend-auth-rules.md`
- `auth/keycloak-realm-template-rules.md`
---
# Step 1 — Parse DSL
Read DSL inputs and extract:
- entities
- attributes, including the actual primary key attribute per entity
- enums
- foreign keys
The generator must treat auth as default runtime infrastructure, not as a DSL feature toggle.
---
# Step 2 — CLI scaffolding
Use official CLIs before generating backend code:
- NestJS project scaffold in `server/` (see `generation/scaffolding-rules.md`)
- install backend dependencies:
- `@prisma/client`
- `prisma`
- `@nestjs/config`
- `jose`
- seed runner when needed by the chosen package tooling
The generator must **not** use deprecated Keycloak-specific Node adapters such as `keycloak-connect`.
---
# Step 3 — Core backend code generation
Generate backend source artifacts:
1. **Prisma schema** (`server/prisma/schema.prisma`) from the domain DSL:
- attributes
- primary keys
- relations
- enums
2. **NestJS modules** per entity:
- module
- controller
- service
3. **DTO files**:
- `create-entity.dto.ts`
- `update-entity.dto.ts`
- `entity.response.dto.ts` (or equivalent)
4. **PrismaService**:
- implement `OnModuleInit`
- call `await this.$connect()` in `onModuleInit()`
- do not use a `beforeExit` hook
5. **Service update methods**:
- sanitize update payload before Prisma
- remove `id`, the entity primary key, and readonly attributes from `data`
- do not pass the raw request body directly to `prisma.*.update()`
Use mapping rules from `backend/prisma-rules.md`:
- DSL `decimal` -> DTO `string`
- DSL `date` -> DTO `string` (ISO)
---
# Step 4 — Backend auth generation
Generate auth infrastructure as part of the normal backend output. Auth must not be deferred to a manual post-step.
Required generated auth artifacts:
- `server/src/auth/auth.module.ts`
- JWT auth guard
- roles guard
- `@Public()` decorator
- `@Roles()` decorator
- typed authenticated principal interface
- typed auth-aware config validation in `server/src/config/`
JWT verification rules:
- verify JWTs with issuer, audience, and JWKS
- use a standards-based library such as `jose`
- resolve JWKS in this exact order:
1. explicit `KEYCLOAK_JWKS_URL`
2. OIDC discovery
3. `${issuer}/protocol/openid-connect/certs`
RBAC rules:
- extract authorization roles only from `realm_access.roles`
- do not infer roles from other claims
- apply CRUD defaults by HTTP method:
- `GET` -> `viewer`, `editor`, `admin`
- `POST`, `PATCH`, `PUT` -> `editor`, `admin`
- `DELETE` -> `admin`
- mark `/health` as public with `@Public()`
Controller generation rules:
- generated CRUD controllers must receive auth decorators by default
- path params must still use the actual entity primary key name (`:id`, `:code`, etc.)
- public routes must be explicit rather than implicit
---
# Step 5 — Runtime infrastructure
Generate backend runtime config files:
- `server/.env`
- `server/.env.example`
- `server/src/config/*` typed validation helpers
- `server/package.json` lifecycle:
- `"postinstall": "prisma generate"`
- `server/package.json` Prisma seed:
- `"prisma": { "seed": "ts-node prisma/seed.ts" }` (or `tsx` equivalent if that is the chosen project standard)
The generated backend env contract must include:
- `PORT`
- `DATABASE_URL`
- `CORS_ALLOWED_ORIGINS`
- `KEYCLOAK_ISSUER_URL`
- `KEYCLOAK_AUDIENCE`
- `KEYCLOAK_JWKS_URL` (optional)
Fail-fast config rule:
- backend startup must fail fast when required auth or database env vars are missing
- the generator must not silently fall back to production auth values in code
Commands that must be supported and documented:
- `npx prisma generate`
- `npx prisma migrate dev`
- `npx prisma db seed`
---
# Step 6 — Database runtime
Create `docker-compose.yml` at the **project root** with PostgreSQL only.
Minimum required compose characteristics:
- `services.postgres`
- `image: postgres:16`
- `ports: ["5432:5432"]`
Credentials and database name in compose must match `DATABASE_URL`.
Keycloak must remain an external runtime dependency and must not be added to `docker-compose.yml` in this repository.
---
# Step 7 — Migration
Apply schema to the development database:
```bash
cd server
npx prisma migrate dev
```
---
# Step 8 — Seed
Run development seed:
```bash
cd server
npx prisma db seed
```
Seed file location: `server/prisma/seed.ts`.
---
# Step 9 — Validation
Run runtime, auth, and contract checks from `generation/post-generation-validation.md`, including:
- docker-compose exists and DB container starts
- Prisma lifecycle commands succeed
- seed runs
- `/health` responds without auth
- protected routes reject unauthenticated requests with `401`
- authenticated users with insufficient role receive `403`
- React Admin-compatible API responses include `id` for every record

View File

@@ -1,135 +0,0 @@
# Developer Workflow
This document describes the **developer workflow** for running a generated fullstack application locally. The generator must produce a project that supports this workflow so the app is **fully runnable** after generation, including authentication.
---
# Prerequisites
- **Node.js** (LTS, e.g. 18+)
- **npm**
- **Docker** and **Docker Compose** (for the development database)
- **External Keycloak server** reachable by the generated frontend and backend
The generated project must not require the developer to invent auth wiring manually after generation.
---
# Workflow Steps
## 1. Prepare Keycloak and env files
From the project root, the generated project must include:
- root-level generated Keycloak realm import artifact
- repository default example filename: `toir-realm.json`
- `server/.env.example`
- `client/.env.example`
Required workflow:
1. Copy the generated env examples to real env files as needed.
2. Fill all required backend and frontend auth variables.
3. Import or verify the generated Keycloak realm import artifact in the external Keycloak server before starting the app.
The generator must document that auth config is fail-fast. Missing required auth env vars must stop startup instead of silently falling back to production values in code.
---
## 2. Start the database
From the **project root**:
```bash
docker compose up -d
```
This starts the PostgreSQL container defined in `docker-compose.yml`. Wait a few seconds for the database to accept connections.
Verify if needed:
```bash
docker compose ps
```
---
## 3. Backend setup and start
From the **server** directory:
```bash
cd server
npm install
npx prisma generate
npx prisma migrate dev
npx prisma db seed
npm run start
```
- `npm install` installs dependencies and runs `postinstall` when configured.
- `npx prisma generate` explicitly generates Prisma client.
- `npx prisma migrate dev` creates/applies migrations.
- `npx prisma db seed` inserts minimal development data.
- `npm run start` starts the NestJS backend.
The API should be available at the configured port (for example `http://localhost:3000`).
Verify:
```bash
curl http://localhost:3000/health
```
Expected: `{ "status": "ok" }` (or equivalent).
Generated backend behavior must also ensure:
- protected CRUD routes require authentication by default
- insufficient roles result in `403`
- `/health` remains public
---
## 4. Frontend setup and start
In a **separate terminal**, from the **project root**:
```bash
cd client
npm install
npm run dev
```
- `npm install` installs frontend dependencies including `keycloak-js`.
- `npm run dev` starts the Vite dev server (for example `http://localhost:5173`).
Open the frontend URL in a browser. The generated React Admin app must:
- initialize Keycloak before render
- use redirect-based login only
- authenticate against the configured Keycloak realm/client
- call the backend with bearer tokens through the shared request seam
---
# Summary
| Step | Command / location |
|------|---------------------|
| Prepare Keycloak + env | Fill `server/.env` and `client/.env`; import or verify the generated realm import artifact |
| Start database | From root: `docker compose up -d` |
| Backend setup/start | `cd server && npm install && npx prisma generate && npx prisma migrate dev && npx prisma db seed && npm run start` |
| Frontend setup/start | `cd client && npm install && npm run dev` |
The generator must produce all required artifacts so that this workflow succeeds:
- docker-compose
- env examples
- schema
- migrations
- seed
- health endpoint
- frontend auth integration
- backend auth infrastructure
- root-level Keycloak realm import artifact

View File

@@ -1,138 +0,0 @@
# Frontend Generation Process
Frontend generation must produce the React Admin CRUD application **and** the default Keycloak integration required by the runtime architecture.
Follow:
- `frontend/architecture.md`
- `frontend/react-admin-rules.md`
- `auth/keycloak-architecture.md`
- `auth/frontend-auth-rules.md`
- `auth/keycloak-realm-template-rules.md`
---
# Step 1 — Parse DSL
Extract:
- entities
- attributes
- relation fields required for reference inputs and reference displays
The generator must treat auth as default frontend infrastructure rather than as an optional feature.
---
# Step 2 — Generate frontend runtime structure
Generate the base frontend structure required by the proven runtime:
- `client/src/main.tsx`
- `client/src/App.tsx`
- `client/src/dataProvider.ts`
- `client/src/config/env.ts`
- `client/src/auth/keycloak.ts`
- `client/src/auth/authProvider.ts`
- `client/.env.example`
The generated frontend must:
- initialize Keycloak before rendering the SPA
- register React Admin with a mandatory `authProvider`
- enforce authenticated operation rather than anonymous operation
- use environment-driven runtime config and fail fast when required auth vars are missing
---
# Step 3 — Generate React Admin resources
For each entity create:
- `EntityList.tsx`
- `EntityCreate.tsx`
- `EntityEdit.tsx`
- `EntityShow.tsx`
Resource generation must remain compatible with the auth-aware shared request seam.
---
# Step 4 — Map fields
Map DSL attributes to React Admin components according to existing field rules and relation semantics.
Reference/resource lookups must continue to flow through the same shared authenticated request layer used by the main CRUD resources.
---
# Step 5 — Generate auth-aware API layer
Generate a shared `dataProvider.ts` that:
- reads the API base URL from `VITE_API_URL`
- attaches `Authorization: Bearer <access_token>` to every backend request
- uses the same request seam for all React Admin operations, including:
- list
- get one
- get many
- get many reference
- create
- update
- delete
- handles token refresh before protected requests
- keeps token refresh concurrency-safe by sharing one in-flight refresh operation
- does not store access tokens or refresh tokens in `localStorage` or `sessionStorage`
The generator must not create multiple competing HTTP clients for authenticated and unauthenticated traffic. The shared request seam is the single bearer injection point.
---
# Step 6 — Generate frontend auth flow
Generate Keycloak frontend integration with these required rules:
- use `keycloak-js`
- redirect-based login only
- no custom in-app username/password login form
- Authorization Code + PKCE with `S256`
- initialize Keycloak before React render
- provide a React Admin `authProvider`
- distinguish auth failures from authorization failures:
- `401` -> force logout / re-authentication
- `403` -> do not re-authenticate; surface access denied / permission error
The generator must not silently fall back to production auth settings in code. Example values belong in `.env.example`, but runtime config must fail fast if required values are absent.
---
# Step 7 — Register resources and auth
Register resources in `App.tsx` and wire them into an authenticated `Admin` root.
Generated `App.tsx` must:
- register the shared `dataProvider`
- register the mandatory `authProvider`
- configure the app so it does not operate anonymously once auth is enabled
Example shape:
```tsx
<Admin dataProvider={dataProvider} authProvider={authProvider}>
<Resource name="equipment" ... />
</Admin>
```
---
# Step 8 — Frontend runtime config
Generate frontend env examples and config access for:
- `VITE_API_URL`
- `VITE_KEYCLOAK_URL`
- `VITE_KEYCLOAK_REALM`
- `VITE_KEYCLOAK_CLIENT_ID`
`client/.env.example` must use filled example values that match the documented Keycloak and local dev topology, while runtime code must fail fast if any required variable is missing.

815
generation/generate.mjs Normal file
View File

@@ -0,0 +1,815 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Always resolve repo root relative to this script location
// <repo>/generation/generate.mjs -> root is parent folder of generation/
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '..');
function readFile(p) {
return fs.readFileSync(p, 'utf8');
}
function writeFile(p, content) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, 'utf8');
}
function toKebab(s) {
return s
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/_/g, '-')
.toLowerCase();
}
function pluralize(resource) {
// Minimal heuristic; can be improved later.
if (resource === 'equipment') return 'equipment';
if (resource.endsWith('s')) return `${resource}es`;
return `${resource}s`;
}
function upperFirst(s) {
return s ? s[0].toUpperCase() + s.slice(1) : s;
}
function lowerFirst(s) {
return s ? s[0].toLowerCase() + s.slice(1) : s;
}
function toIdentifierFromKebab(kebab) {
// "repair-order" -> "repairOrder"
return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
function parseBlocks(text, kind) {
// kind: 'enum' | 'entity'
const blocks = [];
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(new RegExp(`^\\s*${kind}\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\{\\s*$`));
if (!m) continue;
const name = m[1];
let depth = 0;
const start = i;
let end = i;
for (let j = i; j < lines.length; j++) {
const l = lines[j];
if (l.includes('{')) depth += (l.match(/\{/g) || []).length;
if (l.includes('}')) depth -= (l.match(/\}/g) || []).length;
if (depth === 0 && j > i) {
end = j;
break;
}
}
const body = lines.slice(start + 1, end).join('\n');
blocks.push({ name, body, startLine: start, endLine: end });
i = end;
}
return blocks;
}
function parseEnum(body) {
const values = [];
const labels = {};
const re = /value\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\}/gm;
let m;
while ((m = re.exec(body))) {
values.push(m[1]);
const label = (m[2].match(/label\s+"([^"]+)"/m) || [])[1];
labels[m[1]] = label || m[1];
}
return { values, labels };
}
function parseEntity(body) {
const attrs = [];
const entityLabel = (body.match(/description\s+"([^"]+)"/m) || [])[1];
const lines = body.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^\s*attribute\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$/);
if (!m) continue;
const name = m[1];
let depth = 0;
const start = i;
let end = i;
for (let j = i; j < lines.length; j++) {
const l = lines[j];
if (l.includes('{')) depth += (l.match(/\{/g) || []).length;
if (l.includes('}')) depth -= (l.match(/\}/g) || []).length;
if (depth === 0 && j > i) {
end = j;
break;
}
}
const abody = lines.slice(start + 1, end).join('\n');
const type = (abody.match(/^\s*type\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/m) || [])[1];
const isRequired = /^\s*is required\s*;/m.test(abody);
const isUnique = /^\s*is unique\s*;/m.test(abody);
const isPrimary = /^\s*key primary\s*;/m.test(abody);
const defaultValue = (abody.match(/^\s*default\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/m) || [])[1];
const foreignRel = (abody.match(/relates\s+([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)\s*;/m) || []).slice(1);
const description = (abody.match(/description\s+"([^"]+)"/m) || [])[1];
attrs.push({
name,
type,
label: description || name,
isRequired,
isUnique,
isPrimary,
defaultValue,
foreign: foreignRel.length ? { entity: foreignRel[0], field: foreignRel[1] } : null,
});
i = end;
}
const pk = attrs.find((a) => a.isPrimary);
if (!pk) throw new Error('Entity missing primary key attribute');
return { attributes: attrs, primaryKey: pk.name, label: entityLabel };
}
function parseDomainDSL(dslText) {
const enums = {};
const entities = {};
for (const b of parseBlocks(dslText, 'enum')) {
enums[b.name] = parseEnum(b.body);
}
for (const b of parseBlocks(dslText, 'entity')) {
entities[b.name] = parseEntity(b.body);
}
return { enums, entities };
}
function getEntityAttrNames(entity) {
return new Set(entity.attributes.map((a) => a.name));
}
function getBestSortField(entity, pk) {
const attrs = getEntityAttrNames(entity);
if (attrs.has('inventoryNumber')) return 'inventoryNumber';
if (attrs.has('number')) return 'number';
if (attrs.has('code')) return 'code';
if (attrs.has('name')) return 'name';
return pk;
}
function getReferenceDisplayExpr(foreignEntity) {
const attrs = getEntityAttrNames(foreignEntity);
if (attrs.has('inventoryNumber')) {
return "(record) => record.inventoryNumber ? `${record.inventoryNumber} — ${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)";
}
if (attrs.has('code')) {
return "(record) => record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)";
}
if (attrs.has('number')) {
return "(record) => record.number ? `${record.number} — ${record.name ?? record.number}` : (record.name ?? record.id)";
}
if (attrs.has('name')) {
return "(record) => record.name ?? record.id";
}
return "(record) => record.id";
}
function getAttributeLabel(attr, allEntities) {
if (attr.label && attr.label !== attr.name) return attr.label;
if (attr.name === 'status') return 'Статус';
if (attr.name === 'equipmentId') return 'Оборудование';
if (attr.name === 'equipmentTypeCode') return 'Вид оборудования';
if (attr.foreign) return allEntities[attr.foreign.entity]?.label || attr.name;
return attr.name;
}
function prismaScalarType(dslType) {
switch (dslType) {
case 'string':
case 'text':
return 'String';
case 'uuid':
return 'String';
case 'integer':
return 'Int';
case 'decimal':
return 'Decimal';
case 'date':
return 'DateTime';
default:
// enum type name
return dslType;
}
}
function generatePrismaEnum(name, values) {
return `enum ${name} {\n${values.map((v) => ` ${v}`).join('\n')}\n}\n`;
}
function generatePrismaModel(name, entity, allEntities) {
const lines = [];
lines.push(`model ${name} {`);
for (const attr of entity.attributes) {
if (attr.foreign) {
// Keep scalar FK field, relation field added below
}
const scalar = prismaScalarType(attr.type);
const optional = attr.isRequired || attr.isPrimary ? '' : '?';
const parts = [` ${attr.name} ${scalar}${optional}`];
if (attr.isPrimary) {
if (attr.type === 'uuid' || attr.name === 'id') parts.push('@id @default(uuid())');
else parts.push('@id');
}
if (attr.isUnique && !attr.isPrimary) parts.push('@unique');
if (attr.defaultValue) parts.push(`@default(${attr.defaultValue})`);
lines.push(`${parts.join(' ')}`);
}
// Add relations for foreign keys
for (const attr of entity.attributes.filter((a) => a.foreign)) {
const relEntity = attr.foreign.entity;
const relField = attr.foreign.field;
const relName = lowerFirst(relEntity);
// relation field must not collide; fallback to relEntity name if needed
const relationFieldName = entity.attributes.some((a) => a.name === relName) ? `${relName}Ref` : relName;
lines.push(
` ${relationFieldName} ${relEntity} @relation(fields: [${attr.name}], references: [${relField}])`
);
}
// Add back-relations (required by Prisma when a relation field exists)
// For each other entity that has a FK pointing to this model, create a list field.
for (const [otherName, otherEntity] of Object.entries(allEntities)) {
for (const fk of otherEntity.attributes.filter((a) => a.foreign)) {
if (fk.foreign.entity !== name) continue;
const candidate = pluralize(lowerFirst(otherName));
const fieldName = lines.some((l) => l.startsWith(` ${candidate} `))
? `${candidate}List`
: candidate;
// Avoid duplicates if multiple FKs exist (basic de-dupe)
if (lines.some((l) => l.startsWith(` ${fieldName} `))) continue;
lines.push(` ${fieldName} ${otherName}[]`);
}
}
lines.push('}');
return `${lines.join('\n')}\n`;
}
function ensurePrismaSchema({ enums, entities }, prismaPath, apply) {
const existing = fs.existsSync(prismaPath) ? readFile(prismaPath) : '';
const hasGenerator = /generator\s+client\s*\{/m.test(existing);
const header = hasGenerator
? existing.split(/\n(?=enum|model)\b/)[0].trimEnd() + '\n\n'
: `generator client {\n provider = "prisma-client-js"\n}\n\ndatasource db {\n provider = "postgresql"\n url = env("DATABASE_URL")\n}\n\n`;
const out = [header];
// Preserve existing enum/model blocks not in DSL? For now, regenerate from DSL only.
for (const [name, e] of Object.entries(enums)) out.push(generatePrismaEnum(name, e.values) + '\n');
for (const [name, ent] of Object.entries(entities)) out.push(generatePrismaModel(name, ent, entities) + '\n');
const next = out.join('').trimEnd() + '\n';
if (!apply) return { changed: next !== existing, content: next };
writeFile(prismaPath, next);
return { changed: true, content: next };
}
function renderBackendModule(entityName, entity, resourceName, pk) {
const className = entityName;
const moduleName = `${className}Module`;
const serviceName = `${className}Service`;
const controllerName = `${className}Controller`;
const folder = toKebab(entityName);
// DTOs
const dtoType = (attr) => {
switch (attr.type) {
case 'uuid':
case 'string':
case 'text':
return 'string';
case 'integer':
return 'number';
case 'decimal':
return 'string';
case 'date':
return 'string';
default:
// enum
return 'string';
}
};
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(`@IsNumberString({}, { message: '${field}: должно быть числом' })`);
imports.add('IsNumberString');
break;
case 'date':
decorators.push(`@IsISO8601({}, { message: '${field}: должно содержать корректную дату' })`);
imports.add('IsISO8601');
break;
default:
// enum (kept as string in generated DTOs)
decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`);
imports.add('IsString');
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 = [];
for (const a of entity.attributes) {
if (a.isPrimary && a.type === 'uuid') continue; // generated
const { decorators, imports, needsTypeImport } = getValidationDecorators(a, false);
imports.forEach((i) => createDecorators.add(i));
if (needsTypeImport) createNeedsTypeImport = true;
decorators.forEach((d) => createDtoLines.push(` ${d}`));
const opt = a.isRequired && !(a.isPrimary && a.type !== 'uuid') ? '!' : '?';
createDtoLines.push(` ${a.name}${opt}: ${dtoType(a)};`);
}
const updateDtoLines = [];
if (pk !== 'id') {
updateDtoLines.push(' @IsOptional()');
updateDtoLines.push(` @IsString({ message: 'id: должно быть строкой' })`);
updateDtoLines.push(' id?: string;');
updateDecorators.add('IsString');
}
for (const a of entity.attributes) {
if (pk !== 'id' && a.name === 'id') continue;
const { decorators, imports, needsTypeImport } = getValidationDecorators(a, true);
imports.forEach((i) => updateDecorators.add(i));
if (needsTypeImport) updateNeedsTypeImport = true;
decorators.forEach((d) => updateDtoLines.push(` ${d}`));
updateDtoLines.push(` ${a.name}?: ${dtoType(a)};`);
}
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 service = `import { Injectable } from '@nestjs/common';\nimport { Prisma } from '@prisma/client';\nimport { PrismaService } from '../../prisma/prisma.service';\nimport { Create${className}Dto } from './dto/create-${folder}.dto';\nimport { Update${className}Dto } from './dto/update-${folder}.dto';\n\nfunction serializeRecord(record: any) {\n return {\n ...record,\n${entity.attributes
.filter((a) => a.type === 'decimal')
.map((a) => ` ${a.name}: record.${a.name}?.toString() ?? null,`)
.join('\n')}\n${entity.attributes
.filter((a) => a.type === 'date')
.map((a) => ` ${a.name}: record.${a.name}?.toISOString() ?? null,`)
.join('\n')}\n };\n}\n\n@Injectable()\nexport class ${serviceName} {\n constructor(private readonly prisma: PrismaService) {}\n\n async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {\n const start = parseInt(query._start) || 0;\n const end = parseInt(query._end) || 10;\n const take = end - start;\n const skip = start;\n const sortField = query._sort || '${getBestSortField(entity, pk)}';\n const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';\n\n const where: any = {};\n\n if (query.q) {\n const q = String(query.q);\n const ors: any[] = [];\n ${entity.attributes
.filter((a) => ['string', 'text'].includes(a.type))
.slice(0, 6)
.map((a) => `ors.push({ ${a.name}: { contains: q, mode: 'insensitive' } });`)
.join('\n ')}\n if (ors.length) where.OR = ors;\n }\n\n ${entity.attributes
.filter((a) => ['string', 'text'].includes(a.type) && !a.foreign)
.map((a) => `if (query.${a.name}) where.${a.name} = { contains: query.${a.name}, mode: 'insensitive' };`)
.join('\n ')}\n\n ${entity.attributes
.filter((a) => a.foreign)
.map((a) => `if (query.${a.name}) where.${a.name} = query.${a.name};`)
.join('\n ')}\n\n // Enum multi-value support (e.g. status=A&status=B)\n ${entity.attributes
.filter((a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type))
.map((a) => `if (query.${a.name}) { const vals = Array.isArray(query.${a.name}) ? query.${a.name} : [query.${a.name}]; where.${a.name} = vals.length > 1 ? { in: vals } : vals[0]; }`)
.join('\n ')}\n\n if (query.id) {\n const ids = Array.isArray(query.id) ? query.id : [query.id];\n where.${pk} = { in: ids };\n }\n\n const [data, total] = await Promise.all([\n this.prisma.${lowerFirst(className)}.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),\n this.prisma.${lowerFirst(className)}.count({ where }),\n ]);\n\n const mapped = ${pk === 'id' ? 'data.map(serializeRecord)' : `data.map((r: any) => ({ id: r.${pk}, ...serializeRecord(r) }))`};\n return { data: mapped, total };\n }\n\n async findOne(id: string) {\n const record = await this.prisma.${lowerFirst(className)}.findUniqueOrThrow({ where: { ${pk}: id } as any });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n\n async create(dto: Create${className}Dto) {\n const data: any = { ...(dto as any) };\n${entity.attributes
.filter((a) => a.type === 'date')
.map((a) => ` if (data.${a.name}) data.${a.name} = new Date(data.${a.name});`)
.join('\n')}\n${entity.attributes
.filter((a) => a.type === 'decimal')
.map((a) => ` if (data.${a.name}) data.${a.name} = new Prisma.Decimal(data.${a.name});`)
.join('\n')}\n\n const record = await this.prisma.${lowerFirst(className)}.create({ data });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n\n async update(id: string, dto: Update${className}Dto) {\n const data: any = { ...(dto as any) };\n delete data.id;\n delete data.${pk};\n${entity.attributes
.filter((a) => a.type === 'date')
.map((a) => ` if (data.${a.name}) data.${a.name} = new Date(data.${a.name});`)
.join('\n')}\n${entity.attributes
.filter((a) => a.type === 'decimal')
.map((a) => ` if (data.${a.name} !== undefined && data.${a.name} !== null) data.${a.name} = new Prisma.Decimal(data.${a.name});`)
.join('\n')}\n\n const record = await this.prisma.${lowerFirst(className)}.update({ where: { ${pk}: id } as any, data });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n\n async remove(id: string) {\n const record = await this.prisma.${lowerFirst(className)}.delete({ where: { ${pk}: id } as any });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n}\n`;
let serviceContent = service
.replace(
`const sortField = query._sort || '${getBestSortField(entity, pk)}';\n const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';`,
`const sortField = query._sort || '${getBestSortField(entity, pk)}';\n const prismaSortField = sortField === 'id' ? '${pk}' : sortField;\n const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';`
)
.replace('orderBy: { [sortField]: sortOrder }', 'orderBy: { [prismaSortField]: sortOrder }')
.replace(
`data.map((r: any) => ({ id: r.${pk}, ...serializeRecord(r) }))`,
`data.map((item: any) => ({ id: item.${pk}, ...serializeRecord(item) }))`
);
if (pk !== 'id') {
serviceContent = serviceContent.replace(
`const data: any = { ...(dto as any) };\n delete data.id;\n delete data.${pk};`,
`const { id: _pk, ${pk}, ...rest } = (dto as any);\n const data: any = { ...rest };`
);
}
const mod = `import { Module } from '@nestjs/common';\nimport { ${controllerName} } from './${folder}.controller';\nimport { ${serviceName} } from './${folder}.service';\n\n@Module({\n controllers: [${controllerName}],\n providers: [${serviceName}],\n})\nexport class ${moduleName} {}\n`;
return {
folder,
files: {
[`server/src/modules/${folder}/${folder}.controller.ts`]: controller,
[`server/src/modules/${folder}/${folder}.service.ts`]: serviceContent,
[`server/src/modules/${folder}/${folder}.module.ts`]: mod,
[`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDto,
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDto,
},
moduleName,
importPath: `./modules/${folder}/${folder}.module`,
};
}
function renderFrontendResource(entityName, entity, resourceName, pk, enums, allEntities) {
const folder = toKebab(entityName);
const className = entityName;
const enumAttrs = entity.attributes.filter(
(a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)
);
const statusEnumAttr = enumAttrs.find((a) => a.name === 'status');
const identBase = toIdentifierFromKebab(folder);
const filtersIdent = `${identBase}Filters`;
const sortField = getBestSortField(entity, pk);
const hasNumber = entity.attributes.some((a) => ['integer', 'decimal'].includes(a.type));
const hasDate = entity.attributes.some((a) => a.type === 'date');
const hasFK = entity.attributes.some((a) => a.foreign);
const hasNonStatusEnum = enumAttrs.some((a) => a.name !== 'status');
const listImportSet = new Set([
'List',
'Datagrid',
'TextField',
'TextInput',
'TopToolbar',
'FilterButton',
'CreateButton',
'ExportButton',
]);
if (hasNumber) listImportSet.add('NumberField');
if (hasDate) listImportSet.add('DateField');
if (enumAttrs.length) listImportSet.add('SelectField');
if (hasFK) listImportSet.add('ReferenceField');
if (statusEnumAttr) listImportSet.add('SelectArrayInput');
if (hasNonStatusEnum) listImportSet.add('SelectInput');
if (hasFK) {
listImportSet.add('ReferenceInput');
listImportSet.add('AutocompleteInput');
}
const listImports = Array.from(listImportSet);
const choiceConsts = [];
for (const a of enumAttrs) {
const enumName = a.type;
const values = enums?.[enumName]?.values ?? [];
const labels = enums?.[enumName]?.labels ?? {};
const constName = `${a.name}Choices`;
if (a.name === 'status') {
choiceConsts.push(
`const statusChoices = [\n${values.map((v) => ` { id: '${v}', name: '${labels[v] ?? v}' },`).join('\n')}\n];\n`
);
} else {
choiceConsts.push(
`const ${constName} = [\n${values.map((v) => ` { id: '${v}', name: '${labels[v] ?? v}' },`).join('\n')}\n];\n`
);
}
}
const filterInputs = [];
// Always include q if any string fields
if (entity.attributes.some((a) => ['string', 'text'].includes(a.type))) {
filterInputs.push(`<TextInput key="q" source="q" label="Поиск" alwaysOn />`);
}
for (const a of entity.attributes) {
const label = getAttributeLabel(a, allEntities);
if (a.name === pk) continue;
if (a.foreign) {
const referenceDisplay = getReferenceDisplayExpr(allEntities[a.foreign.entity]);
filterInputs.push(
`<ReferenceInput key="${a.name}" source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}" label="${label}">\n <AutocompleteInput optionText={${referenceDisplay}} filterToQuery={(searchText) => ({ q: searchText })} />\n </ReferenceInput>`
);
continue;
}
if (['string', 'text', 'uuid'].includes(a.type)) {
filterInputs.push(`<TextInput key="${a.name}" source="${a.name}" label="${label}" />`);
continue;
}
if (['integer', 'decimal'].includes(a.type)) continue;
if (a.type === 'date') continue;
// enum
if (a.name === 'status') {
filterInputs.push(`<SelectArrayInput key="${a.name}" source="${a.name}" label="${label}" choices={statusChoices} />`);
} else {
filterInputs.push(`<SelectInput key="${a.name}" source="${a.name}" label="${label}" choices={${a.name}Choices} emptyText="Все" />`);
}
}
const listFields = [];
for (const a of entity.attributes) {
const label = getAttributeLabel(a, allEntities);
if (a.foreign) {
const referenceEntity = allEntities[a.foreign.entity];
const referenceAttrs = getEntityAttrNames(referenceEntity);
const fieldSource = referenceAttrs.has('inventoryNumber')
? 'inventoryNumber'
: referenceAttrs.has('code')
? 'code'
: referenceAttrs.has('number')
? 'number'
: 'name';
listFields.push(
`<ReferenceField source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}" label="${label}" link="show">\n <TextField source="${fieldSource}" />\n </ReferenceField>`
);
continue;
}
if (a.type === 'date') {
listFields.push(`<DateField source="${a.name}" label="${label}" />`);
} else if (['integer', 'decimal'].includes(a.type)) {
listFields.push(`<NumberField source="${a.name}" label="${label}" />`);
} else if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) {
listFields.push(`<SelectField source="${a.name}" label="${label}" choices={${a.name === 'status' ? 'statusChoices' : `${a.name}Choices`}} />`);
} else {
listFields.push(`<TextField source="${a.name}" label="${label}" />`);
}
}
const list = `import {\n ${listImports.join(',\n ')}\n} from 'react-admin';\n\n${choiceConsts.join('\n')}\nconst ${filtersIdent} = [\n ${filterInputs.join(',\n ')}\n];\n\nconst ${className}ListActions = () => (\n <TopToolbar>\n <FilterButton filters={${filtersIdent}} />\n <CreateButton />\n <ExportButton />\n </TopToolbar>\n);\n\nexport const ${className}List = () => (\n <List actions={<${className}ListActions />} filters={${filtersIdent}} sort={{ field: '${sortField}', order: 'ASC' }}>\n <Datagrid rowClick=\"show\">\n ${listFields.join('\n ')}\n </Datagrid>\n </List>\n);\n`;
const formField = (a, mode) => {
const label = getAttributeLabel(a, allEntities);
if (a.isPrimary && mode === 'create' && a.type === 'uuid') return null;
if (a.isPrimary && mode === 'edit') {
return `<TextInput source="${a.name}" label="${label}" disabled />`;
}
if (a.foreign) {
const referenceDisplay = getReferenceDisplayExpr(allEntities[a.foreign.entity]);
return `<ReferenceInput source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}">\n <AutocompleteInput label="${label}" optionText={${referenceDisplay}} filterToQuery={(searchText) => ({ q: searchText })} />\n </ReferenceInput>`;
}
if (a.type === 'date') return `<DateInput source="${a.name}" label="${label}" />`;
if (['integer', 'decimal'].includes(a.type)) return `<NumberInput source="${a.name}" label="${label}" />`;
if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) {
if (a.name === 'status' && statusEnumAttr) return `<SelectInput source="${a.name}" label="${label}" choices={statusChoices} emptyText="Не выбрано" />`;
return `<SelectInput source="${a.name}" label="${label}" choices={${a.name}Choices} emptyText="Не выбрано" />`;
}
return `<TextInput source="${a.name}" label="${label}" ${a.isRequired ? 'isRequired' : ''} />`;
};
const formImportSet = new Set(['SimpleForm', 'TextInput']);
if (hasNumber) formImportSet.add('NumberInput');
if (hasDate) formImportSet.add('DateInput');
if (enumAttrs.length) formImportSet.add('SelectInput');
if (hasFK) {
formImportSet.add('ReferenceInput');
formImportSet.add('AutocompleteInput');
}
const createImports = ['Create', ...Array.from(formImportSet)].join(', ');
const create = `import { ${createImports} } from 'react-admin';\n\n${choiceConsts.join('\n')}\nexport const ${className}Create = () => (\n <Create>\n <SimpleForm>\n ${entity.attributes.map((a) => formField(a, 'create')).filter(Boolean).join('\n ')}\n </SimpleForm>\n </Create>\n);\n`;
const editImports = ['Edit', ...Array.from(formImportSet)].join(', ');
const edit = `import { ${editImports} } from 'react-admin';\n\n${choiceConsts.join('\n')}\nexport const ${className}Edit = () => (\n <Edit>\n <SimpleForm>\n ${entity.attributes.map((a) => formField(a, 'edit')).filter(Boolean).join('\n ')}\n </SimpleForm>\n </Edit>\n);\n`;
const showImportSet = new Set(['Show', 'SimpleShowLayout', 'TextField']);
if (hasNumber) showImportSet.add('NumberField');
if (hasDate) showImportSet.add('DateField');
if (enumAttrs.length) showImportSet.add('SelectField');
if (hasFK) showImportSet.add('ReferenceField');
const showFields = [];
for (const a of entity.attributes) {
const label = getAttributeLabel(a, allEntities);
if (a.foreign) {
const referenceEntity = allEntities[a.foreign.entity];
const referenceAttrs = getEntityAttrNames(referenceEntity);
const fieldSource = referenceAttrs.has('inventoryNumber')
? 'inventoryNumber'
: referenceAttrs.has('code')
? 'code'
: referenceAttrs.has('number')
? 'number'
: 'name';
showFields.push(
`<ReferenceField source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}" label="${label}" link="show">\n <TextField source="${fieldSource}" />\n </ReferenceField>`
);
continue;
}
if (a.type === 'date') {
showFields.push(`<DateField source="${a.name}" label="${label}" />`);
} else if (['integer', 'decimal'].includes(a.type)) {
showFields.push(`<NumberField source="${a.name}" label="${label}" />`);
} else if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) {
showFields.push(`<SelectField source="${a.name}" label="${label}" choices={${a.name === 'status' ? 'statusChoices' : `${a.name}Choices`}} />`);
} else {
showFields.push(`<TextField source="${a.name}" label="${label}" />`);
}
}
const show = `import { ${Array.from(showImportSet).join(', ')} } from 'react-admin';\n\n${choiceConsts.join('\n')}export const ${className}Show = () => (\n <Show>\n <SimpleShowLayout>\n ${showFields.join('\n ')}\n </SimpleShowLayout>\n </Show>\n);\n`;
return {
files: {
[`client/src/resources/${folder}/${className}List.tsx`]: list,
[`client/src/resources/${folder}/${className}Create.tsx`]: create,
[`client/src/resources/${folder}/${className}Edit.tsx`]: edit,
[`client/src/resources/${folder}/${className}Show.tsx`]: show,
},
resourceName,
className,
folder,
};
}
function upsertInFile(filePath, apply, updater) {
const abs = path.join(ROOT, filePath);
const existing = fs.existsSync(abs) ? readFile(abs) : '';
const next = updater(existing);
if (apply) writeFile(abs, next);
return { changed: next !== existing, content: next };
}
function ensureAppModule(apply, backendModules) {
return upsertInFile('server/src/app.module.ts', apply, (src) => {
let out = src;
for (const m of backendModules) {
if (!out.includes(`import { ${m.moduleName} }`)) {
const importLine = `import { ${m.moduleName} } from '${m.importPath}';`;
const importMatches = [...out.matchAll(/^import\s+.*;$/gm)];
if (importMatches.length) {
const lastImport = importMatches[importMatches.length - 1];
const insertAt = lastImport.index + lastImport[0].length;
out = `${out.slice(0, insertAt)}\n${importLine}${out.slice(insertAt)}`;
} else {
out = `${importLine}\n${out}`;
}
}
}
out = out.replace(/imports:\s*\[\s*([\s\S]*?)\s*\],/m, (match, inner) => {
let block = inner;
for (const m of backendModules) {
if (!block.includes(m.moduleName)) block = block.replace(/\s*\],?\s*$/m, '') + `\n ${m.moduleName},`;
}
// normalize trailing comma/indent by reusing original replacement style
return `imports: [${block}\n ],`;
});
return out;
});
}
function ensureClientApp(apply, frontendResources) {
return upsertInFile('client/src/App.tsx', apply, (src) => {
let out = src;
for (const r of frontendResources) {
const imports = [
`import { ${r.className}List } from './resources/${r.folder}/${r.className}List';`,
`import { ${r.className}Create } from './resources/${r.folder}/${r.className}Create';`,
`import { ${r.className}Edit } from './resources/${r.folder}/${r.className}Edit';`,
`import { ${r.className}Show } from './resources/${r.folder}/${r.className}Show';`,
];
for (const imp of imports) {
if (!out.includes(imp)) {
const importMatches = [...out.matchAll(/^import\s+.*;$/gm)];
if (importMatches.length) {
const lastImport = importMatches[importMatches.length - 1];
const insertAt = lastImport.index + lastImport[0].length;
out = `${out.slice(0, insertAt)}\n${imports.join('\n')}${out.slice(insertAt)}`;
} else {
out = `${imports.join('\n')}\n${out}`;
}
break;
}
}
if (!out.includes(`name="${r.resourceName}"`)) {
out = out.replace(
/<\/Admin>/m,
` <Resource\n name="${r.resourceName}"\n options={{ label: '${r.className}' }}\n list={${r.className}List}\n create={${r.className}Create}\n edit={${r.className}Edit}\n show={${r.className}Show}\n />\n </Admin>`
);
}
}
return out;
});
}
/** Собирает файлы как при --apply, без записи. Учитывает текущие app.module.ts и App.tsx на диске. */
function collectGeneratedBundle(parsed) {
const files = {};
const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma');
const pr = ensurePrismaSchema(parsed, prismaPath, false);
files['server/prisma/schema.prisma'] = pr.content;
const backendModules = [];
const frontendResources = [];
for (const [entityName, ent] of Object.entries(parsed.entities)) {
const pk = ent.primaryKey;
const resource = pluralize(toKebab(entityName));
const be = renderBackendModule(entityName, ent, resource, pk);
const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums);
backendModules.push(be);
frontendResources.push(fe);
Object.assign(files, be.files, fe.files);
}
const appMod = ensureAppModule(false, backendModules);
files['server/src/app.module.ts'] = appMod.content;
const clientApp = ensureClientApp(false, frontendResources);
files['client/src/App.tsx'] = clientApp.content;
return {
entityCount: Object.keys(parsed.entities).length,
enumCount: Object.keys(parsed.enums).length,
files,
};
}
function main() {
const args = process.argv.slice(2);
const apply = args.includes('--apply');
const printBundleJson = args.includes('--print-bundle-json');
const dslArgIdx = args.indexOf('--dsl');
const dslPath = dslArgIdx >= 0 ? args[dslArgIdx + 1] : 'domain/TOiR.domain.dsl';
const absDsl = path.resolve(ROOT, dslPath);
const dslText = readFile(absDsl);
const parsed = parseDomainDSL(dslText);
if (printBundleJson) {
const bundle = collectGeneratedBundle(parsed);
process.stdout.write(JSON.stringify(bundle));
return;
}
// Prisma schema
const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma');
ensurePrismaSchema(parsed, prismaPath, apply);
// Backend modules + frontend resources
const backendModules = [];
const frontendResources = [];
for (const [entityName, ent] of Object.entries(parsed.entities)) {
const pk = ent.primaryKey;
const resource = pluralize(toKebab(entityName));
const be = renderBackendModule(entityName, ent, resource, pk);
const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums, parsed.entities);
backendModules.push(be);
frontendResources.push(fe);
if (apply) {
for (const [rel, content] of Object.entries(be.files)) writeFile(path.join(ROOT, rel), content);
for (const [rel, content] of Object.entries(fe.files)) writeFile(path.join(ROOT, rel), content);
}
}
ensureAppModule(apply, backendModules);
ensureClientApp(apply, frontendResources);
process.stdout.write(
`${apply ? 'Generated' : 'Planned'} ${Object.keys(parsed.entities).length} entities from ${dslPath}\n`
);
}
main();

View File

@@ -1,236 +0,0 @@
# Post-Generation Validation
After generating the backend or fullstack application, run these checks to ensure the project will start cleanly and that the default auth model has been generated correctly.
---
# Validation Checklist
## 1. Frontend and backend env files
- [ ] `server/.env.example` exists and documents:
- `PORT`
- `DATABASE_URL`
- `CORS_ALLOWED_ORIGINS`
- `KEYCLOAK_ISSUER_URL`
- `KEYCLOAK_AUDIENCE`
- optional `KEYCLOAK_JWKS_URL`
- [ ] `client/.env.example` exists and documents:
- `VITE_API_URL`
- `VITE_KEYCLOAK_URL`
- `VITE_KEYCLOAK_REALM`
- `VITE_KEYCLOAK_CLIENT_ID`
- [ ] Runtime code fails fast when required auth or database env vars are missing.
- [ ] Runtime code does not silently fall back to production auth settings.
**Failure symptoms:** startup succeeds with undefined auth config, or fails later with opaque auth/runtime errors.
---
## 2. Keycloak realm artifact
- [ ] A root-level generated Keycloak realm import artifact exists.
- [ ] If the repository default filename `toir-realm.json` is not used, the project-specific equivalent is documented consistently across bootstrap and workflow docs.
- [ ] The generated realm artifact is self-contained and reproducible.
- [ ] The generated realm artifact parameterizes realm name, frontend client ID, backend audience/client ID, production URLs, and artifact filename consistently with the generated project auth config.
- [ ] It defines realm roles:
- `admin`
- `editor`
- `viewer`
- [ ] It defines a frontend SPA client consistent with the generated frontend Keycloak client ID.
- [ ] It defines a backend audience/resource client consistent with the generated backend audience/client ID.
- [ ] It defines explicit audience delivery, such as an `api-audience` client scope.
- [ ] It does not rely on undeclared built-in client scopes being present after import.
- [ ] It explicitly addresses delivery of:
- `sub`
- `aud`
- `realm_access.roles`
**Failure symptoms:** access tokens are missing required claims, or realm import succeeds but generated apps still cannot authenticate/authorize reliably.
---
## 3. Frontend auth files and behavior
- [ ] Generated frontend includes:
- `client/src/config/env.ts`
- `client/src/auth/keycloak.ts`
- `client/src/auth/authProvider.ts`
- `client/src/main.tsx`
- `client/src/App.tsx`
- `client/src/dataProvider.ts`
- [ ] `keycloak-js` is installed.
- [ ] Keycloak is initialized before the SPA renders.
- [ ] Login is redirect-based only.
- [ ] No custom in-app username/password login form is generated.
- [ ] `Authorization Code + PKCE (S256)` is encoded in the frontend auth flow.
- [ ] `client/src/dataProvider.ts` or the documented shared request seam injects `Authorization: Bearer <access_token>` into all API requests.
- [ ] Token refresh is concurrency-safe:
- one shared in-flight refresh operation
- no parallel refresh stampede
- [ ] Generated auth code does not persist access tokens or refresh tokens in `localStorage` or `sessionStorage`.
- [ ] React Admin auth semantics distinguish:
- `401` -> force logout / re-authentication
- `403` -> do not re-authenticate; surface access denied / permission error
**Failure symptoms:** app renders before auth is ready, reference calls miss auth headers, refresh storms occur, or `403` incorrectly forces logout.
---
## 4. Backend auth files and behavior
- [ ] Generated backend includes:
- `server/src/auth/auth.module.ts`
- JWT guard
- roles guard
- `@Public()` decorator
- `@Roles()` decorator
- typed authenticated principal interface
- typed config validation in `server/src/config/`
- [ ] `jose` is installed.
- [ ] JWT verification uses issuer + audience + JWKS.
- [ ] JWKS resolution follows this exact priority:
1. `KEYCLOAK_JWKS_URL`
2. OIDC discovery
3. `${issuer}/protocol/openid-connect/certs`
- [ ] Authorization roles are extracted only from `realm_access.roles`.
- [ ] Deprecated Keycloak-specific Node adapters are not used.
**Failure symptoms:** invalid tokens are accepted, valid tokens are rejected due to bad JWKS resolution, or RBAC depends on unstable/non-standard claims.
---
## 5. CRUD protection and RBAC defaults
- [ ] `/health` is public.
- [ ] Each generated CRUD controller method other than explicit public routes is protected by the generated auth/RBAC infrastructure.
- [ ] CRUD RBAC defaults are present:
- `GET` -> `viewer | editor | admin`
- `POST`, `PATCH`, `PUT` -> `editor | admin`
- `DELETE` -> `admin`
- [ ] Unauthenticated request to a protected route returns `401`.
- [ ] Authenticated user with insufficient role receives `403`.
**Failure symptoms:** anonymous CRUD access remains open, or insufficient-role users are not denied properly.
---
## 6. PrismaService implementation
- [ ] A `PrismaService` (or equivalent) class exists and extends `PrismaClient`.
- [ ] It implements `OnModuleInit` and calls `await this.$connect()` in `onModuleInit()`.
- [ ] It does not use `this.$on('beforeExit', ...)` or any `beforeExit` hook.
**Failure symptom:** deprecation/runtime errors in Prisma 5 when using `beforeExit`.
**Reference:** `backend/prisma-service.md`
---
## 7. Prisma client lifecycle
- [ ] `package.json` includes a script that runs Prisma client generation:
- either `"postinstall": "prisma generate"` (or `npx prisma generate`)
- or clear documentation to run `npx prisma generate` after install
- [ ] After schema generation or change, `npx prisma generate` has been run or will run via `postinstall`.
**Failure symptom:** `Cannot find module '@prisma/client'` or missing generated types.
---
## 8. Database migration
- [ ] Migration workflow is documented.
- [ ] Instruction to run `npx prisma migrate dev` exists after first generation or schema change.
- [ ] Generation pipeline includes or documents the migration step.
**Failure symptom:** tables do not exist; Prisma errors on first query.
---
## 9. REST route parameters
- [ ] For each entity, path parameters use the correct primary key name from the DSL.
- [ ] Entity with PK `id` uses `/:id`.
- [ ] Entity with non-`id` primary key (for example `code`) uses the actual PK name such as `/:code`, not `/:id`.
**Failure symptom:** controller expects one param name while the route defines another, leading to broken CRUD behavior.
**Reference:** `backend/architecture.md`
---
## 10. DTO type mapping and React Admin ID compatibility
- [ ] DSL `decimal` maps to DTO/API `string`.
- [ ] DSL `date` maps to DTO/API `string` (ISO) or equivalent string serialization.
- [ ] Every API response object contains a field named `id`.
- [ ] If the entity primary key is not named `id`, the response maps the primary key to `id`.
**Failure symptoms:** serialization issues for decimals/dates, or React Admin cannot identify records.
---
## 11. Update payload sanitization
- [ ] Update endpoints do not pass `id` or the primary key in Prisma `data`.
- [ ] Generated update methods remove `id`, the entity primary key, and readonly attributes before calling `prisma.*.update()`.
**Failure symptom:** Prisma throws because immutable or invalid fields are passed in update `data`.
---
## 12. Database runtime
- [ ] `docker-compose.yml` exists at the project root.
- [ ] It defines a PostgreSQL service with image `postgres:16`, port `5432`, and credentials matching `DATABASE_URL`.
- [ ] `docker compose up -d` starts the database successfully.
- [ ] `docker-compose.yml` does not add Keycloak in this repository.
**Failure symptom:** Prisma cannot reach the development database or repo topology drifts from the documented external-Keycloak model.
---
## 13. Migrations, seed, and health endpoint
- [ ] `npx prisma migrate dev` runs successfully from `server/`.
- [ ] Seed script exists at `server/prisma/seed.ts` (or equivalent).
- [ ] `npx prisma db seed` runs without error.
- [ ] Backend exposes `GET /health`.
- [ ] `GET /health` returns HTTP 200 with a status payload.
**Failure symptom:** development bootstrap cannot complete end-to-end.
---
# Summary Table
| Check | Required artifact / rule |
| --- | --- |
| Frontend env | `client/.env.example` with required Vite auth vars |
| Backend env | `server/.env.example` with DB, CORS, and Keycloak vars |
| Fail-fast config | Startup fails when required auth env is missing |
| Realm artifact | Root generated realm import artifact with self-contained auth setup |
| Frontend auth | `keycloak.ts`, `authProvider.ts`, authenticated `dataProvider.ts` |
| Backend auth | `AuthModule`, guards, decorators, typed principal |
| JWKS strategy | explicit URL -> discovery -> certs fallback |
| Role source | `realm_access.roles` only |
| CRUD RBAC | GET viewer/editor/admin; write editor/admin; delete admin |
| `/health` | Public and returns 200 |
| Protected route unauthenticated | Returns `401` |
| Protected route insufficient role | Returns `403` |
| Token storage | No `localStorage` / `sessionStorage` persistence |
| Token refresh | Concurrency-safe single in-flight refresh |
| Prisma lifecycle | `OnModuleInit` + `$connect()`, no `beforeExit` |
| Update sanitization | Strip `id` / PK / readonly before Prisma update |
| React Admin `id` | Every record includes `id` |
| Database runtime | PostgreSQL compose exists and starts |
---
# Integration with generation pipeline
1. Backend and frontend generation must produce artifacts that satisfy the above by default.
2. Runtime bootstrap must include Keycloak realm import/verification before app startup.
3. After generation, run this checklist manually or via an automated script.
4. If any check fails, update the generator context so future runs pass without manual repair.

View File

@@ -1,120 +0,0 @@
# Runtime Bootstrap
After project generation, the following commands and prerequisites must work in order so that the application runs without ad-hoc auth or database setup.
The generator must produce a **runnable development environment** consisting of:
- external Keycloak identity provider
- backend API
- frontend SPA
- PostgreSQL database
The generator must also produce the runtime artifacts required to bootstrap auth from zero, including a root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but future generations must allow a project-specific equivalent.
---
# Bootstrap Sequence
## 1. Prepare Keycloak
Before starting the application, ensure an external Keycloak server is reachable and import or verify the generated realm artifact:
- root-level generated Keycloak realm import artifact
- repository default example filename: `toir-realm.json`
The runtime instructions must require importing or verifying this realm before frontend/backend startup.
Keycloak bootstrap expectations:
- realm import is self-contained
- frontend client exists
- backend audience client exists
- realm roles exist
- issued access tokens reliably contain `sub`, `aud`, and `realm_access.roles`
The generator must not rely on undeclared built-in client scopes magically existing after realm import.
---
## 2. Start the database
From the **project root**:
```bash
docker compose up -d
```
This starts the PostgreSQL container. The backend connects to it using `DATABASE_URL` from `server/.env`.
---
## 3. Backend setup and start
```bash
cd server
npm install
npx prisma generate
npx prisma migrate dev
npx prisma db seed
npm run start
```
- `npm install` installs dependencies and runs `postinstall` if configured.
- `npx prisma generate` ensures Prisma client is generated explicitly after install or schema changes.
- `npx prisma migrate dev` creates/applies migrations.
- `npx prisma db seed` inserts minimal development data.
- `npm run start` starts the NestJS server.
Backend startup requirements:
- required database and auth env vars must be present
- startup must fail fast if required env vars are missing
- CORS must support the SPA -> API bearer-token model
- `/health` must remain public
---
## 4. Frontend setup and start
In a separate terminal, from the **project root**:
```bash
cd client
npm install
npm run dev
```
- `npm install` installs frontend dependencies including `keycloak-js`.
- `npm run dev` starts the Vite dev server.
Frontend startup requirements:
- required Vite auth vars must be present
- startup must fail fast if required auth vars are missing
- Keycloak must initialize before the SPA is rendered
- login must use redirect-based Keycloak authentication only
---
# Success Criteria
After completing the bootstrap sequence:
- Keycloak is reachable and the generated realm has been imported or verified.
- Database container is running and Prisma can connect.
- Backend responds on `/health` without auth.
- Protected backend routes require a valid bearer token.
- Frontend loads, redirects through Keycloak login when needed, and uses bearer auth for all API calls.
The generator is responsible for producing all artifacts and instructions needed for this sequence:
- `docker-compose.yml`
- schema
- migrations
- seed
- backend/frontend env examples
- health endpoint
- auth infrastructure
- root-level Keycloak realm import artifact
Docker scope must remain limited to PostgreSQL. Keycloak remains an external dependency in this repository.

View File

@@ -1,92 +0,0 @@
# Project Scaffolding Rules
The generator must use **official CLI tools** to create base project structures.
The AI must **not** manually generate the entire project skeleton by hand. CLI scaffolding reduces drift and keeps the generated project aligned with current NestJS and Vite conventions.
Auth is part of the default generated runtime. Scaffolding must therefore install the required frontend and backend auth dependencies during the normal project bootstrap path.
---
# Backend Scaffolding
Use **NestJS CLI**.
## Command
```bash
npx @nestjs/cli@10.3.2 new server --package-manager npm --skip-git
```
## Rules
- **Project directory** must be `server`.
- **TypeScript** must be used.
- **npm** must be the package manager.
- **Git** initialization must be skipped.
## After scaffolding — install required dependencies
Run from the `server` directory:
```bash
npm install @prisma/client
npm install @nestjs/config
npm install jose
npm install prisma --save-dev
```
## Backend auth dependency rules
- `jose` must be installed by default because JWT verification is part of the default generated backend.
- The generator must **not** install deprecated Keycloak-specific Node adapters such as `keycloak-connect`.
---
# Frontend Scaffolding
Use **Vite CLI**.
## Command
```bash
npm create vite@5.2.0 client -- --template react-ts
```
## Rules
- **Project directory** must be `client`.
- **React + TypeScript** template must be used.
## After scaffolding — install required dependencies
Run from the `client` directory:
```bash
npm install react-admin
npm install ra-data-simple-rest
npm install @mui/material @emotion/react @emotion/styled
npm install keycloak-js
```
## Frontend auth dependency rules
- `keycloak-js` must be installed by default because redirect-based Keycloak login is part of the default generated frontend.
- The generated frontend must use `keycloak-js` for Authorization Code + PKCE and must not generate a custom in-app username/password login form.
---
# Scaffolding Strategy
Generation pipeline order:
1. **Parse DSL** — Read domain, DTO, API, and UI DSL files.
2. **Run CLI scaffolding** — Create `server` with NestJS CLI and `client` with Vite CLI; install runtime and auth dependencies listed above.
3. **Code generation** — Generate Prisma schema, NestJS modules/DTOs/PrismaService/auth infrastructure, and React Admin resources/auth integration.
4. **Runtime infrastructure** — Generate backend/frontend `.env.example`, runtime config files, lifecycle scripts, and a root-level Keycloak realm import artifact (repository default example filename: `toir-realm.json`).
5. **Database runtime** — Generate `docker-compose.yml` in project root with PostgreSQL service (`postgres`, image `postgres:16`, port `5432:5432`).
6. **Migration** — Apply schema with `npx prisma migrate dev`.
7. **Seed** — Populate minimal development data with `npx prisma db seed`.
8. **Validation** — Run checks from `generation/post-generation-validation.md`, including auth validation and realm-template validation.
Scaffolding (steps 12) must be done with the CLI. Steps 38 must be generated from the DSL and the project context documents, including the auth-specific context in `auth/*.md`.

View File

@@ -1,40 +0,0 @@
# Update Strategy
When the DSL changes, regeneration must preserve the default auth-enabled runtime rather than falling back to CRUD-only output.
## Required regeneration sequence
1. Regenerate `prisma/schema.prisma`.
2. Run `npx prisma migrate dev`.
3. Regenerate NestJS entity modules, DTOs, controllers, and services.
4. Regenerate backend auth infrastructure:
- `AuthModule`
- guards
- decorators
- typed authenticated principal
- typed config validation
- CRUD RBAC decorations
5. Regenerate React Admin resources.
6. Regenerate frontend auth infrastructure:
- `src/config/env.ts`
- `src/auth/keycloak.ts`
- `src/auth/authProvider.ts`
- authenticated `dataProvider.ts`
- `App.tsx` auth wiring
- `main.tsx` init-before-render flow
7. Regenerate backend and frontend `.env.example` files so the auth env contract stays in sync.
8. Regenerate the root-level Keycloak realm import artifact. The repository default example filename is `toir-realm.json`, but the generator must allow a project-specific equivalent.
9. Re-run post-generation validation, including:
- auth dependency checks
- fail-fast env checks
- `/health` public check
- unauthenticated protected route -> `401`
- insufficient role -> `403`
- realm-template validation
## Guardrails
- Regeneration must not strip auth back out of the project.
- Auth remains outside the DSL grammar, but it is part of the default generated runtime.
- If a DSL change affects entities or routes, the generator must re-apply the default CRUD RBAC rules automatically.
- If a DSL change affects runtime topology or naming, the generator must keep backend/frontend env examples, CORS rules, and the generated realm import artifact aligned with the generated app.

View File

@@ -0,0 +1,2 @@
// Optional overrides:
// resource EquipmentType path "equipment-types";

View File

@@ -0,0 +1,2 @@
// Optional overrides:
// field EquipmentType.code widget "text";

10
package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "toir-generation-context",
"private": true,
"scripts": {
"generate:domain-summary": "node tools/generate-domain-summary.mjs",
"validate:generation": "node tools/validate-generation.mjs",
"validate:generation:runtime": "node tools/validate-generation.mjs --run-runtime",
"validate:generation:artifacts": "node tools/validate-generation.mjs --artifacts-only"
}
}

79
prompts/auth-rules.md Normal file
View File

@@ -0,0 +1,79 @@
# Auth Rules
This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline.
- Auth is part of the default generation path, not a post-generation addon.
- `server/` is the active backend target output path.
- `client/` is the active frontend target output path.
- The generated runtime stays SPA + API + external Keycloak + PostgreSQL only.
## Frontend auth invariants
- Use `keycloak-js` with redirect-based login only.
- Initialize Keycloak before rendering the SPA.
- Use Authorization Code Flow + PKCE (`S256`).
- Keep `authProvider`, `dataProvider`, `getIdentity()`, `getPermissions()`, and `checkError()` as stable provider seams.
- Derive identity from token claims already present in the parsed token.
- Do not call `loadUserProfile()` and do not depend on `/account` for the baseline app.
- `401` must force re-authentication; `403` must stay an authorization error.
- Keep token handling in memory and refresh through one shared in-flight operation.
## Working runtime defaults
Use the already working project defaults unless a prompt explicitly overrides them.
- Frontend Keycloak base URL example:
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
- Frontend realm and client example:
- `VITE_KEYCLOAK_REALM=toir`
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
- Backend issuer and audience example:
- `KEYCLOAK_ISSUER_URL=https://sso.greact.ru/realms/toir`
- `KEYCLOAK_AUDIENCE=toir-backend`
- CORS example:
- `CORS_ALLOWED_ORIGINS=http://localhost:5173,https://toir-frontend.greact.ru`
Anti-regression rule:
- Do not switch the baseline Keycloak example back to `http://localhost:8080` in generated `.env.example` files unless the prompt explicitly asks for a local Keycloak runtime.
- Localhost Keycloak values may exist in private `.env.local` or `.env` overrides, but they are not the default project examples.
## Backend auth invariants
- Verify JWTs with `jose`.
- Validate issuer + audience + signature via JWKS.
- Resolve JWKS in this order:
1. `KEYCLOAK_JWKS_URL`
2. OIDC discovery at `/.well-known/openid-configuration`
3. `${issuer}/protocol/openid-connect/certs`
- Extract roles only from `realm_access.roles`.
- Keep `/health` public and all generated CRUD routes protected by default.
## Auth anti-regression invariants
- The accepted JWKS resolution chain above is the only baseline truth path. Do not document one order and implement another.
- If auth implementation changes, `prompts/auth-rules.md` and `prompts/validation-rules.md` must be updated in the same change.
- Do not skip OIDC discovery when no explicit `KEYCLOAK_JWKS_URL` is provided.
- Do not switch role extraction to alternative claims unless the prompt explicitly changes the baseline contract.
- Do not reintroduce localhost Keycloak defaults into shared baseline examples.
## Realm artifact contract
- A physical root-level `*-realm.json` artifact is mandatory output.
- The artifact must be importable, versioned, and aligned with generated backend/frontend env contracts.
- It must parameterize:
- realm name
- frontend client id
- backend client id / audience
- local and production frontend URLs
- artifact filename
- It must explicitly deliver:
- `sub`
- `aud`
- `realm_access.roles`
- It must define:
- realm roles `admin`, `editor`, `viewer`
- a public SPA client with PKCE S256
- a bearer-only backend client
- an explicit audience client scope
- explicit protocol mappers for baseline identity and role claims

88
prompts/backend-rules.md Normal file
View File

@@ -0,0 +1,88 @@
# Backend Rules
The backend remains derived from `domain/*.dsl` inside the existing LLM-first pipeline. No compiler platform or generator engine is introduced.
## Backend scaffold baseline
- Start backend initialization from the official NestJS CLI workspace, not from manually created files.
- The backend must remain compatible with standard Nest workspace tooling such as `nest build` and `nest start`.
- Preserve the core Nest workspace files generated by the CLI, especially:
- `server/tsconfig.json`
- `server/tsconfig.build.json`
- `server/nest-cli.json`
- `server/src/main.ts`
- `server/src/app.module.ts`
- For domain resources, prefer official Nest CLI generation patterns for modules/controllers/services/resources and then adapt the generated code to Prisma and auth requirements.
- Do not delete required Nest workspace files just because the LLM can inline a smaller custom structure.
## Forbidden backend generation patterns
- Do not bootstrap `server/` by hand-writing a pseudo-Nest project from memory.
- Do not remove `tsconfig.json`, `tsconfig.build.json`, or `nest-cli.json` after generation.
- Do not replace standard Nest package scripts with ad hoc commands that break `nest build` or `nest start`.
- Do not continue CRUD generation on top of a degraded backend workspace without repairing the workspace first.
## Domain-derived output
- `domain/*.dsl` is the source of truth for entities, fields, primary keys, foreign keys, and enums.
- `domain-summary.json` is a derived artifact used to stabilize LLM generation and validation. It must never replace the DSL as the source of truth.
- Each entity becomes:
- a Prisma model
- a NestJS module
- a controller
- a service
- create/update DTOs
## DTO and Prisma mapping
- `decimal` -> Prisma `Decimal`, DTO/API `string`
- `date` -> Prisma `DateTime`, DTO/API `string`
- Enums remain string-valued in DTO/API contracts
## CRUD and natural-key invariants
- CRUD routes use the real primary key name in the path.
- Every API record returned to React Admin must include `id`.
- For entities whose primary key is not `id`, the backend must map the real key to `id`.
- Natural-key list/sort logic must never build ORM `orderBy` against a fake physical `id`.
## Service invariants
- Never pass raw update DTOs into Prisma update `data`.
- Remove `id`, the real primary key, and readonly fields from update payloads before calling Prisma.
- Keep PrismaService lightweight:
- extend `PrismaClient`
- implement `OnModuleInit`
- call `$connect()`
- do not use `beforeExit`
## Filtering contract
- List endpoints must support React Admin query parameters:
- `_start`, `_end`, `_sort`, `_order`
- arbitrary field filters from query string
- `q` for reference autocomplete search
- String/text search filters may use `contains` with case-insensitive mode.
- Foreign key filters must use exact-match semantics (no `contains` for FK scalar keys).
- Enum filters must support both single and repeated query params:
- `status=Draft`
- `status=Draft&status=Approved`
- Repeated enum params must map to Prisma `{ in: [...] }`.
- Sorting must use real model scalar fields only; natural-key entities must not fallback to fake physical `id`.
## Reproducibility invariants
- A freshly generated backend must be bootstrappable with ordinary Nest + Prisma commands from `prompts/runtime-rules.md`.
- Missing TypeScript or Nest workspace config is a generation failure, not an acceptable simplification.
- The baseline backend should fail only on missing runtime dependencies or env values, not because the Nest workspace itself is incomplete.
## Recovery rule if backend workspace degraded
- If required Nest scaffold files are missing or broken, restore the official workspace baseline before editing Prisma models, modules, controllers, services, or DTOs.
- Treat workspace repair as higher priority than feature generation, because generated domain code on top of a broken workspace is invalid baseline output.
## Backend auth defaults
- `GET` -> `viewer | editor | admin`
- `POST`, `PATCH`, `PUT` -> `editor | admin`
- `DELETE` -> `admin`

65
prompts/frontend-rules.md Normal file
View File

@@ -0,0 +1,65 @@
# Frontend Rules
The frontend stays a React Admin SPA generated from `domain/*.dsl` and anchored to the existing auth seams.
## Frontend scaffold baseline
- Start frontend initialization from the official Vite React TypeScript scaffold, not from manually assembled files.
- Preserve a valid Vite workspace baseline, including:
- `client/index.html`
- `client/tsconfig.json`
- `client/vite.config.*`
- `client/src/main.tsx`
- Add React Admin and auth seams on top of that baseline instead of replacing the workspace with a hand-written minimal shell.
- Do not delete required Vite entry/config files just because the LLM can write a shorter custom setup.
## Forbidden frontend generation patterns
- Do not bootstrap `client/` by hand-writing a pseudo-Vite project from memory.
- Do not remove `index.html`, `tsconfig*`, or `vite.config.*` after generation.
- Do not replace standard Vite package scripts with ad hoc commands that break `vite build`, `vite dev`, or `vite preview`.
- Do not continue React Admin resource generation on top of a degraded frontend workspace without repairing the workspace first.
## Resource generation
- Each entity becomes a React Admin resource with list/create/edit/show views.
- Resource names must stay aligned with backend path segments.
- Foreign keys must use `ReferenceInput` / `ReferenceField`.
- Foreign keys shown in list/show views must stay clickable via `ReferenceField link="show"` to open full details of the related resource.
- Lists must expose filters through `List` `filters` and an actions toolbar with `FilterButton`.
- For enum fields where multi-select is required (for example `status`), use `SelectArrayInput` in list filters.
- For foreign key filters and form selection use `ReferenceInput` + `AutocompleteInput` with `filterToQuery={(searchText) => ({ q: searchText })}`.
- Form mapping must stay type-safe:
- `integer` / `decimal` -> `NumberInput`
- `date` -> `DateInput`
## Provider seams
- `client/src/dataProvider.ts` is the single authenticated request seam.
- `client/src/auth/authProvider.ts` is the single React Admin auth seam.
- Auth logic must not leak into resource components.
## Identity and permissions
- `getIdentity()` must resolve from parsed token claims.
- `getPermissions()` may expose realm roles for UI awareness.
- Backend enforcement remains authoritative.
## React Admin compatibility
- Every resource record must include `id`.
- Natural-key resources must preserve route, update, and sort compatibility with React Admin contracts.
- Frontend requests must continue to work when the real primary key is not named `id`.
- `dataProvider` query serialization must preserve repeated query params for array filters (for example enum multi-select).
- `Resource` wiring in `App.tsx` must keep `show={...}` registration for all generated resources.
## Reproducibility invariants
- A freshly generated frontend must remain compatible with standard Vite commands such as `npm run dev` and `npm run build`.
- Missing Vite workspace files or missing local Vite executable wiring is a generation failure, not an acceptable simplification.
- The generated frontend should fail only on missing installation/env/runtime backend availability, not because the Vite app structure itself is incomplete.
## Recovery rule if frontend workspace degraded
- If required Vite scaffold files are missing or broken, restore the official workspace baseline before editing resources, auth seams, or UI code.
- Treat workspace repair as higher priority than feature generation, because generated React Admin code on top of a broken Vite workspace is invalid baseline output.

146
prompts/general-prompt.md Normal file
View File

@@ -0,0 +1,146 @@
ROLE
You are a Staff-level Fullstack Platform Engineer working inside the established LLM-first CRUD generation baseline.
Use context7 when official framework guidance is needed.
This repository is not a new generator engine. Do not redesign it into a planner/emitter/runtime platform.
GOAL
Strengthen and use the existing LLM-first CRUD generation pipeline.
- Keep `domain/*.dsl` as the source of truth for the domain model.
- Keep `server/` as the active backend target output path.
- Keep `client/` as the active frontend target output path.
- Keep Keycloak auth in the default generation path.
- Keep PostgreSQL as the only Dockerized runtime dependency.
- Generate and maintain:
- `domain-summary.json`
- a root-level `*-realm.json`
- backend/frontend auth seams
- runtime/env/bootstrap artifacts
- validation-gate-compatible output
Use the already working runtime defaults for this project unless the prompt explicitly overrides them:
- frontend Keycloak URL example: `https://sso.greact.ru`
- frontend realm/client examples: `toir`, `toir-frontend`
- backend issuer/audience examples: `https://sso.greact.ru/realms/toir`, `toir-backend`
- production frontend origin example: `https://toir-frontend.greact.ru`
Do not silently regress these examples to localhost Keycloak defaults.
ACTIVE KNOWLEDGE BLOCKS
Read in this order:
1. `domain/dsl-spec.md`
2. `domain/*.dsl`
3. `domain-summary.json` if present
4. `prompts/auth-rules.md`
5. `prompts/backend-rules.md`
6. `prompts/frontend-rules.md`
7. `prompts/runtime-rules.md`
8. `prompts/validation-rules.md`
Interpretation rules:
- `domain/*.dsl` is authoritative.
- `domain-summary.json` is derived. Regenerate or validate it against the DSL; never treat it as the source of truth.
INPUT CONTRACT
Required:
- `domain/*.dsl`
Optional:
- `overrides/api-overrides.dsl`
- `overrides/ui-overrides.dsl`
Rules:
- Do not require DTO/API/UI DSL files.
- Do not resurrect multi-DSL source-of-truth behavior.
- Optional overrides may refine derived API/UI behavior but must not redefine the domain model.
PIPELINE CONTRACT
1. Parse `domain/*.dsl`.
2. Generate or refresh `domain-summary.json`.
3. Scaffold with official framework CLI conventions where scaffolding is needed.
4. Generate or maintain backend/frontend code in `server/` and `client/`.
5. Generate or maintain the root-level realm artifact.
6. Generate or maintain env/bootstrap/runtime artifacts.
7. Run the lightweight validation gate.
REPAIR-BEFORE-GENERATE ORDER
1. Inspect whether `server/` and `client/` are still valid framework workspaces.
2. If either workspace is degraded, repair the official scaffold baseline first.
3. Only after workspace repair, generate or update domain-derived feature code.
4. Only after generation, run validation and buildability checks.
CLI-FIRST SCAFFOLDING CONTRACT
- Never hand-write a fresh framework workspace from scratch when an official CLI exists for that framework.
- For `server/`, initialize the workspace from the official NestJS CLI baseline first, then generate/adapt modules/resources inside that workspace.
- For `client/`, initialize the workspace from the official Vite React TypeScript baseline first, then add React Admin, auth seams, and generated resources.
- Treat CLI scaffolding as the required baseline for compiler config, package scripts, workspace metadata, and default entry files.
- Manual creation is allowed only for domain-derived feature code after the official workspace exists.
- If a workspace already exists but is missing required CLI baseline files, repair it back to a valid official-style workspace before adding more generated code.
ANTI-REGRESSION FAILURES
Treat the following as baseline violations, not acceptable improvisations:
- creating a NestJS workspace by manually writing `package.json`, `tsconfig*`, `nest-cli.json`, and `src/*` from memory instead of starting from official CLI conventions
- creating a Vite React TypeScript workspace by manually writing `package.json`, `index.html`, `tsconfig*`, `vite.config.*`, and `src/*` from memory instead of starting from official scaffold conventions
- deleting required framework scaffold files after generation because the app appears to work with a smaller custom structure
- declaring generation successful when workspace validity or buildability is broken or unverified
- letting prompts promise an auth/runtime contract that validation does not enforce
- treating one project-specific DSL filename as the only allowed source instead of supporting `domain/*.dsl`
OUTPUT CONTRACT
The baseline output must include:
- `server/prisma/schema.prisma`
- `server/.env.example`
- `client/.env.example`
- `client/src/auth/keycloak.ts`
- `client/src/auth/authProvider.ts`
- `client/src/dataProvider.ts`
- `server/src/auth/*`
- `docker-compose.yml`
- `domain-summary.json`
- root-level `*-realm.json`
The baseline output must also remain a real framework workspace, not a prompt-only file collection:
- `server/tsconfig.json`
- `server/tsconfig.build.json`
- `server/nest-cli.json`
- `client/index.html`
- `client/tsconfig.json`
- `client/vite.config.*`
NON-GOALS
- No new generator engine
- No compiler/IR platform
- No heavy codegen redesign
- No replacement of the old LLM-first architecture
COMPLETION INVARIANTS
- Generation is incomplete if `server/` is not a valid NestJS workspace.
- Generation is incomplete if `client/` is not a valid Vite React TypeScript workspace.
- Generation is incomplete if auth rules, runtime rules, and validation rules describe different truth paths.
- Generation is incomplete if buildability is broken.
- If buildability cannot be checked because dependencies are missing, report that state explicitly; do not report a green result for buildability.
VALIDATION
Before considering the output complete, satisfy `prompts/validation-rules.md`.

96
prompts/runtime-rules.md Normal file
View File

@@ -0,0 +1,96 @@
# Runtime Rules
This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline and strengthens the existing pipeline instead of replacing it.
## Baseline runtime topology
- `server/` is the active backend target output path.
- `client/` is the active frontend target output path.
- Docker scope remains PostgreSQL only.
- Keycloak remains external to the repository runtime.
- The project remains LLM-first: markdown knowledge blocks in `prompts/` orchestrate generation, while active generated/maintained code lives in `server/` and `client/`.
## Required input and derived artifacts
- Source of truth:
- `domain/*.dsl`
- Required derived artifacts:
- `domain-summary.json`
- root-level `*-realm.json`
`domain-summary.json` exists to stabilize generation and validation; it must be regenerated from the DSL and treated as non-authoritative.
## Output contract
The strengthened baseline must produce and keep aligned:
- `server/prisma/schema.prisma`
- backend/frontend env examples
- backend/frontend auth seams
- root `.gitignore`, `server/.gitignore`, `client/.gitignore`
- `docker-compose.yml`
- `domain-summary.json`
- root-level realm import artifact
## Concrete runtime examples
Use these as the baseline examples for this project unless the prompt explicitly overrides them:
- Backend:
- `PORT=3000`
- `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"`
- `CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"`
- `KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"`
- `KEYCLOAK_AUDIENCE="toir-backend"`
- Frontend:
- `VITE_API_URL=http://localhost:3000`
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
- `VITE_KEYCLOAK_REALM=toir`
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
These example values come from the already working runtime shape and are preferred over local-only Keycloak placeholders.
## Runtime bootstrap
1. Import the root-level realm artifact into Keycloak.
2. Start PostgreSQL with `docker compose up -d`.
3. From `server/` run:
- initialize or repair the workspace with official Nest CLI scaffolding if required before generating domain code
- `npm install`
- `npx prisma generate`
- `npx prisma migrate dev`
- `npx prisma db seed`
- `npm run build`
- `npm run start`
4. From `client/` run:
- initialize or repair the workspace with official Vite React TypeScript scaffolding if required before generating app code
- `npm install`
- `npm run build`
- `npm run dev`
## Recovery and completion rules
- Repair degraded framework workspaces before applying any new domain-derived generation changes.
- Do not mark generation complete while `server/` or `client/` remains non-buildable.
- If dependency installation has not happened yet, buildability may be reported as skipped, but it must never be reported as green without verification.
- Runtime/bootstrap instructions are reusable project baseline rules; TOiR names remain examples, not the only supported domain project.
## Scaffold expectations
- NestJS workspace creation should follow the official Nest CLI path for new applications and resource scaffolding.
- Vite frontend creation should follow the official Vite `create-vite` path for React TypeScript applications.
- The LLM may customize generated code after scaffold creation, but must not replace official workspace initialization with ad hoc file creation.
## Common generation failures to avoid
- starting feature generation before scaffold repair
- leaving deleted framework config files unrepaired because the current diff looks smaller
- accepting a form-only validation pass while buildability is unknown
- binding runtime rules to one project-specific DSL filename instead of `domain/*.dsl`
## Baseline intent
- No new generator engine
- No compiler platform
- No planner/emitter/runtime redesign
- Only the current LLM-first pipeline, strengthened by summary, realm, and validation artifacts

View File

@@ -0,0 +1,98 @@
# Validation Rules
Validation is now a lightweight automated gate instead of a prose-only checklist.
## Commands
- `npm run generate:domain-summary`
- `npm run validate:generation`
- `npm run validate:generation:runtime`
## Prompt-Gate Alignment Rule
- Every invariant described as required in the active prompt corpus must either be enforced by this gate or be called out explicitly as a manual/runtime-only check.
- Validation must not stay silent about a violation that the prompts describe as forbidden.
- Validation must not report green buildability when build verification was skipped.
## Gate groups
### Build checks
- at least one `domain/*.dsl` file exists
- required artifacts exist
- Prisma schema exists
- frontend/backend env contracts exist
- frontend/backend framework workspace files exist
- `domain-summary.json` matches the current DSL
- project `.env.example` files keep the working domain-based Keycloak examples unless explicitly overridden
- `server/` remains a valid Nest workspace
- `client/` remains a valid Vite workspace
- 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 missing, build verification is reported as skipped with reason instead of green
### Auth checks
- frontend auth seam files exist
- backend auth seam files exist
- `401` and `403` semantics stay split
- auth code keeps the required Keycloak/JWT contracts
- JWKS resolution chain matches the contract:
1. explicit `KEYCLOAK_JWKS_URL`
2. OIDC discovery
3. certs fallback
### Filter checks
- list resources expose filter UI (including `FilterButton`)
- reference filters use `ReferenceInput` + `AutocompleteInput` with `filterToQuery`
- data provider preserves repeated query params for array filters
- backend FK filters keep exact-match semantics
- enum repeated params are mapped to Prisma `in`
- typed form mapping is preserved:
- `integer` / `decimal` -> `NumberInput`
- `date` -> `DateInput`
- reference fields intended for navigation keep `ReferenceField link="show"`
- resources keep `show={...}` registration in `App.tsx`
### Natural-key checks
- response records expose `id`
- route/update contracts use the real primary key
- natural-key sort/update paths do not regress to a fake physical `id`
### Realm checks
- a root `*-realm.json` artifact exists
- realm roles exist
- audience delivery exists
- required claims are explicit
- SPA/backend client structure is explicit
### Runtime checks
- compose topology stays PostgreSQL-only
- Prisma lifecycle scripts remain in place
- `/health` stays public
- backend can execute `npm run build` inside `server/`
- frontend can execute `npm run build` inside `client/` after dependencies are installed
- client/server `.env.example` stay aligned with the working runtime defaults:
- `https://sso.greact.ru`
- `toir`
- `toir-frontend`
- `toir-backend`
- `https://toir-frontend.greact.ru`
- optional runtime execution mode runs:
- `npx prisma generate`
- `npx prisma migrate dev`
- `npx prisma db seed`
### Scaffold checks
- backend initialization starts from official Nest CLI scaffolding
- frontend initialization starts from official Vite React TypeScript scaffolding
- feature generation happens after scaffold creation, not instead of scaffold creation
- repair happens before generation when workspace is degraded
- required framework configs and entry files must survive subsequent LLM edits
The automated gate is intentionally small. It enforces the critical reproducibility contract without turning the repository into a test platform or a generator engine.

View File

@@ -15,6 +15,8 @@
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"jose": "^6.2.2", "jose": "^6.2.2",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
@@ -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",

View File

@@ -18,6 +18,8 @@
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"generate:from-dsl": "node ../generation/generate.mjs --apply --dsl domain/TOiR.domain.dsl",
"generate:bundle-json": "node ../generation/generate.mjs --print-bundle-json --dsl domain/TOiR.domain.dsl",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"prisma": { "prisma": {
@@ -29,6 +31,8 @@
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"jose": "^6.2.2", "jose": "^6.2.2",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"

View File

@@ -0,0 +1,68 @@
-- CreateEnum
CREATE TYPE "EquipmentStatus" AS ENUM ('Active', 'Repair', 'Reserve', 'WriteOff');
-- CreateEnum
CREATE TYPE "RepairKind" AS ENUM ('TO', 'TR', 'TRE', 'KR', 'AR', 'MP');
-- CreateEnum
CREATE TYPE "RepairOrderStatus" AS ENUM ('Draft', 'Approved', 'InWork', 'Done', 'Cancelled');
-- CreateTable
CREATE TABLE "EquipmentType" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"manufacturer" TEXT,
"maintenanceIntervalHours" INTEGER,
"overhaulIntervalHours" INTEGER,
CONSTRAINT "EquipmentType_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "Equipment" (
"id" TEXT NOT NULL,
"inventoryNumber" TEXT NOT NULL,
"serialNumber" TEXT,
"name" TEXT NOT NULL,
"equipmentTypeCode" TEXT NOT NULL,
"status" "EquipmentStatus" NOT NULL DEFAULT 'Active',
"location" TEXT,
"commissionedAt" TIMESTAMP(3),
"totalEngineHours" DECIMAL(65,30),
"engineHoursSinceLastRepair" DECIMAL(65,30),
"lastRepairAt" TIMESTAMP(3),
"notes" TEXT,
CONSTRAINT "Equipment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RepairOrder" (
"id" TEXT NOT NULL,
"number" TEXT NOT NULL,
"equipmentId" TEXT NOT NULL,
"repairKind" "RepairKind" NOT NULL,
"status" "RepairOrderStatus" NOT NULL DEFAULT 'Draft',
"plannedAt" TIMESTAMP(3) NOT NULL,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"contractor" TEXT,
"engineHoursAtRepair" DECIMAL(65,30),
"description" TEXT,
"notes" TEXT,
CONSTRAINT "RepairOrder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Equipment_inventoryNumber_key" ON "Equipment"("inventoryNumber");
-- CreateIndex
CREATE UNIQUE INDEX "RepairOrder_number_key" ON "RepairOrder"("number");
-- AddForeignKey
ALTER TABLE "Equipment" ADD CONSTRAINT "Equipment_equipmentTypeCode_fkey" FOREIGN KEY ("equipmentTypeCode") REFERENCES "EquipmentType"("code") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RepairOrder" ADD CONSTRAINT "RepairOrder_equipmentId_fkey" FOREIGN KEY ("equipmentId") REFERENCES "Equipment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -46,7 +46,6 @@ model Equipment {
serialNumber String? serialNumber String?
name String name String
equipmentTypeCode String equipmentTypeCode String
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
status EquipmentStatus @default(Active) status EquipmentStatus @default(Active)
location String? location String?
commissionedAt DateTime? commissionedAt DateTime?
@@ -54,6 +53,7 @@ model Equipment {
engineHoursSinceLastRepair Decimal? engineHoursSinceLastRepair Decimal?
lastRepairAt DateTime? lastRepairAt DateTime?
notes String? notes String?
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
repairOrders RepairOrder[] repairOrders RepairOrder[]
} }
@@ -61,7 +61,6 @@ model RepairOrder {
id String @id @default(uuid()) id String @id @default(uuid())
number String @unique number String @unique
equipmentId String equipmentId String
equipment Equipment @relation(fields: [equipmentId], references: [id])
repairKind RepairKind repairKind RepairKind
status RepairOrderStatus @default(Draft) status RepairOrderStatus @default(Draft)
plannedAt DateTime plannedAt DateTime
@@ -71,4 +70,5 @@ model RepairOrder {
engineHoursAtRepair Decimal? engineHoursAtRepair Decimal?
description String? description String?
notes String? notes String?
equipment Equipment @relation(fields: [equipmentId], references: [id])
} }

View File

@@ -3,89 +3,161 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
const equipmentType = await prisma.equipmentType.upsert({ const equipmentTypes = [
where: { code: 'pump' }, {
update: {},
create: {
code: 'pump', code: 'pump',
name: 'Насосный агрегат', name: 'Насосный агрегат',
manufacturer: 'АО НасосПром', manufacturer: 'АО НасосПром',
maintenanceIntervalHours: 2000, maintenanceIntervalHours: 2000,
overhaulIntervalHours: 16000, overhaulIntervalHours: 16000,
}, },
}); {
const equipmentType2 = await prisma.equipmentType.upsert({
where: { code: 'compressor' },
update: {},
create: {
code: 'compressor', code: 'compressor',
name: 'Компрессорная установка', name: 'Компрессорная установка',
manufacturer: 'ОАО Компрессормаш', manufacturer: 'ОАО Компрессормаш',
maintenanceIntervalHours: 1500, maintenanceIntervalHours: 1500,
overhaulIntervalHours: 12000, overhaulIntervalHours: 12000,
}, },
}); {
code: 'generator',
name: 'Дизель-генератор',
manufacturer: 'АО ЭнергоМаш',
maintenanceIntervalHours: 500,
overhaulIntervalHours: 6000,
},
{
code: 'valve',
name: 'Запорная арматура',
manufacturer: 'ЗАО АрматурПром',
maintenanceIntervalHours: 1000,
overhaulIntervalHours: 10000,
},
{
code: 'sensor',
name: 'Датчик давления',
manufacturer: 'ООО ПриборСервис',
maintenanceIntervalHours: 800,
overhaulIntervalHours: 8000,
},
{
code: 'motor',
name: 'Электродвигатель',
manufacturer: 'ПАО ЭлектроМотор',
maintenanceIntervalHours: 1200,
overhaulIntervalHours: 14000,
},
{
code: 'fan',
name: 'Вентилятор',
manufacturer: 'АО ВентПром',
maintenanceIntervalHours: 700,
overhaulIntervalHours: 9000,
},
{
code: 'heat-exchanger',
name: 'Теплообменник',
manufacturer: 'ОАО ТеплоТех',
maintenanceIntervalHours: 1800,
overhaulIntervalHours: 15000,
},
{
code: 'filter',
name: 'Фильтровальная установка',
manufacturer: 'ООО ФильтрТех',
maintenanceIntervalHours: 600,
overhaulIntervalHours: 7000,
},
{
code: 'separator',
name: 'Сепаратор',
manufacturer: 'АО СепараторМаш',
maintenanceIntervalHours: 1600,
overhaulIntervalHours: 13000,
},
{
code: 'transformer',
name: 'Трансформатор',
manufacturer: 'ПАО ТрансЭнерго',
maintenanceIntervalHours: 2500,
overhaulIntervalHours: 20000,
},
] as const;
const equipment = await prisma.equipment.upsert({ for (const type of equipmentTypes) {
where: { inventoryNumber: 'INV-001' }, await prisma.equipmentType.upsert({
update: {}, where: { code: type.code },
update: { ...type },
create: { ...type },
});
}
const equipmentRecords: { id: string; inventoryNumber: string; name: string }[] = [];
for (let i = 1; i <= 11; i++) {
const type = equipmentTypes[(i - 1) % equipmentTypes.length];
const inventoryNumber = `INV-${String(i).padStart(3, '0')}`;
const serialNumber = `SN-2026-${String(i).padStart(4, '0')}`;
const record = await prisma.equipment.upsert({
where: { inventoryNumber },
update: {
serialNumber,
name: `${type.name} #${i}`,
equipmentTypeCode: type.code,
status: i % 5 === 0 ? 'Repair' : 'Active',
location: i % 2 === 0 ? `Площадка ${Math.ceil(i / 2)}` : `Цех ${Math.ceil(i / 3)}`,
commissionedAt: new Date(2022, (i - 1) % 12, 1 + ((i - 1) % 28)),
totalEngineHours: 1000 + i * 350,
engineHoursSinceLastRepair: 200 + i * 25,
},
create: { create: {
inventoryNumber: 'INV-001', inventoryNumber,
serialNumber: 'SN-2024-0001', serialNumber,
name: 'Насос ЦНС 180-212', name: `${type.name} #${i}`,
equipmentTypeCode: 'pump', equipmentTypeCode: type.code,
status: 'Active', status: i % 5 === 0 ? 'Repair' : 'Active',
location: 'Куст №5, скважина 42', location: i % 2 === 0 ? `Площадка ${Math.ceil(i / 2)}` : `Цех ${Math.ceil(i / 3)}`,
commissionedAt: new Date('2023-06-15'), commissionedAt: new Date(2022, (i - 1) % 12, 1 + ((i - 1) % 28)),
totalEngineHours: 4500, totalEngineHours: 1000 + i * 350,
engineHoursSinceLastRepair: 1200, engineHoursSinceLastRepair: 200 + i * 25,
}, },
}); });
equipmentRecords.push({ id: record.id, inventoryNumber: record.inventoryNumber, name: record.name });
}
const equipment2 = await prisma.equipment.upsert({ const repairKinds = ['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'] as const;
where: { inventoryNumber: 'INV-002' }, const statuses = ['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'] as const;
update: {},
create: {
inventoryNumber: 'INV-002',
serialNumber: 'SN-2024-0002',
name: 'Компрессор 4ВМ10-120/9',
equipmentTypeCode: 'compressor',
status: 'Active',
location: 'ГКС-3',
commissionedAt: new Date('2022-03-10'),
totalEngineHours: 8200,
engineHoursSinceLastRepair: 800,
},
});
for (let i = 1; i <= 11; i++) {
const number = `RO-2026-${String(i).padStart(3, '0')}`;
const equipment = equipmentRecords[(i - 1) % equipmentRecords.length];
await prisma.repairOrder.upsert({ await prisma.repairOrder.upsert({
where: { number: 'RO-2026-001' }, where: { number },
update: {}, update: {
create: {
number: 'RO-2026-001',
equipmentId: equipment.id, equipmentId: equipment.id,
repairKind: 'TO', repairKind: repairKinds[(i - 1) % repairKinds.length],
status: 'Approved', status: statuses[(i - 1) % statuses.length],
plannedAt: new Date('2026-04-01'), plannedAt: new Date(2026, ((i - 1) % 12), 1 + ((i - 1) % 28)),
contractor: 'ООО СервисРемонт', startedAt: i % 4 === 0 ? new Date(2026, ((i - 1) % 12), 2 + ((i - 1) % 28)) : null,
engineHoursAtRepair: 4500, completedAt: i % 5 === 0 ? new Date(2026, ((i - 1) % 12), 5 + ((i - 1) % 28)) : null,
description: 'Плановое техническое обслуживание насосного агрегата', contractor: i % 3 === 0 ? 'ООО СервисРемонт' : 'АО ТехПодряд',
engineHoursAtRepair: 1000 + i * 350,
description: `Заявка на ремонт ${equipment.inventoryNumber} (${equipment.name})`,
notes: i % 2 === 0 ? 'Тестовая заметка' : null,
}, },
});
await prisma.repairOrder.upsert({
where: { number: 'RO-2026-002' },
update: {},
create: { create: {
number: 'RO-2026-002', number,
equipmentId: equipment2.id, equipmentId: equipment.id,
repairKind: 'TR', repairKind: repairKinds[(i - 1) % repairKinds.length],
status: 'Draft', status: statuses[(i - 1) % statuses.length],
plannedAt: new Date('2026-05-15'), plannedAt: new Date(2026, ((i - 1) % 12), 1 + ((i - 1) % 28)),
description: 'Текущий ремонт компрессорной установки', startedAt: i % 4 === 0 ? new Date(2026, ((i - 1) % 12), 2 + ((i - 1) % 28)) : null,
completedAt: i % 5 === 0 ? new Date(2026, ((i - 1) % 12), 5 + ((i - 1) % 28)) : null,
contractor: i % 3 === 0 ? 'ООО СервисРемонт' : 'АО ТехПодряд',
engineHoursAtRepair: 1000 + i * 350,
description: `Заявка на ремонт ${equipment.inventoryNumber} (${equipment.name})`,
notes: i % 2 === 0 ? 'Тестовая заметка' : null,
}, },
}); });
}
console.log('Seed data created successfully'); console.log('Seed data created successfully');
} }

View File

@@ -0,0 +1,87 @@
# AID export: OpenAPI + генератор приложения
**Полное описание задачи, сценариев и CLI:** [docs/AID_EXPORT_README.md](../../../docs/AID_EXPORT_README.md)
Ниже — краткая справка по HTTP-эндпоинтам.
---
## 1. `api-format` → OpenAPI 3.0
`POST /aid/export/openapi`
Внутри: `tools/api-format-to-openapi/convert.mjs` (режимы `deterministic` и `llm`).
**Тело:**
```json
{
"apiFormat": { "apiFormatVersion": "1", "...": "..." },
"mode": "deterministic"
}
```
**Ответ:** `{ "openapi": { ... } }`
---
## 2. DSL → сгенерированное приложение (бандл или запись на диск)
`POST /aid/export/app`
Внутри: `generation/generate.mjs`.
**Тело:**
```json
{
"dsl": "domain TOiR {\n ...\n}\n",
"apply": false
}
```
- **`apply` по умолчанию `false` (рекомендуется для AID):** ответ содержит `files` — карта **путь от корня репозитория → текст файла** (Prisma, Nest-модули, React Admin и обновлённые `app.module.ts` / `App.tsx`). **На диск ничего не пишется.**
- **`apply: true`:** выполняется тот же процесс, что и `npm run generate:from-dsl` с `--apply`**перезапись файлов в рабочей копии** на машине, где запущен Nest. Включено только если в окружении задано **`AID_GENERATOR_ALLOW_APPLY=1`** (или `true`). Иначе `403 Forbidden`.
**Ответ (бандл):**
```json
{
"applied": false,
"entityCount": 3,
"enumCount": 3,
"files": {
"server/prisma/schema.prisma": "...",
"server/src/modules/equipment/equipment.controller.ts": "...",
"client/src/App.tsx": "..."
}
}
```
**Ответ (apply):**
```json
{
"applied": true,
"message": "Generated 3 entities from ..."
}
```
### CLI-аналог бандла (без Nest)
Из **корня репозитория**:
```bash
node generation/generate.mjs --print-bundle-json --dsl examples/TOiR.domain.dsl > bundle.json
```
---
## Безопасность
- Если в `.env` задан **`AID_EXPORT_API_KEY`**, для **обоих** эндпоинтов нужен заголовок **`X-AID-Export-Key`** с тем же значением.
- Не включайте **`AID_GENERATOR_ALLOW_APPLY`** на публичных инстансах без понимания рисков.
## Требования
- Запуск Nest с **cwd = `server/`** относительно корня репо, чтобы находились `../generation/generate.mjs` и `../tools/...`.

View File

@@ -0,0 +1,112 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Headers,
HttpCode,
Post,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AidExportService, OpenApiExportMode } from './aid-export.service';
/**
* HTTP-экспортёры для AID: OpenAPI из api-format и генерация приложения из DSL.
*/
@Controller('aid/export')
export class AidExportController {
constructor(
private readonly aidExport: AidExportService,
private readonly config: ConfigService,
) {}
private assertExportKey(exportKey: string | undefined) {
const requiredKey = this.config.get<string>('AID_EXPORT_API_KEY');
if (requiredKey && exportKey !== requiredKey) {
throw new UnauthorizedException('Invalid or missing X-AID-Export-Key');
}
}
/**
* POST /aid/export/openapi
* Body: { "apiFormat": <объект api-format>, "mode"?: "deterministic" | "llm" }
* Response: { "openapi": <OpenAPI 3.0.3 document> }
*
* mode=llm требует OPENAI_API_KEY в окружении сервера.
*
* Если задан AID_EXPORT_API_KEY, клиент должен передать заголовок X-AID-Export-Key с тем же значением.
*/
@Post('openapi')
@HttpCode(200)
async exportOpenApi(
@Body()
body: { apiFormat?: unknown; mode?: string },
@Headers('x-aid-export-key') exportKey?: string,
) {
this.assertExportKey(exportKey);
if (body == null || typeof body !== 'object' || body.apiFormat == null) {
throw new BadRequestException('Body must be a JSON object with an "apiFormat" property');
}
if (typeof body.apiFormat !== 'object' || Array.isArray(body.apiFormat)) {
throw new BadRequestException('"apiFormat" must be a JSON object');
}
const mode: OpenApiExportMode =
body.mode === 'llm' ? 'llm' : 'deterministic';
const openapi = await this.aidExport.convertApiFormatToOpenApi(
body.apiFormat,
mode,
);
return { openapi };
}
/**
* POST /aid/export/app
* Body: { "dsl": "<текст DSL как в examples/TOiR.domain.dsl>", "apply"?: boolean }
*
* По умолчанию `apply: false` — возвращается JSON с полем `files` (пути относительно корня репо → содержимое),
* без записи на диск (безопасно для вызова из AID).
*
* `apply: true` перезаписывает файлы в **текущей** рабочей копии репозитория на машине, где крутится Nest.
* Разрешено только если в окружении задано `AID_GENERATOR_ALLOW_APPLY=1` (или `true`).
*/
@Post('app')
@HttpCode(200)
async exportApp(
@Body()
body: { dsl?: string; apply?: boolean },
@Headers('x-aid-export-key') exportKey?: string,
) {
this.assertExportKey(exportKey);
if (body == null || typeof body !== 'object') {
throw new BadRequestException('Body must be a JSON object');
}
if (typeof body.dsl !== 'string' || !body.dsl.trim()) {
throw new BadRequestException('Body must include a non-empty string "dsl"');
}
const apply = body.apply === true;
if (apply) {
const allow = this.config.get<string>('AID_GENERATOR_ALLOW_APPLY');
if (allow !== '1' && allow !== 'true') {
throw new ForbiddenException(
'apply=true is disabled. Set AID_GENERATOR_ALLOW_APPLY=1 on the server to allow writing generated files to disk.',
);
}
const { message } = await this.aidExport.generateAppApply(body.dsl);
return { applied: true, message };
}
const bundle = await this.aidExport.generateAppBundle(body.dsl);
return {
applied: false,
entityCount: bundle.entityCount,
enumCount: bundle.enumCount,
files: bundle.files,
};
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AidExportController } from './aid-export.controller';
import { AidExportService } from './aid-export.service';
@Module({
controllers: [AidExportController],
providers: [AidExportService],
})
export class AidExportModule {}

View File

@@ -0,0 +1,154 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { execFile } from 'child_process';
import { access, readFile, unlink, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const LARGE_BUFFER = 64 * 1024 * 1024;
export type OpenApiExportMode = 'deterministic' | 'llm';
export type AppGeneratorBundle = {
entityCount: number;
enumCount: number;
files: Record<string, string>;
};
@Injectable()
export class AidExportService {
/**
* Путь к tools/api-format-to-openapi/convert.mjs относительно cwd процесса (обычно каталог server/).
*/
private resolveConvertScript(): string {
return join(process.cwd(), '..', 'tools', 'api-format-to-openapi', 'convert.mjs');
}
/** Путь к generation/generate.mjs относительно cwd = server/. */
private resolveGenerateScript(): string {
return join(process.cwd(), '..', 'generation', 'generate.mjs');
}
async convertApiFormatToOpenApi(
apiFormat: unknown,
mode: OpenApiExportMode,
): Promise<Record<string, unknown>> {
const script = this.resolveConvertScript();
try {
await access(script);
} catch {
throw new InternalServerErrorException(
`Converter script not found at ${script}. Run the server with cwd = server/ from repo root.`,
);
}
const id = randomUUID();
const inPath = join(tmpdir(), `api-format-${id}.json`);
const outPath = join(tmpdir(), `openapi-${id}.json`);
try {
await writeFile(inPath, JSON.stringify(apiFormat), 'utf8');
const { stderr } = await execFileAsync(
process.execPath,
[script, '--in', inPath, '--out', outPath, '--mode', mode],
{
env: { ...process.env },
maxBuffer: 16 * 1024 * 1024,
},
);
if (stderr?.trim()) {
// convert.mjs пишет ошибки в stderr при падении; при успехе обычно пусто
console.warn('[aid-export]', stderr);
}
const raw = await readFile(outPath, 'utf8');
return JSON.parse(raw) as Record<string, unknown>;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new InternalServerErrorException(`OpenAPI conversion failed: ${msg}`);
} finally {
await unlink(inPath).catch(() => undefined);
await unlink(outPath).catch(() => undefined);
}
}
/**
* DSL → снимок сгенерированных файлов (без записи в репозиторий).
* Использует `generation/generate.mjs --print-bundle-json`.
*/
async generateAppBundle(dsl: string): Promise<AppGeneratorBundle> {
const script = this.resolveGenerateScript();
try {
await access(script);
} catch {
throw new InternalServerErrorException(
`Generator script not found at ${script}. Run the server with cwd = server/ from repo root.`,
);
}
const id = randomUUID();
const dslPath = join(tmpdir(), `domain-${id}.dsl`);
try {
await writeFile(dslPath, dsl, 'utf8');
const { stdout, stderr } = await execFileAsync(
process.execPath,
[script, '--print-bundle-json', '--dsl', dslPath],
{
env: { ...process.env },
maxBuffer: LARGE_BUFFER,
},
);
if (stderr?.trim()) console.warn('[aid-export][generate]', stderr);
const bundle = JSON.parse(stdout) as AppGeneratorBundle;
if (!bundle.files || typeof bundle.files !== 'object') {
throw new Error('Invalid bundle: missing files');
}
return bundle;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new InternalServerErrorException(`App generator (bundle) failed: ${msg}`);
} finally {
await unlink(dslPath).catch(() => undefined);
}
}
/**
* DSL → запись сгенерированного кода в рабочую копию репозитория (`--apply`).
* Опасно для публичных эндпоинтов; включать только осознанно.
*/
async generateAppApply(dsl: string): Promise<{ message: string }> {
const script = this.resolveGenerateScript();
try {
await access(script);
} catch {
throw new InternalServerErrorException(
`Generator script not found at ${script}. Run the server with cwd = server/ from repo root.`,
);
}
const id = randomUUID();
const dslPath = join(tmpdir(), `domain-${id}.dsl`);
try {
await writeFile(dslPath, dsl, 'utf8');
const { stdout, stderr } = await execFileAsync(
process.execPath,
[script, '--apply', '--dsl', dslPath],
{
env: { ...process.env },
maxBuffer: LARGE_BUFFER,
},
);
if (stderr?.trim()) console.warn('[aid-export][generate-apply]', stderr);
return { message: (stdout || 'ok').trim() };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new InternalServerErrorException(`App generator (apply) failed: ${msg}`);
} finally {
await unlink(dslPath).catch(() => undefined);
}
}
}

View File

@@ -7,16 +7,17 @@ import { HealthModule } from './health/health.module';
import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module'; import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module';
import { EquipmentModule } from './modules/equipment/equipment.module'; import { EquipmentModule } from './modules/equipment/equipment.module';
import { RepairOrderModule } from './modules/repair-order/repair-order.module'; import { RepairOrderModule } from './modules/repair-order/repair-order.module';
import { AidExportModule } from './aid-export/aid-export.module';
@Module({ @Module({
imports: [ imports: [ConfigModule.forRoot({
ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
validate: validateEnvironment, validate: validateEnvironment,
}), }),
AuthModule, AuthModule,
PrismaModule, PrismaModule,
HealthModule, HealthModule,
AidExportModule,
EquipmentTypeModule, EquipmentTypeModule,
EquipmentModule, EquipmentModule,
RepairOrderModule, RepairOrderModule,

View File

@@ -0,0 +1,175 @@
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;
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) {
this.logger.error(
`Unhandled error on ${request.method} ${request.url}: ${mapped.message}`,
exception instanceof Error ? exception.stack : undefined,
);
}
response.status(mapped.statusCode).json(body);
}
private mapException(exception: unknown): {
statusCode: number;
message: 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 = Array.isArray(rawMessage)
? rawMessage.join(', ')
: rawMessage || exception.message;
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: exception.message || 'Internal server error',
code: 'INTERNAL_ERROR',
};
}
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
code: 'INTERNAL_ERROR',
};
}
private mapPrismaKnownRequestError(
exception: Prisma.PrismaClientKnownRequestError,
) {
switch (exception.code) {
case 'P2002': {
const target = Array.isArray(exception.meta?.target)
? exception.meta?.target.join(', ')
: String(exception.meta?.target ?? '');
return {
statusCode: HttpStatus.CONFLICT,
message: target
? `Запись с таким значением уже существует (${target}).`
: 'Запись с таким значением уже существует.',
code: 'UNIQUE_CONSTRAINT_VIOLATION',
details: exception.meta,
};
}
case 'P2003':
return {
statusCode: HttpStatus.CONFLICT,
message:
'Операцию нельзя выполнить из-за связанных данных или некорректной ссылки.',
code: 'FOREIGN_KEY_CONSTRAINT_VIOLATION',
details: exception.meta,
};
case 'P2025':
return {
statusCode: HttpStatus.NOT_FOUND,
message: 'Запись не найдена.',
code: 'RECORD_NOT_FOUND',
details: exception.meta,
};
default:
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Ошибка при обработке данных в базе.',
code: exception.code,
details: exception.meta,
};
}
}
}

View File

@@ -1,7 +1,96 @@
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';
const FIELD_LABELS: Record<string, string> = {
code: 'Код',
name: 'Название',
manufacturer: 'Производитель',
maintenanceIntervalHours: 'Интервал ТО (часы)',
overhaulIntervalHours: 'Интервал капремонта (часы)',
inventoryNumber: 'Инвентарный номер',
serialNumber: 'Серийный номер',
equipmentTypeCode: 'Тип оборудования',
equipmentId: 'Оборудование',
status: 'Статус',
location: 'Местоположение',
commissionedAt: 'Дата ввода в эксплуатацию',
totalEngineHours: 'Наработка общая',
engineHoursSinceLastRepair: 'Наработка после ремонта',
lastRepairAt: 'Дата последнего ремонта',
notes: 'Примечание',
number: 'Номер',
repairKind: 'Вид ремонта',
plannedAt: 'Плановая дата',
startedAt: 'Дата начала',
completedAt: 'Дата завершения',
contractor: 'Подрядчик',
engineHoursAtRepair: 'Наработка на момент ремонта',
description: 'Описание',
id: 'Идентификатор',
};
function prettifyFieldName(field: string): string {
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 'isIso8601':
return `Поле "${label}" должно содержать корректную дату`;
case 'isEnum':
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 +118,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);
} }

View File

@@ -1,7 +1,19 @@
import { IsInt, IsNotEmpty, IsString } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateEquipmentTypeDto { export class CreateEquipmentTypeDto {
code: string; @IsString({ message: 'code: должно быть строкой' })
name: string; @IsNotEmpty({ message: 'code: обязательное поле' })
code?: string;
@IsString({ message: 'name: должно быть строкой' })
@IsNotEmpty({ message: 'name: обязательное поле' })
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;
} }

View File

@@ -1,6 +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;
@IsOptional()
@IsString({ message: 'code: должно быть строкой' })
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;
} }

View File

@@ -8,12 +8,12 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
@Controller('equipment-types') @Controller('equipment-types')
export class EquipmentTypeController { export class EquipmentTypeController {
constructor(private readonly equipmentTypeService: EquipmentTypeService) {} constructor(private readonly service: EquipmentTypeService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get() @Get()
async findAll(@Query() query: any, @Res() res: Response) { async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.equipmentTypeService.findAll(query); const result = await this.service.findAll(query);
res.set('Content-Range', `equipment-types ${query._start || 0}-${query._end || result.total}/${result.total}`); res.set('Content-Range', `equipment-types ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range'); res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data); return res.json(result.data);
@@ -21,25 +21,25 @@ export class EquipmentTypeController {
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':code') @Get(':code')
findOne(@Param('code') code: string) { findOne(@Param('code') id: string) {
return this.equipmentTypeService.findOne(code); return this.service.findOne(id);
} }
@Roles(RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Editor, RealmRole.Admin)
@Post() @Post()
create(@Body() dto: CreateEquipmentTypeDto) { create(@Body() dto: CreateEquipmentTypeDto) {
return this.equipmentTypeService.create(dto); return this.service.create(dto);
} }
@Roles(RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':code') @Patch(':code')
update(@Param('code') code: string, @Body() dto: UpdateEquipmentTypeDto) { update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) {
return this.equipmentTypeService.update(code, dto); return this.service.update(id, dto);
} }
@Roles(RealmRole.Admin) @Roles(RealmRole.Admin)
@Delete(':code') @Delete(':code')
remove(@Param('code') code: string) { remove(@Param('code') id: string) {
return this.equipmentTypeService.remove(code); return this.service.remove(id);
} }
} }

View File

@@ -1,78 +1,90 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto'; import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto';
import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto'; import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
function serializeRecord(record: any) {
return {
...record,
};
}
@Injectable() @Injectable()
export class EquipmentTypeService { export class EquipmentTypeService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async findAll(query: { async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {
_start?: string;
_end?: string;
_sort?: string;
_order?: string;
[key: string]: any;
}) {
const start = parseInt(query._start) || 0; const start = parseInt(query._start) || 0;
const end = parseInt(query._end) || 10; const end = parseInt(query._end) || 10;
const take = end - start; const take = end - start;
const skip = start; const skip = start;
const sortField = 'code'; const sortField = query._sort || 'code';
const prismaSortField = sortField === 'id' ? 'code' : sortField;
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc'; const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {}; const where: any = {};
if (query.q) {
const q = String(query.q);
const ors: any[] = [];
ors.push({ code: { contains: q, mode: 'insensitive' } });
ors.push({ name: { contains: q, mode: 'insensitive' } });
ors.push({ manufacturer: { contains: q, mode: 'insensitive' } });
if (ors.length) where.OR = ors;
}
if (query.code) where.code = { contains: query.code, mode: 'insensitive' }; if (query.code) where.code = { contains: query.code, mode: 'insensitive' };
if (query.name) where.name = { contains: query.name, mode: 'insensitive' }; if (query.name) where.name = { contains: query.name, mode: 'insensitive' };
if (query.manufacturer) if (query.manufacturer) where.manufacturer = { contains: query.manufacturer, mode: 'insensitive' };
where.manufacturer = {
contains: query.manufacturer,
mode: 'insensitive',
}; // Enum multi-value support (e.g. status=A&status=B)
if (query.id) { if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id]; const ids = Array.isArray(query.id) ? query.id : [query.id];
where.code = { in: ids }; where.code = { in: ids };
} }
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.equipmentType.findMany({ this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
where,
skip,
take,
orderBy: { [sortField]: sortOrder },
}),
this.prisma.equipmentType.count({ where }), this.prisma.equipmentType.count({ where }),
]); ]);
return { const mapped = data.map((item: any) => ({ id: item.code, ...serializeRecord(item) }));
data: data.map((item) => ({ id: item.code, ...item })), return { data: mapped, total };
total,
};
} }
async findOne(code: string) { async findOne(id: string) {
const record = await this.prisma.equipmentType.findUniqueOrThrow({ const record = await this.prisma.equipmentType.findUniqueOrThrow({ where: { code: id } as any });
where: { code }, return { id: (record as any).code, ...serializeRecord(record) };
});
return { id: record.code, ...record };
} }
async create(dto: CreateEquipmentTypeDto) { async create(dto: CreateEquipmentTypeDto) {
const record = await this.prisma.equipmentType.create({ data: dto }); const data: any = { ...(dto as any) };
return { id: record.code, ...record };
const record = await this.prisma.equipmentType.create({ data });
return { id: (record as any).code, ...serializeRecord(record) };
} }
async update(code: string, dto: UpdateEquipmentTypeDto) { async update(id: string, dto: UpdateEquipmentTypeDto) {
const { id, code: _pk, ...data } = dto as any; const { id: _pk, code, ...rest } = (dto as any);
const record = await this.prisma.equipmentType.update({ const data: any = { ...rest };
where: { code },
data,
});
return { id: record.code, ...record }; const record = await this.prisma.equipmentType.update({ where: { code: id } as any, data });
return { id: (record as any).code, ...serializeRecord(record) };
} }
async remove(code: string) { async remove(id: string) {
const record = await this.prisma.equipmentType.delete({ where: { code } }); const record = await this.prisma.equipmentType.delete({ where: { code: id } as any });
return { id: record.code, ...record }; return { id: (record as any).code, ...serializeRecord(record) };
} }
} }

View File

@@ -1,13 +1,30 @@
import { IsISO8601, IsNotEmpty, IsNumberString, IsString } from 'class-validator';
export class CreateEquipmentDto { export class CreateEquipmentDto {
inventoryNumber: string; @IsString({ message: 'inventoryNumber: должно быть строкой' })
@IsNotEmpty({ message: 'inventoryNumber: обязательное поле' })
inventoryNumber!: string;
@IsString({ message: 'serialNumber: должно быть строкой' })
serialNumber?: string; serialNumber?: string;
name: string; @IsString({ message: 'name: должно быть строкой' })
equipmentTypeCode: string; @IsNotEmpty({ message: 'name: обязательное поле' })
status?: string; name!: string;
@IsString({ message: 'equipmentTypeCode: должно быть строкой' })
@IsNotEmpty({ message: 'equipmentTypeCode: обязательное поле' })
equipmentTypeCode!: string;
@IsString({ message: 'status: должно быть строкой' })
@IsNotEmpty({ message: 'status: обязательное поле' })
status!: string;
@IsString({ message: 'location: должно быть строкой' })
location?: string; location?: string;
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
commissionedAt?: string; commissionedAt?: string;
@IsNumberString({}, { message: 'totalEngineHours: должно быть числом' })
totalEngineHours?: string; totalEngineHours?: string;
@IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' })
engineHoursSinceLastRepair?: string; engineHoursSinceLastRepair?: string;
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
lastRepairAt?: string; lastRepairAt?: string;
@IsString({ message: 'notes: должно быть строкой' })
notes?: string; notes?: string;
} }

View File

@@ -1,13 +1,40 @@
import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateEquipmentDto { export class UpdateEquipmentDto {
@IsOptional()
@IsUUID(undefined, { message: 'id: должно быть UUID' })
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()
@IsString({ message: 'status: должно быть строкой' })
status?: string; status?: string;
@IsOptional()
@IsString({ message: 'location: должно быть строкой' })
location?: string; location?: string;
@IsOptional()
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
commissionedAt?: string; commissionedAt?: string;
@IsOptional()
@IsNumberString({}, { message: 'totalEngineHours: должно быть числом' })
totalEngineHours?: string; totalEngineHours?: string;
@IsOptional()
@IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' })
engineHoursSinceLastRepair?: string; engineHoursSinceLastRepair?: string;
@IsOptional()
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
lastRepairAt?: string; lastRepairAt?: string;
@IsOptional()
@IsString({ message: 'notes: должно быть строкой' })
notes?: string; notes?: string;
} }

View File

@@ -8,12 +8,12 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto';
@Controller('equipment') @Controller('equipment')
export class EquipmentController { export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {} constructor(private readonly service: EquipmentService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get() @Get()
async findAll(@Query() query: any, @Res() res: Response) { async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.equipmentService.findAll(query); const result = await this.service.findAll(query);
res.set('Content-Range', `equipment ${query._start || 0}-${query._end || result.total}/${result.total}`); res.set('Content-Range', `equipment ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range'); res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data); return res.json(result.data);
@@ -22,24 +22,24 @@ export class EquipmentController {
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.equipmentService.findOne(id); return this.service.findOne(id);
} }
@Roles(RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Editor, RealmRole.Admin)
@Post() @Post()
create(@Body() dto: CreateEquipmentDto) { create(@Body() dto: CreateEquipmentDto) {
return this.equipmentService.create(dto); return this.service.create(dto);
} }
@Roles(RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':id') @Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) { update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
return this.equipmentService.update(id, dto); return this.service.update(id, dto);
} }
@Roles(RealmRole.Admin) @Roles(RealmRole.Admin)
@Delete(':id') @Delete(':id')
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.equipmentService.remove(id); return this.service.remove(id);
} }
} }

View File

@@ -23,66 +23,80 @@ export class EquipmentService {
const end = parseInt(query._end) || 10; const end = parseInt(query._end) || 10;
const take = end - start; const take = end - start;
const skip = start; const skip = start;
const sortField = query._sort || 'id'; const sortField = query._sort || 'inventoryNumber';
const prismaSortField = sortField === 'id' ? 'id' : sortField;
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc'; const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {}; const where: any = {};
if (query.q) {
const q = String(query.q);
const ors: any[] = [];
ors.push({ inventoryNumber: { contains: q, mode: 'insensitive' } });
ors.push({ serialNumber: { contains: q, mode: 'insensitive' } });
ors.push({ name: { contains: q, mode: 'insensitive' } });
ors.push({ equipmentTypeCode: { contains: q, mode: 'insensitive' } });
ors.push({ location: { contains: q, mode: 'insensitive' } });
ors.push({ notes: { contains: q, mode: 'insensitive' } });
if (ors.length) where.OR = ors;
}
if (query.inventoryNumber) where.inventoryNumber = { contains: query.inventoryNumber, mode: 'insensitive' }; if (query.inventoryNumber) where.inventoryNumber = { contains: query.inventoryNumber, mode: 'insensitive' };
if (query.serialNumber) where.serialNumber = { contains: query.serialNumber, mode: 'insensitive' };
if (query.name) where.name = { contains: query.name, mode: 'insensitive' }; if (query.name) where.name = { contains: query.name, mode: 'insensitive' };
if (query.equipmentTypeCode) where.equipmentTypeCode = query.equipmentTypeCode;
if (query.status) where.status = query.status;
if (query.location) where.location = { contains: query.location, mode: 'insensitive' }; if (query.location) where.location = { contains: query.location, mode: 'insensitive' };
if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' };
if (query.equipmentTypeCode) where.equipmentTypeCode = query.equipmentTypeCode;
// Enum multi-value support (e.g. status=A&status=B)
if (query.status) { const vals = Array.isArray(query.status) ? query.status : [query.status]; where.status = vals.length > 1 ? { in: vals } : vals[0]; }
if (query.id) { if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id]; const ids = Array.isArray(query.id) ? query.id : [query.id];
where.id = { in: ids }; where.id = { in: ids };
} }
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.equipment.findMany({ this.prisma.equipment.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
where,
skip,
take,
orderBy: { [sortField]: sortOrder },
}),
this.prisma.equipment.count({ where }), this.prisma.equipment.count({ where }),
]); ]);
return { const mapped = data.map(serializeRecord);
data: data.map(serializeRecord), return { data: mapped, total };
total,
};
} }
async findOne(id: string) { async findOne(id: string) {
const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id } }); const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id: id } as any });
return serializeRecord(record); return serializeRecord(record);
} }
async create(dto: CreateEquipmentDto) { async create(dto: CreateEquipmentDto) {
const data: any = { ...dto }; const data: any = { ...(dto as any) };
if (dto.commissionedAt) data.commissionedAt = new Date(dto.commissionedAt); if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt);
if (dto.lastRepairAt) data.lastRepairAt = new Date(dto.lastRepairAt); if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt);
if (dto.totalEngineHours) data.totalEngineHours = new Prisma.Decimal(dto.totalEngineHours); if (data.totalEngineHours) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours);
if (dto.engineHoursSinceLastRepair) data.engineHoursSinceLastRepair = new Prisma.Decimal(dto.engineHoursSinceLastRepair); if (data.engineHoursSinceLastRepair) data.engineHoursSinceLastRepair = new Prisma.Decimal(data.engineHoursSinceLastRepair);
const record = await this.prisma.equipment.create({ data }); const record = await this.prisma.equipment.create({ data });
return serializeRecord(record); return serializeRecord(record);
} }
async update(id: string, dto: UpdateEquipmentDto) { async update(id: string, dto: UpdateEquipmentDto) {
const { id: _pk, ...rest } = dto as any; const data: any = { ...(dto as any) };
const data: any = { ...rest }; delete data.id;
delete data.id;
if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt); if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt);
if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt); if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt);
if (data.totalEngineHours !== undefined && data.totalEngineHours !== null) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours); if (data.totalEngineHours !== undefined && data.totalEngineHours !== null) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours);
if (data.engineHoursSinceLastRepair !== undefined && data.engineHoursSinceLastRepair !== null) data.engineHoursSinceLastRepair = new Prisma.Decimal(data.engineHoursSinceLastRepair); if (data.engineHoursSinceLastRepair !== undefined && data.engineHoursSinceLastRepair !== null) data.engineHoursSinceLastRepair = new Prisma.Decimal(data.engineHoursSinceLastRepair);
const record = await this.prisma.equipment.update({ where: { id }, data }); const record = await this.prisma.equipment.update({ where: { id: id } as any, data });
return serializeRecord(record); return serializeRecord(record);
} }
async remove(id: string) { async remove(id: string) {
const record = await this.prisma.equipment.delete({ where: { id } }); const record = await this.prisma.equipment.delete({ where: { id: id } as any });
return serializeRecord(record); return serializeRecord(record);
} }
} }

View File

@@ -1,13 +1,31 @@
import { IsISO8601, IsNotEmpty, IsNumberString, IsString, IsUUID } from 'class-validator';
export class CreateRepairOrderDto { export class CreateRepairOrderDto {
number: string; @IsString({ message: 'number: должно быть строкой' })
equipmentId: string; @IsNotEmpty({ message: 'number: обязательное поле' })
repairKind: string; number!: string;
status?: string; @IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
plannedAt: string; @IsNotEmpty({ message: 'equipmentId: обязательное поле' })
equipmentId!: string;
@IsString({ message: 'repairKind: должно быть строкой' })
@IsNotEmpty({ message: 'repairKind: обязательное поле' })
repairKind!: string;
@IsString({ message: 'status: должно быть строкой' })
@IsNotEmpty({ message: 'status: обязательное поле' })
status!: string;
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
@IsNotEmpty({ message: 'plannedAt: обязательное поле' })
plannedAt!: string;
@IsISO8601({}, { message: 'startedAt: должно содержать корректную дату' })
startedAt?: string; startedAt?: string;
@IsISO8601({}, { message: 'completedAt: должно содержать корректную дату' })
completedAt?: string; completedAt?: string;
@IsString({ message: 'contractor: должно быть строкой' })
contractor?: string; contractor?: string;
@IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' })
engineHoursAtRepair?: string; engineHoursAtRepair?: string;
@IsString({ message: 'description: должно быть строкой' })
description?: string; description?: string;
@IsString({ message: 'notes: должно быть строкой' })
notes?: string; notes?: string;
} }

View File

@@ -1,13 +1,40 @@
import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateRepairOrderDto { export class UpdateRepairOrderDto {
@IsOptional()
@IsUUID(undefined, { message: 'id: должно быть UUID' })
id?: string;
@IsOptional()
@IsString({ message: 'number: должно быть строкой' })
number?: string; number?: string;
@IsOptional()
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
equipmentId?: string; equipmentId?: string;
@IsOptional()
@IsString({ message: 'repairKind: должно быть строкой' })
repairKind?: string; repairKind?: string;
@IsOptional()
@IsString({ 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;
@IsOptional()
@IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' })
engineHoursAtRepair?: string; engineHoursAtRepair?: string;
@IsOptional()
@IsString({ message: 'description: должно быть строкой' })
description?: string; description?: string;
@IsOptional()
@IsString({ message: 'notes: должно быть строкой' })
notes?: string; notes?: string;
} }

View File

@@ -8,12 +8,12 @@ import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
@Controller('repair-orders') @Controller('repair-orders')
export class RepairOrderController { export class RepairOrderController {
constructor(private readonly repairOrderService: RepairOrderService) {} constructor(private readonly service: RepairOrderService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get() @Get()
async findAll(@Query() query: any, @Res() res: Response) { async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.repairOrderService.findAll(query); const result = await this.service.findAll(query);
res.set('Content-Range', `repair-orders ${query._start || 0}-${query._end || result.total}/${result.total}`); res.set('Content-Range', `repair-orders ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range'); res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data); return res.json(result.data);
@@ -22,24 +22,24 @@ export class RepairOrderController {
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.repairOrderService.findOne(id); return this.service.findOne(id);
} }
@Roles(RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Editor, RealmRole.Admin)
@Post() @Post()
create(@Body() dto: CreateRepairOrderDto) { create(@Body() dto: CreateRepairOrderDto) {
return this.repairOrderService.create(dto); return this.service.create(dto);
} }
@Roles(RealmRole.Editor, RealmRole.Admin) @Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':id') @Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) { update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
return this.repairOrderService.update(id, dto); return this.service.update(id, dto);
} }
@Roles(RealmRole.Admin) @Roles(RealmRole.Admin)
@Delete(':id') @Delete(':id')
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.repairOrderService.remove(id); return this.service.remove(id);
} }
} }

View File

@@ -23,66 +23,78 @@ export class RepairOrderService {
const end = parseInt(query._end) || 10; const end = parseInt(query._end) || 10;
const take = end - start; const take = end - start;
const skip = start; const skip = start;
const sortField = query._sort || 'id'; const sortField = query._sort || 'number';
const prismaSortField = sortField === 'id' ? 'id' : sortField;
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc'; const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {}; const where: any = {};
if (query.q) {
const q = String(query.q);
const ors: any[] = [];
ors.push({ number: { contains: q, mode: 'insensitive' } });
ors.push({ contractor: { contains: q, mode: 'insensitive' } });
ors.push({ description: { contains: q, mode: 'insensitive' } });
ors.push({ notes: { contains: q, mode: 'insensitive' } });
if (ors.length) where.OR = ors;
}
if (query.number) where.number = { contains: query.number, mode: 'insensitive' }; if (query.number) where.number = { contains: query.number, mode: 'insensitive' };
if (query.equipmentId) where.equipmentId = query.equipmentId;
if (query.repairKind) where.repairKind = query.repairKind;
if (query.status) where.status = query.status;
if (query.contractor) where.contractor = { contains: query.contractor, mode: 'insensitive' }; if (query.contractor) where.contractor = { contains: query.contractor, mode: 'insensitive' };
if (query.description) where.description = { contains: query.description, mode: 'insensitive' };
if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' };
if (query.equipmentId) where.equipmentId = query.equipmentId;
// Enum multi-value support (e.g. status=A&status=B)
if (query.repairKind) { const vals = Array.isArray(query.repairKind) ? query.repairKind : [query.repairKind]; where.repairKind = vals.length > 1 ? { in: vals } : vals[0]; }
if (query.status) { const vals = Array.isArray(query.status) ? query.status : [query.status]; where.status = vals.length > 1 ? { in: vals } : vals[0]; }
if (query.id) { if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id]; const ids = Array.isArray(query.id) ? query.id : [query.id];
where.id = { in: ids }; where.id = { in: ids };
} }
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.repairOrder.findMany({ this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
where,
skip,
take,
orderBy: { [sortField]: sortOrder },
}),
this.prisma.repairOrder.count({ where }), this.prisma.repairOrder.count({ where }),
]); ]);
return { const mapped = data.map(serializeRecord);
data: data.map(serializeRecord), return { data: mapped, total };
total,
};
} }
async findOne(id: string) { async findOne(id: string) {
const record = await this.prisma.repairOrder.findUniqueOrThrow({ where: { id } }); const record = await this.prisma.repairOrder.findUniqueOrThrow({ where: { id: id } as any });
return serializeRecord(record); return serializeRecord(record);
} }
async create(dto: CreateRepairOrderDto) { async create(dto: CreateRepairOrderDto) {
const data: any = { ...dto }; const data: any = { ...(dto as any) };
if (dto.plannedAt) data.plannedAt = new Date(dto.plannedAt); if (data.plannedAt) data.plannedAt = new Date(data.plannedAt);
if (dto.startedAt) data.startedAt = new Date(dto.startedAt); if (data.startedAt) data.startedAt = new Date(data.startedAt);
if (dto.completedAt) data.completedAt = new Date(dto.completedAt); if (data.completedAt) data.completedAt = new Date(data.completedAt);
if (dto.engineHoursAtRepair) data.engineHoursAtRepair = new Prisma.Decimal(dto.engineHoursAtRepair); if (data.engineHoursAtRepair) data.engineHoursAtRepair = new Prisma.Decimal(data.engineHoursAtRepair);
const record = await this.prisma.repairOrder.create({ data }); const record = await this.prisma.repairOrder.create({ data });
return serializeRecord(record); return serializeRecord(record);
} }
async update(id: string, dto: UpdateRepairOrderDto) { async update(id: string, dto: UpdateRepairOrderDto) {
const { id: _pk, ...rest } = dto as any; const data: any = { ...(dto as any) };
const data: any = { ...rest }; delete data.id;
delete data.id;
if (data.plannedAt) data.plannedAt = new Date(data.plannedAt); if (data.plannedAt) data.plannedAt = new Date(data.plannedAt);
if (data.startedAt) data.startedAt = new Date(data.startedAt); if (data.startedAt) data.startedAt = new Date(data.startedAt);
if (data.completedAt) data.completedAt = new Date(data.completedAt); if (data.completedAt) data.completedAt = new Date(data.completedAt);
if (data.engineHoursAtRepair !== undefined && data.engineHoursAtRepair !== null) data.engineHoursAtRepair = new Prisma.Decimal(data.engineHoursAtRepair); if (data.engineHoursAtRepair !== undefined && data.engineHoursAtRepair !== null) data.engineHoursAtRepair = new Prisma.Decimal(data.engineHoursAtRepair);
const record = await this.prisma.repairOrder.update({ where: { id }, data }); const record = await this.prisma.repairOrder.update({ where: { id: id } as any, data });
return serializeRecord(record); return serializeRecord(record);
} }
async remove(id: string) { async remove(id: string) {
const record = await this.prisma.repairOrder.delete({ where: { id } }); const record = await this.prisma.repairOrder.delete({ where: { id: id } as any });
return serializeRecord(record); return serializeRecord(record);
} }
} }

172
toir-realm.json Normal file
View File

@@ -0,0 +1,172 @@
{
"realm": "toir",
"enabled": true,
"displayName": "TOIR",
"sslRequired": "external",
"registrationAllowed": false,
"registrationEmailAsUsername": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"rememberMe": true,
"verifyEmail": false,
"roles": {
"realm": [
{
"name": "admin",
"description": "Full administrative access"
},
{
"name": "editor",
"description": "Can create and modify data"
},
{
"name": "viewer",
"description": "Read-only access"
}
]
},
"clientScopes": [
{
"name": "api-audience",
"description": "Adds backend audience to SPA access token",
"protocol": "openid-connect",
"attributes": {
"display.on.consent.screen": "false",
"include.in.token.scope": "false"
},
"protocolMappers": [
{
"name": "aud-toir-backend",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "toir-backend",
"id.token.claim": "false",
"access.token.claim": "true",
"introspection.token.claim": "true"
}
}
]
}
],
"clients": [
{
"clientId": "toir-frontend",
"name": "toir-frontend",
"description": "Frontend SPA client",
"enabled": true,
"protocol": "openid-connect",
"publicClient": true,
"bearerOnly": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"fullScopeAllowed": true,
"rootUrl": "https://toir-frontend.greact.ru",
"baseUrl": "https://toir-frontend.greact.ru",
"redirectUris": [
"https://toir-frontend.greact.ru/*",
"http://localhost:5173/*"
],
"webOrigins": [
"https://toir-frontend.greact.ru",
"http://localhost:5173"
],
"attributes": {
"pkce.code.challenge.method": "S256"
},
"defaultClientScopes": [
"api-audience"
],
"optionalClientScopes": [
"offline_access"
],
"protocolMappers": [
{
"name": "sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "id",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "sub",
"jsonType.label": "String"
}
},
{
"name": "preferred_username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
},
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "name",
"protocol": "openid-connect",
"protocolMapper": "oidc-full-name-mapper",
"consentRequired": false,
"config": {
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
"multivalued": "true",
"userinfo.token.claim": "true",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String"
}
}
]
},
{
"clientId": "toir-backend",
"name": "toir-backend",
"description": "Backend API resource server",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"bearerOnly": true,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"fullScopeAllowed": false
}
]
}

View File

@@ -0,0 +1,78 @@
# api-format → OpenAPI 3.0
Абстрактная заготовка под задачу: **из доменного описания API получить OpenAPI 3.0**, затем встроить в общий пайплайн (кнопка / CI / генератор).
## Что внутри
| Файл | Назначение |
|------|------------|
| `examples/api-format.example.json` | Пример **не-OpenAPI** формата: ресурсы, поля, операции, query для списка |
| `prompts/llm-system.md` | Системный промпт для LLM: «верни только JSON OpenAPI 3.0.3» |
| `convert.mjs` | CLI: режим `deterministic` (маппинг в коде) и `llm` (OpenAI API) |
## Пошаговая демонстрация в терминале
Чтобы **постепенно** увидеть: входной api-format → что делает конвертер → структура OpenAPI:
```bash
cd tools/api-format-to-openapi
npm run demo
```
С паузой после каждого шага (нажимай Enter):
```bash
npm run demo:pause
```
Результат кладётся в `demo-output/openapi.json`.
## Детерминированный режим (без LLM)
Подходит для **фиксированной** схемы `apiFormatVersion: "1"` как в примере.
```bash
cd tools/api-format-to-openapi
node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
```
Или через npm:
```bash
cd tools/api-format-to-openapi
npm run convert
```
## Режим LLM
Когда реальный формат отличается или богаче — прогон через модель с промптом из `prompts/llm-system.md`.
```bash
set OPENAI_API_KEY=sk-...
cd tools/api-format-to-openapi
node convert.mjs --mode llm --in path/to/your-api-format.json --out ../../openapi.llm.json
```
Переменные:
- `OPENAI_API_KEY` — обязательно
- `OPENAI_MODEL` — по умолчанию `gpt-4o-mini`
- `OPENAI_BASE_URL` — по умолчанию `https://api.openai.com/v1` (совместимо с прокси)
## HTTP-экспортёр для AID (NestJS)
В `server` добавлен модуль **`AidExportModule`**: `POST /aid/export/openapi` принимает `{ "apiFormat": {...}, "mode"?: "deterministic"|"llm" }` и возвращает `{ "openapi": {...} }`. Подробности: `server/src/aid-export/README.md`.
## Интеграция позже
1. Заменить/расширить `examples/api-format.example.json` под ваш настоящий контракт из «алабужского» гита.
2. Либо расширить `toOpenApiDeterministic` в `convert.mjs`, либо перейти на `--mode llm` с отточенным промптом.
3. Согласовать с AID точный URL, заголовки и (при необходимости) обёртку ответа; при необходимости добавить отдельный маршрут «сырой» OpenAPI без `{ openapi: ... }`.
## Валидация OpenAPI (опционально)
После генерации можно проверить спеку любым валидатором, например:
```bash
npx -y @apidevtools/swagger-cli validate ../../openapi.generated.json
```

View File

@@ -0,0 +1,341 @@
#!/usr/bin/env node
/**
* api-format → OpenAPI 3.0
*
* Режимы:
* --mode deterministic — маппинг только для схемы examples/api-format.example.json (и совместимых)
* --mode llm — отправка входного JSON в OpenAI Chat Completions (нужен OPENAI_API_KEY)
*
* Примеры:
* node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
* node convert.mjs --mode llm --in my-api.json --out openapi.json
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
function parseArgs(argv) {
const out = { mode: "deterministic", input: null, output: null };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === "--mode") out.mode = argv[++i];
else if (a === "--in") out.input = argv[++i];
else if (a === "--out") out.output = argv[++i];
else if (a === "-h" || a === "--help") out.help = true;
}
return out;
}
function usage() {
console.log(`
Usage: node convert.mjs --in <api-format.json> --out <openapi.json> [--mode deterministic|llm]
Environment (llm mode):
OPENAI_API_KEY required
OPENAI_MODEL optional, default gpt-4o-mini
OPENAI_BASE_URL optional, default https://api.openai.com/v1
`);
}
/** @param {string} t */
function fieldToSchema(t) {
const map = {
string: { type: "string" },
uuid: { type: "string", format: "uuid" },
int: { type: "integer" },
integer: { type: "integer" },
number: { type: "number" },
float: { type: "number" },
boolean: { type: "boolean" },
date: { type: "string", format: "date" },
datetime: { type: "string", format: "date-time" },
};
return map[t] || { type: "string", description: `unknown type: ${t}` };
}
/**
* Детерминированная конвертация для apiFormatVersion "1" с полями как в example.
* @param {any} api
*/
function toOpenApiDeterministic(api) {
if (!api || api.apiFormatVersion !== "1") {
throw new Error(
'deterministic mode: ожидается apiFormatVersion "1". Для другого формата используйте --mode llm или расширьте маппинг в convert.mjs.',
);
}
const base = (api.server?.basePath || "/api").replace(/\/$/, "");
const info = api.info || { title: "API", version: "1.0.0" };
const paths = {};
const schemas = {};
for (const res of api.resources || []) {
const name = res.name;
const seg = res.pathSegment || name.toLowerCase();
const idParam = res.idParam || "id";
const idType = res.idType || "uuid";
const props = {};
const required = [];
for (const f of res.fields || []) {
let sch;
if (f.type === "enum" && Array.isArray(f.enumValues)) {
sch = { type: "string", enum: f.enumValues };
} else {
sch = { ...fieldToSchema(f.type) };
}
if (f.readOnly) sch.readOnly = true;
props[f.name] = sch;
if (f.required) required.push(f.name);
}
schemas[name] = {
type: "object",
properties: props,
...(required.length ? { required } : {}),
};
const listPath = `${base}/${seg}`;
const itemPath = `${base}/${seg}/{${idParam}}`;
const idSchema = fieldToSchema(idType);
const listQuery = [];
const lq = res.listQuery;
if (lq?.pagination) {
for (const p of lq.pagination) {
if (p === "_start" || p === "_end")
listQuery.push({ name: p, in: "query", schema: { type: "integer" }, description: "pagination" });
}
}
if (lq?.sort) {
for (const p of lq.sort) {
if (p === "_sort")
listQuery.push({ name: "_sort", in: "query", schema: { type: "string" }, description: "sort field" });
if (p === "_order")
listQuery.push({
name: "_order",
in: "query",
schema: { type: "string", enum: ["asc", "desc"] },
description: "sort order",
});
}
}
if (lq?.filters) {
for (const p of lq.filters) {
if (p === "q")
listQuery.push({ name: "q", in: "query", schema: { type: "string" }, description: "full-text search" });
else {
const field = (res.fields || []).find((x) => x.name === p);
const isEnum = field?.type === "enum";
listQuery.push({
name: p,
in: "query",
schema: isEnum
? { type: "array", items: { type: "string", enum: field.enumValues || [] } }
: { type: "string" },
style: isEnum ? "form" : undefined,
explode: isEnum ? true : undefined,
description: isEnum ? "repeat param for multiple values" : undefined,
});
}
}
}
const ops = new Set(res.operations || []);
if (ops.has("list")) {
paths[listPath] = paths[listPath] || {};
paths[listPath].get = {
tags: [name],
summary: `List ${name}`,
parameters: listQuery,
responses: {
"200": {
description: "OK",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: { type: "array", items: { $ref: `#/components/schemas/${name}` } },
total: { type: "integer" },
},
},
},
},
},
},
};
}
if (ops.has("create")) {
paths[listPath] = paths[listPath] || {};
paths[listPath].post = {
tags: [name],
summary: `Create ${name}`,
requestBody: {
required: true,
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
},
responses: {
"201": {
description: "Created",
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
},
"400": { description: "Bad request" },
},
};
}
if (ops.has("get")) {
paths[itemPath] = paths[itemPath] || {};
paths[itemPath].get = {
tags: [name],
summary: `Get ${name} by ${idParam}`,
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
responses: {
"200": {
description: "OK",
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
},
"404": { description: "Not found" },
},
};
}
if (ops.has("update")) {
paths[itemPath] = paths[itemPath] || {};
paths[itemPath].patch = {
tags: [name],
summary: `Update ${name}`,
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
requestBody: {
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
},
responses: {
"200": {
description: "OK",
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
},
"404": { description: "Not found" },
},
};
}
if (ops.has("delete")) {
paths[itemPath] = paths[itemPath] || {};
paths[itemPath].delete = {
tags: [name],
summary: `Delete ${name}`,
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
responses: {
"204": { description: "No content" },
"404": { description: "Not found" },
},
};
}
}
const doc = {
openapi: "3.0.3",
info: {
title: info.title,
version: info.version,
...(info.description ? { description: info.description } : {}),
},
servers: [{ url: base || "/" }],
paths,
components: {
schemas,
...(api.security?.type === "bearer" || api.security?.scheme === "JWT"
? {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
},
}
: {}),
},
};
if (doc.components.securitySchemes) {
doc.security = [{ bearerAuth: [] }];
for (const method of Object.values(paths)) {
for (const op of Object.values(method)) {
if (op && typeof op === "object" && op.responses) op.security = [{ bearerAuth: [] }];
}
}
}
return doc;
}
async function toOpenApiLlm(apiJson) {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error("OPENAI_API_KEY не задан");
const model = process.env.OPENAI_MODEL || "gpt-4o-mini";
const baseUrl = (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").replace(/\/$/, "");
const systemPath = resolve(__dirname, "prompts", "llm-system.md");
const system = readFileSync(systemPath, "utf8");
const user = JSON.stringify(apiJson, null, 2);
const res = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${key}`,
},
body: JSON.stringify({
model,
temperature: 0.1,
messages: [
{ role: "system", content: system },
{ role: "user", content: `Преобразуй следующий api-format в OpenAPI 3.0.3 JSON:\n\n${user}` },
],
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`OpenAI HTTP ${res.status}: ${text}`);
}
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("Пустой ответ от модели");
const trimmed = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
return JSON.parse(trimmed);
}
async function main() {
const args = parseArgs(process.argv);
if (args.help || !args.input || !args.output) {
usage();
process.exit(args.help ? 0 : 1);
}
const inputPath = resolve(process.cwd(), args.input);
const outputPath = resolve(process.cwd(), args.output);
const raw = readFileSync(inputPath, "utf8");
const api = JSON.parse(raw);
let openapi;
if (args.mode === "llm") {
openapi = await toOpenApiLlm(api);
} else {
openapi = toOpenApiDeterministic(api);
}
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, JSON.stringify(openapi, null, 2), "utf8");
console.log(`Written: ${outputPath}`);
}
main().catch((e) => {
console.error(e.message || e);
process.exit(1);
});

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env node
/**
* Пошаговая демонстрация: api-format → OpenAPI 3.0 (детерминированный режим).
*
* node demo-steps.mjs — все шаги подряд в консоли
* node demo-steps.mjs --pause — пауза после каждого шага (Enter)
*
* Результат также пишется в demo-output/openapi.json рядом со скриптом.
*/
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const EXAMPLE = join(__dirname, "examples", "api-format.example.json");
const OUT_DIR = join(__dirname, "demo-output");
const OUT_OPENAPI = join(OUT_DIR, "openapi.json");
const usePause = process.argv.includes("--pause");
function banner(title) {
const line = "═".repeat(Math.min(60, title.length + 8));
console.log(`\n${line}\n ${title}\n${line}\n`);
}
async function pause(msg = "Нажми Enter, чтобы перейти к следующему шагу…") {
if (!usePause) return;
const rl = createInterface({ input, output });
await rl.question(msg);
rl.close();
}
async function main() {
console.clear?.();
banner("Шаг 0. Задача");
console.log(
"У нас есть описание API в СВОЁМ формате (api-format), не OpenAPI.\n" +
"Нужно получить стандартную спецификацию OpenAPI 3.0 — для Swagger, клиентов, AID.\n" +
"Сейчас покажем путь на учебном примере (детерминированный маппинг в convert.mjs).",
);
await pause();
banner("Шаг 1. Входной файл (фрагмент api-format)");
console.log(`Файл: ${EXAMPLE}\n`);
const rawIn = readFileSync(EXAMPLE, "utf8");
const apiFormat = JSON.parse(rawIn);
console.log(JSON.stringify(apiFormat, null, 2));
console.log(
"\n↑ Это НЕ OpenAPI. Здесь: версия формата, info, basePath, ресурс Equipment с полями и операциями CRUD.",
);
await pause();
banner("Шаг 2. Что делает конвертер (логика)");
console.log(`
• apiFormatVersion "1" → включается ветка toOpenApiDeterministic в convert.mjs
Ресурс Equipment → components.schemas.Equipment + пути /api/equipment и /api/equipment/{id}
• Поля (string, uuid, enum…) → JSON Schema в components.schemas
• listQuery → query-параметры (_start, _end, _sort, _order, q, status…)
• security bearer → components.securitySchemes + security на операциях
`);
await pause();
banner("Шаг 3. Запуск convert.mjs");
mkdirSync(OUT_DIR, { recursive: true });
const convertScript = join(__dirname, "convert.mjs");
console.log(`Команда:\n node convert.mjs --in examples/api-format.example.json --out demo-output/openapi.json\n`);
execFileSync(process.execPath, [convertScript, "--in", EXAMPLE, "--out", OUT_OPENAPI], {
stdio: "inherit",
});
console.log(`\nГотово. Файл: ${OUT_OPENAPI}`);
await pause();
banner("Шаг 4. Результат — структура OpenAPI");
const spec = JSON.parse(readFileSync(OUT_OPENAPI, "utf8"));
console.log(`openapi: ${spec.openapi}`);
console.log(`title: ${spec.info?.title}`);
console.log(`version: ${spec.info?.version}`);
console.log("\nПути (paths):");
for (const p of Object.keys(spec.paths || {}).sort()) {
const methods = Object.keys(spec.paths[p]).join(", ");
console.log(` ${p} [${methods}]`);
}
console.log("\nСхемы (components.schemas):", Object.keys(spec.components?.schemas || {}).join(", "));
await pause();
banner("Шаг 5. Фрагмент: GET список (одна операция)");
const listPath = Object.keys(spec.paths || {}).find((k) => k.endsWith("/equipment") && !k.includes("{"));
if (listPath && spec.paths[listPath]?.get) {
console.log(JSON.stringify({ [listPath]: { get: spec.paths[listPath].get } }, null, 2));
} else {
console.log("(путь списка не найден — открой demo-output/openapi.json)");
}
await pause();
banner("Шаг 6. Как проверить дальше");
console.log(`
1) Открой целиком: ${OUT_OPENAPI}
2) Валидация (из корня репозитория):
npx -y @apidevtools/swagger-cli validate tools/api-format-to-openapi/demo-output/openapi.json
3) Через Nest (сервер на 3001):
POST http://127.0.0.1:3001/aid/export/openapi
тело: { "apiFormat": <содержимое api-format.example.json>, "mode": "deterministic" }
4) Режим LLM (другой входной JSON):
node convert.mjs --mode llm --in your.json --out openapi.llm.json
(нужен OPENAI_API_KEY)
`);
console.log("Демо завершено.\n");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,36 @@
{
"apiFormatVersion": "1",
"info": {
"title": "TOiR Demo API",
"version": "1.0.0",
"description": "Абстрактный пример доменного описания API (не OpenAPI)."
},
"server": {
"basePath": "/api"
},
"security": {
"type": "bearer",
"scheme": "JWT"
},
"resources": [
{
"name": "Equipment",
"pathSegment": "equipment",
"idParam": "id",
"idType": "uuid",
"fields": [
{ "name": "id", "type": "uuid", "readOnly": true },
{ "name": "inventoryNumber", "type": "string", "required": true },
{ "name": "name", "type": "string", "required": true },
{ "name": "status", "type": "enum", "enumValues": ["Active", "Repair", "Decommissioned"] },
{ "name": "location", "type": "string" }
],
"operations": ["list", "get", "create", "update", "delete"],
"listQuery": {
"pagination": ["_start", "_end"],
"sort": ["_sort", "_order"],
"filters": ["q", "status"]
}
}
]
}

View File

@@ -0,0 +1,12 @@
{
"name": "api-format-to-openapi",
"private": true,
"type": "module",
"description": "Конвертация доменного api-format в OpenAPI 3.0 (детерминированно или через LLM)",
"scripts": {
"convert": "node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json",
"convert:llm": "node convert.mjs --mode llm --in examples/api-format.example.json --out ../../openapi.llm.json",
"demo": "node demo-steps.mjs",
"demo:pause": "node demo-steps.mjs --pause"
}
}

View File

@@ -0,0 +1,30 @@
# Роль
Ты конвертер доменного описания API в спецификацию **OpenAPI 3.0.3** (JSON).
# Вход
Пользователь пришлёт один JSON-файл в произвольном «доменном» формате (api-format). В нём могут быть сущности, поля, типы, пути, операции, фильтры, авторизация.
# Выход
- Верни **только** валидный JSON объекта OpenAPI 3.0.3.
- Без markdown, без комментариев, без текста до или после JSON.
- Используй `openapi: "3.0.3"`.
- Опиши `info`, `servers`, при необходимости `tags`.
- Для каждой сущности/ресурса создай `components.schemas` и `paths` с типичными REST-операциями, если они указаны.
- Типы полей маппь так:
- `string``type: string`
- `uuid``type: string`, `format: uuid`
- `int` / `integer``type: integer`
- `number` / `float``type: number`
- `boolean``type: boolean`
- `date` / `datetime``type: string`, `format: date` или `date-time`
- `enum` + список значений → `type: string`, `enum: [...]`
- Для списков с пагинацией добавь query-параметры из входа (`_start`, `_end`, `_sort`, `_order`, фильтры).
- Для `401/403/404/500` добавь минимальные `responses` с `description`.
- Если во входе указана Bearer/JWT — добавь `components.securitySchemes` и `security` на путях или глобально.
# Если чего-то не хватает
Делай разумные допущения и кратко отражай их в `info.description` одним предложением.

357
tools/dsl-summary.mjs Normal file
View File

@@ -0,0 +1,357 @@
import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
function stripInlineComment(line) {
let inString = false;
let result = '';
for (let index = 0; index < line.length; index += 1) {
const current = line[index];
const next = line[index + 1];
if (current === '"' && line[index - 1] !== '\\') {
inString = !inString;
result += current;
continue;
}
if (!inString && current === '/' && next === '/') {
break;
}
result += current;
}
return result.trim();
}
function parseDefaultValue(rawValue) {
const trimmed = rawValue.trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1);
}
if (/^-?\d+$/.test(trimmed)) {
return Number(trimmed);
}
return trimmed;
}
export function getDslFiles(rootDir) {
const domainDir = path.join(rootDir, 'domain');
return readdirSync(domainDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.dsl'))
.map((entry) => path.join(domainDir, entry.name))
.sort((left, right) => left.localeCompare(right));
}
function getOverrideFiles(rootDir) {
const overridesDir = path.join(rootDir, 'overrides');
const files = ['api-overrides.dsl', 'ui-overrides.dsl']
.map((name) => path.join(overridesDir, name))
.filter((filePath) => {
try {
return readdirSync(path.dirname(filePath)).includes(path.basename(filePath));
} catch {
return false;
}
});
return files;
}
export function parseDslFiles(rootDir) {
const dslFiles = getDslFiles(rootDir);
const enums = [];
const entities = [];
const stack = [];
for (const filePath of dslFiles) {
const contents = readFileSync(filePath, 'utf8');
const lines = contents.split(/\r?\n/);
for (const rawLine of lines) {
const line = stripInlineComment(rawLine);
if (!line) {
continue;
}
const top = stack.at(-1);
const enumMatch = line.match(/^enum\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
if (!top && enumMatch) {
const enumDefinition = { name: enumMatch[1], values: [] };
enums.push(enumDefinition);
stack.push({ type: 'enum', ref: enumDefinition });
continue;
}
const entityMatch = line.match(/^entity\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
if (!top && entityMatch) {
const entityDefinition = {
name: entityMatch[1],
primaryKey: null,
fields: [],
foreignKeys: [],
};
entities.push(entityDefinition);
stack.push({ type: 'entity', ref: entityDefinition });
continue;
}
const valueMatch = line.match(/^value\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
if (top?.type === 'enum' && valueMatch) {
const enumValue = { name: valueMatch[1] };
top.ref.values.push(enumValue);
stack.push({ type: 'enumValue', ref: enumValue });
continue;
}
const attributeMatch = line.match(/^attribute\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
if (top?.type === 'entity' && attributeMatch) {
const field = {
name: attributeMatch[1],
type: null,
required: false,
unique: false,
default: null,
};
top.ref.fields.push(field);
stack.push({ type: 'field', ref: field, entity: top.ref });
continue;
}
if (top?.type === 'field' && /^key\s+foreign\s*\{$/.test(line)) {
stack.push({ type: 'foreignKey', ref: top.ref, entity: top.entity });
continue;
}
if (top?.type === 'enumValue') {
const labelMatch = line.match(/^label\s+"(.*)"\s*;$/);
if (labelMatch) {
top.ref.label = labelMatch[1];
continue;
}
}
if (top?.type === 'field') {
const typeMatch = line.match(/^type\s+([A-Za-z][A-Za-z0-9_]*)\s*;$/);
if (typeMatch) {
top.ref.type = typeMatch[1];
continue;
}
if (/^key\s+primary\s*;$/.test(line)) {
top.ref.primary = true;
top.entity.primaryKey = top.ref.name;
continue;
}
const defaultMatch = line.match(/^default\s+(.+)\s*;$/);
if (defaultMatch) {
top.ref.default = parseDefaultValue(defaultMatch[1]);
continue;
}
if (/^is\s+required\s*;$/.test(line)) {
top.ref.required = true;
continue;
}
if (/^is\s+unique\s*;$/.test(line)) {
top.ref.unique = true;
continue;
}
}
if (top?.type === 'foreignKey') {
const relatesMatch = line.match(
/^relates\s+([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\s*;$/,
);
if (relatesMatch) {
const foreignKey = {
field: top.ref.name,
references: {
entity: relatesMatch[1],
field: relatesMatch[2],
},
};
top.ref.foreignKey = foreignKey.references;
top.entity.foreignKeys.push(foreignKey);
continue;
}
}
if (/^}\s*;?$/.test(line)) {
stack.pop();
}
}
}
return { dslFiles, enums, entities };
}
function assertNoDomainRedefinition(line, filePath) {
const blocked = [/^\s*entity\s+/i, /^\s*enum\s+/i, /^\s*attribute\s+/i, /^\s*key\s+primary/i, /^\s*key\s+foreign/i];
for (const pattern of blocked) {
if (pattern.test(line)) {
throw new Error(
`Override file ${path.basename(filePath)} attempts to redefine domain structure: ${line.trim()}`,
);
}
}
}
export function parseOverrides(rootDir, entities) {
const overrideFiles = getOverrideFiles(rootDir);
const entityNames = new Set(entities.map((entity) => entity.name));
const fieldNames = new Set(
entities.flatMap((entity) => entity.fields.map((field) => `${entity.name}.${field.name}`)),
);
const api = { resources: {} };
const ui = { fields: {} };
for (const filePath of overrideFiles) {
const isApi = filePath.endsWith('api-overrides.dsl');
const isUi = filePath.endsWith('ui-overrides.dsl');
const lines = readFileSync(filePath, 'utf8').split(/\r?\n/);
for (const rawLine of lines) {
const line = stripInlineComment(rawLine);
if (!line) {
continue;
}
assertNoDomainRedefinition(line, filePath);
if (isApi) {
const match = line.match(/^resource\s+([A-Za-z][A-Za-z0-9_]*)\s+path\s+"([^"]+)"\s*;$/);
if (!match) {
throw new Error(
`Unsupported API override syntax in ${path.basename(filePath)}: ${line}`,
);
}
const [, entityName, resourcePath] = match;
if (!entityNames.has(entityName)) {
throw new Error(`API override references unknown entity ${entityName}`);
}
api.resources[entityName] = { path: resourcePath };
}
if (isUi) {
const match = line.match(
/^field\s+([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\s+widget\s+"([^"]+)"\s*;$/,
);
if (!match) {
throw new Error(
`Unsupported UI override syntax in ${path.basename(filePath)}: ${line}`,
);
}
const [, entityName, fieldName, widget] = match;
const key = `${entityName}.${fieldName}`;
if (!fieldNames.has(key)) {
throw new Error(`UI override references unknown field ${key}`);
}
ui.fields[key] = { widget };
}
}
}
return { api, ui, sourceFiles: overrideFiles.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')) };
}
export function buildDomainSummary(rootDir) {
const { dslFiles, enums, entities } = parseDslFiles(rootDir);
parseOverrides(rootDir, entities);
const entityByName = new Map(entities.map((entity) => [entity.name, entity]));
const enumByName = new Map(enums.map((entry) => [entry.name, entry]));
for (const entity of entities) {
if (!entity.primaryKey) {
throw new Error(`Entity ${entity.name} is missing a primary key`);
}
const primaryKeyField = entity.fields.find((field) => field.name === entity.primaryKey);
if (!primaryKeyField) {
throw new Error(
`Entity ${entity.name} declares primary key ${entity.primaryKey}, but the field is missing`,
);
}
if (entity.fields.some((field) => !field.type)) {
throw new Error(`Entity ${entity.name} has attributes with missing type`);
}
for (const foreignKey of entity.foreignKeys) {
const targetEntity = entityByName.get(foreignKey.references.entity);
if (!targetEntity) {
throw new Error(
`Foreign key ${entity.name}.${foreignKey.field} references missing entity ${foreignKey.references.entity}`,
);
}
const targetField = targetEntity.fields.find(
(field) => field.name === foreignKey.references.field,
);
if (!targetField) {
throw new Error(
`Foreign key ${entity.name}.${foreignKey.field} references missing field ${foreignKey.references.entity}.${foreignKey.references.field}`,
);
}
const sourceField = entity.fields.find((field) => field.name === foreignKey.field);
if (sourceField && sourceField.type !== targetField.type) {
throw new Error(
`Foreign key ${entity.name}.${foreignKey.field} type ${sourceField.type} does not match ${foreignKey.references.entity}.${foreignKey.references.field} type ${targetField.type}`,
);
}
}
const fieldNames = new Set();
for (const field of entity.fields) {
if (fieldNames.has(field.name)) {
throw new Error(`Entity ${entity.name} has duplicate field ${field.name}`);
}
fieldNames.add(field.name);
}
}
const entityNames = new Set();
for (const entity of entities) {
if (entityNames.has(entity.name)) {
throw new Error(`Duplicate entity definition: ${entity.name}`);
}
entityNames.add(entity.name);
}
for (const [enumName, enumDefinition] of enumByName.entries()) {
const valueNames = new Set();
for (const value of enumDefinition.values) {
if (valueNames.has(value.name)) {
throw new Error(`Enum ${enumName} has duplicate value ${value.name}`);
}
valueNames.add(value.name);
}
}
return {
sourceFiles: dslFiles.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')),
entities: entities.map((entity) => ({
name: entity.name,
primaryKey: entity.primaryKey,
fields: entity.fields.map((field) => ({
name: field.name,
type: field.type,
required: field.required,
unique: field.unique,
default: field.default,
})),
foreignKeys: entity.foreignKeys,
})),
enums,
};
}

View File

@@ -0,0 +1,13 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { buildDomainSummary } from './dsl-summary.mjs';
const rootDir = process.cwd();
const outputPath = path.join(rootDir, 'domain-summary.json');
mkdirSync(path.dirname(outputPath), { recursive: true });
const summary = buildDomainSummary(rootDir);
writeFileSync(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
console.log(`Generated ${path.relative(rootDir, outputPath)}`);

View File

@@ -0,0 +1,513 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import {
buildDomainSummary,
getDslFiles,
parseDslFiles,
parseOverrides,
} from './dsl-summary.mjs';
const rootDir = process.cwd();
const args = new Set(process.argv.slice(2));
const artifactsOnly = args.has('--artifacts-only');
const runRuntime = args.has('--run-runtime');
const failures = [];
const warnings = [];
function assertCondition(condition, message) {
if (!condition) {
failures.push(message);
}
}
function warn(message) {
warnings.push(message);
}
function read(relativePath) {
return readFileSync(path.join(rootDir, relativePath), 'utf8');
}
function readIfExists(relativePath) {
const filePath = path.join(rootDir, relativePath);
if (!existsSync(filePath)) {
return null;
}
return readFileSync(filePath, 'utf8');
}
function requireFile(relativePath) {
assertCondition(existsSync(path.join(rootDir, relativePath)), `Missing file: ${relativePath}`);
}
function requireFiles(relativePaths) {
relativePaths.forEach(requireFile);
}
function requireContent(relativePath, pattern, message) {
const contents = readIfExists(relativePath);
assertCondition(Boolean(contents), `Missing file: ${relativePath}`);
if (!contents) {
return;
}
assertCondition(pattern.test(contents), `${message} (${relativePath})`);
}
function parseJson(relativePath) {
const raw = readIfExists(relativePath);
if (!raw) {
failures.push(`Missing file: ${relativePath}`);
return null;
}
try {
return JSON.parse(raw);
} catch (error) {
failures.push(`Invalid JSON in ${relativePath}: ${error.message}`);
return null;
}
}
function kebabCase(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/\s+/g, '-')
.toLowerCase();
}
function getRealmArtifactPath() {
const rootFiles = readdirSync(rootDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name);
const realmArtifacts = rootFiles.filter((entry) => /-realm\.json$/i.test(entry));
assertCondition(realmArtifacts.length === 1, 'Expected exactly one root-level *-realm.json artifact');
return realmArtifacts[0] ?? null;
}
function getWorkspaceInfo() {
return {
server: {
dir: path.join(rootDir, 'server'),
packagePath: 'server/package.json',
scaffoldFiles: [
'server/package.json',
'server/tsconfig.json',
'server/tsconfig.build.json',
'server/nest-cli.json',
'server/src/main.ts',
'server/src/app.module.ts',
],
},
client: {
dir: path.join(rootDir, 'client'),
packagePath: 'client/package.json',
scaffoldFiles: [
'client/package.json',
'client/index.html',
'client/tsconfig.json',
'client/tsconfig.node.json',
'client/vite.config.ts',
'client/src/main.tsx',
'client/src/vite-env.d.ts',
],
},
};
}
function validateBuildChecks() {
requireFiles([
'README.md',
'package.json',
'domain/dsl-spec.md',
'domain-summary.json',
'server/prisma/schema.prisma',
'server/.env.example',
'client/.env.example',
'prompts/general-prompt.md',
'prompts/auth-rules.md',
'prompts/backend-rules.md',
'prompts/frontend-rules.md',
'prompts/runtime-rules.md',
'prompts/validation-rules.md',
]);
const dslFiles = getDslFiles(rootDir).map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/'));
assertCondition(dslFiles.length > 0, 'Expected at least one domain/*.dsl file');
const actualSummaryRaw = readIfExists('domain-summary.json');
if (actualSummaryRaw) {
const expectedSummary = JSON.stringify(buildDomainSummary(rootDir), null, 2);
assertCondition(
actualSummaryRaw.trim() === expectedSummary,
'domain-summary.json is out of date. Run `npm run generate:domain-summary`.',
);
}
try {
const { entities } = parseDslFiles(rootDir);
parseOverrides(rootDir, entities);
} catch (error) {
failures.push(`Override validation failed: ${error.message}`);
}
const { server, client } = getWorkspaceInfo();
requireFiles(server.scaffoldFiles);
requireFiles(client.scaffoldFiles);
const serverPackage = parseJson(server.packagePath);
if (serverPackage) {
assertCondition(serverPackage.scripts?.build === 'nest build', 'server/package.json must keep `build = nest build`');
assertCondition(serverPackage.scripts?.start === 'nest start', 'server/package.json must keep `start = nest start`');
assertCondition(serverPackage.scripts?.['start:dev'] === 'nest start --watch', 'server/package.json must keep `start:dev = nest start --watch`');
assertCondition(Boolean(serverPackage.scripts?.['start:prod']), 'server/package.json must define a start:prod script');
assertCondition(Boolean(serverPackage.dependencies?.['@nestjs/core']), 'server/package.json must keep Nest runtime dependencies');
}
const clientPackage = parseJson(client.packagePath);
if (clientPackage) {
assertCondition(clientPackage.scripts?.dev === 'vite', 'client/package.json must keep `dev = vite`');
assertCondition(clientPackage.scripts?.build === 'vite build', 'client/package.json must keep `build = vite build`');
assertCondition(clientPackage.scripts?.preview === 'vite preview', 'client/package.json must keep `preview = vite preview`');
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');
}
}
function validateAuthChecks() {
requireFiles([
'client/src/auth/keycloak.ts',
'client/src/auth/authProvider.ts',
'client/src/dataProvider.ts',
'client/src/config/env.ts',
'client/src/main.tsx',
'client/src/App.tsx',
'server/src/auth/auth.module.ts',
'server/src/auth/auth.service.ts',
'server/src/auth/guards/jwt-auth.guard.ts',
'server/src/auth/guards/roles.guard.ts',
'server/src/auth/decorators/public.decorator.ts',
'server/src/auth/decorators/roles.decorator.ts',
]);
requireContent(
'client/src/auth/keycloak.ts',
/onLoad:\s*'login-required'/,
'Frontend auth must initialize Keycloak with login-required',
);
requireContent(
'client/src/auth/keycloak.ts',
/pkceMethod:\s*'S256'/,
'Frontend auth must use PKCE S256',
);
requireContent(
'client/src/auth/keycloak.ts',
/updateToken\(/,
'Frontend auth must refresh access tokens through the Keycloak adapter',
);
const keycloakSource = readIfExists('client/src/auth/keycloak.ts') ?? '';
assertCondition(
!/loadUserProfile\(/.test(keycloakSource),
'Frontend auth must not call keycloak.loadUserProfile()',
);
requireContent(
'client/src/dataProvider.ts',
/Authorization', `Bearer \$\{token\}`/,
'dataProvider must attach bearer tokens in the shared request seam',
);
const authProvider = readIfExists('client/src/auth/authProvider.ts') ?? '';
assertCondition(
/status === 401/.test(authProvider) && /status === 403/.test(authProvider),
'authProvider must distinguish 401 and 403 semantics',
);
const authService = readIfExists('server/src/auth/auth.service.ts') ?? '';
assertCondition(
/jwtVerify/.test(authService) && /KEYCLOAK_ISSUER_URL/.test(authService) && /KEYCLOAK_AUDIENCE/.test(authService),
'Backend auth must verify JWTs with issuer and audience',
);
assertCondition(/realm_access/.test(authService), 'Backend auth must extract roles from realm_access.roles');
assertCondition(/KEYCLOAK_JWKS_URL/.test(authService), 'Backend auth must support explicit KEYCLOAK_JWKS_URL');
assertCondition(/\.well-known\/openid-configuration/.test(authService), 'Backend auth must try OIDC discovery before fallback certs');
assertCondition(/protocol\/openid-connect\/certs/.test(authService), 'Backend auth must keep Keycloak certs fallback resolution');
}
function validateNaturalKeyChecks() {
const summary = parseJson('domain-summary.json');
if (!summary) {
return;
}
const naturalKeyEntities = summary.entities.filter((entity) => entity.primaryKey !== 'id');
for (const entity of naturalKeyEntities) {
const moduleName = kebabCase(entity.name);
const controllerPath = `server/src/modules/${moduleName}/${moduleName}.controller.ts`;
const servicePath = `server/src/modules/${moduleName}/${moduleName}.service.ts`;
const controller = readIfExists(controllerPath) ?? '';
const service = readIfExists(servicePath) ?? '';
assertCondition(Boolean(controller), `Missing file: ${controllerPath}`);
assertCondition(Boolean(service), `Missing file: ${servicePath}`);
if (!controller || !service) {
continue;
}
assertCondition(
controller.includes(`@Get(':${entity.primaryKey}')`) &&
controller.includes(`@Patch(':${entity.primaryKey}')`) &&
controller.includes(`@Delete(':${entity.primaryKey}')`),
`${entity.name} controller must use :${entity.primaryKey} route params`,
);
assertCondition(
service.includes(`id: item.${entity.primaryKey}`) || service.includes(`id: record.${entity.primaryKey}`),
`${entity.name} service must map the natural key to React Admin id`,
);
assertCondition(
service.includes(`const { id, ${entity.primaryKey}: _pk`) || service.includes(`const { id: _pk, ${entity.primaryKey}`),
`${entity.name} update path must sanitize id and primary key from Prisma update data`,
);
assertCondition(
/sortField\s*===\s*'id'/.test(service) || /sortField\s*===\s*"id"/.test(service),
`${entity.name} natural-key sort must map React Admin id sorting back to the real primary key`,
);
assertCondition(
!service.includes("query._sort || 'id'"),
`${entity.name} natural-key sort must not fall back to the physical id field`,
);
}
}
function validateRealmChecks() {
const realmArtifactName = getRealmArtifactPath();
if (!realmArtifactName) {
return;
}
const artifact = parseJson(realmArtifactName);
if (!artifact) {
return;
}
const realmRoles = artifact.roles?.realm?.map((role) => role.name) ?? [];
const frontendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-frontend'));
const backendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-backend'));
const audienceScope = artifact.clientScopes?.find((scope) => scope.name === 'api-audience');
['admin', 'editor', 'viewer'].forEach((role) => {
assertCondition(realmRoles.includes(role), `Realm artifact must define realm role ${role}`);
});
assertCondition(Boolean(frontendClient), 'Realm artifact must define the frontend SPA client');
assertCondition(Boolean(backendClient), 'Realm artifact must define the backend resource client');
assertCondition(Boolean(audienceScope), 'Realm artifact must define the api-audience client scope');
if (frontendClient) {
assertCondition(frontendClient.publicClient === true, 'Frontend realm client must be public');
assertCondition(
frontendClient.standardFlowEnabled === true &&
frontendClient.implicitFlowEnabled === false &&
frontendClient.directAccessGrantsEnabled === false,
'Frontend realm client must use standard flow only',
);
assertCondition(
frontendClient.attributes?.['pkce.code.challenge.method'] === 'S256',
'Frontend realm client must enforce PKCE S256',
);
const mapperNames = new Set((frontendClient.protocolMappers ?? []).map((mapper) => mapper.name));
['sub', 'preferred_username', 'email', 'name', 'realm roles'].forEach((mapperName) => {
assertCondition(mapperNames.has(mapperName), `Frontend realm client must include protocol mapper ${mapperName}`);
});
}
if (backendClient && audienceScope) {
const audienceMapper = (audienceScope.protocolMappers ?? []).find(
(mapper) => mapper.protocolMapper === 'oidc-audience-mapper',
);
assertCondition(Boolean(audienceMapper), 'api-audience scope must include an audience mapper');
assertCondition(
audienceMapper?.config?.['included.client.audience'] === backendClient.clientId,
'api-audience scope must deliver the backend audience/client id',
);
assertCondition(backendClient.bearerOnly === true, 'Backend realm client must be bearer-only');
}
}
function validateRuntimeContractChecks() {
requireFile('docker-compose.yml');
const compose = readIfExists('docker-compose.yml') ?? '';
assertCondition(/image:\s*postgres:16/.test(compose), 'docker-compose must provision postgres:16');
assertCondition(!/keycloak/i.test(compose), 'docker-compose must remain PostgreSQL-only');
const serverEnvExample = readIfExists('server/.env.example') ?? '';
assertCondition(
/KEYCLOAK_ISSUER_URL="https:\/\/sso\.greact\.ru\/realms\/toir"/.test(serverEnvExample),
'server/.env.example must keep the working Keycloak issuer example',
);
assertCondition(
/KEYCLOAK_AUDIENCE="?toir-backend"?/.test(serverEnvExample),
'server/.env.example must keep the working backend audience example',
);
assertCondition(
/CORS_ALLOWED_ORIGINS="http:\/\/localhost:5173,https:\/\/toir-frontend\.greact\.ru"/.test(serverEnvExample),
'server/.env.example must keep the working CORS example with the production frontend domain',
);
assertCondition(
!/KEYCLOAK_ISSUER_URL=http:\/\/localhost:8080\/realms\/toir/.test(serverEnvExample),
'server/.env.example must not regress to localhost Keycloak as the baseline issuer example',
);
const clientEnvExample = readIfExists('client/.env.example') ?? '';
assertCondition(
/VITE_KEYCLOAK_URL=https:\/\/sso\.greact\.ru/.test(clientEnvExample),
'client/.env.example must keep the working domain-based Keycloak URL example',
);
assertCondition(
/VITE_KEYCLOAK_REALM=toir/.test(clientEnvExample) && /VITE_KEYCLOAK_CLIENT_ID=toir-frontend/.test(clientEnvExample),
'client/.env.example must keep the working realm and frontend client examples',
);
assertCondition(
!/VITE_KEYCLOAK_URL=http:\/\/localhost:8080/.test(clientEnvExample),
'client/.env.example must not regress to localhost Keycloak as the baseline example',
);
const healthController = readIfExists('server/src/health/health.controller.ts') ?? '';
assertCondition(Boolean(healthController), 'Missing file: server/src/health/health.controller.ts');
if (healthController) {
assertCondition(
/@Public\(\)/.test(healthController) && /@Controller\('health'\)/.test(healthController),
'/health must stay public',
);
}
}
function runCommand(command, commandArgs, workdir, failureLabel) {
const runtimeEnv = { ...process.env };
const envExamplePath = path.join(workdir, '.env.example');
if (existsSync(envExamplePath)) {
const envExample = readFileSync(envExamplePath, 'utf8');
for (const line of envExample.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const separator = trimmed.indexOf('=');
if (separator <= 0) {
continue;
}
const key = trimmed.slice(0, separator).trim();
const value = trimmed.slice(separator + 1).trim().replace(/^"|"$/g, '');
if (!(key in runtimeEnv)) {
runtimeEnv[key] = value;
}
}
}
const commandLine = [command, ...commandArgs].join(' ');
const result = spawnSync(commandLine, {
cwd: workdir,
encoding: 'utf8',
stdio: 'pipe',
shell: true,
env: runtimeEnv,
});
if (result.error) {
failures.push(`${failureLabel}: ${commandLine}\n${result.error.message}`);
return false;
}
if (result.status !== 0) {
const stderr = result.stderr?.trim();
const stdout = result.stdout?.trim();
failures.push(
`${failureLabel}: ${commandLine}${stderr ? `\n${stderr}` : stdout ? `\n${stdout}` : ''}`,
);
return false;
}
return true;
}
function maybeValidateWorkspaceBuild(relativeDir) {
const workspaceDir = path.join(rootDir, relativeDir);
if (!existsSync(path.join(workspaceDir, 'package.json'))) {
failures.push(`Missing file: ${relativeDir}/package.json`);
return;
}
if (!existsSync(path.join(workspaceDir, 'node_modules'))) {
warn(`Skipped build verification for ${relativeDir}: install dependencies in ${relativeDir}/ to validate workspace buildability.`);
return;
}
runCommand('npm', ['run', 'build'], workspaceDir, `Build verification failed in ${relativeDir}`);
}
function validateBuildExecutionChecks() {
maybeValidateWorkspaceBuild('server');
maybeValidateWorkspaceBuild('client');
}
function validateRuntimeExecutionChecks() {
const serverDir = path.join(rootDir, 'server');
if (!existsSync(path.join(serverDir, 'node_modules'))) {
failures.push(
'Runtime validation requires installed backend dependencies. Run `npm install` in server/ before `npm run validate:generation:runtime`.',
);
return;
}
runCommand('npx', ['prisma', 'generate'], serverDir, 'Prisma generate failed');
runCommand(
'npx',
['prisma', 'migrate', 'dev', '--name', 'baseline', '--skip-generate'],
serverDir,
'Prisma migrate failed',
);
runCommand('npx', ['prisma', 'db', 'seed'], serverDir, 'Prisma seed failed');
}
validateBuildChecks();
validateAuthChecks();
validateNaturalKeyChecks();
validateRealmChecks();
validateRuntimeContractChecks();
if (!artifactsOnly) {
validateBuildExecutionChecks();
}
if (!artifactsOnly && runRuntime) {
validateRuntimeExecutionChecks();
} else if (!artifactsOnly) {
warn('Runtime command execution skipped. Use --run-runtime after installing dependencies and starting the local database.');
}
for (const warning of warnings) {
console.warn(`WARN: ${warning}`);
}
if (failures.length > 0) {
console.error('Generation validation failed:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('Generation validation passed.');