2 Commits

Author SHA1 Message Date
time_
9352f15fd6 chore: pin npm deps, quiet install, validate exact versions 2026-03-29 14:36:10 +03:00
time_
1cdd80f51b feat: align RU validation, error contract, and generator runtime templates
Wire DSL-derived field labels, safe API error JSON (string|string[]), decimal/enum DTO fixes, and client dataProvider without comma-splitting. Add generation/templates/runtime as canonical source copied on generate; extend AID bundle, prompts, validation gate, and docs.
2026-03-29 14:36:10 +03:00
203 changed files with 10237 additions and 15818 deletions

View File

@@ -1,108 +0,0 @@
# Codex CLI — KIS-TOiR workspace supplement
This file supplements the repository root `AGENTS.md` with Codex-specific
operational notes. The root `AGENTS.md` is the authoritative contract —
if anything here contradicts root, root wins.
---
## Agent role summary
| Role | Config file | Sandbox | Write boundary |
|------|-------------|---------|----------------|
| `generator` | `agents/generator.toml` | workspace-write | Tier 3 generation zones |
| `explorer` | `agents/explorer.toml` | read-only | None |
| `reviewer` | `agents/reviewer.toml` | read-only | Proposes patches only |
| `docs_researcher` | `agents/docs-researcher.toml` | read-only | None |
Use `/agent generator` for implementation work. Use `/agent explorer` first for
discovery, `/agent docs_researcher` when framework or prompt patterns need
verification, and `/agent reviewer` before claiming a generation run is complete.
---
## Mutation boundary map
```
Tier 1 — Source of truth (NEVER written by any agent)
domain/*.api.dsl — single source of truth for all generation
prompts/*.md — generation spec / rules
AGENTS.md — agent operating rules
.codex/AGENTS.md (this file) — Codex-specific supplement
Tier 2 — Deterministic derivatives (written only by npm scripts, not by agents)
api-summary.json ← npm run generate:api-summary
openapi.json ← npm run generate:openapi (auxiliary)
Tier 3 — LLM-generated artifacts (written ONLY by generator agent)
server/src/modules/<entity>/
client/src/resources/<entity>/
server/src/app.module.ts
client/src/App.tsx
server/prisma/schema.prisma ← LLM-generated per prompts/prisma-rules.md
server/src/auth/
client/src/auth/
client/src/dataProvider.ts
toir-realm.json
docker-compose.yml
server/.env.example
client/.env.example
Tier 4 — Handwritten / framework-managed support files
framework scaffold and other manual support files outside prompt-governed outputs
```
---
## Standard generation invocation
```bash
# 1. Read AGENTS.md + prompts/general-prompt.md
# 2. Read the entity-scoped DSL block from domain/toir.api.dsl
# 3. Load only the stage-specific companion rules you need
# 4. Run generation or repair with the appropriate agent
# 5. Refresh api-summary.json only if validator/tooling expects the auxiliary freshness artifact
# 6. Verify (both stages must pass)
node tools/validate-generation.mjs --artifacts-only
npm run eval:generation
```
---
## MCP servers (project-local)
Defined in `.codex/config.toml`:
- **github** — repository access
- **context7** — library documentation lookup (use for framework questions)
- **exa** — web search
- **memory** — persistent cross-session context
- **playwright** — browser automation for smoke tests
- **sequential-thinking** — structured multi-step reasoning
Add heavier or credential-backed servers in `~/.codex/config.toml`.
---
## Validation gate
Run before every commit and after every generation:
```bash
# Stage 1 — structural gate
node tools/validate-generation.mjs --artifacts-only
# Stage 2 — eval harness
npm run eval:generation
```
The pre-commit hook (`tools/hooks/pre-commit`) runs both stages automatically
after `npm run install-hooks`.
---
## Security notes
- Never commit secrets. Use environment variables from `.env.example` templates.
- Run `npm audit` when adding new dependencies to `server/` or `client/`.
- Auth contracts live in `prompts/auth-rules.md`. Do not deviate from them.

View File

@@ -1,10 +0,0 @@
model = "gpt-5.4-mini"
model_reasoning_effort = "low"
sandbox_mode = "read-only"
developer_instructions = """
Verify APIs, framework behavior, and release-note claims against primary documentation before changes land.
Cite the exact docs or file paths that support each claim.
Do not invent undocumented behavior.
Use Context7 and official docs first when the current environment exposes them.
"""

View File

@@ -1,23 +0,0 @@
model = "gpt-5.4-mini"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
developer_instructions = """
Stay in exploration mode. Read files freely; write nothing.
Trace the real execution path, cite files and symbols, and avoid proposing
fixes unless the parent agent asks for them.
Prefer targeted search and file reads over broad scans.
KIS-TOiR source-of-truth tier reference (read-only for this agent):
Tier 1: domain/*.api.dsl, prompts/*.md, AGENTS.md
Tier 2: api-summary.json (deterministic auxiliary derivative; never authoritative)
Tier 3: server/src/modules/, client/src/resources/, server/src/app.module.ts,
client/src/App.tsx, server/prisma/schema.prisma, server/src/auth/,
client/src/auth/, client/src/dataProvider.ts, toir-realm.json,
docker-compose.yml, server/.env.example, client/.env.example
Tier 4: framework scaffold and other handwritten support files
When asked about generation output, always trace it back to its Tier 1 DSL source
and do not recommend api-summary.json as the primary input when the DSL is available.
"""

View File

@@ -1,51 +0,0 @@
model = "gpt-5.4"
model_reasoning_effort = "high"
sandbox_mode = "workspace-write"
approval_policy = "on-request"
developer_instructions = """
You are the LLM generation agent for KIS-TOiR.
PERMITTED write zones (Tier 3 — LLM-generated artifacts):
server/src/modules/<entity>/ — NestJS modules, controllers, services, DTOs
client/src/resources/<entity>/ — React Admin List/Create/Edit/Show
server/src/app.module.ts — module registration section only
client/src/App.tsx — resource registration section only
server/prisma/schema.prisma — LLM-generated per prompts/prisma-rules.md
server/src/auth/ — auth artifacts per prompts/auth-rules.md
client/src/auth/ — auth artifacts per prompts/auth-rules.md
client/src/dataProvider.ts — authenticated data provider seam per prompts/auth-rules.md
toir-realm.json — realm artifact per prompts/auth-rules.md
docker-compose.yml — runtime artifact per prompts/runtime-rules.md
server/.env.example — runtime defaults per prompts/runtime-rules.md
client/.env.example — runtime defaults per prompts/runtime-rules.md
FORBIDDEN write zones — never modify these files:
domain/*.api.dsl — source of truth (Tier 1)
prompts/*.md — generation spec (Tier 1)
AGENTS.md — workflow contract (Tier 1)
api-summary.json — deterministic derivative (Tier 2)
tools/ — deterministic tooling, not generated artifacts
CONTEXT BUDGET (mandatory):
1. Read prompts/general-prompt.md first.
2. Read ONLY the entity-scoped api.dsl block (api API.<EntityName> + its DTOs + enums)
from domain/toir.api.dsl. Do NOT inject the full api.dsl as a blob.
3. Read ONLY the relevant companion rule file for the active stage.
4. Before generating any DTO or component, quote the relevant DSL field definitions
verbatim, then generate from those quotes. This prevents training-data contamination.
5. Use api-summary.json only as an auxiliary inventory or validator-related artifact,
never as the source of truth or default starting point.
GENERATION WORKFLOW:
1. Read prompts/general-prompt.md.
2. Read the entity-scoped block from domain/toir.api.dsl.
3. Read the relevant stage rule docs.
4. Generate or update Tier 3 artifacts.
5. Refresh api-summary.json only if the validator/tooling requires it.
6. Run: node tools/validate-generation.mjs --artifacts-only
7. Run: npm run eval:generation
8. Fix all failures before reporting complete.
NEVER report generation complete if either validation gate fails.
"""

View File

@@ -1,32 +0,0 @@
model = "gpt-5.4"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
developer_instructions = """
Review mode. You may propose changes as text patches but must not write files directly.
Focus on:
- Correctness: does generated code match the api.dsl and prompt contracts?
- Security: auth guard placement, CORS, env variable handling.
- Regression: do both verification gates pass?
node tools/validate-generation.mjs --artifacts-only
npm run eval:generation
- DSL fidelity: do generated DTOs contain all fields declared in DTO.<Entity>Create/Update?
- Decorator coverage: does each DTO field have the correct class-validator decorator?
- Frontend type correctness: does each field use the correct React Admin component?
- Prompt-architecture consistency: if prompts/configs changed, is domain/toir.api.dsl still clearly authoritative and api-summary.json still clearly auxiliary?
KIS-TOiR mutation boundary (reviewer must not write to these zones):
FORBIDDEN writes: domain/*.api.dsl, prompts/*.md, AGENTS.md,
api-summary.json, tools/, server/prisma/schema.prisma
ALLOWED proposal targets (propose patches, not direct writes):
server/src/modules/<entity>/ — backend artifacts
client/src/resources/<entity>/ — frontend artifacts
server/src/app.module.ts, client/src/App.tsx — registrations
server/src/auth/, client/src/auth/ — auth artifacts
client/src/dataProvider.ts — authenticated data provider seam
toir-realm.json, docker-compose.yml — runtime/realm artifacts
server/.env.example, client/.env.example — runtime defaults
docs/ — documentation updates
"""

View File

@@ -1,125 +0,0 @@
#:schema https://developers.openai.com/codex/config-schema.json
# Everything Claude Code (ECC) — Codex Reference Configuration
#
# Copy this file to ~/.codex/config.toml for global defaults, or keep it in
# the project root as .codex/config.toml for project-local settings.
#
# Official docs:
# - https://developers.openai.com/codex/config-reference
# - https://developers.openai.com/codex/multi-agent
# Model selection
# Leave `model` and `model_provider` unset so Codex CLI uses its current
# built-in defaults. Uncomment and pin them only if you intentionally want
# repo-local or global model overrides.
# Top-level runtime settings (current Codex schema)
approval_policy = "on-request"
sandbox_mode = "workspace-write"
web_search = "live"
# External notifications receive a JSON payload on stdin.
# macOS example (uncomment on Mac if `terminal-notifier` is installed):
# notify = [
# "terminal-notifier",
# "-title", "Codex KIS",
# "-message", "Task completed!",
# "-sound", "default",
# ]
# Persistent instructions are appended to every prompt (additive, unlike
# model_instructions_file which replaces AGENTS.md).
persistent_instructions = "Follow project AGENTS.md guidelines. Use available MCP servers when they can help."
# model_instructions_file replaces built-in instructions instead of AGENTS.md,
# so leave it unset unless you intentionally want a single override file.
# model_instructions_file = "/absolute/path/to/instructions.md"
# MCP servers
# Keep the default project set lean. API-backed servers inherit credentials from
# the launching environment or can be supplied by a user-level ~/.codex/config.toml.
[mcp_servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
startup_timeout_sec = 30
[mcp_servers.context7]
command = "npx"
# Canonical Codex section name is `context7`; the package itself remains
# `@upstash/context7-mcp`.
args = ["-y", "@upstash/context7-mcp@latest"]
startup_timeout_sec = 30
[mcp_servers.exa]
url = "https://mcp.exa.ai/mcp"
[mcp_servers.memory]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-memory"]
startup_timeout_sec = 30
[mcp_servers.playwright]
command = "npx"
args = ["-y", "@playwright/mcp@latest", "--extension"]
startup_timeout_sec = 30
[mcp_servers.sequential-thinking]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]
startup_timeout_sec = 30
# Additional MCP servers (uncomment as needed):
# [mcp_servers.supabase]
# command = "npx"
# args = ["-y", "supabase-mcp-server@latest", "--read-only"]
#
# [mcp_servers.firecrawl]
# command = "npx"
# args = ["-y", "firecrawl-mcp"]
#
# [mcp_servers.fal-ai]
# command = "npx"
# args = ["-y", "fal-ai-mcp-server"]
#
# [mcp_servers.cloudflare]
# command = "npx"
# args = ["-y", "@cloudflare/mcp-server-cloudflare"]
[features]
# Codex multi-agent collaboration is stable and on by default in current builds.
# Keep the explicit toggle here so the repo documents its expectation clearly.
multi_agent = true
# Profiles — switch with `codex -p <name>`
[profiles.strict]
approval_policy = "on-request"
sandbox_mode = "read-only"
web_search = "cached"
[profiles.yolo]
approval_policy = "never"
sandbox_mode = "workspace-write"
web_search = "live"
[agents]
# Multi-agent role limits and local role definitions.
# These map to `.codex/agents/*.toml` and mirror the repo's explorer/reviewer/docs workflow.
max_threads = 6
max_depth = 1
[agents.explorer]
description = "Read-only codebase explorer for gathering evidence before changes are proposed."
config_file = "agents/explorer.toml"
[agents.reviewer]
description = "PR reviewer focused on correctness, security, and DSL fidelity. Proposes patches; writes nothing directly."
config_file = "agents/reviewer.toml"
[agents.docs_researcher]
description = "Documentation specialist that verifies APIs, framework behavior, and release notes."
config_file = "agents/docs-researcher.toml"
[agents.generator]
description = "LLM generation agent: writes server/src/modules/, client/src/resources/, app.module.ts, App.tsx only. Never touches DSL, prompts, or deterministic tooling."
config_file = "agents/generator.toml"

View File

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

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
*.sh text eol=lf

2
.gitignore vendored
View File

@@ -39,5 +39,3 @@ Thumbs.db
openapi.generated.json openapi.generated.json
openapi.llm.json openapi.llm.json
tools/api-format-to-openapi/demo-output/ tools/api-format-to-openapi/demo-output/
.cursor/

216
AGENTS.md
View File

@@ -1,216 +0,0 @@
# KIS-TOiR — Agent Operating Rules
Read this file at the start of every session before reading any other file.
---
## What this repository is
KIS-TOiR is a fullstack CRUD application (NestJS backend + React Admin frontend)
for equipment maintenance management (Техническое обслуживание и ремонт).
Generation is driven by a single authoritative source file:
- `domain/toir.api.dsl` — the complete API contract: enums, DTOs, endpoints, HTTP methods, pagination
---
## Source-of-truth hierarchy
### Tier 1 — authoritative (hand-authored; never overwritten by generation)
| File | Authoritative for |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `domain/*.api.dsl` | Enums, DTO shapes per operation, nullability, HTTP verb+path per endpoint, endpoint names, pagination contracts. Single source of truth. Drives: NestJS modules + React Admin resources + Prisma schema. |
| `prompts/*.md` | Auth rules, runtime rules, framework scaffold rules, Prisma rules, validation rules, generation policy, naming conventions. |
| `AGENTS.md` (this file) | Agent workflow, mutation boundaries, verification contract. |
### Tier 2 — deterministic derivative (generated by script; never edited manually)
| File | Generated from | Command |
| ------------------ | ------------------ | ------------------------------ |
| `api-summary.json` | `domain/*.api.dsl` | `npm run generate:api-summary` |
### Tier 3 — LLM-generated artifacts (never edit manually after generation)
| Zone | Generated from |
| -------------------------------- | ------------------------------------------------------ |
| `server/src/modules/<entity>/` | `domain/*.api.dsl` + `prompts/backend-rules.md` |
| `client/src/resources/<entity>/` | `domain/*.api.dsl` + `prompts/frontend-rules.md` |
| `server/src/app.module.ts` | Module registrations derived from api.dsl api blocks |
| `client/src/App.tsx` | Resource registrations derived from api.dsl api blocks |
| `server/prisma/schema.prisma` | `domain/*.api.dsl` + `prompts/prisma-rules.md` |
| `server/src/auth/` | `prompts/auth-rules.md` |
| `client/src/auth/` | `prompts/auth-rules.md` |
| `client/src/dataProvider.ts` | `prompts/auth-rules.md` |
| `toir-realm.json` | `prompts/auth-rules.md` |
| `docker-compose.yml` | `prompts/runtime-rules.md` |
| `server/.env.example` | `prompts/runtime-rules.md` |
| `client/.env.example` | `prompts/runtime-rules.md` |
### Tier 4 — handwritten / framework-managed support files
- Framework scaffold: `server/nest-cli.json`, `server/tsconfig*.json`, `client/vite.config.*`, etc.
---
## Forbidden mutations during any generation run
**NEVER write to `*.dsl` files.**
They are read-only inputs. To change the API contract or domain model, edit `domain/*.api.dsl`
as a separate explicit task.
**NEVER manually edit files under `server/src/modules/` or `client/src/resources/`.**
To change generated code: update `domain/*.api.dsl` and regenerate.
**NEVER edit `server/prisma/schema.prisma` directly.**
It is LLM-generated from `domain/*.api.dsl` following `prompts/prisma-rules.md`.
---
## Generation workflow (required sequence)
1. Read `AGENTS.md` and `prompts/general-prompt.md`.
2. Read the active `api API.<EntityName>` block + its DTOs + its enums from `domain/toir.api.dsl` (entity-scoped — do not inject the full DSL as a blob).
3. Load the companion rule files required for the active stage:
- `prompts/prisma-rules.md`
- `prompts/backend-rules.md`
- `prompts/frontend-rules.md`
- `prompts/auth-rules.md`
- `prompts/runtime-rules.md`
- `prompts/validation-rules.md`
4. Verify or repair framework scaffolds before domain generation:
- official Nest CLI for backend workspace creation/repair
- official Vite React TypeScript CLI for frontend workspace creation/repair
- official Prisma CLI when Prisma initialization or repair is required
5. Generate `server/prisma/schema.prisma` per `prompts/prisma-rules.md`.
6. Generate backend modules and registrations per `prompts/backend-rules.md`.
7. Generate frontend resources and registrations per `prompts/frontend-rules.md`.
8. Generate auth/runtime/realm artifacts per `prompts/auth-rules.md` and `prompts/runtime-rules.md`.
9. Refresh `api-summary.json` only when validation/tooling requires the auxiliary freshness artifact: `npm run generate:api-summary`.
10. Run: `node tools/validate-generation.mjs --artifacts-only`
11. Run: `npm run eval:generation`
12. Fix all failures before considering the task complete.
**Context budget rule:** Before generating any DTO or component, quote the field
definitions from the DSL api block verbatim, then generate from those quotes. This
prevents training-data contamination. See `prompts/general-prompt.md`.
---
## Type mappings
| DSL type | Prisma type | TS DTO type | React Admin component |
| --------- | ----------------------------- | ----------- | ----------------------------- |
| `uuid` | `String @id @default(uuid())` | `string` | `TextInput` / `TextField` |
| `string` | `String` | `string` | `TextInput` / `TextField` |
| `text` | `String` | `string` | `TextInput` / `TextField` |
| `integer` | `Int` | `number` | `NumberInput` / `NumberField` |
| `number` | `Float` | `number` | `NumberInput` / `NumberField` |
| `decimal` | `Decimal` | `string` | `NumberInput` / `NumberField` |
| `date` | `DateTime` | `string` | `DateInput` / `DateField` |
| `boolean` | `Boolean` | `boolean` | (appropriate boolean input) |
| enum name | enum name | `string` | `SelectInput` / `SelectField` |
---
## Naming conventions
Resource name (plural, kebab-case):
- `Equipment``equipment` (irregular — stays as-is)
- `EquipmentType``equipment-types`
- `RepairOrder``repair-orders`
- General: PascalCase → kebab-case → append `s` (or `es` if ends in `s`; irregular cases explicit)
Default sort field (when not declared in api.dsl):
Priority: `inventoryNumber` > `number` > `code` > `name` > primary key
---
## Verification gate (two-stage)
### Stage 1 — Structural gate
```
node tools/validate-generation.mjs --artifacts-only
```
Checks: file existence, api-summary freshness, auth seam contracts, natural-key handling, realm structure, runtime contract, api.dsl coverage (backend + frontend files per entity), DTO field name coverage, **DTO class-validator decorator coverage**, **@UseGuards presence on controllers**, **frontend component type correctness**.
Full structural verification (requires installed `node_modules`):
```
node tools/validate-generation.mjs
```
### Stage 2 — Eval harness (Rule 6)
```
npm run eval:generation
```
Fixture-based semantic checks from `tools/eval/fixtures/`. Checks: correct HTTP method decorators, Content-Range header, enum filter patterns, FK reference wiring, component type correctness, class-validator decorator patterns.
See `tools/eval/README.md` for fixture authoring and eval-driven development workflow.
**Generation is incomplete unless both stages pass.**
## Pre-commit hook
Install with `npm run install-hooks`. The hook runs **both** the structural gate and
the eval harness before every commit. Commits are blocked when either fails.
---
## Auth and runtime defaults
Full auth contract: `prompts/auth-rules.md`
Working defaults (do not regress to localhost):
- Keycloak base: `https://sso.greact.ru`
- Realm/client: `toir` / `toir-frontend` / `toir-backend`
- Production frontend: `https://toir-frontend.greact.ru`
- CORS: `http://localhost:5173,https://toir-frontend.greact.ru`
---
## OpenRouter configuration
```
OPENAI_API_KEY=<openrouter-key>
OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_MODEL=<model-id>
```
These variables are used by `tools/api-format-to-openapi/convert.mjs --mode llm`.
---
## Reading order for generation tasks
**Critical zone (load first, never drop):**
1. `AGENTS.md` (this file) — project governance, mutation boundaries, tier hierarchy
2. `prompts/general-prompt.md` — master orchestration prompt: mission, stage ownership, delegation model, completion criteria
3. `domain/toir.api.dsl §API.<EntityName>` — active api block only, plus its referenced DTOs and enums
**Companion zone (load when the matching stage is active):**
4. `prompts/prisma-rules.md` — Prisma schema generation details
5. `prompts/backend-rules.md` — NestJS generation details
6. `prompts/frontend-rules.md` — React Admin generation details
7. `prompts/auth-rules.md` — auth seam and realm requirements
8. `prompts/runtime-rules.md` — scaffold, env, and bootstrap requirements
9. `prompts/validation-rules.md` — success gate requirements
**Auxiliary zone (never authoritative):**
10. `api-summary.json` — optional inventory/freshness artifact for validators and supporting tooling; do not use it instead of the DSL
**Reference only (do not load proactively):**
- `domain/dsl-spec.md` — DSL syntax reference; load only if DSL is ambiguous
- `docs/generation-playbook.md` — step-by-step workflow reference
- `docs/future-work.md` — deferred items (Rules 7 and 8)

View File

@@ -8,29 +8,25 @@ It is not a new generator engine and it is not a compiler platform. The reposito
- `client/` as the active frontend target output path - `client/` as the active frontend target output path
- an LLM-first orchestration baseline with CLI-first framework bootstrap - an LLM-first orchestration baseline with CLI-first framework bootstrap
- a compact rule set that strengthens the existing pipeline with: - a compact rule set that strengthens the existing pipeline with:
- `api-summary.json` (deterministic intermediate context) - `domain-summary.json`
- a physical root-level realm artifact - a physical root-level realm artifact
- a lightweight automated validation gate - a lightweight automated validation gate
## Active knowledge blocks ## Active knowledge blocks
The master generation prompt is `prompts/general-prompt.md`. It contains the complete The active prompt corpus is intentionally normalized to six stable blocks:
generation workflow, type mappings, naming conventions, and all core rules.
Companion rule files for artifact-specific details: 1. [prompts/general-prompt.md](prompts/general-prompt.md)
2. [prompts/auth-rules.md](prompts/auth-rules.md)
1. [prompts/general-prompt.md](prompts/general-prompt.md) — master generation prompt 3. [prompts/backend-rules.md](prompts/backend-rules.md)
2. [prompts/auth-rules.md](prompts/auth-rules.md) — auth seam / realm spec 4. [prompts/frontend-rules.md](prompts/frontend-rules.md)
3. [prompts/backend-rules.md](prompts/backend-rules.md) — backend reference 5. [prompts/runtime-rules.md](prompts/runtime-rules.md)
4. [prompts/frontend-rules.md](prompts/frontend-rules.md) — frontend reference 6. [prompts/validation-rules.md](prompts/validation-rules.md)
5. [prompts/prisma-rules.md](prompts/prisma-rules.md) — Prisma schema rules
6. [prompts/runtime-rules.md](prompts/runtime-rules.md) — runtime / bootstrap
7. [prompts/validation-rules.md](prompts/validation-rules.md) — validation gate
## Baseline contracts ## Baseline contracts
- `domain/*.api.dsl` is the single source of truth for the domain model and API contract. - `domain/*.dsl` is the source of truth for the domain model.
- [api-summary.json](api-summary.json) is a derived artifact for LLM stabilization and validation. - [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. - [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/` and `client/` are the active target output paths for this repository.
- `server/` must remain a valid NestJS workspace baseline. - `server/` must remain a valid NestJS workspace baseline.
@@ -49,7 +45,7 @@ Companion rule files for artifact-specific details:
- The active prompts define forbidden generation patterns, required invariants, and recovery rules for future agents. - 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. - Buildability is part of the baseline contract, not an optional follow-up.
- Validation targets `domain/*.api.dsl` as reusable source inputs, while TOiR names remain project defaults/examples. - Validation targets `domain/*.dsl` as reusable source inputs, while TOiR names remain project defaults/examples.
## Repository layout ## Repository layout
@@ -60,19 +56,13 @@ Companion rule files for artifact-specific details:
## Commands ## Commands
```bash ```bash
npm run generate:api-summary npm run generate:domain-summary
npm run validate:generation npm run validate:generation
npm run validate:generation:runtime npm run validate:generation:runtime
npm run eval:generation
``` ```
`npm run validate:generation` 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. `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) ## AID export (OpenAPI + app generator)
HTTP-экспортёр для интеграции с AID: **`POST /aid/export/openapi`** (api-format → OpenAPI 3.0). Подробно: **[docs/AID_EXPORT_README.md](docs/AID_EXPORT_README.md)**. 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)**.
> **Note:** The `POST /aid/export/app` endpoint (DSL → generated app bundle) is currently
> non-operative because its backing script (`generation/generate.mjs`) was removed during
> the architecture migration to api.dsl-first generation. See `docs/AID_EXPORT_README.md`
> for details.

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
node_modules
dist
.git
*.md
.env
.env.*

View File

@@ -2,3 +2,4 @@ VITE_API_URL=http://localhost:3000
VITE_KEYCLOAK_URL=https://sso.greact.ru VITE_KEYCLOAK_URL=https://sso.greact.ru
VITE_KEYCLOAK_REALM=toir VITE_KEYCLOAK_REALM=toir
VITE_KEYCLOAK_CLIENT_ID=toir-frontend VITE_KEYCLOAK_CLIENT_ID=toir-frontend

18
client/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

28
client/.gitignore vendored
View File

@@ -1,22 +1,32 @@
# Dependencies
node_modules/
# Build
dist/
dist-ssr/
# Environment
.env
.env.local
.env.*.local
*.local
# Logs # Logs
logs logs/
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules # OS files
dist .DS_Store
dist-ssr Thumbs.db
*.local
# Editor directories and files # Editor / IDE
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea/
.DS_Store
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj

3
client/.npmrc Normal file
View File

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

View File

@@ -1,31 +0,0 @@
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL
ARG VITE_KEYCLOAK_URL
ARG VITE_KEYCLOAK_REALM
ARG VITE_KEYCLOAK_CLIENT_ID
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL
ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM
ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID
RUN npm run build
FROM nginx:1.27-alpine AS runtime
RUN apk add --no-cache wget
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -4,70 +4,27 @@ This template provides a minimal setup to get React working in Vite with HMR and
Currently, two official plugins are available: Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration ## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js ```js
export default defineConfig([ export default {
globalIgnores(['dist']), // other rules...
{ parserOptions: {
files: ['**/*.{ts,tsx}'], ecmaVersion: 'latest',
extends: [ sourceType: 'module',
// Other configs... project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
}, },
]) }
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
```js - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title> <title>Vite + React + TS</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,28 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
location = /healthz {
access_log off;
default_type text/plain;
return 200 'ok';
}
location /api/ {
proxy_pass http://server:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

2941
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,31 +6,29 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "11.14.1",
"@mui/icons-material": "^7.3.5", "@mui/material": "7.3.9",
"@mui/material": "^7.3.5", "keycloak-js": "26.2.3",
"keycloak-js": "^26.2.3", "ra-data-simple-rest": "5.14.4",
"react": "^19.2.4", "react": "18.3.1",
"react-admin": "^5.14.5", "react-admin": "5.14.4",
"react-dom": "^19.2.4" "react-dom": "18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@types/react": "18.3.28",
"@types/node": "^24.12.0", "@types/react-dom": "18.3.7",
"@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "6.21.0",
"@types/react-dom": "^19.2.3", "@typescript-eslint/parser": "6.21.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "4.7.0",
"eslint": "^9.39.4", "eslint": "8.57.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "0.4.26",
"globals": "^17.4.0", "typescript": "5.9.3",
"typescript": "~5.9.3", "vite": "5.4.21"
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
} }
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

1
client/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: "";
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -1,70 +1,55 @@
import { Admin, Resource } from "react-admin"; import { Admin, Resource } from 'react-admin';
import { authProvider } from "./auth/authProvider"; import dataProvider from './dataProvider';
import { dataProvider } from "./dataProvider"; import authProvider from './auth/authProvider';
import { CategoryResourceCreate } from "./resources/category-resource/CategoryResourceCreate"; import { AppNotification } from './AppNotification';
import { CategoryResourceEdit } from "./resources/category-resource/CategoryResourceEdit";
import { CategoryResourceList } from "./resources/category-resource/CategoryResourceList";
import { CategoryResourceShow } from "./resources/category-resource/CategoryResourceShow";
import { EmployeeCreate } from "./resources/employee/EmployeeCreate";
import { EmployeeEdit } from "./resources/employee/EmployeeEdit";
import { EmployeeList } from "./resources/employee/EmployeeList";
import { EmployeeShow } from "./resources/employee/EmployeeShow";
import { EquipmentCreate } from "./resources/equipment/EquipmentCreate";
import { EquipmentEdit } from "./resources/equipment/EquipmentEdit";
import { EquipmentList } from "./resources/equipment/EquipmentList";
import { EquipmentShow } from "./resources/equipment/EquipmentShow";
import { PartCreate } from "./resources/part/PartCreate";
import { PartEdit } from "./resources/part/PartEdit";
import { PartList } from "./resources/part/PartList";
import { PartShow } from "./resources/part/PartShow";
import { PriceListCreate } from "./resources/price-list/PriceListCreate";
import { PriceListEdit } from "./resources/price-list/PriceListEdit";
import { PriceListList } from "./resources/price-list/PriceListList";
import { PriceListShow } from "./resources/price-list/PriceListShow";
import "./App.css";
export default function App() { import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList';
return ( import { EquipmentTypeCreate } from './resources/equipment-type/EquipmentTypeCreate';
<Admin import { EquipmentTypeEdit } from './resources/equipment-type/EquipmentTypeEdit';
dataProvider={dataProvider} import { EquipmentTypeShow } from './resources/equipment-type/EquipmentTypeShow';
authProvider={authProvider}
disableTelemetry import { EquipmentList } from './resources/equipment/EquipmentList';
> import { EquipmentCreate } from './resources/equipment/EquipmentCreate';
<Resource import { EquipmentEdit } from './resources/equipment/EquipmentEdit';
name="equipment" import { EquipmentShow } from './resources/equipment/EquipmentShow';
list={EquipmentList}
create={EquipmentCreate} import { RepairOrderList } from './resources/repair-order/RepairOrderList';
edit={EquipmentEdit} import { RepairOrderCreate } from './resources/repair-order/RepairOrderCreate';
show={EquipmentShow} import { RepairOrderEdit } from './resources/repair-order/RepairOrderEdit';
/> import { RepairOrderShow } from './resources/repair-order/RepairOrderShow';
<Resource
name="employees" const App = () => (
list={EmployeeList} <Admin
create={EmployeeCreate} dataProvider={dataProvider}
edit={EmployeeEdit} authProvider={authProvider}
show={EmployeeShow} notification={AppNotification}
/> requireAuth
<Resource >
name="parts" <Resource
list={PartList} name="equipment-types"
create={PartCreate} options={{ label: 'Виды оборудования' }}
edit={PartEdit} list={EquipmentTypeList}
show={PartShow} create={EquipmentTypeCreate}
/> edit={EquipmentTypeEdit}
<Resource show={EquipmentTypeShow}
name="category-resources" />
list={CategoryResourceList} <Resource
create={CategoryResourceCreate} name="equipment"
edit={CategoryResourceEdit} options={{ label: 'Оборудование' }}
show={CategoryResourceShow} list={EquipmentList}
/> create={EquipmentCreate}
<Resource edit={EquipmentEdit}
name="price-list" show={EquipmentShow}
list={PriceListList} />
create={PriceListCreate} <Resource
edit={PriceListEdit} name="repair-orders"
show={PriceListShow} options={{ label: 'Заявки на ремонт' }}
/> list={RepairOrderList}
</Admin> create={RepairOrderCreate}
); edit={RepairOrderEdit}
} show={RepairOrderShow}
/>
</Admin>
);
export default App;

View File

@@ -0,0 +1,16 @@
import { Notification, NotificationProps } from 'react-admin';
export const AppNotification = (props: NotificationProps) => (
<Notification
{...props}
sx={{
whiteSpace: 'pre-line',
'& .MuiAlert-message': {
whiteSpace: 'pre-line',
},
'& .MuiSnackbarContent-message': {
whiteSpace: 'pre-line',
},
}}
/>
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,44 +1,45 @@
import type { AuthProvider } from 'react-admin'; import { AuthProvider } from 'react-admin';
import { getKeycloak, initKeycloak } from './keycloak'; import {
forceReauthentication,
getIdentity,
getRealmRoles,
getValidAccessToken,
initKeycloak,
logoutFromKeycloak,
} from './keycloak';
export const authProvider: AuthProvider = { const authProvider: AuthProvider = {
login: async () => { login: async () => {
await initKeycloak(); await initKeycloak();
await getKeycloak().login();
}, },
logout: async () => { logout: async () => {
await getKeycloak().logout({ redirectUri: window.location.origin }); await logoutFromKeycloak();
}, },
checkAuth: async () => { checkAuth: async () => {
await initKeycloak(); await getValidAccessToken();
if (!getKeycloak().authenticated) {
await getKeycloak().login();
}
}, },
checkError: async (error) => { checkError: async (error) => {
const status = error?.status ?? error?.response?.status; const status = error?.status;
if (status === 401) { if (status === 401) {
getKeycloak().clearToken(); await forceReauthentication();
return Promise.reject(error); return Promise.reject(error);
} }
if (status === 403) { if (status === 403) {
return Promise.reject(error); return Promise.resolve();
} }
return Promise.resolve(); return Promise.resolve();
}, },
getIdentity: async () => {
await initKeycloak(); getIdentity: async () => getIdentity(),
const tokenParsed = getKeycloak().tokenParsed as Record<string, unknown> | undefined;
return { getPermissions: async () => getRealmRoles(),
id: String(tokenParsed?.sub ?? 'anonymous'),
fullName: typeof tokenParsed?.name === 'string' ? tokenParsed.name : (typeof tokenParsed?.preferred_username === 'string' ? tokenParsed.preferred_username : 'User'),
avatar: undefined,
};
},
getPermissions: async () => {
await initKeycloak();
const tokenParsed = getKeycloak().tokenParsed as { realm_access?: { roles?: unknown } } | undefined;
const roles = tokenParsed?.realm_access?.roles;
return Array.isArray(roles) ? roles.filter((role): role is string => typeof role === 'string') : [];
},
}; };
export default authProvider;

View File

@@ -1,43 +1,96 @@
import Keycloak from 'keycloak-js'; import Keycloak, { KeycloakTokenParsed } from 'keycloak-js';
import { env } from '../config/env'; import { env } from '../config/env';
interface RealmAccessTokenParsed extends KeycloakTokenParsed {
realm_access?: {
roles: string[];
};
}
const keycloak = new Keycloak({ const keycloak = new Keycloak({
url: env.keycloakUrl, url: env.keycloakUrl,
realm: env.keycloakRealm, realm: env.keycloakRealm,
clientId: env.keycloakClientId, clientId: env.keycloakClientId,
}); });
let initPromise: Promise<boolean> | null = null; let keycloakInitPromise: Promise<void> | null = null;
let refreshPromise: Promise<string | null> | null = null; let refreshInFlight: Promise<void> | null = null;
export async function initKeycloak(): Promise<boolean> {
if (!initPromise) {
initPromise = keycloak.init({
onLoad: 'login-required',
pkceMethod: 'S256',
checkLoginIframe: false,
});
}
return initPromise;
}
export async function getAccessToken(): Promise<string | null> {
await initKeycloak();
if (!keycloak.authenticated) return null;
if (!refreshPromise) {
refreshPromise = keycloak
.updateToken(30)
.then(() => keycloak.token ?? null)
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
export function getKeycloak() { export function getKeycloak() {
return keycloak; return keycloak;
} }
export async function initKeycloak() {
if (!keycloakInitPromise) {
keycloakInitPromise = keycloak
.init({
onLoad: 'login-required',
pkceMethod: 'S256',
checkLoginIframe: false,
})
.then((authenticated) => {
if (!authenticated) {
return keycloak.login({ redirectUri: window.location.href });
}
});
}
await keycloakInitPromise;
}
async function refreshAccessToken(minValiditySeconds = 30) {
if (!refreshInFlight) {
refreshInFlight = keycloak
.updateToken(minValiditySeconds)
.then(() => undefined)
.finally(() => {
refreshInFlight = null;
});
}
await refreshInFlight;
}
export async function getValidAccessToken(minValiditySeconds = 30): Promise<string> {
await initKeycloak();
if (!keycloak.authenticated) {
await keycloak.login({ redirectUri: window.location.href });
throw new Error('User is not authenticated');
}
await refreshAccessToken(minValiditySeconds);
if (!keycloak.token) {
throw new Error('Missing access token');
}
return keycloak.token;
}
export async function forceReauthentication() {
keycloak.clearToken();
await keycloak.login({ redirectUri: window.location.href });
}
export async function logoutFromKeycloak() {
await keycloak.logout({ redirectUri: window.location.origin });
}
export function getRealmRoles(): string[] {
const parsed = keycloak.tokenParsed as RealmAccessTokenParsed | undefined;
const roles = parsed?.realm_access?.roles;
return Array.isArray(roles) ? roles : [];
}
export function getIdentity() {
const parsed = keycloak.tokenParsed as RealmAccessTokenParsed | undefined;
const id = parsed?.sub ?? 'unknown';
const fullName =
parsed?.name ??
parsed?.preferred_username ??
parsed?.email ??
'Unknown User';
return { id, fullName };
}

View File

@@ -1,6 +1,24 @@
const REQUIRED_ENV_KEYS = [
'VITE_API_URL',
'VITE_KEYCLOAK_URL',
'VITE_KEYCLOAK_REALM',
'VITE_KEYCLOAK_CLIENT_ID',
] as const;
type RequiredEnvKey = (typeof REQUIRED_ENV_KEYS)[number];
function readRequiredEnv(key: RequiredEnvKey): string {
const value = import.meta.env[key];
if (!value || !value.trim()) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export const env = { export const env = {
apiUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:3000', apiUrl: readRequiredEnv('VITE_API_URL'),
keycloakUrl: import.meta.env.VITE_KEYCLOAK_URL ?? 'https://sso.greact.ru', keycloakUrl: readRequiredEnv('VITE_KEYCLOAK_URL'),
keycloakRealm: import.meta.env.VITE_KEYCLOAK_REALM ?? 'toir', keycloakRealm: readRequiredEnv('VITE_KEYCLOAK_REALM'),
keycloakClientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'toir-frontend', keycloakClientId: readRequiredEnv('VITE_KEYCLOAK_CLIENT_ID'),
}; } as const;

View File

@@ -1,194 +1,185 @@
import type { DataProvider } from "react-admin"; import { DataProvider, fetchUtils, HttpError } from 'react-admin';
import { env } from "./config/env"; import { getValidAccessToken } from './auth/keycloak';
import { getAccessToken } from "./auth/keycloak"; import { env } from './config/env';
async function fetchJson( const apiUrl = env.apiUrl;
url: string,
options: RequestInit = {},
): Promise<{ json: any; headers: Headers; status: number }> {
const headers = new Headers(
options.headers ?? { Accept: "application/json" },
);
const token = await getAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
if (!headers.has("Content-Type") && options.body) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(url, { ...options, headers }); /** HTTP status from fetch / react-admin error objects (avoid coupling the client to Nest). */
if (!response.ok) { type HttpStatusCode = number;
const error = new Error(
"Request failed with status " + response.status, /** JSON body shape returned by ApiExceptionFilter (and compatible Nest errors). */
) as Error & { status?: number; body?: unknown }; type ApiErrorBody = {
error.status = response.status; message?: string | string[];
try { code?: string;
error.body = await response.json(); details?: unknown;
} catch { };
error.body = null;
/** Shape thrown by fetchUtils.fetchJson on non-2xx responses. */
type FetchJsonError = {
status?: HttpStatusCode;
body?: ApiErrorBody;
message?: string;
};
function userMessageFromApiBody(
body: ApiErrorBody | undefined,
fallback: string,
): string {
const raw = body?.message;
if (Array.isArray(raw)) return raw.join('\n');
if (typeof raw === 'string') return raw;
return fallback;
}
const httpClient = async (url: string, options: fetchUtils.Options = {}) => {
const token = await getValidAccessToken();
const headers = new Headers(options.headers ?? { Accept: 'application/json' });
headers.set('Authorization', `Bearer ${token}`);
try {
return await fetchUtils.fetchJson(url, {
...options,
headers,
});
} catch (error: unknown) {
const fetchError = error as FetchJsonError;
const fromPayload = userMessageFromApiBody(fetchError.body, '');
const fallbackMessage = fetchError.message || 'Request failed';
throw new HttpError(
fromPayload || fallbackMessage,
fetchError.status ?? 500,
fetchError.body,
);
}
};
function buildQueryString(query: Record<string, unknown>) {
const search = new URLSearchParams();
Object.entries(query).forEach(([key, val]) => {
if (val === undefined || val === null || val === '') return;
if (Array.isArray(val)) {
val.forEach((v) => {
if (v === undefined || v === null || v === '') return;
search.append(key, String(v));
});
return;
} }
throw error; search.set(key, String(val));
}
if (response.status === 204) {
return { json: null, headers: response.headers, status: response.status };
}
const json = await response.json();
return { json, headers: response.headers, status: response.status };
}
function appendSearchParam(
searchParams: URLSearchParams,
key: string,
value: unknown,
): void {
if (Array.isArray(value)) {
value.forEach((entry) => appendSearchParam(searchParams, key, entry));
return;
}
if (value === undefined || value === null || value === "") {
return;
}
searchParams.append(key, String(value));
}
function parseListBody(json: unknown): { rows: unknown[]; totalHint?: number } {
if (Array.isArray(json)) {
return { rows: json };
}
if (
json !== null &&
typeof json === "object" &&
"data" in json &&
Array.isArray((json as { data: unknown }).data)
) {
const body = json as { data: unknown[]; total?: unknown };
const totalHint =
typeof body.total === "number" && Number.isFinite(body.total)
? body.total
: undefined;
return { rows: body.data, totalHint };
}
return { rows: [] };
}
function buildListUrl(resource: string, params: any): string {
const resourcePath = resource === "equipment" ? "equipments" : resource;
const searchParams = new URLSearchParams();
searchParams.set(
"_start",
String((params.pagination.page - 1) * params.pagination.perPage),
);
searchParams.set(
"_end",
String(params.pagination.page * params.pagination.perPage),
);
searchParams.set("_sort", params.sort.field);
searchParams.set("_order", params.sort.order);
Object.entries(params.filter ?? {}).forEach(([key, value]) => {
appendSearchParam(searchParams, key, value);
}); });
const queryString = searchParams.toString(); return search.toString();
return (
env.apiUrl + "/" + resourcePath + (queryString ? "?" + queryString : "")
);
} }
export const dataProvider: DataProvider = { const dataProvider: DataProvider = {
getList: async (resource, params) => { getList: async (resource, params) => {
if (resource === "price-list") { const { page, perPage } = params.pagination!;
const { json } = await fetchJson(env.apiUrl + "/price-list"); const { field, order } = params.sort!;
return { data: [json], total: 1 }; const start = (page - 1) * perPage;
} const end = page * perPage;
const { json, headers } = await fetchJson(buildListUrl(resource, params));
const { rows, totalHint } = parseListBody(json); const query: Record<string, unknown> = {
const contentRange = headers.get("Content-Range"); _start: start,
_end: end,
_sort: field,
_order: order,
...(params.filter ?? {}),
};
const queryString = buildQueryString(query);
const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url);
const contentRange = headers.get('Content-Range');
const total = contentRange const total = contentRange
? Number( ? parseInt(contentRange.split('/').pop() || '0', 10)
contentRange.split("/").pop() ?? : json.length;
totalHint ??
rows.length, return { data: json, total };
)
: (totalHint ?? rows.length);
return { data: rows as any[], total };
}, },
getOne: async (resource, params) => { getOne: async (resource, params) => {
const resourcePath = resource === "equipment" ? "equipments" : resource; const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`);
const url =
resource === "price-list"
? env.apiUrl + "/price-list"
: env.apiUrl + "/" + resourcePath + "/" + params.id;
const { json } = await fetchJson(url);
return { data: json }; return { data: json };
}, },
getMany: async (resource, params) => { getMany: async (resource, params) => {
if (resource === "price-list") { const query = params.ids.map((id) => `id=${id}`).join('&');
const { json } = await fetchJson(env.apiUrl + "/price-list"); const { json } = await httpClient(`${apiUrl}/${resource}?${query}`);
return { data: params.ids.includes("price-list") ? [json] : [] }; return { data: json };
}
const records = await Promise.all(
params.ids.map((id) =>
dataProvider.getOne(resource, { id, meta: params.meta } as any),
),
);
return { data: records.map((result) => result.data) };
}, },
getManyReference: async (resource, params) =>
dataProvider.getList(resource, { getManyReference: async (resource, params) => {
pagination: params.pagination, const { page, perPage } = params.pagination!;
sort: params.sort, const { field, order } = params.sort!;
filter: { ...(params.filter ?? {}), [params.target]: params.id }, const start = (page - 1) * perPage;
meta: params.meta, const end = page * perPage;
} as any),
const query: Record<string, unknown> = {
_start: start,
_end: end,
_sort: field,
_order: order,
[params.target]: params.id,
...(params.filter ?? {}),
};
const queryString = buildQueryString(query);
const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url);
const contentRange = headers.get('Content-Range');
const total = contentRange
? parseInt(contentRange.split('/').pop() || '0', 10)
: json.length;
return { data: json, total };
},
create: async (resource, params) => { create: async (resource, params) => {
const resourcePath = resource === "equipment" ? "equipments" : resource; const { json } = await httpClient(`${apiUrl}/${resource}`, {
const { json } = await fetchJson(env.apiUrl + "/" + resourcePath, { method: 'POST',
method: "POST",
body: JSON.stringify(params.data), body: JSON.stringify(params.data),
}); });
return { data: json }; return { data: json };
}, },
update: async (resource, params) => { update: async (resource, params) => {
const resourcePath = resource === "equipment" ? "equipments" : resource; const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`, {
const { json } = await fetchJson( method: 'PATCH',
env.apiUrl + "/" + resourcePath + "/" + params.id, body: JSON.stringify(params.data),
{ method: "PATCH", body: JSON.stringify(params.data) }, });
);
return { data: json }; return { data: json };
}, },
updateMany: async (resource, params) => { updateMany: async (resource, params) => {
const results = await Promise.all( const responses = await Promise.all(
params.ids.map((id) => params.ids.map((id) =>
dataProvider.update(resource, { httpClient(`${apiUrl}/${resource}/${id}`, {
id, method: 'PATCH',
data: params.data, body: JSON.stringify(params.data),
previousData: {}, })
meta: params.meta, )
} as any),
),
); );
return { data: results.map((result) => result.data.id) }; return { data: responses.map(({ json }) => json.id) };
}, },
delete: async (resource, params) => { delete: async (resource, params) => {
const resourcePath = resource === "equipment" ? "equipments" : resource; const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`, {
const { json } = await fetchJson( method: 'DELETE',
env.apiUrl + "/" + resourcePath + "/" + params.id, });
{ method: "DELETE" }, return { data: json };
);
return { data: json ?? { id: params.id } };
}, },
deleteMany: async (resource, params) => { deleteMany: async (resource, params) => {
const results = await Promise.all( const responses = await Promise.all(
params.ids.map((id) => params.ids.map((id) =>
dataProvider.delete(resource, { httpClient(`${apiUrl}/${resource}/${id}`, {
id, method: 'DELETE',
previousData: {}, })
meta: params.meta, )
} as any),
),
); );
return { data: results.map((result) => result.data.id) }; return { data: responses.map(({ json }) => json.id) };
}, },
}; };
export default dataProvider;

View File

@@ -1,110 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, "Segoe UI", Roboto, sans-serif;
--heading: system-ui, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

View File

@@ -1,16 +1,26 @@
import { StrictMode } from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App'; import App from './App';
import { initKeycloak } from './auth/keycloak'; import { initKeycloak } from './auth/keycloak';
const root = ReactDOM.createRoot(document.getElementById('root')!);
async function bootstrap() { async function bootstrap() {
await initKeycloak(); await initKeycloak();
createRoot(document.getElementById('root')!).render(
<StrictMode> root.render(
<React.StrictMode>
<App /> <App />
</StrictMode>, </React.StrictMode>,
); );
} }
void bootstrap(); bootstrap().catch((error) => {
console.error('Failed to initialize authentication', error);
root.render(
<React.StrictMode>
<div>Authentication initialization failed. Check your environment variables.</div>
</React.StrictMode>,
);
});

View File

@@ -1,25 +0,0 @@
import {
AutocompleteInput,
Create,
ReferenceInput,
SimpleForm,
} from "react-admin";
import { employeeOptionText, partOptionText } from "../shared/enums";
export const CategoryResourceCreate = () => (
<Create>
<SimpleForm>
<ReferenceInput source="partId" reference="parts">
<AutocompleteInput
optionText={partOptionText}
filterToQuery={(searchText) => ({ q: searchText })}
/>
</ReferenceInput>
<ReferenceInput source="employeeCode" reference="employees">
<AutocompleteInput
optionText={employeeOptionText}
filterToQuery={(searchText) => ({ q: searchText })}
/>
</ReferenceInput>
</SimpleForm>
</Create>
);

View File

@@ -1,25 +0,0 @@
import {
AutocompleteInput,
Edit,
ReferenceInput,
SimpleForm,
} from "react-admin";
import { employeeOptionText, partOptionText } from "../shared/enums";
export const CategoryResourceEdit = () => (
<Edit>
<SimpleForm>
<ReferenceInput source="partId" reference="parts">
<AutocompleteInput
optionText={partOptionText}
filterToQuery={(searchText) => ({ q: searchText })}
/>
</ReferenceInput>
<ReferenceInput source="employeeCode" reference="employees">
<AutocompleteInput
optionText={employeeOptionText}
filterToQuery={(searchText) => ({ q: searchText })}
/>
</ReferenceInput>
</SimpleForm>
</Edit>
);

View File

@@ -1,53 +0,0 @@
import {
AutocompleteInput,
CreateButton,
Datagrid,
FilterButton,
List,
ReferenceField,
ReferenceInput,
TextField,
TextInput,
TopToolbar,
} from "react-admin";
import { employeeOptionText, partOptionText } from "../shared/enums";
const categoryResourceFilters = [
<TextInput key="q" source="q" label="Search" alwaysOn />,
<ReferenceInput key="partId" source="partId" reference="parts">
<AutocompleteInput
optionText={partOptionText}
filterToQuery={(searchText) => ({ q: searchText })}
/>
</ReferenceInput>,
<ReferenceInput
key="employeeCode"
source="employeeCode"
reference="employees"
>
<AutocompleteInput
optionText={employeeOptionText}
filterToQuery={(searchText) => ({ q: searchText })}
/>
</ReferenceInput>,
];
export const CategoryResourceList = () => (
<List
filters={categoryResourceFilters}
actions={
<TopToolbar>
<FilterButton />
<CreateButton />
</TopToolbar>
}
>
<Datagrid rowClick="show">
<TextField source="id" />
<ReferenceField source="partId" reference="parts" link="show">
<TextField source="name" />
</ReferenceField>
<ReferenceField source="employeeCode" reference="employees" link="show">
<TextField source="fullName" />
</ReferenceField>
</Datagrid>
</List>
);

View File

@@ -1,14 +0,0 @@
import { ReferenceField, Show, SimpleShowLayout, TextField } from "react-admin";
export const CategoryResourceShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="id" />
<ReferenceField source="partId" reference="parts" link="show">
<TextField source="name" />
</ReferenceField>
<ReferenceField source="employeeCode" reference="employees" link="show">
<TextField source="fullName" />
</ReferenceField>
</SimpleShowLayout>
</Show>
);

View File

@@ -1,28 +0,0 @@
import {
AutocompleteInput,
Create,
NumberInput,
ReferenceInput,
SelectInput,
SimpleForm,
TextInput,
} from "react-admin";
import { employeeOptionText, roleChoices } from "../shared/enums";
export const EmployeeCreate = () => (
<Create>
<SimpleForm>
<TextInput source="code" required />
<TextInput source="fullName" required />
<SelectInput source="role" choices={roleChoices} required />
<TextInput source="position" required />
<ReferenceInput source="boss" reference="employees">
<AutocompleteInput
optionText={employeeOptionText}
filterToQuery={(searchText) => ({ q: searchText })}
/>
</ReferenceInput>
<NumberInput source="price" />
<NumberInput source="phoneNumber" />
</SimpleForm>
</Create>
);

View File

@@ -1,27 +0,0 @@
import {
AutocompleteInput,
Edit,
NumberInput,
ReferenceInput,
SelectInput,
SimpleForm,
TextInput,
} from "react-admin";
import { employeeOptionText, roleChoices } from "../shared/enums";
export const EmployeeEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="fullName" />
<SelectInput source="role" choices={roleChoices} />
<TextInput source="position" />
<ReferenceInput source="boss" reference="employees">
<AutocompleteInput
optionText={employeeOptionText}
filterToQuery={(searchText) => ({ q: searchText })}
/>
</ReferenceInput>
<NumberInput source="price" />
<NumberInput source="phoneNumber" />
</SimpleForm>
</Edit>
);

View File

@@ -1,38 +0,0 @@
import {
Datagrid,
List,
ReferenceField,
SelectArrayInput,
SelectField,
TextField,
TextInput,
} from "react-admin";
import { ResourceListActions } from "../shared/ListActions";
import { roleChoices } from "../shared/enums";
const employeeFilters = [
<TextInput key="q" source="q" label="Search" alwaysOn />,
<SelectArrayInput
key="role"
source="role"
label="Role"
choices={roleChoices}
/>,
<TextInput key="position" source="position" label="Position" />,
];
export const EmployeeList = () => (
<List
filters={employeeFilters}
actions={<ResourceListActions filters={employeeFilters} />}
>
<Datagrid rowClick="show">
<TextField source="code" />
<TextField source="fullName" />
<SelectField source="role" choices={roleChoices} />
<TextField source="position" />
<ReferenceField source="bossCode" reference="employees" link="show">
<TextField source="fullName" />
</ReferenceField>
<TextField source="phoneNumber" />
</Datagrid>
</List>
);

View File

@@ -1,22 +0,0 @@
import {
ReferenceField,
SelectField,
Show,
SimpleShowLayout,
TextField,
} from "react-admin";
import { roleChoices } from "../shared/enums";
export const EmployeeShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="code" />
<TextField source="fullName" />
<SelectField source="role" choices={roleChoices} />
<TextField source="position" />
<ReferenceField source="bossCode" reference="employees" link="show">
<TextField source="fullName" />
</ReferenceField>
<TextField source="phoneNumber" />
</SimpleShowLayout>
</Show>
);

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
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 = () => (
<List actions={<EquipmentTypeListActions />} filters={equipmentTypeFilters} sort={{ field: 'code', order: 'ASC' }}>
<Datagrid rowClick="show">
<TextField source="code" label="Код вида оборудования" />
<TextField source="name" label="Наименование вида" />
<TextField source="manufacturer" label="Производитель" />
<NumberField source="maintenanceIntervalHours" label="Периодичность ТО, моточасов" />
<NumberField source="overhaulIntervalHours" label="Периодичность КР, моточасов" />
</Datagrid>
</List>
);

View File

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

View File

@@ -1,44 +1,28 @@
import { import { Create, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
Create,
DateInput, const statusChoices = [
NumberInput, { id: 'Active', name: 'В эксплуатации' },
SelectInput, { id: 'Repair', name: 'В ремонте' },
SimpleForm, { id: 'Reserve', name: 'В резерве' },
} from "react-admin"; { id: 'WriteOff', name: 'Списано' },
import { ];
equipmentStatusChoices,
equipmentTypeChoices,
laborOperationChoices,
periodicityChoices,
} from "../shared/enums";
import { PlainInput } from "../shared/inputs";
export const EquipmentCreate = () => ( export const EquipmentCreate = () => (
<Create> <Create>
<SimpleForm> <SimpleForm>
<PlainInput source="name" required /> <TextInput source="inventoryNumber" label="Инвентарный номер" isRequired />
<PlainInput source="serialNumber" required /> <TextInput source="serialNumber" label="Заводской (серийный) номер" />
<PlainInput source="inventoryNumber" required /> <TextInput source="name" label="Наименование единицы оборудования" isRequired />
<SelectInput <ReferenceInput source="equipmentTypeCode" reference="equipment-types">
source="equipmentType" <AutocompleteInput label="Вид оборудования" optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
choices={equipmentTypeChoices} </ReferenceInput>
required <SelectInput source="status" label="Текущий статус" choices={statusChoices} emptyText="Не выбрано" />
/> <TextInput source="location" label="Место эксплуатации / скважина / куст" />
<DateInput source="dateOfInspection" /> <DateInput source="commissionedAt" label="Дата ввода в эксплуатацию" />
<SelectInput <NumberInput source="totalEngineHours" label="Общая наработка, моточасов" />
source="periodicityTO" <NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
choices={periodicityChoices} <DateInput source="lastRepairAt" label="Дата последнего ремонта" />
required <TextInput source="notes" label="Примечания" />
/>
<PlainInput source="location" />
<SelectInput source="status" choices={equipmentStatusChoices} required />
<DateInput source="commissionedAt" />
<NumberInput source="totalEngineHours" />
<NumberInput source="engineHoursSinceLastRepair" />
<DateInput source="lastRepairAt" />
<PlainInput source="notes" multiline />
<SelectInput source="workAsPartOf" choices={laborOperationChoices} />
<NumberInput source="fuelConsumed" />
</SimpleForm> </SimpleForm>
</Create> </Create>
); );

View File

@@ -1,36 +1,29 @@
import { import { Edit, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
DateInput,
Edit, const statusChoices = [
NumberInput, { id: 'Active', name: 'В эксплуатации' },
SelectInput, { id: 'Repair', name: 'В ремонте' },
SimpleForm, { id: 'Reserve', name: 'В резерве' },
} from "react-admin"; { id: 'WriteOff', name: 'Списано' },
import { ];
equipmentStatusChoices,
equipmentTypeChoices,
laborOperationChoices,
periodicityChoices,
} from "../shared/enums";
import { PlainInput } from "../shared/inputs";
export const EquipmentEdit = () => ( export const EquipmentEdit = () => (
<Edit> <Edit>
<SimpleForm> <SimpleForm>
<PlainInput source="name" /> <TextInput source="id" label="Идентификатор" disabled />
<PlainInput source="serialNumber" /> <TextInput source="inventoryNumber" label="Инвентарный номер" isRequired />
<PlainInput source="inventoryNumber" /> <TextInput source="serialNumber" label="Заводской (серийный) номер" />
<SelectInput source="equipmentType" choices={equipmentTypeChoices} /> <TextInput source="name" label="Наименование единицы оборудования" isRequired />
<DateInput source="dateOfInspection" /> <ReferenceInput source="equipmentTypeCode" reference="equipment-types">
<SelectInput source="periodicityTO" choices={periodicityChoices} /> <AutocompleteInput label="Вид оборудования" optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
<PlainInput source="location" /> </ReferenceInput>
<SelectInput source="status" choices={equipmentStatusChoices} /> <SelectInput source="status" label="Текущий статус" choices={statusChoices} emptyText="Не выбрано" />
<DateInput source="commissionedAt" /> <TextInput source="location" label="Место эксплуатации / скважина / куст" />
<NumberInput source="totalEngineHours" /> <DateInput source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberInput source="engineHoursSinceLastRepair" /> <NumberInput source="totalEngineHours" label="Общая наработка, моточасов" />
<DateInput source="lastRepairAt" /> <NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<PlainInput source="notes" multiline /> <DateInput source="lastRepairAt" label="Дата последнего ремонта" />
<SelectInput source="workAsPartOf" choices={laborOperationChoices} /> <TextInput source="notes" label="Примечания" />
<NumberInput source="fuelConsumed" />
</SimpleForm> </SimpleForm>
</Edit> </Edit>
); );

View File

@@ -1,78 +1,66 @@
import { import {
CreateButton,
Datagrid,
DateField,
FilterButton,
List, List,
NumberField, Datagrid,
SelectArrayInput,
SelectField,
TextField, TextField,
TextInput, TextInput,
TopToolbar, TopToolbar,
} from "react-admin"; FilterButton,
import { CreateButton,
equipmentStatusChoices, ExportButton,
equipmentTypeChoices, NumberField,
laborOperationChoices, DateField,
periodicityChoices, SelectField,
} from "../shared/enums"; ReferenceField,
SelectArrayInput,
ReferenceInput,
AutocompleteInput
} from 'react-admin';
const equipmentFilters = [ const statusChoices = [
<TextInput key="q" source="q" label="Search" alwaysOn />, { id: 'Active', name: 'В эксплуатации' },
<TextInput { id: 'Repair', name: 'В ремонте' },
key="inventoryNumber" { id: 'Reserve', name: 'В резерве' },
source="inventoryNumber" { id: 'WriteOff', name: 'Списано' },
label="Inventory number"
/>,
<TextInput key="serialNumber" source="serialNumber" label="Serial number" />,
<TextInput key="name" source="name" label="Name" />,
<SelectArrayInput
key="equipmentType"
source="equipmentType"
label="Type"
choices={equipmentTypeChoices}
/>,
<SelectArrayInput
key="periodicityTO"
source="periodicityTO"
label="Periodicity"
choices={periodicityChoices}
/>,
<SelectArrayInput
key="status"
source="status"
label="Status"
choices={equipmentStatusChoices}
/>,
<TextInput key="location" source="location" label="Location" />,
<SelectArrayInput
key="workAsPartOf"
source="workAsPartOf"
label="Operation"
choices={laborOperationChoices}
/>,
]; ];
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' }}>
filters={equipmentFilters}
actions={
<TopToolbar>
<FilterButton />
<CreateButton />
</TopToolbar>
}
>
<Datagrid rowClick="show"> <Datagrid rowClick="show">
<TextField source="inventoryNumber" /> <TextField source="id" label="Идентификатор" />
<TextField source="name" /> <TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="serialNumber" /> <TextField source="serialNumber" label="Заводской (серийный) номер" />
<SelectField source="equipmentType" choices={equipmentTypeChoices} /> <TextField source="name" label="Наименование единицы оборудования" />
<SelectField source="status" choices={equipmentStatusChoices} /> <ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<DateField source="dateOfInspection" /> <TextField source="code" />
<NumberField source="totalEngineHours" /> </ReferenceField>
<TextField source="location" /> <SelectField source="status" label="Текущий статус" choices={statusChoices} />
<TextField source="location" 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,35 +1,28 @@
import { import { Show, SimpleShowLayout, TextField, NumberField, DateField, SelectField, ReferenceField } from 'react-admin';
DateField,
NumberField, const statusChoices = [
SelectField, { id: 'Active', name: 'В эксплуатации' },
Show, { id: 'Repair', name: 'В ремонте' },
SimpleShowLayout, { id: 'Reserve', name: 'В резерве' },
TextField, { id: 'WriteOff', name: 'Списано' },
} from "react-admin"; ];
import {
equipmentStatusChoices,
equipmentTypeChoices,
laborOperationChoices,
periodicityChoices,
} from "../shared/enums";
export const EquipmentShow = () => ( export const EquipmentShow = () => (
<Show> <Show>
<SimpleShowLayout> <SimpleShowLayout>
<TextField source="inventoryNumber" /> <TextField source="id" label="Идентификатор" />
<TextField source="name" /> <TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="serialNumber" /> <TextField source="serialNumber" label="Заводской (серийный) номер" />
<SelectField source="equipmentType" choices={equipmentTypeChoices} /> <TextField source="name" label="Наименование единицы оборудования" />
<DateField source="dateOfInspection" /> <ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<SelectField source="periodicityTO" choices={periodicityChoices} /> <TextField source="code" />
<TextField source="location" /> </ReferenceField>
<SelectField source="status" choices={equipmentStatusChoices} /> <SelectField source="status" label="Текущий статус" choices={statusChoices} />
<DateField source="commissionedAt" /> <TextField source="location" label="Место эксплуатации / скважина / куст" />
<NumberField source="totalEngineHours" /> <DateField source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberField source="engineHoursSinceLastRepair" /> <NumberField source="totalEngineHours" label="Общая наработка, моточасов" />
<DateField source="lastRepairAt" /> <NumberField source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<TextField source="notes" /> <DateField source="lastRepairAt" label="Дата последнего ремонта" />
<SelectField source="workAsPartOf" choices={laborOperationChoices} /> <TextField source="notes" label="Примечания" />
<NumberField source="fuelConsumed" />
</SimpleShowLayout> </SimpleShowLayout>
</Show> </Show>
); );

View File

@@ -1,15 +0,0 @@
import { Create, NumberInput, SelectInput, SimpleForm } from "react-admin";
import { categoryPartChoices } from "../shared/enums";
import { PlainInput } from "../shared/inputs";
export const PartCreate = () => (
<Create>
<SimpleForm>
<PlainInput source="name" required />
<SelectInput source="categories" choices={categoryPartChoices} />
<NumberInput source="price" />
<PlainInput source="description" multiline />
<PlainInput source="serialNumber" />
</SimpleForm>
</Create>
);

View File

@@ -1,15 +0,0 @@
import { Edit, NumberInput, SelectInput, SimpleForm } from "react-admin";
import { categoryPartChoices } from "../shared/enums";
import { PlainInput } from "../shared/inputs";
export const PartEdit = () => (
<Edit>
<SimpleForm>
<PlainInput source="name" />
<SelectInput source="categories" choices={categoryPartChoices} />
<NumberInput source="price" />
<PlainInput source="description" multiline />
<PlainInput source="serialNumber" />
</SimpleForm>
</Edit>
);

View File

@@ -1,34 +0,0 @@
import {
Datagrid,
List,
NumberField,
SelectArrayInput,
SelectField,
TextField,
TextInput,
} from "react-admin";
import { ResourceListActions } from "../shared/ListActions";
import { categoryPartChoices } from "../shared/enums";
const partFilters = [
<TextInput key="q" source="q" label="Search" alwaysOn />,
<SelectArrayInput
key="categories"
source="categories"
label="Category"
choices={categoryPartChoices}
/>,
<TextInput key="serialNumber" source="serialNumber" label="Serial number" />,
];
export const PartList = () => (
<List
filters={partFilters}
actions={<ResourceListActions filters={partFilters} />}
>
<Datagrid rowClick="show">
<TextField source="name" />
<SelectField source="categories" choices={categoryPartChoices} />
<NumberField source="price" />
<TextField source="serialNumber" />
</Datagrid>
</List>
);

View File

@@ -1,19 +0,0 @@
import {
NumberField,
SelectField,
Show,
SimpleShowLayout,
TextField,
} from "react-admin";
import { categoryPartChoices } from "../shared/enums";
export const PartShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="name" />
<SelectField source="categories" choices={categoryPartChoices} />
<NumberField source="price" />
<TextField source="description" />
<TextField source="serialNumber" />
</SimpleShowLayout>
</Show>
);

View File

@@ -1,9 +0,0 @@
import { Create, NumberInput, SimpleForm } from "react-admin";
export const PriceListCreate = () => (
<Create>
<SimpleForm toolbar={false}>
<NumberInput source="costOfWorkingHours" disabled />
<NumberInput source="partPrice" disabled />
</SimpleForm>
</Create>
);

View File

@@ -1,9 +0,0 @@
import { Edit, NumberInput, SimpleForm } from "react-admin";
export const PriceListEdit = () => (
<Edit>
<SimpleForm toolbar={false}>
<NumberInput source="costOfWorkingHours" disabled />
<NumberInput source="partPrice" disabled />
</SimpleForm>
</Edit>
);

View File

@@ -1,20 +0,0 @@
import { Datagrid, List, NumberField, TextField, TextInput } from "react-admin";
import { ResourceListActions } from "../shared/ListActions";
const priceListFilters = [
<TextInput key="q" source="q" label="Search" alwaysOn />,
];
export const PriceListList = () => (
<List
filters={priceListFilters}
actions={
<ResourceListActions filters={priceListFilters} hasCreate={false} />
}
perPage={1}
>
<Datagrid rowClick="show">
<TextField source="id" />
<NumberField source="costOfWorkingHours" />
<NumberField source="partPrice" />
</Datagrid>
</List>
);

View File

@@ -1,10 +0,0 @@
import { NumberField, Show, SimpleShowLayout, TextField } from "react-admin";
export const PriceListShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="id" />
<NumberField source="costOfWorkingHours" />
<NumberField source="partPrice" />
</SimpleShowLayout>
</Show>
);

View File

@@ -0,0 +1,38 @@
import { Create, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
{ id: 'TR', name: 'Текущий ремонт' },
{ id: 'TRE', name: 'Текущий расширенный ремонт' },
{ id: 'KR', name: 'Капитальный ремонт' },
{ id: 'AR', name: 'Аварийный ремонт' },
{ id: 'MP', name: 'Метрологическая поверка' },
];
const statusChoices = [
{ id: 'Draft', name: 'Черновик' },
{ id: 'Approved', name: 'Утверждена' },
{ id: 'InWork', name: 'В работе' },
{ id: 'Done', name: 'Выполнена' },
{ id: 'Cancelled', name: 'Отменена' },
];
export const RepairOrderCreate = () => (
<Create>
<SimpleForm>
<TextInput source="number" label="Номер заявки" isRequired />
<ReferenceInput source="equipmentId" reference="equipment">
<AutocompleteInput label="Оборудование" optionText={(record) => record.inventoryNumber ? `${record.inventoryNumber}${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>
<SelectInput source="repairKind" label="Вид ремонта" choices={repairKindChoices} emptyText="Не выбрано" />
<SelectInput source="status" label="Статус" choices={statusChoices} emptyText="Не выбрано" />
<DateInput source="plannedAt" label="Плановая дата начала" />
<DateInput source="startedAt" label="Фактическая дата начала" />
<DateInput source="completedAt" label="Фактическая дата завершения" />
<TextInput source="contractor" label="Подрядная организация (если внешний ремонт)" />
<NumberInput source="engineHoursAtRepair" label="Наработка на момент ремонта, моточасов" />
<TextInput source="description" label="Описание работ / дефекта" />
<TextInput source="notes" label="Примечания" />
</SimpleForm>
</Create>
);

View File

@@ -0,0 +1,39 @@
import { Edit, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
{ id: 'TR', name: 'Текущий ремонт' },
{ id: 'TRE', name: 'Текущий расширенный ремонт' },
{ id: 'KR', name: 'Капитальный ремонт' },
{ id: 'AR', name: 'Аварийный ремонт' },
{ id: 'MP', name: 'Метрологическая поверка' },
];
const statusChoices = [
{ id: 'Draft', name: 'Черновик' },
{ id: 'Approved', name: 'Утверждена' },
{ id: 'InWork', name: 'В работе' },
{ id: 'Done', name: 'Выполнена' },
{ id: 'Cancelled', name: 'Отменена' },
];
export const RepairOrderEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="id" label="Идентификатор" disabled />
<TextInput source="number" label="Номер заявки" isRequired />
<ReferenceInput source="equipmentId" reference="equipment">
<AutocompleteInput label="Оборудование" optionText={(record) => record.inventoryNumber ? `${record.inventoryNumber}${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>
<SelectInput source="repairKind" label="Вид ремонта" choices={repairKindChoices} emptyText="Не выбрано" />
<SelectInput source="status" label="Статус" choices={statusChoices} emptyText="Не выбрано" />
<DateInput source="plannedAt" label="Плановая дата начала" />
<DateInput source="startedAt" label="Фактическая дата начала" />
<DateInput source="completedAt" label="Фактическая дата завершения" />
<TextInput source="contractor" label="Подрядная организация (если внешний ремонт)" />
<NumberInput source="engineHoursAtRepair" label="Наработка на момент ремонта, моточасов" />
<TextInput source="description" label="Описание работ / дефекта" />
<TextInput source="notes" label="Примечания" />
</SimpleForm>
</Edit>
);

View File

@@ -0,0 +1,77 @@
import {
List,
Datagrid,
TextField,
TextInput,
TopToolbar,
FilterButton,
CreateButton,
ExportButton,
NumberField,
DateField,
SelectField,
ReferenceField,
SelectArrayInput,
SelectInput,
ReferenceInput,
AutocompleteInput
} from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
{ id: 'TR', name: 'Текущий ремонт' },
{ id: 'TRE', name: 'Текущий расширенный ремонт' },
{ id: 'KR', name: 'Капитальный ремонт' },
{ id: 'AR', name: 'Аварийный ремонт' },
{ id: 'MP', name: 'Метрологическая поверка' },
];
const statusChoices = [
{ id: 'Draft', name: 'Черновик' },
{ id: 'Approved', name: 'Утверждена' },
{ id: 'InWork', name: 'В работе' },
{ id: 'Done', 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 = () => (
<List actions={<RepairOrderListActions />} filters={repairOrderFilters} sort={{ field: 'number', order: 'ASC' }}>
<Datagrid rowClick="show">
<TextField source="id" label="Идентификатор" />
<TextField source="number" label="Номер заявки" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
<TextField source="inventoryNumber" />
</ReferenceField>
<SelectField source="repairKind" label="Вид ремонта" choices={repairKindChoices} />
<SelectField source="status" label="Статус" choices={statusChoices} />
<DateField source="plannedAt" 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>
</List>
);

View File

@@ -0,0 +1,38 @@
import { Show, SimpleShowLayout, TextField, NumberField, DateField, SelectField, ReferenceField } from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
{ id: 'TR', name: 'Текущий ремонт' },
{ id: 'TRE', name: 'Текущий расширенный ремонт' },
{ id: 'KR', name: 'Капитальный ремонт' },
{ id: 'AR', name: 'Аварийный ремонт' },
{ id: 'MP', name: 'Метрологическая поверка' },
];
const statusChoices = [
{ id: 'Draft', name: 'Черновик' },
{ id: 'Approved', name: 'Утверждена' },
{ id: 'InWork', name: 'В работе' },
{ id: 'Done', name: 'Выполнена' },
{ id: 'Cancelled', name: 'Отменена' },
];
export const RepairOrderShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="id" label="Идентификатор" />
<TextField source="number" label="Номер заявки" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
<TextField source="inventoryNumber" />
</ReferenceField>
<SelectField source="repairKind" label="Вид ремонта" choices={repairKindChoices} />
<SelectField source="status" label="Статус" choices={statusChoices} />
<DateField source="plannedAt" 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="Примечания" />
</SimpleShowLayout>
</Show>
);

View File

@@ -1,17 +0,0 @@
import type { ReactElement } from "react";
import { CreateButton, FilterButton, TopToolbar } from "react-admin";
interface ListActionsProps {
filters?: ReactElement[];
hasCreate?: boolean;
}
export const ResourceListActions = ({
filters,
hasCreate = true,
}: ListActionsProps) => (
<TopToolbar>
{filters ? <FilterButton filters={filters} /> : null}
{hasCreate ? <CreateButton /> : null}
</TopToolbar>
);

View File

@@ -1,73 +0,0 @@
export const equipmentStatusChoices = [
"Active",
"Repair",
"Reserve",
"WriteOff",
].map((value) => ({ id: value, name: value }));
export const laborOperationChoices = ["Manual", "MachineManual", "Machine"].map(
(value) => ({ id: value, name: value }),
);
/** id = Prisma/API wire value; name = human label (matches DB enum @map). */
export const periodicityChoices = [
{ id: "EZHEDNEVNOE", name: "Ежедневное" },
{ id: "EZHENEDELNOE", name: "Еженедельное" },
{ id: "EZHEMESYACHNOE", name: "Ежемесячное" },
{ id: "POLUGODOVOE", name: "Полугодовое" },
{ id: "GODOVOE", name: "Годовое" },
];
export const roleChoices = [
{ id: "ISPOLNITEL", name: "Исполнитель" },
{ id: "PODPISANT", name: "Подписант" },
{ id: "POLZOVATEL", name: "Пользователь" },
];
export const categoryPartChoices = [
{ id: "RASKHODNIK", name: "Расходник" },
{ id: "ZAPCHAST", name: "Запчасть" },
{ id: "INSTRUMENT", name: "Инструмент" },
{ id: "SPETSODEZHDA", name: "Спецодежда" },
];
export const equipmentTypeChoices = [
{ id: "PROIZVODSTVENNOE", name: "Производственное" },
{ id: "ENERGETICHESKOE", name: "Энергетическое" },
{ id: "NASOSNOE", name: "Насосное" },
{ id: "KOMPRESSORNOE", name: "Компрессорное" },
];
export const equipmentOptionText = (
record?: Record<string, unknown> | null,
): string => {
if (!record) return "";
const inventoryNumber =
typeof record.inventoryNumber === "string" ? record.inventoryNumber : "";
const name = typeof record.name === "string" ? record.name : inventoryNumber;
return inventoryNumber
? inventoryNumber + " — " + (name || inventoryNumber)
: typeof record.name === "string"
? record.name
: String(record.id ?? "");
};
export const employeeOptionText = (
record?: Record<string, unknown> | null,
): string => {
if (!record) return "";
if (typeof record.code === "string") {
const fallback =
typeof record.fullName === "string" ? record.fullName : record.code;
return record.code + " — " + fallback;
}
if (typeof record.fullName === "string") return record.fullName;
return String(record.id ?? "");
};
export const partOptionText = (
record?: Record<string, unknown> | null,
): string => {
if (!record) return "";
if (typeof record.name === "string") return record.name;
return String(record.id ?? "");
};

View File

@@ -1,3 +0,0 @@
import { TextInput } from "react-admin";
export const PlainInput = TextInput;

View File

@@ -1 +1,12 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_KEYCLOAK_URL: string;
readonly VITE_KEYCLOAK_REALM: string;
readonly VITE_KEYCLOAK_CLIENT_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,7 +1,25 @@
{ {
"files": [], "compilerOptions": {
"references": [ "target": "ES2020",
{ "path": "./tsconfig.app.json" }, "useDefineForClassFields": true,
{ "path": "./tsconfig.node.json" } "lib": ["ES2020", "DOM", "DOM.Iterable"],
] "module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@@ -1,26 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "composite": true,
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowSyntheticDefaultImports": true,
"verbatimModuleSyntax": true, "strict": true
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
}) })

View File

@@ -4,87 +4,13 @@ services:
container_name: toir-postgres container_name: toir-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} POSTGRES_PASSWORD: postgres
POSTGRES_DB: ${POSTGRES_DB:-toir} POSTGRES_DB: toir
# UTF-8 cluster (applies only on first volume init) — migrations use Cyrillic enum labels ports:
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C.UTF-8" - "5432:5432"
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-toir}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
networks:
- toir
server:
build:
context: ./server
dockerfile: Dockerfile
container_name: toir-server
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
PORT: 3000
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-toir}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8080,https://toir.greact.ru}
KEYCLOAK_ISSUER_URL: ${KEYCLOAK_ISSUER_URL:-https://sso.greact.ru/realms/toir}
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-toir-backend}
KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL:-}
healthcheck:
test:
["CMD", "curl", "-fsS", "http://127.0.0.1:3000/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 90s
expose:
- 3000
networks:
- toir
- proxy
client:
build:
context: ./client
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-/api}
VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL:-https://sso.greact.ru}
VITE_KEYCLOAK_REALM: ${VITE_KEYCLOAK_REALM:-toir}
VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID:-toir-frontend}
container_name: toir-client
restart: unless-stopped
depends_on:
server:
# Do not wait for healthy: if migrations fail, stack stays up so you can read server logs.
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
expose:
- 80
networks:
- toir
- proxy
volumes: volumes:
postgres-data: postgres-data:
networks:
toir:
driver: bridge
proxy:
external: true

View File

@@ -1,29 +1,27 @@
# AID: экспорт OpenAPI и генератор приложения # AID: экспорт OpenAPI и генератор приложения
В репозитории добавлены **сервисы-экспортёры** для интеграции с **AID** (или любым другим клиентом по HTTP): автоматическое получение **OpenAPI 3.0** из доменного **api-format**. В репозитории добавлены **сервисы-экспортёры** для интеграции с **AID** (или любым другим клиентом по HTTP): автоматическое получение **OpenAPI 3.0** из доменного **api-format** и выдача **сгенерированного fullstack-приложения** из **DSL** без ручного копирования файлов.
--- ---
## Что работает ## Что сделано
| Компонент | Назначение | | Компонент | Назначение |
|-----------|------------| |-----------|------------|
| **`POST /aid/export/openapi`** (NestJS) | На вход JSON **api-format** → на выход документ **OpenAPI 3.0** в поле `openapi`. | | **`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. | | **`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` рядом с кодом. | | **`server/src/aid-export/`** | Модуль Nest: контроллер, сервис, краткая справка в `README.md` рядом с кодом. |
## Что временно не работает Ветка с этими изменениями: **`add_aid_exporters`**.
| Компонент | Статус |
|-----------|--------|
| **`POST /aid/export/app`** (DSL → бандл/apply) | **Non-operative.** The backing script (`generation/generate.mjs`) was removed during the architecture migration to api.dsl-first LLM generation. The endpoint returns 500 with a descriptive error. |
--- ---
## Требования к запуску ## Требования к запуску
1. Репозиторий клонирован целиком (есть `tools/`, `server/`, `client/`). 1. Репозиторий клонирован целиком (есть `generation/`, `tools/`, `server/`, `client/`).
2. Backend запускается из каталога **`server/`** (`npm run start` / `start:dev`), чтобы относительные пути `../tools/api-format-to-openapi/convert.mjs` были корректны. 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`). 3. Для режима OpenAPI через LLM на сервере нужны **`OPENAI_API_KEY`** (и при необходимости `OPENAI_MODEL`, `OPENAI_BASE_URL`).
--- ---
@@ -33,6 +31,7 @@
| Переменная | Зачем | | Переменная | Зачем |
|------------|--------| |------------|--------|
| `AID_EXPORT_API_KEY` | Если задана, к **`/aid/export/*`** нужен заголовок **`X-AID-Export-Key`** с тем же значением. | | `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"`**. | | `OPENAI_API_KEY` | Для `POST /aid/export/openapi` с **`"mode": "llm"`**. |
Остальное как для обычного бэкенда (`DATABASE_URL`, `PORT` и т.д.). Остальное как для обычного бэкенда (`DATABASE_URL`, `PORT` и т.д.).
@@ -70,19 +69,40 @@ X-AID-Export-Key: <если задан AID_EXPORT_API_KEY>
Пример входа для теста: `tools/api-format-to-openapi/examples/api-format.example.json` (подставьте как значение `apiFormat`). Пример входа для теста: `tools/api-format-to-openapi/examples/api-format.example.json` (подставьте как значение `apiFormat`).
### 2. Генератор приложения из DSL (non-operative) ### 2. Генератор приложения из DSL
**`POST /aid/export/app`** **`POST /aid/export/app`**
> **Non-operative.** This endpoint depended on `generation/generate.mjs` which was removed ```json
> during the migration to api.dsl-first LLM generation. It currently returns HTTP 500 {
> with a descriptive error message. Restoring this endpoint requires implementing a new "dsl": "domain TOiR {\n ...\n}\n",
> backing script compatible with the current `domain/*.api.dsl` pipeline. "apply": false
}
```
- **`apply: false`** (рекомендуется для AID): в ответе **`files`** — объект «путь от корня репо → текст файла». Диск на сервере не меняется. В бандл входят сгенерированные модули, `App.tsx`, **`server/src/common/field-labels.generated.ts`** (из DSL) и **канонические шаблоны рантайма** из **`generation/templates/runtime/`** (копируются в `server/src/main.ts`, `ApiExceptionFilter`, `dataProvider`, `AppNotification` при `npm run generate:from-dsl`).
- **`apply: true`**: выполняется запись файлов как у `npm run generate:from-dsl` с `--apply`; нужен **`AID_GENERATOR_ALLOW_APPLY=1`**.
**Ответ (бандл):** `{ "applied": false, "entityCount": N, "enumCount": M, "files": { ... } }`
**Ответ (apply):** `{ "applied": true, "message": "Generated ..." }`
Эталон DSL: `examples/TOiR.domain.dsl`.
--- ---
## CLI (без Nest) ## 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 ### api-format → OpenAPI
```bash ```bash
@@ -99,22 +119,46 @@ node convert.mjs --mode llm --in your-api-format.json --out ../../openapi.llm.js
Подробнее: **`tools/api-format-to-openapi/README.md`**. Подробнее: **`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 ## Типичный сценарий для AID
1. AID уже сформировал **api-format** (как у вас принято после DTO). 1. AID уже сформировал **api-format** (как у вас принято после DTO).
2. AID вызывает **`POST /aid/export/openapi`** → получает **OpenAPI 3.0** → сохраняет в проект / отдаёт в Swagger / в реестр. 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`** - Полное описание эндпоинтов рядом с реализацией: **`server/src/aid-export/README.md`**
- Общий dev-workflow (в т.ч. упоминание AID): **`generation/dev-workflow.md`**
--- ---
## Ограничения и дальнейшие шаги ## Ограничения и дальнейшие шаги
- Пример **api-format** в репозитории — **учебный**; под ваш продакшен-формат может понадобиться расширить маппинг в `convert.mjs` или отточить промпт **`llm-system.md`**. - Пример **api-format** в репозитории — **учебный**; под ваш продакшен-формат может понадобиться расширить маппинг в `convert.mjs` или отточить промпт **`llm-system.md`**.
- **`/aid/export/app`** requires a new backing implementation compatible with the `domain/*.api.dsl` pipeline to be restored. See `docs/future-work.md` for planned future work. - Ответ **`/aid/export/app`** с большим числом сущностей может быть объёмным; при необходимости добавьте сжатие, отдельное хранилище артефактов или пагинацию по файлам — контракт с AID лучше зафиксировать отдельно.

View File

@@ -1,162 +0,0 @@
# api.dsl Conventions
Grammar and authoring conventions for `domain/*.api.dsl` files.
See `domain/toir.api.dsl` as the canonical example.
---
## File location and naming
- All api.dsl files live in `domain/`.
- Naming: `<project>.api.dsl` (e.g. `toir.api.dsl`).
- Multiple api.dsl files are allowed but not required.
---
## Two top-level block types
An api.dsl file contains two types of top-level blocks:
1. `dto` — defines the shape of a data transfer object
2. `api` — declares a group of API endpoints for one resource
---
## dto block
```
dto DTO.<Name> {
description "Human-readable description";
attribute <fieldName> {
description "Field description";
type <type>;
is required; // or: is nullable;
map <Entity>.<field>; // links to a field in domain/*.api.dsl
}
}
```
### DTO naming convention
| DTO name | Purpose |
|----------|---------|
| `DTO.<Entity>` | Full response shape (GET by id, list items) |
| `DTO.<Entity>Create` | Create request body |
| `DTO.<Entity>Update` | Update request body (partial — all fields nullable) |
| `DTO.<Entity>ListRequest` | Paginated list request (filters + page) |
| `DTO.<Entity>ListResponse` | Paginated list response (content + page info) |
### Types
Same scalar types as the domain DSL:
`uuid`, `string`, `text`, `integer`, `decimal`, `date`, `boolean`, or an enum name from
`domain/*.api.dsl`.
Cross-DTO references: `DTO.<Name>` or `DTO.<Name>[]`.
Standard pagination types (not entity-specific; treated as well-known):
`DTO.Filter[]`, `DTO.PageRequest`, `DTO.PageInfo`.
### is required vs is nullable
Unlike the domain DSL (where these drive DB constraints), in api.dsl these drive the
TypeScript DTO property modifier:
- `is required``field!: type` in the generated class
- `is nullable``field?: type` in the generated class
- Neither modifier → treated as optional (`field?: type`)
### map directive
`map Entity.field` links the DTO attribute to a domain entity field.
Rules:
- The entity and field must exist in `domain/*.api.dsl`.
- The scalar type must match the domain DSL field type (nullability may differ).
- Omit `map` only for pagination helper types (`DTO.Filter[]`, `DTO.PageRequest`, etc.)
that have no direct entity field counterpart.
### Conflict resolution
If the type declared in a `dto` attribute conflicts with the domain DSL field type,
the domain DSL is correct. Fix the api.dsl.
---
## api block
```
api API.<Name> {
description "API group description";
endpoint <endpointName> {
label "METHOD /path";
description "Endpoint description";
// For endpoints with a request body:
attribute request {
type DTO.<RequestDto>;
}
// For endpoints with a response body:
attribute response {
type DTO.<ResponseDto>;
}
// For path parameters:
attribute <paramName> {
type <type>;
}
}
}
```
### api block naming
`API.<EntityName>` (e.g. `API.Equipment`, `API.RepairOrder`)
### endpoint label
`label "METHOD /path"` is the authoritative declaration of HTTP verb and route.
| Label pattern | NestJS decorator | Notes |
|---------------|-----------------|-------|
| `"GET /resource/{id}"` | `@Get(':id')` | |
| `"POST /resource"` | `@Post()` | Create |
| `"POST /resource/page"` | `@Post('page')` | Paginated list (body-based filter) |
| `"PUT /resource/{id}"` | `@Put(':id')` | Full or partial update |
| `"DELETE /resource/{id}"` | `@Delete(':id')` | |
Do not infer HTTP verbs from endpoint names. Always read the `label`.
### Path parameters
Declared as a plain `attribute` inside the endpoint block (not wrapped in `request`
or `response`):
```
attribute id {
type uuid;
}
```
### Standard 5-endpoint CRUD pattern
| Endpoint name | Label | Body |
|---------------|-------|------|
| `list<Entity>s` | `"POST /<path>/page"` | request: `DTO.<Entity>ListRequest`, response: `DTO.<Entity>ListResponse` |
| `get<Entity>` | `"GET /<path>/{id}"` | path param `id`, response: `DTO.<Entity>` |
| `create<Entity>` | `"POST /<path>"` | request: `DTO.<Entity>Create` |
| `update<Entity>` | `"PUT /<path>/{id}"` | path param `id`, request: `DTO.<Entity>Update` |
| `delete<Entity>` | `"DELETE /<path>/{id}"` | path param `id` |
---
## Constraints
1. Every `map Entity.field` must resolve to an existing entity + field in `domain/*.api.dsl`.
2. Types in api.dsl must be compatible with domain DSL field types (same scalar type).
3. api.dsl must not define entities or enums. Those belong in `domain/*.api.dsl`.
4. An api.dsl may omit domain entity fields from a DTO (e.g. no PK in Create DTO).
It must not add fields that don't exist in the domain model.

View File

@@ -1,154 +0,0 @@
# Future Work — Deferred Items
This file tracks engineering improvements that are deliberately deferred due to the
current stage of the project. They are not forgotten — they are acknowledged technical
debt that should be addressed before scaling.
---
## Rule 7 — Tracing, Telemetry, Cost/Latency Observability
**Status:** Deferred. No LLM calls are instrumented.
**Why it matters (Anthropic / Google / Microsoft guidance):**
Without observability, you cannot:
- Know which prompts are expensive (token count, latency)
- Detect prompt regressions via cost drift
- Attribute generation failures to specific prompt versions
- Track improvement over time
**What needs to be built:**
### 7.1 — Generation log
Create `tools/generation-log.mjs` that wraps any LLM generation call and writes a
structured JSON entry to `logs/generation.jsonl`:
```json
{
"timestamp": "2026-04-03T10:00:00.000Z",
"entity": "Equipment",
"artifact": "backend",
"prompt_version": "1.0",
"model": "...",
"input_tokens": 4200,
"output_tokens": 1800,
"latency_ms": 3200,
"validation_passed": true,
"eval_passed": true
}
```
### 7.2 — Cost budget alerts
Add a threshold check (e.g., warn if input_tokens > 8000 for a single entity generation).
This enforces the context budget from `prompts/general-prompt.md §CONTEXT BUDGET`.
### 7.3 — Prompt version tracking
Add `<!-- prompt-version: X.Y -->` comments to all prompt files (already started in
`backend-rules.md` and `frontend-rules.md`). Increment version on any non-trivial change.
Log the prompt versions alongside the generation log entry.
### 7.4 — Drift detection
Compare generation log entries across runs. If token count for the same entity increases
by >20% without a DSL change, flag it as context rot.
**Effort estimate:** Medium. 23 days to build the logging layer. Zero effort for
prompt versioning (already partially done).
**Trigger:** Implement before the system is used for more than 10 entities or before
any production deployment.
---
## Rule 8 — Risk Controls and Red-Teaming
**Status:** Deferred. No sanitization or adversarial testing exists.
**Why it matters (Anthropic / Google / Microsoft guidance):**
LLM-generated code at scale introduces risks that do not exist in hand-written code:
- **Prompt injection**: malicious content in DSL `description` fields could steer
generation (e.g., `description "Ignore previous instructions and..."`)
- **Generated credential leakage**: LLM may hallucinate hardcoded secrets that look
real (e.g., `apiKey: 'sk-...'`)
- **Missing auth guards**: already caught by Rule 4 validator, but adversarial prompts
could bypass it by generating valid-looking guard syntax that is semantically inactive
- **Supply chain**: generated package imports could reference non-existent or malicious
packages if the LLM hallucinates
**What needs to be built:**
### 8.1 — DSL input sanitization
In `tools/api-summary.mjs`, before building the summary, check all `description` and
`label` fields for injection patterns:
```javascript
function sanitizeDslString(value, fieldPath) {
const injectionPatterns = [
/ignore previous/i,
/disregard.*instruction/i,
/you are now/i,
/system:/i,
];
for (const pattern of injectionPatterns) {
if (pattern.test(value)) {
throw new Error(`Potential prompt injection in DSL field ${fieldPath}: "${value}"`);
}
}
return value;
}
```
### 8.2 — Generated code security scan
Add to `tools/validate-generation.mjs` (or a separate `tools/security-scan.mjs`):
```javascript
// Check no hardcoded secrets leaked into generated code
function validateNoSecretLeakage() {
const patterns = [
/sk-[a-zA-Z0-9]{20,}/, // OpenAI key pattern
/[a-zA-Z0-9+/]{40}={0,2}/, // Base64 secret-like
/password\s*=\s*['"][^'"]{4,}['"]/, // Hardcoded password
/apiKey\s*=\s*['"][^'"]{4,}['"]/, // Hardcoded API key
];
// Run against all generated files...
}
```
### 8.3 — UseGuards completeness audit
Beyond the current validator check (UseGuards present), add: verify that the guard
constructor arguments are non-empty and match the expected guard class names. A guard
call like `@UseGuards()` (empty) passes the current regex but provides no protection.
### 8.4 — Red-team fixture
Create `tools/eval/fixtures/_adversarial/` with a fixture that includes a DSL snippet
containing a benign injection attempt (e.g., a `description` field with "ignore format
rules") and verifies the generation still produces spec-compliant output.
### 8.5 — Generated import allowlist
Maintain a list of approved npm packages that generated code may import. Flag any
import not on the allowlist as a manual review item.
**Effort estimate:** Medium-High. 35 days. Security scan and sanitization are low
effort; red-team fixtures and import allowlisting are higher effort.
**Trigger:** Implement before any external user can influence `domain/*.api.dsl` content
(i.e., before a UI or API to edit the DSL is exposed).
---
## Tracking
| Rule | Status | Priority | Trigger |
|------|--------|----------|---------|
| Rule 7 — Telemetry | Deferred | Medium | Before >10 entities or production deployment |
| Rule 8 — Risk controls | Deferred | High | Before DSL editing is exposed to external users |
Last updated: 2026-04-03

View File

@@ -1,237 +0,0 @@
# Generation Playbook
Step-by-step instructions for generating and regenerating artifacts in this repository.
Read `AGENTS.md` and `docs/source-of-truth.md` first.
---
## Pipeline overview
```
Tier 1 — Single Source of Truth (hand-authored, never generated)
domain/toir.api.dsl ──┐ enums, DTOs, endpoints, HTTP methods,
│ entity field mappings, primary keys
Tier 2 — Deterministic Preprocessing (npm scripts, no LLM)
api-summary.json ←─ npm run generate:api-summary
openapi.json ←─ npm run generate:openapi (auxiliary)
Tier 1 (api.dsl) + Tier 2 (context) + prompts/*.md ──► LLM Generation
prompts/general-prompt.md
prompts/backend-rules.md ──► server/src/modules/<entity>/
prompts/frontend-rules.md ──► client/src/resources/<entity>/
prompts/prisma-rules.md ──► server/prisma/schema.prisma
prompts/auth-rules.md ──► (auth seam reference)
prompts/runtime-rules.md ──► (env/docker reference)
prompts/validation-rules.md ──► (validation gate reference)
Tier 4 — Validation Gate
node tools/validate-generation.mjs --artifacts-only
```
---
## Prerequisites
Before any generation run:
1. `domain/*.api.dsl` is current and valid.
2. Refresh the Tier 2 intermediate context:
```bash
npm run generate:api-summary
```
3. Run `node tools/validate-generation.mjs --artifacts-only` to confirm the baseline passes.
---
## Standard generation workflow
### Step 1 — Refresh Tier 2 derived artifacts
```bash
# From repo root
npm run generate:api-summary
```
### Step 2 — Read generation inputs (context budget)
> **`prompts/general-prompt.md` is the master generation prompt.** It contains all core
> type mappings, naming conventions, DTO/controller/service/frontend rules, mutation
> boundaries, and the complete generation workflow. Load it as the single entrypoint.
>
> For artifact-specific details (Prisma FK rules, auth JWKS chain, detailed validation
> groups), load the relevant companion file: `prompts/prisma-rules.md`,
> `prompts/auth-rules.md`, `prompts/validation-rules.md`, or `prompts/runtime-rules.md`.
>
> See `prompts/general-prompt.md §CONTEXT BUDGET` for the full budget model.
1. `prompts/general-prompt.md` — master generation prompt (always load)
2. `api-summary.json §<entity>` — compact entity index (fast-path context anchor)
3. `domain/*.api.dsl §API.<EntityName>` — **only the api block + its referenced DTOs + enums** (entity-scoped)
4. **If needed:** `prompts/prisma-rules.md` (Prisma) or `prompts/auth-rules.md` (auth seams)
Before generating any DTO or component: **quote the relevant DSL field definitions verbatim first**, then generate from those quotes. This prevents training-data contamination.
### Step 3 — Generate Prisma schema
Generate `server/prisma/schema.prisma` from `domain/*.api.dsl` following `prompts/prisma-rules.md`.
If the schema changed, run Prisma migration in `server/`:
```bash
cd server
npx prisma migrate dev --name <description>
```
### Step 4 — Generate backend modules
For each `api` block in `domain/*.api.dsl`, generate:
1. `server/src/modules/<kebab>/<entity>.module.ts`
2. `server/src/modules/<kebab>/dto/create-<kebab>.dto.ts`
— fields from the `DTO.<Entity>Create` block in api.dsl
3. `server/src/modules/<kebab>/dto/update-<kebab>.dto.ts`
— fields from the `DTO.<Entity>Update` block in api.dsl
4. `server/src/modules/<kebab>/<entity>.service.ts`
— CRUD operations using Prisma; respect type mappings from `prompts/backend-rules.md`
5. `server/src/modules/<kebab>/<entity>.controller.ts`
— one method per `endpoint` in the `api` block; HTTP verb and path from the `label`
Register the module in `server/src/app.module.ts`.
### Step 5 — Generate frontend resources
For each `api` block in `domain/*.api.dsl`, generate:
1. `client/src/resources/<kebab>/<Entity>List.tsx`
— columns from `DTO.<Entity>` (response shape)
2. `client/src/resources/<kebab>/<Entity>Create.tsx`
— fields from `DTO.<Entity>Create`
3. `client/src/resources/<kebab>/<Entity>Edit.tsx`
— fields from `DTO.<Entity>Update`
4. `client/src/resources/<kebab>/<Entity>Show.tsx`
— fields from `DTO.<Entity>`
Register the resource in `client/src/App.tsx`.
### Step 6 — Verify (two-stage gate)
**Stage 1 — Structural gate:**
```bash
node tools/validate-generation.mjs --artifacts-only
```
Checks file existence, field names, class-validator decorators, auth guards, and RA component types.
Full structural verification (requires installed deps):
```bash
node tools/validate-generation.mjs
```
**Stage 2 — Eval harness:**
```bash
npm run eval:generation
```
Fixture-based semantic checks. See `tools/eval/README.md`.
Both must pass before the task is complete.
---
## Adding a new entity
1. Add the entity's enums, DTOs, and `api` block to `domain/toir.api.dsl`.
2. Run `npm run generate:api-summary`.
3. **Before generating:** create `tools/eval/fixtures/<kebab>/meta.json`, `backend.assertions.json`, and `frontend.assertions.json` with expected patterns.
4. Run `npm run eval:generation` — it will fail (entity files don't exist yet). That's expected.
5. Generate backend and frontend artifacts (Steps 35).
6. Run `npm run eval:generation` again — now it should pass.
7. Run `node tools/validate-generation.mjs --artifacts-only` — both gates must pass.
> This is **eval-driven development**: write the failing eval first (step 3), then generate to make it pass (step 6). A passing eval confirms the LLM followed the rules.
---
## Changing an existing entity
**If the domain model changes** (new field, changed type, new FK, new enum):
1. Update `domain/toir.api.dsl` (enums, DTO attributes, `map` references).
2. Run `npm run generate:api-summary`.
3. Regenerate `server/prisma/schema.prisma`, run migration.
4. Regenerate the affected modules and resources (Steps 46).
**If only the API contract changes** (different nullability, new endpoint, different HTTP method):
1. Update `domain/toir.api.dsl` only.
2. Run `npm run generate:api-summary` to refresh `api-summary.json`.
3. Run Steps 46. No Prisma migration needed.
---
## Artifact traceability matrix
| Artifact | DSL source | Prompt rule | Validator check |
| ---------------------------------------------- | ---------------------- | ---------------------------------------------- | --------------------------------------- |
| `server/prisma/schema.prisma` | `domain/*.api.dsl` | `prompts/prisma-rules.md` | `§validateBuildChecks` (file exists) |
| `api-summary.json` | `domain/*.api.dsl` | — (deterministic) | `§validateBuildChecks` (freshness) |
| `server/src/modules/<e>/*.ts` | `domain/*.api.dsl` | `prompts/backend-rules.md` | `§validateApiDslCoverage` |
| `server/src/modules/<e>/dto/create-<e>.dto.ts` | `DTO.<E>Create` fields | `prompts/backend-rules.md §DTO-field-coverage` | `§validateApiDslCoverage` (field names) |
| `server/src/modules/<e>/dto/update-<e>.dto.ts` | `DTO.<E>Update` fields | `prompts/backend-rules.md §DTO-field-coverage` | `§validateApiDslCoverage` (field names) |
| `client/src/resources/<e>/<E>*.tsx` | `domain/*.api.dsl` | `prompts/frontend-rules.md` | `§validateApiDslCoverage` |
| `client/src/auth/keycloak.ts` | — (Tier 4 handwritten) | `prompts/auth-rules.md` | `§validateAuthChecks` |
| `toir-realm.json` | — (Tier 4 handwritten) | `prompts/auth-rules.md` | `§validateRealmChecks` |
| `docker-compose.yml` | — (Tier 4 handwritten) | `prompts/runtime-rules.md` | `§validateRuntimeContractChecks` |
---
## Verification commands reference
| Command | When to use |
| ----------------------------------------------------- | ------------------------------------------ |
| `npm run generate:api-summary` | After any change to `domain/*.api.dsl` |
| `npm run generate:openapi` | To regenerate OpenAPI 3.0.3 documentation |
| `node tools/validate-generation.mjs --artifacts-only` | After every generation run (required) |
| `node tools/validate-generation.mjs` | Before committing; requires installed deps |
| `node tools/validate-generation.mjs --run-runtime` | End-to-end; requires running DB |
---
## OpenRouter invocation
Set environment variables before any LLM-mode tool call:
```
OPENAI_API_KEY=<openrouter-key>
OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_MODEL=<model-id>
```
The `OPENAI_BASE_URL` variable is consumed by `tools/api-format-to-openapi/convert.mjs --mode llm`.
For agent-driven generation via Cursor or direct API calls, the standard workflow above
applies regardless of model provider.
---
## Auxiliary tools
### `tools/api-summary-to-openapi.mjs`
Generates `openapi.json` (OpenAPI 3.0.3) from `api-summary.json` deterministically.
This is a documentation/integration artifact, not part of the core generation pipeline.
```bash
npm run generate:openapi
```
### `tools/api-format-to-openapi/`
Auxiliary integration tool for external consumers using the `api-format` JSON schema.
Not connected to the main DSL pipeline. See `docs/AID_EXPORT_README.md` for details.

View File

@@ -28,7 +28,7 @@ Root-level files stay limited to repository-level artifacts such as:
- `README.md` - `README.md`
- `package.json` - `package.json`
- `docker-compose.yml` - `docker-compose.yml`
- `api-summary.json` - `domain-summary.json`
- `toir-realm.json` - `toir-realm.json`
- `.gitignore` - `.gitignore`

View File

@@ -1,96 +0,0 @@
# Source-of-Truth Hierarchy
This document is the authoritative reference for which files own which decisions.
---
## Tier 1: Authoritative sources (hand-authored; never generated)
### `domain/*.api.dsl`
**Single source of truth for the entire domain and API contract:**
- Entity names and structure
- Attribute names, scalar types, descriptions
- Primary keys (including natural string keys)
- Foreign keys and relations
- Enum definitions and their values
- Database-level constraints: `is required`, `is unique`, `default`
- DTO shapes per operation (Create, Update, Read, ListRequest, ListResponse)
- Which fields appear in each DTO and with what TypeScript modifier (`!` or `?`)
- HTTP method and path for each endpoint (via `label "METHOD /path"`)
- Endpoint names (camelCase identifiers)
- Pagination request/response contract
**Drives:** `server/prisma/schema.prisma`, `server/src/modules/`, `client/src/resources/`,
`server/src/app.module.ts`, `client/src/App.tsx`.
### `prompts/*.md` and `AGENTS.md`
**Authoritative for:**
- Agent generation workflow and reading order
- Auth seam patterns (Keycloak, JWT, PKCE S256, JWKS resolution chain)
- Runtime conventions (env examples, docker-compose topology)
- Framework scaffold baseline requirements (NestJS CLI, Vite React TypeScript)
- Filtering and sorting contract
- Naming conventions and implicit rules (pluralization, sort field priority, type mappings)
- Mutation boundaries (what agents must not overwrite)
---
## Tier 2: Deterministic derivatives (never edit manually)
| File | Generated from | Command |
| ----------------- | ------------------ | ------------------------------ |
| `api-summary.json` | `domain/*.api.dsl` | `npm run generate:api-summary` |
These files are regenerated from their sources. Manual edits are overwritten on the
next generation run.
---
## Tier 3: LLM-generated artifacts (never edit manually after generation)
| Zone | Generated from |
| -------------------------------- | ------------------------------------------------ |
| `server/prisma/schema.prisma` | `domain/*.api.dsl` + `prompts/prisma-rules.md` |
| `server/src/modules/<entity>/` | `domain/*.api.dsl` + `prompts/backend-rules.md` |
| `client/src/resources/<entity>/` | `domain/*.api.dsl` + `prompts/frontend-rules.md` |
| `server/src/app.module.ts` | Module list derived from api.dsl `api` blocks |
| `client/src/App.tsx` | Resource list derived from api.dsl `api` blocks |
To change these files: update `domain/*.api.dsl` and regenerate.
---
## Tier 4: Handwritten (not generated; not derived)
- Auth seams: `client/src/auth/`, `server/src/auth/`
- `toir-realm.json`
- `docker-compose.yml`
- `server/.env.example`, `client/.env.example`
- Framework config: `nest-cli.json`, `tsconfig*.json`, `vite.config.*`, etc.
- All `prompts/*.md`, `AGENTS.md`, `domain/dsl-spec.md`, `domain/*.api.dsl`
---
## Validation gate
### Stage 1 — Structural gate
```
node tools/validate-generation.mjs --artifacts-only
```
Verifies that generated artifacts satisfy the contracts declared in Tier 1 sources.
### Stage 2 — Eval harness
```
npm run eval:generation
```
Fixture-based semantic checks from `tools/eval/fixtures/`.
Both stages must pass before any generation task is considered complete.

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": "Отменена"
}
]
}
]
}

236
domain/TOiR.domain.dsl Normal file
View File

@@ -0,0 +1,236 @@
/*
КИС ТОиР — демонстрационная схема доменной модели
Сущности: Equipment (Оборудование), EquipmentType (Вид оборудования), RepairOrder (Заявка на ремонт)
*/
enum EquipmentStatus {
value Active {
label "В эксплуатации";
}
value Repair {
label "В ремонте";
}
value Reserve {
label "В резерве";
}
value WriteOff {
label "Списано";
}
}
enum RepairKind {
value TO {
label "Техническое обслуживание";
}
value TR {
label "Текущий ремонт";
}
value TRE {
label "Текущий расширенный ремонт";
}
value KR {
label "Капитальный ремонт";
}
value AR {
label "Аварийный ремонт";
}
value MP {
label "Метрологическая поверка";
}
}
enum RepairOrderStatus {
value Draft {
label "Черновик";
}
value Approved {
label "Утверждена";
}
value InWork {
label "В работе";
}
value Done {
label "Выполнена";
}
value Cancelled {
label "Отменена";
}
}
entity EquipmentType {
description "Вид (марка) оборудования — нормативный справочник НСИ";
attribute code {
key primary;
description "Код вида оборудования";
type string;
is required;
is unique;
}
attribute name {
description "Наименование вида";
type string;
is required;
}
attribute manufacturer {
description "Производитель";
type string;
}
attribute maintenanceIntervalHours {
description "Периодичность ТО, моточасов";
type integer;
}
attribute overhaulIntervalHours {
description "Периодичность КР, моточасов";
type integer;
}
}
entity Equipment {
description "Единица оборудования — объект ремонта и технического обслуживания";
attribute id {
type uuid;
key primary;
}
attribute inventoryNumber {
description "Инвентарный номер";
type string;
is required;
is unique;
}
attribute serialNumber {
description "Заводской (серийный) номер";
type string;
}
attribute name {
description "Наименование единицы оборудования";
type string;
is required;
}
attribute equipmentTypeCode {
type string;
key foreign {
relates EquipmentType.code;
}
is required;
}
attribute status {
description "Текущий статус";
type EquipmentStatus;
default Active;
is required;
}
attribute location {
description "Место эксплуатации / скважина / куст";
type string;
}
attribute commissionedAt {
description "Дата ввода в эксплуатацию";
type date;
}
attribute totalEngineHours {
description "Общая наработка, моточасов";
type decimal;
}
attribute engineHoursSinceLastRepair {
description "Наработка с последнего ремонта, моточасов";
type decimal;
}
attribute lastRepairAt {
description "Дата последнего ремонта";
type date;
}
attribute notes {
description "Примечания";
type text;
}
}
entity RepairOrder {
description "Заявка на ремонт — формируется по ППР или по факту обнаруженного дефекта";
attribute id {
type uuid;
key primary;
}
attribute number {
description "Номер заявки";
type string;
is required;
is unique;
}
attribute equipmentId {
type uuid;
key foreign {
relates Equipment.id;
}
is required;
}
attribute repairKind {
description "Вид ремонта";
type RepairKind;
is required;
}
attribute status {
type RepairOrderStatus;
default Draft;
is required;
}
attribute plannedAt {
description "Плановая дата начала";
type date;
is required;
}
attribute startedAt {
description "Фактическая дата начала";
type date;
}
attribute completedAt {
description "Фактическая дата завершения";
type date;
}
attribute contractor {
description "Подрядная организация (если внешний ремонт)";
type string;
}
attribute engineHoursAtRepair {
description "Наработка на момент ремонта, моточасов";
type decimal;
}
attribute description {
description "Описание работ / дефекта";
type text;
}
attribute notes {
description "Примечания";
type text;
}
}

View File

@@ -1,42 +1,34 @@
# DSL Language Specification # DSL Language Specification
This document describes the DSL system used to specify fullstack CRUD applications. This document describes the single DSL (Domain Specific Language) used to specify fullstack CRUD applications. The only required DSL input is `domain/*.dsl`.
`domain/*.api.dsl` is the single source of truth for the entire domain model and API `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/`.
contract. It drives Prisma schema generation, NestJS module generation, and React Admin
resource generation.
`api-summary.json` is a derived artifact generated from the api.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 Architecture # DSL Responsibility
## `domain/*.api.dsl` The domain DSL defines only:
The api.dsl is the authoritative source of truth for: - domain model
- relations
- enums
- entities and their attributes The domain DSL is the single source of truth for:
- scalar types and enums
- primary keys and foreign keys
- database-level constraints (required, unique, default)
- relations between entities
- DTO shapes per operation (Create, Update, Read, List)
- nullability and requiredness of each DTO attribute per operation
- HTTP methods and paths for each endpoint
- endpoint names and groupings
- pagination request/response contracts
The api.dsl drives: Prisma schema, NestJS controller/service/DTO generation, - entities
and React Admin resource generation. - attributes
- primary keys
- foreign keys
- enums
Constraint: every `map Entity.field` or `sync Entity.field` reference in `domain/*.api.dsl` The following layers are always derived from the domain DSL and must not be authored as standalone authoritative DSL inputs:
must resolve to an entity and field defined within the same api.dsl file.
## Optional extension mechanism - DTO
- API
- UI
Optional extension mechanism:
```text ```text
overrides/ overrides/
@@ -48,8 +40,7 @@ Override rules:
- Overrides are optional. - Overrides are optional.
- The generator must work without them. - The generator must work without them.
- Overrides may refine derived API or UI behavior, but they must not duplicate or redefine - Overrides may refine derived API or UI behavior, but they must not duplicate or redefine entities, attributes, primary keys, foreign keys, relations, or enums.
entities, attributes, primary keys, foreign keys, relations, or enums.
--- ---
@@ -86,15 +77,13 @@ attribute name {
**Modifiers:** **Modifiers:**
- `type` — required; one of: `string`, `uuid`, `integer`, `decimal`, `date`, `text`, `boolean`, `number`, or an enum name. - `type` — required; one of: `string`, `uuid`, `integer`, `decimal`, `date`, `text`, or an enum name.
- `key primary` — this attribute is the primary key. - `key primary` — this attribute is the primary key.
- `key foreign { relates Entity.field }` — foreign key to another entity's field. - `key foreign { relates Entity.field }` — foreign key to another entity's field.
- `is required` — non-nullable. - `is required` — non-nullable.
- `is unique` — unique constraint. - `is unique` — unique constraint.
- `is nullable` — explicitly nullable.
- `default Value` — default value (for enums or literals). - `default Value` — default value (for enums or literals).
- `description "..."` — human-readable description. - `description "..."` — human-readable description.
- `label "..."` — display label for UI.
--- ---
@@ -175,21 +164,19 @@ attribute equipmentTypeCode {
## DSL → Prisma ## DSL → Prisma
| DSL Concept | Prisma Result | | DSL Concept | Prisma Result |
| ------------- | --------------------------- | | ------------ | --------------------------- |
| entity | model | | entity | model |
| attribute | field | | attribute | field |
| enum | enum | | enum | enum |
| key primary | @id or @id @default(uuid()) | | key primary | @id or @id @default(uuid()) |
| key foreign | relation + references | | key foreign | relation + references |
| type string | String | | type string | String |
| type uuid | String @id @default(uuid()) | | type uuid | String @id @default(uuid()) |
| type integer | Int | | type integer | Int |
| type number | Float | | type decimal | Decimal |
| type decimal | Decimal | | type date | DateTime |
| type date | DateTime | | type text | String |
| type text | String |
| type boolean | Boolean |
--- ---
@@ -216,26 +203,15 @@ API paths are derived from entity name: PascalCase → kebab-case, pluralized (e
| attribute | Form field / column | | attribute | Form field / column |
| type string | TextInput, TextField | | type string | TextInput, TextField |
| type integer/decimal | NumberInput, NumberField | | type integer/decimal | NumberInput, NumberField |
| type number | NumberInput, NumberField |
| type date | DateInput, DateField | | type date | DateInput, DateField |
| type boolean | BooleanInput, BooleanField |
| enum | SelectInput with choices | | enum | SelectInput with choices |
| foreign key | ReferenceInput, ReferenceField | | foreign key | ReferenceInput, ReferenceField |
--- ---
# API DSL Layer Mapping # Derived Layer Mapping
DTO shapes and endpoint contracts are declared in `domain/*.api.dsl`. The api.dsl - **Create DTO** — derived from domain attributes and must not include generated primary keys (for example no `id` for uuid PKs).
is the authoritative source for: - **Update DTO** — derived from the same domain attributes with all fields optional for partial updates.
- **API response shape** — derived from domain attributes and must expose React Admin-compatible identifiers when needed.
- **Create DTO** — declared as `dto DTO.<Entity>Create` in api.dsl. Must not include - **UI field mapping** — derived from attribute types, descriptions, enums, and foreign keys without a separate UI DSL.
server-generated primary keys (e.g. no `id` for uuid PKs). Required/nullable per field
is explicit in the api.dsl, not inferred.
- **Update DTO** — declared as `dto DTO.<Entity>Update` in api.dsl. All fields are
typically nullable for partial update semantics.
- **API response shape** — declared as `dto DTO.<Entity>` in api.dsl. Must expose
React Admin-compatible `id` field.
- **UI field mapping** — derived from the DTO shapes in api.dsl, not from domain
attributes directly. The Create form uses `DTO.<Entity>Create` fields; the Edit form
uses `DTO.<Entity>Update` fields; List and Show use `DTO.<Entity>` fields.

File diff suppressed because it is too large Load Diff

View File

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

916
generation/generate.mjs Normal file
View File

@@ -0,0 +1,916 @@
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, '..');
/** Canonical runtime files for API errors + RA dataProvider; copied into server/ and client/ on each generate --apply. */
const RUNTIME_TEMPLATE_DIR = path.join(__dirname, 'templates', 'runtime');
const RUNTIME_TEMPLATE_FILES = [
['api-exception.filter.ts', 'server/src/common/filters/api-exception.filter.ts'],
['dataProvider.ts', 'client/src/dataProvider.ts'],
['AppNotification.tsx', 'client/src/AppNotification.tsx'],
['main.ts', 'server/src/main.ts'],
];
function readFile(p) {
return fs.readFileSync(p, 'utf8');
}
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 === 'id' && attr.type === 'uuid') return 'Идентификатор';
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`;
}
/** Human-readable field labels for ValidationPipe messages (derived from DSL descriptions). */
function renderFieldLabelsGenerated(parsed) {
const { entities } = parsed;
const map = new Map();
for (const ent of Object.values(entities)) {
for (const a of ent.attributes) {
const label = getAttributeLabel(a, entities);
const prev = map.get(a.name);
if (prev === undefined) {
map.set(a.name, label);
} else if (String(label).length > String(prev).length) {
map.set(a.name, label);
}
}
}
const keys = [...map.keys()].sort();
const lines = keys.map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(map.get(k))},`);
return `/** AUTO-GENERATED from domain DSL (generation/generate.mjs). Do not edit by hand. */\nexport const FIELD_LABELS: Record<string, string> = {\n${lines.join('\n')}\n};\n`;
}
function ensureFieldLabels(parsed, apply) {
const rel = 'server/src/common/field-labels.generated.ts';
const content = renderFieldLabelsGenerated(parsed);
if (apply) writeFile(path.join(ROOT, rel), content);
return { rel, content };
}
function loadRuntimeTemplateFiles() {
const out = {};
for (const [name, rel] of RUNTIME_TEMPLATE_FILES) {
const abs = path.join(RUNTIME_TEMPLATE_DIR, name);
if (!fs.existsSync(abs)) {
throw new Error(`Missing generator runtime template: ${abs}`);
}
out[rel] = readFile(abs);
}
return out;
}
function applyRuntimeTemplateFiles(apply) {
const files = loadRuntimeTemplateFiles();
if (apply) {
for (const [rel, content] of Object.entries(files)) {
writeFile(path.join(ROOT, rel), content);
}
}
return files;
}
function ensurePrismaSchema({ enums, entities }, prismaPath, apply) {
const existing = fs.existsSync(prismaPath) ? readFile(prismaPath) : '';
const hasGenerator = /generator\s+client\s*\{/m.test(existing);
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, enums) {
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 'number';
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('@Type(() => Number)');
decorators.push(
`@IsNumber({ allowNaN: false, allowInfinity: false }, { message: '${field}: должно быть числом' })`,
);
imports.add('IsNumber');
needsTypeImport = true;
break;
case 'date':
decorators.push(`@IsISO8601({}, { message: '${field}: должно содержать корректную дату' })`);
imports.add('IsISO8601');
break;
default: {
const vals = enums?.[attr.type]?.values;
if (vals && vals.length) {
const list = vals.map((v) => `'${v}'`).join(', ');
decorators.push(
`@IsIn([${list}], { message: '${field}: недопустимое значение' })`,
);
imports.add('IsIn');
} else {
decorators.push(`@IsString({ message: '${field}: должно быть строкой' })`);
imports.add('IsString');
}
break;
}
}
if (!isUpdate && attr.isRequired && !(attr.isPrimary && attr.type === 'uuid')) {
decorators.push(`@IsNotEmpty({ message: '${field}: обязательное поле' })`);
imports.add('IsNotEmpty');
}
return { decorators, imports, needsTypeImport };
};
const createDecorators = new Set();
const updateDecorators = new Set(['IsOptional']);
let createNeedsTypeImport = false;
let updateNeedsTypeImport = false;
const createDtoLines = [];
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>`
);
}
}
if (!out.includes("from './AppNotification'")) {
out = out.replace(
/import authProvider from '\.\/auth\/authProvider';/,
`import authProvider from './auth/authProvider';\nimport { AppNotification } from './AppNotification';`,
);
}
if (!out.includes('notification={AppNotification}')) {
out = out.replace(
/<Admin dataProvider=\{dataProvider\} authProvider=\{authProvider\} requireAuth>/,
'<Admin\n dataProvider={dataProvider}\n authProvider={authProvider}\n notification={AppNotification}\n requireAuth\n >',
);
out = out.replace(
/(<Admin\n\s*dataProvider=\{dataProvider\}\n\s*authProvider=\{authProvider\})(\n\s*requireAuth)/,
'$1\n notification={AppNotification}$2',
);
}
return out;
});
}
/** Собирает файлы как при --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 fieldLabels = ensureFieldLabels(parsed, false);
files[fieldLabels.rel] = fieldLabels.content;
const backendModules = [];
const frontendResources = [];
for (const [entityName, ent] of Object.entries(parsed.entities)) {
const pk = ent.primaryKey;
const resource = pluralize(toKebab(entityName));
const be = renderBackendModule(entityName, ent, resource, pk, parsed.enums);
const fe = renderFrontendResource(
entityName,
ent,
resource,
pk,
parsed.enums,
parsed.entities,
);
backendModules.push(be);
frontendResources.push(fe);
Object.assign(files, be.files, fe.files);
}
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;
Object.assign(files, loadRuntimeTemplateFiles());
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);
ensureFieldLabels(parsed, 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, parsed.enums);
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);
applyRuntimeTemplateFiles(apply);
process.stdout.write(
`${apply ? 'Generated' : 'Planned'} ${Object.keys(parsed.entities).length} entities from ${dslPath}\n`
);
}
main();

View File

@@ -0,0 +1,16 @@
import { Notification, NotificationProps } from 'react-admin';
export const AppNotification = (props: NotificationProps) => (
<Notification
{...props}
sx={{
whiteSpace: 'pre-line',
'& .MuiAlert-message': {
whiteSpace: 'pre-line',
},
'& .MuiSnackbarContent-message': {
whiteSpace: 'pre-line',
},
}}
/>
);

View File

@@ -0,0 +1,184 @@
import {
ArgumentsHost,
BadRequestException,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Request, Response } from 'express';
type ErrorResponseBody = {
statusCode: number;
message: string | string[];
code: string;
details?: unknown;
path: string;
timestamp: string;
};
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ApiExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const mapped = this.mapException(exception);
const body: ErrorResponseBody = {
statusCode: mapped.statusCode,
message: mapped.message,
code: mapped.code,
...(mapped.details !== undefined ? { details: mapped.details } : {}),
path: request.url,
timestamp: new Date().toISOString(),
};
if (mapped.statusCode >= 500) {
const logDetail =
exception instanceof Error ? exception.message : String(mapped.message);
this.logger.error(
`Unhandled error on ${request.method} ${request.url}: ${logDetail}`,
exception instanceof Error ? exception.stack : undefined,
);
}
response.status(mapped.statusCode).json(body);
}
private mapException(exception: unknown): {
statusCode: number;
message: string | string[];
code: string;
details?: unknown;
} {
if (exception instanceof HttpException) {
const statusCode = exception.getStatus();
const payload = exception.getResponse() as
| string
| {
message?: string | string[];
error?: string;
code?: string;
details?: unknown;
};
if (typeof payload === 'string') {
return {
statusCode,
message: payload,
code: `HTTP_${statusCode}`,
};
}
const rawMessage = payload?.message ?? exception.message;
const message: string | string[] = Array.isArray(rawMessage)
? rawMessage.map((m) => String(m))
: typeof rawMessage === 'string' && rawMessage.length > 0
? rawMessage
: String(exception.message ?? 'Bad Request');
return {
statusCode,
message,
code: payload?.code ?? payload?.error ?? `HTTP_${statusCode}`,
details: payload?.details,
};
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
return this.mapPrismaKnownRequestError(exception);
}
if (exception instanceof Prisma.PrismaClientValidationError) {
return {
statusCode: HttpStatus.BAD_REQUEST,
message:
'Некорректные данные запроса. Проверьте обязательные поля и форматы значений.',
code: 'PRISMA_VALIDATION_ERROR',
};
}
if (exception instanceof Prisma.PrismaClientInitializationError) {
return {
statusCode: HttpStatus.SERVICE_UNAVAILABLE,
message: 'Сервис базы данных временно недоступен.',
code: 'DATABASE_UNAVAILABLE',
};
}
if (exception instanceof Prisma.PrismaClientRustPanicError) {
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Внутренняя ошибка сервера.',
code: 'DATABASE_ENGINE_PANIC',
};
}
if (exception instanceof Error) {
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Внутренняя ошибка сервера.',
code: 'INTERNAL_ERROR',
};
}
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Внутренняя ошибка сервера.',
code: 'INTERNAL_ERROR',
};
}
private mapPrismaKnownRequestError(
exception: Prisma.PrismaClientKnownRequestError,
): {
statusCode: number;
message: string;
code: string;
details?: unknown;
} {
switch (exception.code) {
case 'P2002': {
const target = Array.isArray(exception.meta?.target)
? exception.meta?.target.join(', ')
: String(exception.meta?.target ?? '');
return {
statusCode: HttpStatus.CONFLICT,
message: target
? `Запись с таким значением уже существует (${target}).`
: 'Запись с таким значением уже существует.',
code: 'UNIQUE_CONSTRAINT_VIOLATION',
details: exception.meta,
};
}
case 'P2003':
return {
statusCode: HttpStatus.CONFLICT,
message:
'Операцию нельзя выполнить из-за связанных данных или некорректной ссылки.',
code: 'FOREIGN_KEY_CONSTRAINT_VIOLATION',
details: exception.meta,
};
case 'P2025':
return {
statusCode: HttpStatus.NOT_FOUND,
message: 'Запись не найдена.',
code: 'RECORD_NOT_FOUND',
details: exception.meta,
};
default:
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Ошибка при обработке данных в базе.',
code: exception.code,
details: exception.meta,
};
}
}
}

View File

@@ -0,0 +1,185 @@
import { DataProvider, fetchUtils, HttpError } from 'react-admin';
import { getValidAccessToken } from './auth/keycloak';
import { env } from './config/env';
const apiUrl = env.apiUrl;
/** HTTP status from fetch / react-admin error objects (avoid coupling the client to Nest). */
type HttpStatusCode = number;
/** JSON body shape returned by ApiExceptionFilter (and compatible Nest errors). */
type ApiErrorBody = {
message?: string | string[];
code?: string;
details?: unknown;
};
/** Shape thrown by fetchUtils.fetchJson on non-2xx responses. */
type FetchJsonError = {
status?: HttpStatusCode;
body?: ApiErrorBody;
message?: string;
};
function userMessageFromApiBody(
body: ApiErrorBody | undefined,
fallback: string,
): string {
const raw = body?.message;
if (Array.isArray(raw)) return raw.join('\n');
if (typeof raw === 'string') return raw;
return fallback;
}
const httpClient = async (url: string, options: fetchUtils.Options = {}) => {
const token = await getValidAccessToken();
const headers = new Headers(options.headers ?? { Accept: 'application/json' });
headers.set('Authorization', `Bearer ${token}`);
try {
return await fetchUtils.fetchJson(url, {
...options,
headers,
});
} catch (error: unknown) {
const fetchError = error as FetchJsonError;
const fromPayload = userMessageFromApiBody(fetchError.body, '');
const fallbackMessage = fetchError.message || 'Request failed';
throw new HttpError(
fromPayload || fallbackMessage,
fetchError.status ?? 500,
fetchError.body,
);
}
};
function buildQueryString(query: Record<string, unknown>) {
const search = new URLSearchParams();
Object.entries(query).forEach(([key, val]) => {
if (val === undefined || val === null || val === '') return;
if (Array.isArray(val)) {
val.forEach((v) => {
if (v === undefined || v === null || v === '') return;
search.append(key, String(v));
});
return;
}
search.set(key, String(val));
});
return search.toString();
}
const dataProvider: DataProvider = {
getList: async (resource, params) => {
const { page, perPage } = params.pagination!;
const { field, order } = params.sort!;
const start = (page - 1) * perPage;
const end = page * perPage;
const query: Record<string, unknown> = {
_start: start,
_end: end,
_sort: field,
_order: order,
...(params.filter ?? {}),
};
const queryString = buildQueryString(query);
const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url);
const contentRange = headers.get('Content-Range');
const total = contentRange
? parseInt(contentRange.split('/').pop() || '0', 10)
: json.length;
return { data: json, total };
},
getOne: async (resource, params) => {
const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`);
return { data: json };
},
getMany: async (resource, params) => {
const query = params.ids.map((id) => `id=${id}`).join('&');
const { json } = await httpClient(`${apiUrl}/${resource}?${query}`);
return { data: json };
},
getManyReference: async (resource, params) => {
const { page, perPage } = params.pagination!;
const { field, order } = params.sort!;
const start = (page - 1) * perPage;
const end = page * perPage;
const query: Record<string, unknown> = {
_start: start,
_end: end,
_sort: field,
_order: order,
[params.target]: params.id,
...(params.filter ?? {}),
};
const queryString = buildQueryString(query);
const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url);
const contentRange = headers.get('Content-Range');
const total = contentRange
? parseInt(contentRange.split('/').pop() || '0', 10)
: json.length;
return { data: json, total };
},
create: async (resource, params) => {
const { json } = await httpClient(`${apiUrl}/${resource}`, {
method: 'POST',
body: JSON.stringify(params.data),
});
return { data: json };
},
update: async (resource, params) => {
const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`, {
method: 'PATCH',
body: JSON.stringify(params.data),
});
return { data: json };
},
updateMany: async (resource, params) => {
const responses = await Promise.all(
params.ids.map((id) =>
httpClient(`${apiUrl}/${resource}/${id}`, {
method: 'PATCH',
body: JSON.stringify(params.data),
})
)
);
return { data: responses.map(({ json }) => json.id) };
},
delete: async (resource, params) => {
const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`, {
method: 'DELETE',
});
return { data: json };
},
deleteMany: async (resource, params) => {
const responses = await Promise.all(
params.ids.map((id) =>
httpClient(`${apiUrl}/${resource}/${id}`, {
method: 'DELETE',
})
)
);
return { data: responses.map(({ json }) => json.id) };
},
};
export default dataProvider;

View File

@@ -0,0 +1,111 @@
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import {
BadRequestException,
ValidationError,
ValidationPipe,
} from '@nestjs/common';
import { AppModule } from './app.module';
import { RuntimeEnvironment } from './config/env.validation';
import { ApiExceptionFilter } from './common/filters/api-exception.filter';
import { FIELD_LABELS } from './common/field-labels.generated';
function prettifyFieldName(field: string): string {
if (FIELD_LABELS[field]) return FIELD_LABELS[field];
const withSpaces = field
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.trim();
if (!withSpaces) return field;
return withSpaces[0].toUpperCase() + withSpaces.slice(1);
}
function constraintToRuMessage(field: string, constraint: string): string {
const label = prettifyFieldName(field);
switch (constraint) {
case 'isNotEmpty':
return `Поле "${label}" обязательно`;
case 'isString':
return `Поле "${label}" должно быть строкой`;
case 'isInt':
return `Поле "${label}" должно быть целым числом`;
case 'isUUID':
return `Поле "${label}" должно быть UUID`;
case 'isNumberString':
return `Поле "${label}" должно быть числом`;
case 'isNumber':
return `Поле "${label}" должно быть числом`;
case 'isIso8601':
return `Поле "${label}" должно содержать корректную дату`;
case 'isEnum':
case 'isIn':
return `Поле "${label}" содержит недопустимое значение`;
default:
return `Поле "${label}" заполнено некорректно`;
}
}
function buildValidationMessages(errors: ValidationError[]): string[] {
const messages: string[] = [];
const walk = (errorList: ValidationError[]) => {
for (const error of errorList) {
if (error.constraints) {
const constraints = Object.keys(error.constraints);
// If field is empty, "required" is enough; skip type noise.
const filtered = constraints.includes('isNotEmpty')
? ['isNotEmpty']
: constraints;
filtered.forEach((constraint) =>
messages.push(constraintToRuMessage(error.property, constraint)),
);
}
if (error.children?.length) walk(error.children);
}
};
walk(errors);
return Array.from(new Set(messages));
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get<ConfigService<RuntimeEnvironment, true>>(
ConfigService,
);
const allowedOrigins = configService
.getOrThrow('CORS_ALLOWED_ORIGINS')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.enableCors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(new Error(`Origin ${origin} is not allowed by CORS`), false);
},
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type'],
exposedHeaders: ['Content-Range'],
credentials: false,
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidUnknownValues: false,
exceptionFactory: (errors) =>
new BadRequestException(buildValidationMessages(errors)),
}),
);
app.useGlobalFilters(new ApiExceptionFilter());
const port = configService.get('PORT', 3000);
await app.listen(port);
}
bootstrap();

View File

@@ -1,931 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "KIS-TOiR API",
"description": "Equipment maintenance management system. Generated from domain/toir.api.dsl via tools/api-summary-to-openapi.mjs.",
"version": "1.0.0"
},
"servers": [
{
"url": "/api",
"description": "Default server"
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"Equipment": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"inventoryNumber": {
"type": "string",
"description": "Инвентарный номер"
},
"serialNumber": {
"type": "string",
"description": "Заводской (серийный) номер"
},
"name": {
"type": "string",
"description": "Наименование единицы оборудования"
},
"equipmentTypeCode": {
"type": "string",
"description": "Код вида оборудования"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/EquipmentStatus"
}
],
"description": "Текущий статус"
},
"location": {
"type": "string",
"description": "Место эксплуатации / скважина / куст"
},
"commissionedAt": {
"type": "string",
"format": "date-time",
"description": "Дата ввода в эксплуатацию"
},
"totalEngineHours": {
"type": "string",
"format": "decimal",
"description": "Общая наработка, моточасов"
},
"engineHoursSinceLastRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка с последнего ремонта, моточасов"
},
"lastRepairAt": {
"type": "string",
"format": "date-time",
"description": "Дата последнего ремонта"
},
"notes": {
"type": "string",
"description": "Примечания"
}
},
"description": "Оборудование — полный объект ответа"
},
"EquipmentCreate": {
"type": "object",
"properties": {
"inventoryNumber": {
"type": "string",
"description": "Инвентарный номер"
},
"serialNumber": {
"type": "string",
"description": "Заводской (серийный) номер"
},
"name": {
"type": "string",
"description": "Наименование единицы оборудования"
},
"equipmentTypeCode": {
"type": "string",
"description": "Код вида оборудования"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/EquipmentStatus"
}
],
"description": "Текущий статус"
},
"location": {
"type": "string",
"description": "Место эксплуатации / скважина / куст"
},
"commissionedAt": {
"type": "string",
"format": "date-time",
"description": "Дата ввода в эксплуатацию"
},
"totalEngineHours": {
"type": "string",
"format": "decimal",
"description": "Общая наработка, моточасов"
},
"engineHoursSinceLastRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка с последнего ремонта, моточасов"
},
"lastRepairAt": {
"type": "string",
"format": "date-time",
"description": "Дата последнего ремонта"
},
"notes": {
"type": "string",
"description": "Примечания"
}
},
"description": "Оборудование — тело запроса на создание",
"required": [
"inventoryNumber",
"name",
"equipmentTypeCode"
]
},
"EquipmentUpdate": {
"type": "object",
"properties": {
"inventoryNumber": {
"type": "string",
"description": "Инвентарный номер"
},
"serialNumber": {
"type": "string",
"description": "Заводской (серийный) номер"
},
"name": {
"type": "string",
"description": "Наименование единицы оборудования"
},
"equipmentTypeCode": {
"type": "string",
"description": "Код вида оборудования"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/EquipmentStatus"
}
],
"description": "Текущий статус"
},
"location": {
"type": "string",
"description": "Место эксплуатации / скважина / куст"
},
"commissionedAt": {
"type": "string",
"format": "date-time",
"description": "Дата ввода в эксплуатацию"
},
"totalEngineHours": {
"type": "string",
"format": "decimal",
"description": "Общая наработка, моточасов"
},
"engineHoursSinceLastRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка с последнего ремонта, моточасов"
},
"lastRepairAt": {
"type": "string",
"format": "date-time",
"description": "Дата последнего ремонта"
},
"notes": {
"type": "string",
"description": "Примечания"
}
},
"description": "Оборудование — тело запроса на обновление (частичное)"
},
"EquipmentListRequest": {
"type": "object",
"properties": {
"filters": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DTO.Filter"
}
},
"page": {
"$ref": "#/components/schemas/DTO.PageRequest"
}
},
"description": "Оборудование — запрос постраничного списка с фильтрацией"
},
"EquipmentListResponse": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Equipment"
}
},
"page": {
"$ref": "#/components/schemas/DTO.PageInfo"
}
},
"description": "Оборудование — постраничный результат"
},
"RepairOrder": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"number": {
"type": "string",
"description": "Номер заявки"
},
"equipmentId": {
"type": "string",
"format": "uuid",
"description": "Идентификатор оборудования"
},
"repairKind": {
"allOf": [
{
"$ref": "#/components/schemas/RepairKind"
}
],
"description": "Вид ремонта"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/RepairOrderStatus"
}
],
"description": "Статус заявки"
},
"plannedAt": {
"type": "string",
"format": "date-time",
"description": "Плановая дата начала"
},
"startedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата начала"
},
"completedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата завершения"
},
"contractor": {
"type": "string",
"description": "Подрядная организация (если внешний ремонт)"
},
"engineHoursAtRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка на момент ремонта, моточасов"
},
"description": {
"type": "string",
"description": "Описание работ / дефекта"
},
"notes": {
"type": "string",
"description": "Примечания"
},
"confirmed": {
"type": "boolean",
"description": "Согласовано/Не согласовано"
}
},
"description": "Заявка на ремонт — полный объект ответа"
},
"RepairOrderCreate": {
"type": "object",
"properties": {
"number": {
"type": "string",
"description": "Номер заявки"
},
"equipmentId": {
"type": "string",
"format": "uuid",
"description": "Идентификатор оборудования"
},
"repairKind": {
"allOf": [
{
"$ref": "#/components/schemas/RepairKind"
}
],
"description": "Вид ремонта"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/RepairOrderStatus"
}
],
"description": "Статус заявки"
},
"plannedAt": {
"type": "string",
"format": "date-time",
"description": "Плановая дата начала"
},
"startedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата начала"
},
"completedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата завершения"
},
"contractor": {
"type": "string",
"description": "Подрядная организация (если внешний ремонт)"
},
"engineHoursAtRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка на момент ремонта, моточасов"
},
"description": {
"type": "string",
"description": "Описание работ / дефекта"
},
"notes": {
"type": "string",
"description": "Примечания"
},
"confirmed": {
"type": "boolean",
"description": "Согласовано/Не согласовано"
}
},
"description": "Заявка на ремонт — тело запроса на создание",
"required": [
"number",
"equipmentId",
"repairKind",
"plannedAt"
]
},
"RepairOrderUpdate": {
"type": "object",
"properties": {
"number": {
"type": "string",
"description": "Номер заявки"
},
"equipmentId": {
"type": "string",
"format": "uuid",
"description": "Идентификатор оборудования"
},
"repairKind": {
"allOf": [
{
"$ref": "#/components/schemas/RepairKind"
}
],
"description": "Вид ремонта"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/RepairOrderStatus"
}
],
"description": "Статус заявки"
},
"plannedAt": {
"type": "string",
"format": "date-time",
"description": "Плановая дата начала"
},
"startedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата начала"
},
"completedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата завершения"
},
"contractor": {
"type": "string",
"description": "Подрядная организация (если внешний ремонт)"
},
"engineHoursAtRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка на момент ремонта, моточасов"
},
"description": {
"type": "string",
"description": "Описание работ / дефекта"
},
"notes": {
"type": "string",
"description": "Примечания"
},
"confirmed": {
"type": "boolean",
"description": "Согласовано/Не согласовано"
}
},
"description": "Заявка на ремонт — тело запроса на обновление (частичное)"
},
"RepairOrderListRequest": {
"type": "object",
"properties": {
"filters": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DTO.Filter"
}
},
"page": {
"$ref": "#/components/schemas/DTO.PageRequest"
}
},
"description": "Заявка на ремонт — запрос постраничного списка с фильтрацией"
},
"RepairOrderListResponse": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RepairOrder"
}
},
"page": {
"$ref": "#/components/schemas/DTO.PageInfo"
}
},
"description": "Заявка на ремонт — постраничный результат"
},
"EquipmentStatus": {
"type": "string",
"x-dsl-enum": "EquipmentStatus",
"description": "Enum: EquipmentStatus (values defined in domain/*.api.dsl)"
},
"DTO.Filter": {
"type": "string",
"x-dsl-enum": "DTO.Filter",
"description": "Enum: DTO.Filter (values defined in domain/*.api.dsl)"
},
"DTO.PageRequest": {
"type": "string",
"x-dsl-enum": "DTO.PageRequest",
"description": "Enum: DTO.PageRequest (values defined in domain/*.api.dsl)"
},
"DTO.PageInfo": {
"type": "string",
"x-dsl-enum": "DTO.PageInfo",
"description": "Enum: DTO.PageInfo (values defined in domain/*.api.dsl)"
},
"RepairKind": {
"type": "string",
"x-dsl-enum": "RepairKind",
"description": "Enum: RepairKind (values defined in domain/*.api.dsl)"
},
"RepairOrderStatus": {
"type": "string",
"x-dsl-enum": "RepairOrderStatus",
"description": "Enum: RepairOrderStatus (values defined in domain/*.api.dsl)"
}
}
},
"paths": {
"/equipment/page": {
"post": {
"summary": "Постраничный список оборудования с фильтрацией",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"оборудованием"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EquipmentListRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EquipmentListResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/equipment/{id}": {
"get": {
"summary": "Получить оборудование по идентификатору",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"оборудованием"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Equipment"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"put": {
"summary": "Обновить единицу оборудования",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"оборудованием"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EquipmentUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"delete": {
"summary": "Удалить единицу оборудования",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"оборудованием"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"204": {
"description": "No content"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not found"
}
}
}
},
"/equipment": {
"post": {
"summary": "Создать единицу оборудования",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"оборудованием"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EquipmentCreate"
}
}
}
},
"responses": {
"201": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/repair-orders/page": {
"post": {
"summary": "Постраничный список заявок на ремонт с фильтрацией",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"заявками на ремонт"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepairOrderListRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepairOrderListResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/repair-orders/{id}": {
"get": {
"summary": "Получить заявку на ремонт по идентификатору",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"заявками на ремонт"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepairOrder"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"put": {
"summary": "Обновить заявку на ремонт",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"заявками на ремонт"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepairOrderUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"delete": {
"summary": "Удалить заявку на ремонт",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"заявками на ремонт"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"204": {
"description": "No content"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not found"
}
}
}
},
"/repair-orders": {
"post": {
"summary": "Создать заявку на ремонт",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"заявками на ремонт"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepairOrderCreate"
}
}
}
},
"responses": {
"201": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
}
}
}

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";

View File

@@ -2,12 +2,10 @@
"name": "toir-generation-context", "name": "toir-generation-context",
"private": true, "private": true,
"scripts": { "scripts": {
"generate:api-summary": "node tools/generate-api-summary.mjs", "pin:deps": "node tools/pin-package-versions.mjs server && node tools/pin-package-versions.mjs client",
"generate:openapi": "node tools/api-summary-to-openapi.mjs --out openapi.json", "generate:domain-summary": "node tools/generate-domain-summary.mjs",
"validate:generation": "node tools/validate-generation.mjs", "validate:generation": "node tools/validate-generation.mjs",
"validate:generation:runtime": "node tools/validate-generation.mjs --run-runtime", "validate:generation:runtime": "node tools/validate-generation.mjs --run-runtime",
"validate:generation:artifacts": "node tools/validate-generation.mjs --artifacts-only", "validate:generation:artifacts": "node tools/validate-generation.mjs --artifacts-only"
"eval:generation": "node tools/eval/run-evals.mjs",
"install-hooks": "node tools/install-hooks.mjs"
} }
} }

View File

@@ -1,95 +1,79 @@
# Auth Rules # Auth Rules
<!-- prompt-version: 2.0 --> This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline.
<!-- applies-to: client/src/auth/, server/src/auth/, toir-realm.json -->
<!-- validated-by: tools/validate-generation.mjs §validateAuthChecks §validateRealmChecks -->
Use this document during the **Auth / Runtime / Realm** stage defined in `prompts/general-prompt.md`. - 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.
## Purpose ## Frontend auth invariants
Generate and preserve the auth contracts that let the CRUD app run as a React Admin SPA backed by a NestJS API protected by external Keycloak. - 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.
## Mandatory Inputs ## Working runtime defaults
- `prompts/general-prompt.md` Use the already working project defaults unless a prompt explicitly overrides them.
- `prompts/runtime-rules.md`
- current repository auth/runtime defaults
## Expected Outputs - Frontend Keycloak base URL example:
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
- `client/src/auth/` - Frontend realm and client example:
- `client/src/dataProvider.ts` - `VITE_KEYCLOAK_REALM=toir`
- `server/src/auth/` - `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
- `toir-realm.json` - Backend issuer and audience example:
- `KEYCLOAK_ISSUER_URL=https://sso.greact.ru/realms/toir`
## Frontend Auth Invariants - `KEYCLOAK_AUDIENCE=toir-backend`
- CORS example:
- use `keycloak-js` with redirect-based login only - `CORS_ALLOWED_ORIGINS=http://localhost:5173,https://toir-frontend.greact.ru`
- initialize Keycloak before rendering the SPA
- use Authorization Code Flow + PKCE (`S256`)
- keep `authProvider`, `dataProvider`, `getIdentity()`, `getPermissions()`, and `checkError()` as stable seams
- derive identity from token claims already present in the token
- do not call `loadUserProfile()`
- `401` forces re-authentication; `403` remains an authorization error
- keep token handling in memory with one shared in-flight refresh path
## Backend Auth Invariants
- verify JWTs with `jose`
- validate issuer, audience, and 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
- generated CRUD routes stay protected by default
## Working Runtime Defaults
Keep these defaults unless a task explicitly overrides them:
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
- `VITE_KEYCLOAK_REALM=toir`
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
- `KEYCLOAK_ISSUER_URL=https://sso.greact.ru/realms/toir`
- `KEYCLOAK_AUDIENCE=toir-backend`
- `CORS_ALLOWED_ORIGINS=http://localhost:5173,https://toir-frontend.greact.ru`
Anti-regression rule: Anti-regression rule:
- do not revert shared examples to localhost Keycloak defaults unless a task explicitly requests a local Keycloak baseline - 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.
## Realm Artifact Contract ## Backend auth invariants
The root realm artifact is mandatory and must: - 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.
- be importable and versioned ## Auth anti-regression invariants
- align with generated frontend/backend env contracts
- parameterize: - 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 - realm name
- frontend client id - frontend client id
- backend client id / audience - backend client id / audience
- local and production frontend URLs - local and production frontend URLs
- artifact filename - artifact filename
- explicitly deliver: - It must explicitly deliver:
- `sub` - `sub`
- `aud` - `aud`
- `realm_access.roles` - `realm_access.roles`
- define: - It must define:
- realm roles `admin`, `editor`, `viewer` - realm roles `admin`, `editor`, `viewer`
- a public SPA client with PKCE S256 - a public SPA client with PKCE S256
- a bearer-only backend client - a bearer-only backend client
- an explicit audience client scope - an explicit audience client scope
- protocol mappers for baseline identity and role claims - explicit protocol mappers for baseline identity and role claims
## Completion Expectations
Auth/runtime generation is incomplete if any of the following is true:
- frontend and backend auth seams drift from each other
- JWKS resolution order changes
- `/health` stops being public
- shared Keycloak defaults regress to localhost examples
- the realm artifact no longer matches backend/frontend expectations

View File

@@ -1,147 +1,88 @@
# Backend Rules # Backend Rules
<!-- prompt-version: 2.0 --> The backend remains derived from `domain/*.dsl` inside the existing LLM-first pipeline. No compiler platform or generator engine is introduced.
<!-- applies-to: server/src/modules/, server/src/app.module.ts -->
<!-- validated-by: tools/validate-generation.mjs §validateApiDslCoverage §validateNaturalKeyChecks -->
Use this document during the **Backend** stage defined in `prompts/general-prompt.md`. ## Backend scaffold baseline
## Purpose - 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`.
Generate NestJS CRUD artifacts that match the DSL contract exactly and remain compatible with a standard NestJS workspace. - Preserve the core Nest workspace files generated by the CLI, especially:
## Mandatory Inputs
- `prompts/general-prompt.md`
- the active `api API.<Entity>` block from `domain/toir.api.dsl`
- referenced DTOs and enums from `domain/toir.api.dsl`
- an intact or repaired official NestJS scaffold under `server/`
`api-summary.json` may be consulted only as an auxiliary inventory or validator-related artifact. It must never replace the DSL as the backend source of truth.
## Expected Outputs
Per entity:
- `server/src/modules/<kebab>/<kebab>.module.ts`
- `server/src/modules/<kebab>/<kebab>.controller.ts`
- `server/src/modules/<kebab>/<kebab>.service.ts`
- `server/src/modules/<kebab>/dto/create-<kebab>.dto.ts`
- `server/src/modules/<kebab>/dto/update-<kebab>.dto.ts`
Repository-wide:
- `server/src/app.module.ts` registrations
## Scaffold Baseline
- Start backend initialization and repair from the official NestJS CLI workspace, not from hand-written files.
- Preserve Nest workspace essentials:
- `server/tsconfig.json` - `server/tsconfig.json`
- `server/tsconfig.build.json` - `server/tsconfig.build.json`
- `server/nest-cli.json` - `server/nest-cli.json`
- `server/src/main.ts` - `server/src/main.ts`
- `server/src/app.module.ts` - `server/src/app.module.ts`
- If the workspace is degraded, repair it before generating domain code. - 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 patterns: ## Forbidden backend generation patterns
- hand-written pseudo-Nest scaffolds - Do not bootstrap `server/` by hand-writing a pseudo-Nest project from memory.
- deleting required Nest config files after generation - Do not remove `tsconfig.json`, `tsconfig.build.json`, or `nest-cli.json` after generation.
- replacing normal Nest build/start behavior with ad hoc scripts - 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.
## Route And Resource Contract ## 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
- Use the shared entity-to-resource naming convention from `prompts/general-prompt.md`.
- Each entity becomes a NestJS module, controller, service, and create/update DTO pair.
- CRUD routes use the real primary key name in the path. - CRUD routes use the real primary key name in the path.
- Every API record returned to React Admin must include `id`. - Every API record returned to React Admin must include `id`.
- For natural-key entities, map the real primary key to `id` in responses and sort translation. - 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`.
## DTO Contract ## Service invariants
- `DTO.<Entity>Create` defines `Create<Entity>Dto`. - Never pass raw update DTOs into Prisma update `data`.
- `DTO.<Entity>Update` defines `Update<Entity>Dto`. - Remove `id`, the real primary key, and readonly fields from update payloads before calling Prisma.
- Do not invent fields or pull field lists from memory. - Keep PrismaService lightweight:
- Never include `id` in Create/Update DTOs.
Type and decorator rules:
| DSL type | TS DTO type | class-validator decorator | Notes |
|-----------|-------------|---------------------------|-------|
| `uuid` | `string` | `@IsUUID()` | |
| `string` | `string` | `@IsString()` | |
| `text` | `string` | `@IsString()` | |
| `integer` | `number` | `@IsInt()` | |
| `number` | `number` | `@IsNumber()` | |
| `decimal` | `string` | `@IsString()` | serialize with Prisma Decimal |
| `date` | `string` | `@IsString()` | serialize as ISO string |
| `boolean` | `boolean` | `@IsBoolean()` | |
| enum name | `string` | `@IsEnum(EnumName)` | |
Nullability rules:
- every field that is not `is required` gets `@IsOptional()` before the type decorator
- every generated DTO imports from `'class-validator'`
## Controller Contract
- Apply `@UseGuards(JwtAuthGuard, RolesGuard)` at controller class level.
- Roles per verb:
- `GET` -> `viewer | editor | admin`
- `POST`, `PATCH`, `PUT` -> `editor | admin`
- `DELETE` -> `admin`
- Reconcile DSL HTTP shapes for repository compatibility:
- list endpoints declared as `POST .../page` generate as `@Get()` with React Admin query params
- update endpoints declared as `PUT` generate as `@Patch(':<pk>')`
- Path parameters are taken from the DSL endpoint contract, not invented from generic CRUD memory.
## Service Contract
- Never pass raw update DTOs directly into Prisma update `data`.
- Strip `id`, the real primary key, and readonly fields before writes.
- Keep `PrismaService` lightweight:
- extend `PrismaClient` - extend `PrismaClient`
- implement `OnModuleInit` - implement `OnModuleInit`
- call `$connect()` - call `$connect()`
- do not add `beforeExit` - do not use `beforeExit`
List endpoint requirements: ## Filtering contract
- accept React Admin query params: `_start`, `_end`, `_sort`, `_order`, `q` - List endpoints must support React Admin query parameters:
- set `Content-Range` - `_start`, `_end`, `_sort`, `_order`
- set `Access-Control-Expose-Headers: Content-Range` - 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`.
Filtering rules: ## Reproducibility invariants
- string/text filters may use case-insensitive `contains` - A freshly generated backend must be bootstrappable with ordinary Nest + Prisma commands from `prompts/runtime-rules.md`.
- foreign-key scalar filters must use exact-match semantics - Missing TypeScript or Nest workspace config is a generation failure, not an acceptable simplification.
- enum filters must support both single and repeated params - The baseline backend should fail only on missing runtime dependencies or env values, not because the Nest workspace itself is incomplete.
- repeated enum params must map to Prisma `{ in: [...] }`
- `_sort=id` must map to the real primary key for natural-key entities
Decimal and date handling: ## Recovery rule if backend workspace degraded
- `decimal` writes: `new Prisma.Decimal(value)` - If required Nest scaffold files are missing or broken, restore the official workspace baseline before editing Prisma models, modules, controllers, services, or DTOs.
- `decimal` reads: `.toString()` - Treat workspace repair as higher priority than feature generation, because generated domain code on top of a broken workspace is invalid baseline output.
- `date` writes: `new Date(value)`
- `date` reads: `.toISOString()`
## Natural-Key Rules ## Backend auth defaults
For entities whose physical primary key is not `id`: - `GET` -> `viewer | editor | admin`
- `POST`, `PATCH`, `PUT` -> `editor | admin`
- route params use the real primary key name - `DELETE` -> `admin`
- responses expose `id` mapped from that primary key
- sort/update behavior never targets a fake physical `id`
- update payload sanitization removes both `id` and the real primary key
## Completion Expectations
Backend generation is incomplete if any of the following is true:
- required Nest scaffold files are missing
- DTO decorators are incomplete or type-incorrect
- controllers are missing guards or role decorators
- natural-key handling regresses to a fake physical `id`
- list/filter behavior is incompatible with React Admin expectations

View File

@@ -1,118 +1,65 @@
# Frontend Rules # Frontend Rules
<!-- prompt-version: 2.0 --> The frontend stays a React Admin SPA generated from `domain/*.dsl` and anchored to the existing auth seams.
<!-- applies-to: client/src/resources/, client/src/App.tsx -->
<!-- validated-by: tools/validate-generation.mjs §validateApiDslCoverage -->
Use this document during the **Frontend** stage defined in `prompts/general-prompt.md`. ## Frontend scaffold baseline
## Purpose - Start frontend initialization from the official Vite React TypeScript scaffold, not from manually assembled files.
- Preserve a valid Vite workspace baseline, including:
Generate React Admin resources that stay aligned with the DSL contract, the backend contract, and the repository auth/data provider seams.
## Mandatory Inputs
- `prompts/general-prompt.md`
- the active `api API.<Entity>` block from `domain/toir.api.dsl`
- referenced DTOs and enums from `domain/toir.api.dsl`
- an intact or repaired official Vite React TypeScript scaffold under `client/`
## Expected Outputs
Per entity:
- `client/src/resources/<kebab>/<Entity>List.tsx`
- `client/src/resources/<kebab>/<Entity>Create.tsx`
- `client/src/resources/<kebab>/<Entity>Edit.tsx`
- `client/src/resources/<kebab>/<Entity>Show.tsx`
Repository-wide:
- `client/src/App.tsx` resource registrations
## Scaffold Baseline
- Start frontend initialization and repair from the official Vite React TypeScript scaffold, not from a hand-written shell.
- Preserve workspace essentials:
- `client/index.html` - `client/index.html`
- `client/tsconfig.json` - `client/tsconfig.json`
- `client/vite.config.*` - `client/vite.config.*`
- `client/src/main.tsx` - `client/src/main.tsx`
- Repair the scaffold before generating resources if it is degraded. - 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.
## Resource Contract ## Forbidden frontend generation patterns
- Use the shared entity-to-resource naming convention from `prompts/general-prompt.md`. - Do not bootstrap `client/` by hand-writing a pseudo-Vite project from memory.
- Every entity becomes a React Admin resource with `list`, `create`, `edit`, and `show`. - Do not remove `index.html`, `tsconfig*`, or `vite.config.*` after generation.
- `Resource` registration in `client/src/App.tsx` must include `show={...}`. - Do not replace standard Vite package scripts with ad hoc commands that break `vite build`, `vite dev`, or `vite preview`.
- Every frontend record must work with React Admin's `id` contract, including natural-key entities. - Do not continue React Admin resource generation on top of a degraded frontend workspace without repairing the workspace first.
DTO-driven view rules: ## Resource generation
- List and Show views use fields from `DTO.<Entity>` - Each entity becomes a React Admin resource with list/create/edit/show views.
- Create view uses fields from `DTO.<Entity>Create` - Resource names must stay aligned with backend path segments.
- Edit view uses fields from `DTO.<Entity>Update` - Foreign keys must use `ReferenceInput` / `ReferenceField`.
- Do not derive form fields directly from model attributes when the DTO contract is narrower - 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`
## Input And Field Mapping ## Provider seams
Form inputs: - `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.
- `integer`, `number`, `decimal` -> `NumberInput` ## Identity and permissions
- `date` -> `DateInput`
- required `boolean` -> `BooleanInput`
- nullable `boolean` -> `NullableBooleanInput`
- enum -> `SelectInput`
- FK reference -> `ReferenceInput` + `AutocompleteInput`
Display fields: - `getIdentity()` must resolve from parsed token claims.
- `getPermissions()` may expose realm roles for UI awareness.
- Backend enforcement remains authoritative.
- `integer`, `number`, `decimal` -> `NumberField` ## React Admin compatibility
- `date` -> `DateField`
- `boolean` -> `BooleanField`
- enum -> `SelectField`
- FK reference -> `ReferenceField`
Hard failure rule: - 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.
- using plain `TextInput` for `integer`, `number`, `decimal`, `date`, or `boolean` is a generation failure ## Reproducibility invariants
## Filter And Reference Contract - 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.
- Lists must expose filters and include a toolbar with `FilterButton`. ## Recovery rule if frontend workspace degraded
- Enum multi-select filters use `SelectArrayInput`.
- Reference filters and form selectors use `ReferenceInput` + `AutocompleteInput` with `filterToQuery={(searchText) => ({ q: searchText })}`.
- FK list/show rendering must use `ReferenceField link=\"show\"`.
- `dataProvider` query serialization must preserve repeated params for array filters.
Reference display expression priority: - 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.
1. if `inventoryNumber` exists: ``(record) => `${record.inventoryNumber} — ${record.name ?? record.inventoryNumber}``
2. else if `code` exists: ``(record) => `${record.code} — ${record.name ?? record.code}``
3. else if `number` exists: ``(record) => `${record.number} — ${record.name ?? record.number}``
4. else if `name` exists: `(record) => record.name ?? record.id`
5. else: `(record) => record.id`
## Auth And Provider Seams
- `client/src/dataProvider.ts` remains the single authenticated request seam.
- `client/src/auth/authProvider.ts` remains the single React Admin auth seam.
- Resource components must not embed auth logic.
- `getIdentity()` resolves from token claims.
- `getPermissions()` may expose realm roles for UI awareness, but backend enforcement stays authoritative.
## Natural-Key Compatibility
- Frontend requests and routes must continue to work when the real primary key is not named `id`.
- Edit/show/delete flows must preserve compatibility with backend natural-key handling.
- Sorting and filtering assumptions must not regress to a fake physical `id`.
## Completion Expectations
Frontend generation is incomplete if any of the following is true:
- required Vite scaffold files are missing
- Create/Edit inputs are type-incorrect
- filter UI is missing or incomplete
- reference fields stop linking to `show`
- resource registration omits `show={...}`

View File

@@ -1,298 +1,147 @@
<!-- prompt-version: 3.0 --> ROLE
<!-- applies-to: all generation tasks (master orchestration prompt) -->
<!-- validated-by: tools/validate-generation.mjs + npm run eval:generation -->
# Role You are a Staff-level Fullstack Platform Engineer working inside the established LLM-first CRUD generation baseline.
You are the master orchestrator of the KIS-TOiR generation pipeline. Use context7 when official framework guidance is needed.
Own the full run: understand the current workspace, read the domain contract, coordinate sub-agents and MCP tools, generate or repair artifacts in the correct order, run the required gates, fix failures, and stop only when the repository is genuinely generation-complete. This repository is not a new generator engine. Do not redesign it into a planner/emitter/runtime platform.
# Project Description GOAL
KIS-TOiR is an LLM-first fullstack CRUD generation project for equipment maintenance management. Strengthen and use the existing LLM-first CRUD generation pipeline.
- Backend: NestJS + Prisma - Keep `domain/*.dsl` as the source of truth for the domain model.
- Frontend: Vite React TypeScript + React Admin - Keep `server/` as the active backend target output path.
- Auth/runtime: external Keycloak + PostgreSQL + repository-managed env/runtime artifacts - 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
The repository is intentionally prompt-driven. `prompts/*.md` define generation policy; generated code lives under `server/` and `client/`. Use the already working runtime defaults for this project unless the prompt explicitly overrides them:
# Mission - 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`
Turn the repository source contract into a buildable, validated workspace by: Do not silently regress these examples to localhost Keycloak defaults.
1. starting from official framework scaffolding when a workspace is missing or degraded ACTIVE KNOWLEDGE BLOCKS
2. generating Prisma, backend, frontend, auth, runtime, and realm artifacts from the DSL
3. using sub-agents intentionally instead of carrying every concern in one context window
4. proving completion with builds and repository validation gates
# Source Of Truth Read in this order:
`domain/toir.api.dsl` is the operative source of truth for generation runs. 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`
It is authoritative for: Interpretation rules:
- entities and enums - `domain/*.dsl` is authoritative.
- DTO shapes per operation - `domain-summary.json` is derived. Regenerate or validate it against the DSL; never treat it as the source of truth.
- nullability and requiredness INPUT CONTRACT
- primary and foreign keys
- HTTP methods, endpoint paths, and pagination contracts Required:
- `domain/*.dsl`
Optional:
- `overrides/api-overrides.dsl`
- `overrides/ui-overrides.dsl`
Rules: Rules:
- Read the DSL directly. Do not substitute `api-summary.json` for `domain/toir.api.dsl`. - Do not require DTO/API/UI DSL files.
- Work from entity-scoped slices: the active `api API.<Entity>` block plus its referenced DTOs and enums. - Do not resurrect multi-DSL source-of-truth behavior.
- Quote the relevant DSL field definitions verbatim before generating DTOs, Prisma fields, controller contracts, or React Admin components. - Optional overrides may refine derived API/UI behavior but must not redefine the domain model.
- Treat `api-summary.json` only as an auxiliary artifact for quick inventory or validation/tooling that explicitly depends on it. It is never the authoritative generation input.
# Orchestration Model PIPELINE CONTRACT
Use a manager-first, agent-as-tool architecture. 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.
- Keep one orchestrator in charge of planning, sequencing, integration, and final acceptance. REPAIR-BEFORE-GENERATE ORDER
- Delegate bounded work to specialists; do not let sub-agents redefine the source hierarchy or completion criteria.
- Delegate by stage or artifact family, and by entity when parallelism helps.
- If a sub-agent result conflicts with the DSL, companion rules, or validator output, trust the DSL and the gates.
Mandatory delegation pattern for future runs: 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.
- `explorer` CLI-FIRST SCAFFOLDING CONTRACT
Use first for repo discovery, scaffold inspection, locating entity-scoped DSL context, and finding existing registrations/seams.
- `docs_researcher`
Use when framework behavior, CLI scaffolding, or prompt/orchestration patterns need verification against official docs or Context7.
- stage worker / generator
Use for bounded Prisma, backend, frontend, or auth/runtime implementation work after the orchestrator has assembled the right inputs.
- `reviewer`
Use before declaring completion. Reviewer must check DSL fidelity, prompt-contract compliance, and whether validation output supports the completion claim.
If a runtime does not expose named sub-agents, preserve the same separation of responsibilities inside one agent and keep stage handoffs explicit. - 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.
# MCP Usage Model ANTI-REGRESSION FAILURES
Use MCP/tools deliberately, not reflexively. Treat the following as baseline violations, not acceptable improvisations:
- Filesystem/search tools: gather exact local context before making decisions. - creating a NestJS workspace by manually writing `package.json`, `tsconfig*`, `nest-cli.json`, and `src/*` from memory instead of starting from official CLI conventions
- Shell/runtime tools: run official CLI scaffolding, Prisma commands, builds, validators, and evals. Do not simulate command results from memory. - 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
- Context7: verify current NestJS, Prisma, React Admin, Vite, Keycloak, or prompt/orchestration guidance when repository docs are not enough. - deleting required framework scaffold files after generation because the app appears to work with a smaller custom structure
- Web research: only after local files and Context7 are insufficient; prefer primary sources. - declaring generation successful when workspace validity or buildability is broken or unverified
- Diff/validation tools: use before edits, after edits, and always at the end. - 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`
Tool-order policy: OUTPUT CONTRACT
1. local authoritative files The baseline output must include:
2. Context7 / official docs
3. web fallback
4. validation gates
# Generation Roadmap
## 1. Preparation / Discovery
Purpose:
- establish the active scope
- verify scaffold health
- load only the context needed for the next stage
Responsible:
- orchestrator
- `explorer` first
- `docs_researcher` if scaffold conventions or framework behavior are uncertain
Mandatory inputs:
- `AGENTS.md`
- `prompts/general-prompt.md`
- `domain/toir.api.dsl`
- `prompts/runtime-rules.md`
- `.codex/AGENTS.md` and `.codex/agents/*.toml` when the runtime supports those agents
Expected outputs:
- entity-scoped DSL quotes for the active work
- a clean stage plan
- `server/` and `client/` confirmed healthy or repaired from official scaffolding
Handoff:
- proceed to Prisma only after the repository has a valid NestJS workspace, Vite React TypeScript workspace, or a documented repair plan using official CLIs
Stage rules:
- Use official Nest CLI for initial backend workspace creation or repair.
- Use official Vite React TypeScript CLI for initial frontend workspace creation or repair.
- Use Prisma CLI for Prisma initialization when relevant.
- Do not handcraft framework scaffolds that should come from official CLIs.
## 2. Prisma
Purpose:
- generate the repository schema that reflects the DSL exactly
Responsible:
- orchestrator
- Prisma-focused stage worker
- `docs_researcher` when Prisma behavior is uncertain
Mandatory inputs:
- entity-scoped DSL quotes from `domain/toir.api.dsl`
- `prompts/prisma-rules.md`
Expected outputs:
- `server/prisma/schema.prisma` - `server/prisma/schema.prisma`
- Prisma initialization or repair steps completed when the workspace was missing required baseline files
Handoff:
- backend generation starts only after schema output reflects the DSL and Prisma setup is coherent with runtime rules
## 3. Backend
Purpose:
- generate NestJS modules, controllers, services, DTOs, and module registration from the DSL contract
Responsible:
- orchestrator
- backend stage worker, ideally one entity at a time when parallelized
Mandatory inputs:
- entity-scoped DSL quotes from `domain/toir.api.dsl`
- `prompts/backend-rules.md`
Expected outputs:
- `server/src/modules/<entity>/...`
- `server/src/app.module.ts`
Handoff:
- frontend generation starts only after backend contracts, guards, DTOs, and natural-key behavior align with backend rules
## 4. Frontend
Purpose:
- generate React Admin resources and resource registration that match backend and DSL contracts
Responsible:
- orchestrator
- frontend stage worker, ideally one entity at a time when parallelized
Mandatory inputs:
- entity-scoped DSL quotes from `domain/toir.api.dsl`
- `prompts/frontend-rules.md`
Expected outputs:
- `client/src/resources/<entity>/...`
- `client/src/App.tsx`
Handoff:
- auth/runtime integration starts only after frontend resource contracts align with DTO-derived field sets and type mappings
## 5. Auth / Runtime / Realm Artifacts
Purpose:
- wire authentication, environment defaults, realm import, and runtime topology around the generated CRUD app
Responsible:
- orchestrator
- auth/runtime stage worker
- `docs_researcher` when Keycloak or framework integration behavior is uncertain
Mandatory inputs:
- `prompts/auth-rules.md`
- `prompts/runtime-rules.md`
Expected outputs:
- `server/src/auth/`
- `client/src/auth/`
- `client/src/dataProvider.ts`
- `server/.env.example` - `server/.env.example`
- `client/.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` - `docker-compose.yml`
- `toir-realm.json` - `domain-summary.json`
- root-level `*-realm.json`
Handoff: The baseline output must also remain a real framework workspace, not a prompt-only file collection:
- verification starts only after auth seams, runtime artifacts, and realm output are aligned with backend/frontend expectations - `server/tsconfig.json`
- `server/tsconfig.build.json`
- `server/nest-cli.json`
- `client/index.html`
- `client/tsconfig.json`
- `client/vite.config.*`
## 6. Verification / Success Gate NON-GOALS
Purpose: - No new generator engine
- No compiler/IR platform
- No heavy codegen redesign
- No replacement of the old LLM-first architecture
- prove that the generation run is complete and not just plausible COMPLETION INVARIANTS
Responsible: - 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.
- Generation is incomplete if the **API error contract** (ValidationPipe + `ApiExceptionFilter` + client `dataProvider` + DSL-derived `field-labels.generated.ts`) drifts from `prompts/validation-rules.md` without an explicit exception.
- orchestrator VALIDATION
- `reviewer` before completion
Mandatory inputs: Before considering the output complete, satisfy `prompts/validation-rules.md`.
- `prompts/validation-rules.md`
- validation command output
- reviewer findings
Expected outputs:
- refreshed auxiliary artifacts if the validator/tooling requires them, including `api-summary.json`
- passing validation gates
- successful backend and frontend builds
Handoff:
- there is no next stage; report complete only when every success criterion below is satisfied
# Success Criteria
Generation is successful only if all of the following are true:
- `server/` exists in the project root
- `client/` exists in the project root
- the backend builds successfully
- the frontend builds successfully
- `node tools/validate-generation.mjs --artifacts-only` passes
- `npm run eval:generation` passes
- required auth/runtime/realm artifacts exist and match their companion rules
- module/resource registrations are complete
- any validator-required auxiliary artifacts, including `api-summary.json`, are refreshed and consistent
- the reviewer has not identified unresolved contract violations
# Non-Goals / Constraints
- Do not edit `domain/toir.api.dsl` during generation.
- Do not treat `api-summary.json` as the source of truth or default starting point.
- Do not inline large backend/frontend/prisma/auth/runtime/validation rule sets into this master prompt; load the companion docs instead.
- Do not generate domain artifacts on top of a broken scaffold when official CLI repair is required.
- Do not claim success from prompt reasoning alone; use builds and repository gates.
- Do not load the full DSL blob when entity-scoped context is enough.
# Companion Rule Documents
These documents are mandatory when their stage is active:
- Prisma stage: `prompts/prisma-rules.md`
- Backend stage: `prompts/backend-rules.md`
- Frontend stage: `prompts/frontend-rules.md`
- Auth / realm stage: `prompts/auth-rules.md`
- Runtime / bootstrap stage: `prompts/runtime-rules.md`
- Verification stage: `prompts/validation-rules.md`
The master prompt owns orchestration. Companion docs own artifact-specific detail.

View File

@@ -1,119 +0,0 @@
# Prisma Rules
<!-- prompt-version: 2.0 -->
<!-- applies-to: server/prisma/schema.prisma -->
<!-- generated-by: LLM following these rules -->
<!-- source-of-truth: domain/toir.api.dsl -->
<!-- validated-by: tools/validate-generation.mjs §validateBuildChecks -->
Use this document during the **Prisma** stage defined in `prompts/general-prompt.md`.
## Purpose
Generate `server/prisma/schema.prisma` as a faithful reflection of `domain/toir.api.dsl`.
## Mandatory Inputs
- `prompts/general-prompt.md`
- the relevant entity/enum definitions from `domain/toir.api.dsl`
- the existing Prisma header if `server/prisma/schema.prisma` already exists
`api-summary.json` may be used only as an auxiliary validator/inventory artifact. It is not part of the authoritative Prisma source hierarchy.
## Expected Output
- `server/prisma/schema.prisma`
Never edit the schema manually during normal generation. Change the DSL and regenerate instead.
## Source Of Truth
Entity definitions, field types, PKs, FKs, enums, optionality, uniqueness, and defaults come from `domain/toir.api.dsl`.
## Scalar Type Mapping
| DSL type | Prisma scalar type |
| --------- | ------------------ |
| `uuid` | `String` |
| `string` | `String` |
| `text` | `String` |
| `integer` | `Int` |
| `number` | `Float` |
| `decimal` | `Decimal` |
| `date` | `DateTime` |
| `boolean` | `Boolean` |
| enum name | enum name as-is |
Unknown DSL types pass through as-is for forward compatibility.
## Primary Key Rules
- a field marked `key primary` becomes `@id`
- if the primary key type is `uuid` or the field name is `id`, use `@id @default(uuid())`
- non-uuid natural keys keep plain `@id`
- every entity must resolve to exactly one primary key
## Optionality And Defaults
- primary keys are required
- fields marked `is required` are required
- all other fields are optional with Prisma `?`
- non-primary unique fields get `@unique`
- `default <value>` maps to Prisma `@default(...)`
## Foreign Key And Relation Rules
For a DSL field declared `key foreign { relates Entity.field }`:
1. emit the FK scalar field first
2. add a relation field named `lowerFirst(relatedEntity)`
3. if that relation name collides, append `Ref`
4. annotate with `@relation(fields: [<fkField>], references: [<referencedField>])`
Inverse array relations:
- add inverse array fields for referencing entities automatically
- pluralization rules:
- `equipment` stays `equipment`
- names ending in `s` add `es`
- all others add `s`
- if the inverse name collides, append `List`
## Enum Rules
- every DSL enum becomes a Prisma enum
- preserve declaration order
- preserve the enum name exactly
## Header Preservation
If `server/prisma/schema.prisma` already contains a `generator client { ... }` block, preserve everything before the first `enum` or `model` keyword.
If no valid header exists, emit:
```prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
```
## Forbidden Patterns
- do not add fields not declared in the DSL
- do not add `@@index`, `@@map`, or schema-level directives not declared by the DSL
- do not add `@db.*` modifiers
- do not change the datasource provider away from `postgresql`
## Completion Expectations
Prisma generation is incomplete if any of the following is true:
- `server/prisma/schema.prisma` does not exist
- the schema no longer reflects the DSL
- required relation fields or inverse arrays are missing
- header generation or preservation breaks Prisma baseline behavior

Some files were not shown because too many files have changed in this diff Show More