10 Commits

Author SHA1 Message Date
MaKarin
6ae61cadb0 replace ports to expose for client container 2026-04-04 21:55:45 +03:00
MaKarin
05946a51b5 push 2026-04-04 20:37:56 +03:00
MaKarin
698bc64d41 push 2026-04-04 20:06:21 +03:00
MaKarin
cf53ab8cf9 update dockerfile's 2026-04-04 19:53:08 +03:00
MaKarin
fed445df7a update compose 2026-04-04 19:49:11 +03:00
MaKarin
89fa65d6c6 update dockerfiles 2026-04-04 19:43:31 +03:00
MaKarin
dd4f37bddc del depens in client container 2026-04-04 19:29:19 +03:00
MaKarin
de770148c9 del db_seed 2026-04-04 19:26:55 +03:00
MaKarin
8c64f612f2 add infra 2026-04-04 19:25:20 +03:00
MaKarin
c42a88dff6 (llm-first): context budget, validation, and eval harness, orchestration general-prompt 2026-04-03 14:17:21 +03:00
191 changed files with 15646 additions and 8963 deletions

108
.codex/AGENTS.md Normal file
View File

@@ -0,0 +1,108 @@
# 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

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

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

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

@@ -0,0 +1,32 @@
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
"""

125
.codex/config.toml Normal file
View File

@@ -0,0 +1,125 @@
#: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"

1
.gitattributes vendored Normal file
View File

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

2
.gitignore vendored
View File

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

216
AGENTS.md Normal file
View File

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

2185
api-summary.json Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS build
FROM node:20-bookworm-slim AS build
WORKDIR /app
@@ -21,7 +21,11 @@ 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,27 +4,70 @@ This template provides a minimal setup to get React working in Vite with HMR and
Currently, two official plugins are available:
- [@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-swc) uses [SWC](https://swc.rs/) for Fast Refresh
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## 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
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:
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// 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...
},
},
}
])
```
- 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`
- 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
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:
```js
// 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...
},
},
])
```

23
client/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
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">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>client</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,27 +1,28 @@
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://toir-server:3000/;
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;
proxy_set_header X-Forwarded-Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 'ok';
}
}

2911
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
client/public/icons.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

184
client/src/App.css Normal file
View File

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

BIN
client/src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,45 +1,44 @@
import { AuthProvider } from 'react-admin';
import {
forceReauthentication,
getIdentity,
getRealmRoles,
getValidAccessToken,
initKeycloak,
logoutFromKeycloak,
} from './keycloak';
import type { AuthProvider } from 'react-admin';
import { getKeycloak, initKeycloak } from './keycloak';
const authProvider: AuthProvider = {
export const authProvider: AuthProvider = {
login: async () => {
await initKeycloak();
await getKeycloak().login();
},
logout: async () => {
await logoutFromKeycloak();
await getKeycloak().logout({ redirectUri: window.location.origin });
},
checkAuth: async () => {
await getValidAccessToken();
await initKeycloak();
if (!getKeycloak().authenticated) {
await getKeycloak().login();
}
},
checkError: async (error) => {
const status = error?.status;
const status = error?.status ?? error?.response?.status;
if (status === 401) {
await forceReauthentication();
getKeycloak().clearToken();
return Promise.reject(error);
}
if (status === 403) {
return Promise.resolve();
return Promise.reject(error);
}
return Promise.resolve();
},
getIdentity: async () => getIdentity(),
getPermissions: async () => getRealmRoles(),
getIdentity: async () => {
await initKeycloak();
const tokenParsed = getKeycloak().tokenParsed as Record<string, unknown> | undefined;
return {
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,96 +1,43 @@
import Keycloak, { KeycloakTokenParsed } from 'keycloak-js';
import Keycloak from 'keycloak-js';
import { env } from '../config/env';
interface RealmAccessTokenParsed extends KeycloakTokenParsed {
realm_access?: {
roles: string[];
};
}
const keycloak = new Keycloak({
url: env.keycloakUrl,
realm: env.keycloakRealm,
clientId: env.keycloakClientId,
});
let keycloakInitPromise: Promise<void> | null = null;
let refreshInFlight: Promise<void> | null = null;
let initPromise: Promise<boolean> | null = null;
let refreshPromise: Promise<string | null> | 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() {
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,24 +1,6 @@
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 = {
apiUrl: readRequiredEnv('VITE_API_URL'),
keycloakUrl: readRequiredEnv('VITE_KEYCLOAK_URL'),
keycloakRealm: readRequiredEnv('VITE_KEYCLOAK_REALM'),
keycloakClientId: readRequiredEnv('VITE_KEYCLOAK_CLIENT_ID'),
} as const;
apiUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:3000',
keycloakUrl: import.meta.env.VITE_KEYCLOAK_URL ?? 'https://sso.greact.ru',
keycloakRealm: import.meta.env.VITE_KEYCLOAK_REALM ?? 'toir',
keycloakClientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'toir-frontend',
};

View File

@@ -1,146 +1,194 @@
import { DataProvider, fetchUtils } from 'react-admin';
import { getValidAccessToken } from './auth/keycloak';
import { env } from './config/env';
import type { DataProvider } from "react-admin";
import { env } from "./config/env";
import { getAccessToken } from "./auth/keycloak";
const apiUrl = env.apiUrl;
async function fetchJson(
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 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}`);
return fetchUtils.fetchJson(url, {
...options,
headers,
});
};
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;
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
const error = new Error(
"Request failed with status " + response.status,
) as Error & { status?: number; body?: unknown };
error.status = response.status;
try {
error.body = await response.json();
} catch {
error.body = null;
}
search.set(key, String(val));
});
return search.toString();
throw error;
}
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 };
}
const dataProvider: DataProvider = {
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 (
env.apiUrl + "/" + resourcePath + (queryString ? "?" + queryString : "")
);
}
export 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');
if (resource === "price-list") {
const { json } = await fetchJson(env.apiUrl + "/price-list");
return { data: [json], total: 1 };
}
const { json, headers } = await fetchJson(buildListUrl(resource, params));
const { rows, totalHint } = parseListBody(json);
const contentRange = headers.get("Content-Range");
const total = contentRange
? parseInt(contentRange.split('/').pop() || '0', 10)
: json.length;
return { data: json, total };
? Number(
contentRange.split("/").pop() ??
totalHint ??
rows.length,
)
: (totalHint ?? rows.length);
return { data: rows as any[], total };
},
getOne: async (resource, params) => {
const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`);
const resourcePath = resource === "equipment" ? "equipments" : resource;
const url =
resource === "price-list"
? env.apiUrl + "/price-list"
: env.apiUrl + "/" + resourcePath + "/" + params.id;
const { json } = await fetchJson(url);
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 };
if (resource === "price-list") {
const { json } = await fetchJson(env.apiUrl + "/price-list");
return { data: params.ids.includes("price-list") ? [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) => {
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 };
},
getManyReference: async (resource, params) =>
dataProvider.getList(resource, {
pagination: params.pagination,
sort: params.sort,
filter: { ...(params.filter ?? {}), [params.target]: params.id },
meta: params.meta,
} as any),
create: async (resource, params) => {
const { json } = await httpClient(`${apiUrl}/${resource}`, {
method: 'POST',
const resourcePath = resource === "equipment" ? "equipments" : resource;
const { json } = await fetchJson(env.apiUrl + "/" + resourcePath, {
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),
});
const resourcePath = resource === "equipment" ? "equipments" : resource;
const { json } = await fetchJson(
env.apiUrl + "/" + resourcePath + "/" + params.id,
{ method: "PATCH", body: JSON.stringify(params.data) },
);
return { data: json };
},
updateMany: async (resource, params) => {
const responses = await Promise.all(
const results = await Promise.all(
params.ids.map((id) =>
httpClient(`${apiUrl}/${resource}/${id}`, {
method: 'PATCH',
body: JSON.stringify(params.data),
})
)
dataProvider.update(resource, {
id,
data: params.data,
previousData: {},
meta: params.meta,
} as any),
),
);
return { data: responses.map(({ json }) => json.id) };
return { data: results.map((result) => result.data.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',
})
)
const resourcePath = resource === "equipment" ? "equipments" : resource;
const { json } = await fetchJson(
env.apiUrl + "/" + resourcePath + "/" + params.id,
{ method: "DELETE" },
);
return { data: responses.map(({ json }) => json.id) };
return { data: json ?? { id: params.id } };
},
deleteMany: async (resource, params) => {
const results = await Promise.all(
params.ids.map((id) =>
dataProvider.delete(resource, {
id,
previousData: {},
meta: params.meta,
} as any),
),
);
return { data: results.map((result) => result.data.id) };
},
};
export default dataProvider;

110
client/src/index.css Normal file
View File

@@ -0,0 +1,110 @@
: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,26 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import { initKeycloak } from './auth/keycloak';
const root = ReactDOM.createRoot(document.getElementById('root')!);
async function bootstrap() {
await initKeycloak();
root.render(
<React.StrictMode>
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</React.StrictMode>,
</StrictMode>,
);
}
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>,
);
});
void bootstrap();

View File

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -1,66 +1,78 @@
import {
List,
CreateButton,
Datagrid,
DateField,
FilterButton,
List,
NumberField,
SelectArrayInput,
SelectField,
TextField,
TextInput,
TopToolbar,
FilterButton,
CreateButton,
ExportButton,
NumberField,
DateField,
SelectField,
ReferenceField,
SelectArrayInput,
ReferenceInput,
AutocompleteInput
} from 'react-admin';
const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' },
{ id: 'Repair', name: 'В ремонте' },
{ id: 'Reserve', name: 'В резерве' },
{ id: 'WriteOff', name: 'Списано' },
];
} from "react-admin";
import {
equipmentStatusChoices,
equipmentTypeChoices,
laborOperationChoices,
periodicityChoices,
} from "../shared/enums";
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="Примечания" />
<TextInput key="q" source="q" label="Search" alwaysOn />,
<TextInput
key="inventoryNumber"
source="inventoryNumber"
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 EquipmentListActions = () => (
<TopToolbar>
<FilterButton filters={equipmentFilters} />
<CreateButton />
<ExportButton />
</TopToolbar>
);
export const EquipmentList = () => (
<List actions={<EquipmentListActions />} filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}>
<List
filters={equipmentFilters}
actions={
<TopToolbar>
<FilterButton />
<CreateButton />
</TopToolbar>
}
>
<Datagrid rowClick="show">
<TextField source="id" label="id" />
<TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="serialNumber" label="Заводской (серийный) номер" />
<TextField source="name" label="Наименование единицы оборудования" />
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<TextField source="code" />
</ReferenceField>
<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="Примечания" />
<TextField source="inventoryNumber" />
<TextField source="name" />
<TextField source="serialNumber" />
<SelectField source="equipmentType" choices={equipmentTypeChoices} />
<SelectField source="status" choices={equipmentStatusChoices} />
<DateField source="dateOfInspection" />
<NumberField source="totalEngineHours" />
<TextField source="location" />
</Datagrid>
</List>
);

View File

@@ -1,28 +1,35 @@
import { Show, SimpleShowLayout, TextField, NumberField, DateField, SelectField, ReferenceField } from 'react-admin';
const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' },
{ id: 'Repair', name: 'В ремонте' },
{ id: 'Reserve', name: 'В резерве' },
{ id: 'WriteOff', name: 'Списано' },
];
import {
DateField,
NumberField,
SelectField,
Show,
SimpleShowLayout,
TextField,
} from "react-admin";
import {
equipmentStatusChoices,
equipmentTypeChoices,
laborOperationChoices,
periodicityChoices,
} from "../shared/enums";
export const EquipmentShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="id" label="id" />
<TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="serialNumber" label="Заводской (серийный) номер" />
<TextField source="name" label="Наименование единицы оборудования" />
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<TextField source="code" />
</ReferenceField>
<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="Примечания" />
<TextField source="inventoryNumber" />
<TextField source="name" />
<TextField source="serialNumber" />
<SelectField source="equipmentType" choices={equipmentTypeChoices} />
<DateField source="dateOfInspection" />
<SelectField source="periodicityTO" choices={periodicityChoices} />
<TextField source="location" />
<SelectField source="status" choices={equipmentStatusChoices} />
<DateField source="commissionedAt" />
<NumberField source="totalEngineHours" />
<NumberField source="engineHoursSinceLastRepair" />
<DateField source="lastRepairAt" />
<TextField source="notes" />
<SelectField source="workAsPartOf" choices={laborOperationChoices} />
<NumberField source="fuelConsumed" />
</SimpleShowLayout>
</Show>
);

View File

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

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

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

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

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

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

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

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

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

@@ -1,39 +0,0 @@
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="id" 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

@@ -1,77 +0,0 @@
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="id" />
<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

@@ -1,38 +0,0 @@
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="id" />
<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

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

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

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

View File

@@ -1,12 +1 @@
/// <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;
}

28
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"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,25 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"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" }]
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

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

View File

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

View File

@@ -7,6 +7,8 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
POSTGRES_DB: ${POSTGRES_DB:-toir}
# UTF-8 cluster (applies only on first volume init) — migrations use Cyrillic enum labels
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C.UTF-8"
healthcheck:
test:
[
@@ -17,12 +19,10 @@ services:
timeout: 5s
retries: 5
start_period: 10s
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- app
- toir
server:
build:
@@ -42,20 +42,15 @@ services:
KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL:-}
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
["CMD", "curl", "-fsS", "http://127.0.0.1:3000/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
start_period: 90s
expose:
- "3000"
- 3000
networks:
- app
- toir
- proxy
client:
@@ -71,26 +66,25 @@ services:
restart: unless-stopped
depends_on:
server:
condition: service_healthy
# 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
ports:
- "${CLIENT_PORT:-8080}:80"
expose:
- "80"
- 80
networks:
- app
- toir
- proxy
volumes:
postgres-data:
networks:
app:
toir:
driver: bridge
proxy:
external: true

View File

@@ -1,27 +1,29 @@
# AID: экспорт OpenAPI и генератор приложения
В репозитории добавлены **сервисы-экспортёры** для интеграции с **AID** (или любым другим клиентом по HTTP): автоматическое получение **OpenAPI 3.0** из доменного **api-format** и выдача **сгенерированного fullstack-приложения** из **DSL** без ручного копирования файлов.
В репозитории добавлены **сервисы-экспортёры** для интеграции с **AID** (или любым другим клиентом по HTTP): автоматическое получение **OpenAPI 3.0** из доменного **api-format**.
---
## Что сделано
## Что работает
| Компонент | Назначение |
|-----------|------------|
| **`POST /aid/export/openapi`** (NestJS) | На вход JSON **api-format** → на выход документ **OpenAPI 3.0** в поле `openapi`. |
| **`POST /aid/export/app`** (NestJS) | На вход текст **DSL** → либо JSON-бандл всех сгенерированных файлов (`files`), либо запись в рабочую копию репозитория (`apply: true`, опционально). |
| **`tools/api-format-to-openapi/`** | CLI и промпт для LLM: тот же конвертер, что вызывает Nest. |
| **`generation/generate.mjs`** | Новый флаг **`--print-bundle-json`**: вывод в stdout JSON с `entityCount`, `enumCount`, `files` — без записи на диск (аналог «сухого» экспорта для AID). |
| **`server/src/aid-export/`** | Модуль Nest: контроллер, сервис, краткая справка в `README.md` рядом с кодом. |
Ветка с этими изменениями: **`add_aid_exporters`**.
## Что временно не работает
| Компонент | Статус |
|-----------|--------|
| **`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. Репозиторий клонирован целиком (есть `generation/`, `tools/`, `server/`, `client/`).
2. Backend запускается из каталога **`server/`** (`npm run start` / `start:dev`), чтобы относительные пути `../generation/generate.mjs` и `../tools/api-format-to-openapi/convert.mjs` были корректны.
1. Репозиторий клонирован целиком (есть `tools/`, `server/`, `client/`).
2. Backend запускается из каталога **`server/`** (`npm run start` / `start:dev`), чтобы относительные пути `../tools/api-format-to-openapi/convert.mjs` были корректны.
3. Для режима OpenAPI через LLM на сервере нужны **`OPENAI_API_KEY`** (и при необходимости `OPENAI_MODEL`, `OPENAI_BASE_URL`).
---
@@ -31,7 +33,6 @@
| Переменная | Зачем |
|------------|--------|
| `AID_EXPORT_API_KEY` | Если задана, к **`/aid/export/*`** нужен заголовок **`X-AID-Export-Key`** с тем же значением. |
| `AID_GENERATOR_ALLOW_APPLY` | Должна быть **`1`** или **`true`**, иначе **`POST /aid/export/app`** с **`apply: true`** вернёт **403** (защита от случайной перезаписи репозитория на сервере). |
| `OPENAI_API_KEY` | Для `POST /aid/export/openapi` с **`"mode": "llm"`**. |
Остальное как для обычного бэкенда (`DATABASE_URL`, `PORT` и т.д.).
@@ -69,40 +70,19 @@ X-AID-Export-Key: <если задан AID_EXPORT_API_KEY>
Пример входа для теста: `tools/api-format-to-openapi/examples/api-format.example.json` (подставьте как значение `apiFormat`).
### 2. Генератор приложения из DSL
### 2. Генератор приложения из DSL (non-operative)
**`POST /aid/export/app`**
```json
{
"dsl": "domain TOiR {\n ...\n}\n",
"apply": false
}
```
- **`apply: false`** (рекомендуется для AID): в ответе **`files`** — объект «путь от корня репо → текст файла». Диск на сервере не меняется.
- **`apply: true`**: выполняется запись файлов как у `npm run generate:from-dsl` с `--apply`; нужен **`AID_GENERATOR_ALLOW_APPLY=1`**.
**Ответ (бандл):** `{ "applied": false, "entityCount": N, "enumCount": M, "files": { ... } }`
**Ответ (apply):** `{ "applied": true, "message": "Generated ..." }`
Эталон DSL: `examples/TOiR.domain.dsl`.
> **Non-operative.** This endpoint depended on `generation/generate.mjs` which was removed
> 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
> backing script compatible with the current `domain/*.api.dsl` pipeline.
---
## CLI (без Nest)
### Пошаговая демонстрация в терминале
```bash
cd tools/api-format-to-openapi
npm run demo
# или с паузой после каждого шага (Enter):
npm run demo:pause
```
Показывает входной **api-format**, логику маппинга, запуск конвертера и структуру **OpenAPI**; результат — `demo-output/openapi.json`.
### api-format → OpenAPI
```bash
@@ -119,46 +99,22 @@ node convert.mjs --mode llm --in your-api-format.json --out ../../openapi.llm.js
Подробнее: **`tools/api-format-to-openapi/README.md`**.
### DSL → JSON-бандл
Из **корня репозитория**:
```bash
node generation/generate.mjs --print-bundle-json --dsl examples/TOiR.domain.dsl > bundle.json
```
Из **`server/`**:
```bash
npm run generate:bundle-json > ../bundle.json
```
Применить генератор к файлам на диске (как раньше):
```bash
cd server
npm run generate:from-dsl
```
---
## Типичный сценарий для AID
1. AID уже сформировал **api-format** (как у вас принято после DTO).
2. AID вызывает **`POST /aid/export/openapi`** → получает **OpenAPI 3.0** → сохраняет в проект / отдаёт в Swagger / в реестр.
3. Для кода: AID передаёт **DSL** в **`POST /aid/export/app`** с **`apply: false`** → забирает **`files`** → применяет у себя (git apply, распаковка, PR).
4. Запись **`apply: true`** на общем сервере используйте только в доверенной среде и с **`AID_GENERATOR_ALLOW_APPLY`**.
---
## Где смотреть код и короткую справку
- Полное описание эндпоинтов рядом с реализацией: **`server/src/aid-export/README.md`**
- Общий dev-workflow (в т.ч. упоминание AID): **`generation/dev-workflow.md`**
---
## Ограничения и дальнейшие шаги
- Пример **api-format** в репозитории — **учебный**; под ваш продакшен-формат может понадобиться расширить маппинг в `convert.mjs` или отточить промпт **`llm-system.md`**.
- Ответ **`/aid/export/app`** с большим числом сущностей может быть объёмным; при необходимости добавьте сжатие, отдельное хранилище артефактов или пагинацию по файлам — контракт с AID лучше зафиксировать отдельно.
- **`/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.

162
docs/api-dsl-conventions.md Normal file
View File

@@ -0,0 +1,162 @@
# 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.

154
docs/future-work.md Normal file
View File

@@ -0,0 +1,154 @@
# 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

237
docs/generation-playbook.md Normal file
View File

@@ -0,0 +1,237 @@
# 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`
- `package.json`
- `docker-compose.yml`
- `domain-summary.json`
- `api-summary.json`
- `toir-realm.json`
- `.gitignore`

96
docs/source-of-truth.md Normal file
View File

@@ -0,0 +1,96 @@
# 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.

View File

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

View File

@@ -1,236 +0,0 @@
/*
КИС ТОиР — демонстрационная схема доменной модели
Сущности: 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,34 +1,42 @@
# DSL Language Specification
This document describes the single DSL (Domain Specific Language) used to specify fullstack CRUD applications. The only required DSL input is `domain/*.dsl`.
This document describes the DSL system used to specify fullstack CRUD applications.
`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/`.
`domain/*.api.dsl` is the single source of truth for the entire domain model and API
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 Responsibility
# DSL Architecture
The domain DSL defines only:
## `domain/*.api.dsl`
- domain model
- relations
- enums
The api.dsl is the authoritative source of truth for:
The domain DSL is the single source of truth for:
- entities and their attributes
- 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
- entities
- attributes
- primary keys
- foreign keys
- enums
The api.dsl drives: Prisma schema, NestJS controller/service/DTO generation,
and React Admin resource generation.
The following layers are always derived from the domain DSL and must not be authored as standalone authoritative DSL inputs:
Constraint: every `map Entity.field` or `sync Entity.field` reference in `domain/*.api.dsl`
must resolve to an entity and field defined within the same api.dsl file.
- DTO
- API
- UI
Optional extension mechanism:
## Optional extension mechanism
```text
overrides/
@@ -40,7 +48,8 @@ Override rules:
- Overrides are optional.
- The generator must work without them.
- Overrides may refine derived API or UI behavior, but they must not duplicate or redefine entities, attributes, primary keys, foreign keys, relations, or enums.
- Overrides may refine derived API or UI behavior, but they must not duplicate or redefine
entities, attributes, primary keys, foreign keys, relations, or enums.
---
@@ -77,13 +86,15 @@ attribute name {
**Modifiers:**
- `type` — required; one of: `string`, `uuid`, `integer`, `decimal`, `date`, `text`, or an enum name.
- `type` — required; one of: `string`, `uuid`, `integer`, `decimal`, `date`, `text`, `boolean`, `number`, or an enum name.
- `key primary` — this attribute is the primary key.
- `key foreign { relates Entity.field }` — foreign key to another entity's field.
- `is required` — non-nullable.
- `is unique` — unique constraint.
- `is nullable` — explicitly nullable.
- `default Value` — default value (for enums or literals).
- `description "..."` — human-readable description.
- `label "..."` — display label for UI.
---
@@ -164,19 +175,21 @@ attribute equipmentTypeCode {
## DSL → Prisma
| DSL Concept | Prisma Result |
| ------------ | --------------------------- |
| entity | model |
| attribute | field |
| enum | enum |
| key primary | @id or @id @default(uuid()) |
| key foreign | relation + references |
| type string | String |
| type uuid | String @id @default(uuid()) |
| type integer | Int |
| type decimal | Decimal |
| type date | DateTime |
| type text | String |
| DSL Concept | Prisma Result |
| ------------- | --------------------------- |
| entity | model |
| attribute | field |
| enum | enum |
| key primary | @id or @id @default(uuid()) |
| key foreign | relation + references |
| type string | String |
| type uuid | String @id @default(uuid()) |
| type integer | Int |
| type number | Float |
| type decimal | Decimal |
| type date | DateTime |
| type text | String |
| type boolean | Boolean |
---
@@ -203,15 +216,26 @@ API paths are derived from entity name: PascalCase → kebab-case, pluralized (e
| attribute | Form field / column |
| type string | TextInput, TextField |
| type integer/decimal | NumberInput, NumberField |
| type number | NumberInput, NumberField |
| type date | DateInput, DateField |
| type boolean | BooleanInput, BooleanField |
| enum | SelectInput with choices |
| foreign key | ReferenceInput, ReferenceField |
---
# Derived Layer Mapping
# API DSL Layer Mapping
- **Create DTO** — derived from domain attributes and must not include generated primary keys (for example no `id` for uuid PKs).
- **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.
- **UI field mapping** — derived from attribute types, descriptions, enums, and foreign keys without a separate UI DSL.
DTO shapes and endpoint contracts are declared in `domain/*.api.dsl`. The api.dsl
is the authoritative source for:
- **Create DTO** — declared as `dto DTO.<Entity>Create` in api.dsl. Must not include
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.

1291
domain/toir.api.dsl Normal file

File diff suppressed because it is too large Load Diff

View File

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

931
openapi.json Normal file
View File

@@ -0,0 +1,931 @@
{
"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

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

View File

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

View File

@@ -2,9 +2,12 @@
"name": "toir-generation-context",
"private": true,
"scripts": {
"generate:domain-summary": "node tools/generate-domain-summary.mjs",
"generate:api-summary": "node tools/generate-api-summary.mjs",
"generate:openapi": "node tools/api-summary-to-openapi.mjs --out openapi.json",
"validate:generation": "node tools/validate-generation.mjs",
"validate:generation:runtime": "node tools/validate-generation.mjs --run-runtime",
"validate:generation:artifacts": "node tools/validate-generation.mjs --artifacts-only"
"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,79 +1,95 @@
# Auth Rules
This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline.
<!-- prompt-version: 2.0 -->
<!-- applies-to: client/src/auth/, server/src/auth/, toir-realm.json -->
<!-- validated-by: tools/validate-generation.mjs §validateAuthChecks §validateRealmChecks -->
- 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.
Use this document during the **Auth / Runtime / Realm** stage defined in `prompts/general-prompt.md`.
## Frontend auth invariants
## Purpose
- 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.
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.
## Working runtime defaults
## Mandatory Inputs
Use the already working project defaults unless a prompt explicitly overrides them.
- `prompts/general-prompt.md`
- `prompts/runtime-rules.md`
- current repository auth/runtime defaults
- Frontend Keycloak base URL example:
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
- Frontend realm and client example:
- `VITE_KEYCLOAK_REALM=toir`
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
- Backend issuer and audience example:
- `KEYCLOAK_ISSUER_URL=https://sso.greact.ru/realms/toir`
- `KEYCLOAK_AUDIENCE=toir-backend`
- CORS example:
- `CORS_ALLOWED_ORIGINS=http://localhost:5173,https://toir-frontend.greact.ru`
## Expected Outputs
Anti-regression rule:
- `client/src/auth/`
- `client/src/dataProvider.ts`
- `server/src/auth/`
- `toir-realm.json`
- 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.
## Frontend Auth Invariants
## Backend auth invariants
- use `keycloak-js` with redirect-based login only
- initialize Keycloak before rendering the SPA
- use Authorization Code Flow + PKCE (`S256`)
- keep `authProvider`, `dataProvider`, `getIdentity()`, `getPermissions()`, and `checkError()` as stable 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
- Verify JWTs with `jose`.
- Validate issuer + audience + signature via JWKS.
- Resolve JWKS in this order:
## 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 and all generated CRUD routes protected by default.
- extract roles only from `realm_access.roles`
- keep `/health` public
- generated CRUD routes stay protected by default
## Auth anti-regression invariants
## Working Runtime Defaults
- 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.
Keep these defaults unless a task explicitly overrides them:
## Realm artifact contract
- `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`
- 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:
Anti-regression rule:
- do not revert shared examples to localhost Keycloak defaults unless a task explicitly requests a local Keycloak baseline
## Realm Artifact Contract
The root realm artifact is mandatory and must:
- be importable and versioned
- align with generated frontend/backend env contracts
- parameterize:
- realm name
- frontend client id
- backend client id / audience
- local and production frontend URLs
- artifact filename
- It must explicitly deliver:
- explicitly deliver:
- `sub`
- `aud`
- `realm_access.roles`
- It must define:
- define:
- realm roles `admin`, `editor`, `viewer`
- a public SPA client with PKCE S256
- a bearer-only backend client
- an explicit audience client scope
- explicit protocol mappers for baseline identity and role claims
- 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,88 +1,147 @@
# Backend Rules
The backend remains derived from `domain/*.dsl` inside the existing LLM-first pipeline. No compiler platform or generator engine is introduced.
<!-- prompt-version: 2.0 -->
<!-- applies-to: server/src/modules/, server/src/app.module.ts -->
<!-- validated-by: tools/validate-generation.mjs §validateApiDslCoverage §validateNaturalKeyChecks -->
## Backend scaffold baseline
Use this document during the **Backend** stage defined in `prompts/general-prompt.md`.
- Start backend initialization from the official NestJS CLI workspace, not from manually created files.
- The backend must remain compatible with standard Nest workspace tooling such as `nest build` and `nest start`.
- Preserve the core Nest workspace files generated by the CLI, especially:
## Purpose
Generate NestJS CRUD artifacts that match the DSL contract exactly and remain compatible with a standard NestJS workspace.
## 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.build.json`
- `server/nest-cli.json`
- `server/src/main.ts`
- `server/src/app.module.ts`
- For domain resources, prefer official Nest CLI generation patterns for modules/controllers/services/resources and then adapt the generated code to Prisma and auth requirements.
- Do not delete required Nest workspace files just because the LLM can inline a smaller custom structure.
- If the workspace is degraded, repair it before generating domain code.
## Forbidden backend generation patterns
Forbidden patterns:
- Do not bootstrap `server/` by hand-writing a pseudo-Nest project from memory.
- Do not remove `tsconfig.json`, `tsconfig.build.json`, or `nest-cli.json` after generation.
- Do not replace standard Nest package scripts with ad hoc commands that break `nest build` or `nest start`.
- Do not continue CRUD generation on top of a degraded backend workspace without repairing the workspace first.
- hand-written pseudo-Nest scaffolds
- deleting required Nest config files after generation
- replacing normal Nest build/start behavior with ad hoc scripts
## 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
## Route And Resource Contract
- 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.
- Every API record returned to React Admin must include `id`.
- For entities whose primary key is not `id`, the backend must map the real key to `id`.
- Natural-key list/sort logic must never build ORM `orderBy` against a fake physical `id`.
- For natural-key entities, map the real primary key to `id` in responses and sort translation.
## Service invariants
## DTO Contract
- Never pass raw update DTOs into Prisma update `data`.
- Remove `id`, the real primary key, and readonly fields from update payloads before calling Prisma.
- Keep PrismaService lightweight:
- `DTO.<Entity>Create` defines `Create<Entity>Dto`.
- `DTO.<Entity>Update` defines `Update<Entity>Dto`.
- Do not invent fields or pull field lists from memory.
- 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`
- implement `OnModuleInit`
- call `$connect()`
- do not use `beforeExit`
- do not add `beforeExit`
## Filtering contract
List endpoint requirements:
- List endpoints must support React Admin query parameters:
- `_start`, `_end`, `_sort`, `_order`
- arbitrary field filters from query string
- `q` for reference autocomplete search
- String/text search filters may use `contains` with case-insensitive mode.
- Foreign key filters must use exact-match semantics (no `contains` for FK scalar keys).
- Enum filters must support both single and repeated query params:
- `status=Draft`
- `status=Draft&status=Approved`
- Repeated enum params must map to Prisma `{ in: [...] }`.
- Sorting must use real model scalar fields only; natural-key entities must not fallback to fake physical `id`.
- accept React Admin query params: `_start`, `_end`, `_sort`, `_order`, `q`
- set `Content-Range`
- set `Access-Control-Expose-Headers: Content-Range`
## Reproducibility invariants
Filtering rules:
- A freshly generated backend must be bootstrappable with ordinary Nest + Prisma commands from `prompts/runtime-rules.md`.
- Missing TypeScript or Nest workspace config is a generation failure, not an acceptable simplification.
- The baseline backend should fail only on missing runtime dependencies or env values, not because the Nest workspace itself is incomplete.
- string/text filters may use case-insensitive `contains`
- foreign-key scalar filters must use exact-match semantics
- enum filters must support both single and repeated params
- repeated enum params must map to Prisma `{ in: [...] }`
- `_sort=id` must map to the real primary key for natural-key entities
## Recovery rule if backend workspace degraded
Decimal and date handling:
- If required Nest scaffold files are missing or broken, restore the official workspace baseline before editing Prisma models, modules, controllers, services, or DTOs.
- Treat workspace repair as higher priority than feature generation, because generated domain code on top of a broken workspace is invalid baseline output.
- `decimal` writes: `new Prisma.Decimal(value)`
- `decimal` reads: `.toString()`
- `date` writes: `new Date(value)`
- `date` reads: `.toISOString()`
## Backend auth defaults
## Natural-Key Rules
- `GET` -> `viewer | editor | admin`
- `POST`, `PATCH`, `PUT` -> `editor | admin`
- `DELETE` -> `admin`
For entities whose physical primary key is not `id`:
- route params use the real primary key name
- 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,65 +1,118 @@
# Frontend Rules
The frontend stays a React Admin SPA generated from `domain/*.dsl` and anchored to the existing auth seams.
<!-- prompt-version: 2.0 -->
<!-- applies-to: client/src/resources/, client/src/App.tsx -->
<!-- validated-by: tools/validate-generation.mjs §validateApiDslCoverage -->
## Frontend scaffold baseline
Use this document during the **Frontend** stage defined in `prompts/general-prompt.md`.
- Start frontend initialization from the official Vite React TypeScript scaffold, not from manually assembled files.
- Preserve a valid Vite workspace baseline, including:
## Purpose
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/tsconfig.json`
- `client/vite.config.*`
- `client/src/main.tsx`
- Add React Admin and auth seams on top of that baseline instead of replacing the workspace with a hand-written minimal shell.
- Do not delete required Vite entry/config files just because the LLM can write a shorter custom setup.
- Repair the scaffold before generating resources if it is degraded.
## Forbidden frontend generation patterns
## Resource Contract
- Do not bootstrap `client/` by hand-writing a pseudo-Vite project from memory.
- Do not remove `index.html`, `tsconfig*`, or `vite.config.*` after generation.
- Do not replace standard Vite package scripts with ad hoc commands that break `vite build`, `vite dev`, or `vite preview`.
- Do not continue React Admin resource generation on top of a degraded frontend workspace without repairing the workspace first.
- Use the shared entity-to-resource naming convention from `prompts/general-prompt.md`.
- Every entity becomes a React Admin resource with `list`, `create`, `edit`, and `show`.
- `Resource` registration in `client/src/App.tsx` must include `show={...}`.
- Every frontend record must work with React Admin's `id` contract, including natural-key entities.
## Resource generation
DTO-driven view rules:
- Each entity becomes a React Admin resource with list/create/edit/show views.
- Resource names must stay aligned with backend path segments.
- Foreign keys must use `ReferenceInput` / `ReferenceField`.
- Foreign keys shown in list/show views must stay clickable via `ReferenceField link="show"` to open full details of the related resource.
- Lists must expose filters through `List` `filters` and an actions toolbar with `FilterButton`.
- For enum fields where multi-select is required (for example `status`), use `SelectArrayInput` in list filters.
- For foreign key filters and form selection use `ReferenceInput` + `AutocompleteInput` with `filterToQuery={(searchText) => ({ q: searchText })}`.
- Form mapping must stay type-safe:
- `integer` / `decimal` -> `NumberInput`
- `date` -> `DateInput`
- List and Show views use fields from `DTO.<Entity>`
- Create view uses fields from `DTO.<Entity>Create`
- Edit view uses fields from `DTO.<Entity>Update`
- Do not derive form fields directly from model attributes when the DTO contract is narrower
## Provider seams
## Input And Field Mapping
- `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.
Form inputs:
## Identity and permissions
- `integer`, `number`, `decimal` -> `NumberInput`
- `date` -> `DateInput`
- required `boolean` -> `BooleanInput`
- nullable `boolean` -> `NullableBooleanInput`
- enum -> `SelectInput`
- FK reference -> `ReferenceInput` + `AutocompleteInput`
- `getIdentity()` must resolve from parsed token claims.
- `getPermissions()` may expose realm roles for UI awareness.
- Backend enforcement remains authoritative.
Display fields:
## React Admin compatibility
- `integer`, `number`, `decimal` -> `NumberField`
- `date` -> `DateField`
- `boolean` -> `BooleanField`
- enum -> `SelectField`
- FK reference -> `ReferenceField`
- 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.
Hard failure rule:
## Reproducibility invariants
- using plain `TextInput` for `integer`, `number`, `decimal`, `date`, or `boolean` is a generation failure
- 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.
## Filter And Reference Contract
## Recovery rule if frontend workspace degraded
- Lists must expose filters and include a toolbar with `FilterButton`.
- 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.
- 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.
Reference display expression priority:
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,146 +1,298 @@
ROLE
<!-- prompt-version: 3.0 -->
<!-- applies-to: all generation tasks (master orchestration prompt) -->
<!-- validated-by: tools/validate-generation.mjs + npm run eval:generation -->
You are a Staff-level Fullstack Platform Engineer working inside the established LLM-first CRUD generation baseline.
# Role
Use context7 when official framework guidance is needed.
You are the master orchestrator of the KIS-TOiR generation pipeline.
This repository is not a new generator engine. Do not redesign it into a planner/emitter/runtime platform.
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.
GOAL
# Project Description
Strengthen and use the existing LLM-first CRUD generation pipeline.
KIS-TOiR is an LLM-first fullstack CRUD generation project for equipment maintenance management.
- Keep `domain/*.dsl` as the source of truth for the domain model.
- Keep `server/` as the active backend target output path.
- Keep `client/` as the active frontend target output path.
- Keep Keycloak auth in the default generation path.
- Keep PostgreSQL as the only Dockerized runtime dependency.
- Generate and maintain:
- `domain-summary.json`
- a root-level `*-realm.json`
- backend/frontend auth seams
- runtime/env/bootstrap artifacts
- validation-gate-compatible output
- Backend: NestJS + Prisma
- Frontend: Vite React TypeScript + React Admin
- Auth/runtime: external Keycloak + PostgreSQL + repository-managed env/runtime artifacts
Use the already working runtime defaults for this project unless the prompt explicitly overrides them:
The repository is intentionally prompt-driven. `prompts/*.md` define generation policy; generated code lives under `server/` and `client/`.
- 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`
# Mission
Do not silently regress these examples to localhost Keycloak defaults.
Turn the repository source contract into a buildable, validated workspace by:
ACTIVE KNOWLEDGE BLOCKS
1. starting from official framework scaffolding when a workspace is missing or degraded
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
Read in this order:
# Source Of Truth
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`
`domain/toir.api.dsl` is the operative source of truth for generation runs.
Interpretation rules:
It is authoritative for:
- `domain/*.dsl` is authoritative.
- `domain-summary.json` is derived. Regenerate or validate it against the DSL; never treat it as the source of truth.
INPUT CONTRACT
Required:
- `domain/*.dsl`
Optional:
- `overrides/api-overrides.dsl`
- `overrides/ui-overrides.dsl`
- entities and enums
- DTO shapes per operation
- nullability and requiredness
- primary and foreign keys
- HTTP methods, endpoint paths, and pagination contracts
Rules:
- Do not require DTO/API/UI DSL files.
- Do not resurrect multi-DSL source-of-truth behavior.
- Optional overrides may refine derived API/UI behavior but must not redefine the domain model.
- Read the DSL directly. Do not substitute `api-summary.json` for `domain/toir.api.dsl`.
- Work from entity-scoped slices: the active `api API.<Entity>` block plus its referenced DTOs and enums.
- Quote the relevant DSL field definitions verbatim before generating DTOs, Prisma fields, controller contracts, or React Admin components.
- 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.
PIPELINE CONTRACT
# Orchestration Model
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.
Use a manager-first, agent-as-tool architecture.
REPAIR-BEFORE-GENERATE ORDER
- Keep one orchestrator in charge of planning, sequencing, integration, and final acceptance.
- 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.
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.
Mandatory delegation pattern for future runs:
CLI-FIRST SCAFFOLDING CONTRACT
- `explorer`
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.
- 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.
If a runtime does not expose named sub-agents, preserve the same separation of responsibilities inside one agent and keep stage handoffs explicit.
ANTI-REGRESSION FAILURES
# MCP Usage Model
Treat the following as baseline violations, not acceptable improvisations:
Use MCP/tools deliberately, not reflexively.
- creating a NestJS workspace by manually writing `package.json`, `tsconfig*`, `nest-cli.json`, and `src/*` from memory instead of starting from official CLI conventions
- creating a Vite React TypeScript workspace by manually writing `package.json`, `index.html`, `tsconfig*`, `vite.config.*`, and `src/*` from memory instead of starting from official scaffold conventions
- deleting required framework scaffold files after generation because the app appears to work with a smaller custom structure
- declaring generation successful when workspace validity or buildability is broken or unverified
- letting prompts promise an auth/runtime contract that validation does not enforce
- treating one project-specific DSL filename as the only allowed source instead of supporting `domain/*.dsl`
- Filesystem/search tools: gather exact local context before making decisions.
- Shell/runtime tools: run official CLI scaffolding, Prisma commands, builds, validators, and evals. Do not simulate command results from memory.
- Context7: verify current NestJS, Prisma, React Admin, Vite, Keycloak, or prompt/orchestration guidance when repository docs are not enough.
- Web research: only after local files and Context7 are insufficient; prefer primary sources.
- Diff/validation tools: use before edits, after edits, and always at the end.
OUTPUT CONTRACT
Tool-order policy:
The baseline output must include:
1. local authoritative files
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`
- 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`
- `client/.env.example`
- `client/src/auth/keycloak.ts`
- `client/src/auth/authProvider.ts`
- `client/src/dataProvider.ts`
- `server/src/auth/*`
- `docker-compose.yml`
- `domain-summary.json`
- root-level `*-realm.json`
- `toir-realm.json`
The baseline output must also remain a real framework workspace, not a prompt-only file collection:
Handoff:
- `server/tsconfig.json`
- `server/tsconfig.build.json`
- `server/nest-cli.json`
- `client/index.html`
- `client/tsconfig.json`
- `client/vite.config.*`
- verification starts only after auth seams, runtime artifacts, and realm output are aligned with backend/frontend expectations
NON-GOALS
## 6. Verification / Success Gate
- No new generator engine
- No compiler/IR platform
- No heavy codegen redesign
- No replacement of the old LLM-first architecture
Purpose:
COMPLETION INVARIANTS
- prove that the generation run is complete and not just plausible
- 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.
Responsible:
VALIDATION
- orchestrator
- `reviewer` before completion
Before considering the output complete, satisfy `prompts/validation-rules.md`.
Mandatory inputs:
- `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.

119
prompts/prisma-rules.md Normal file
View File

@@ -0,0 +1,119 @@
# 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

View File

@@ -1,96 +1,86 @@
# Runtime Rules
This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline and strengthens the existing pipeline instead of replacing it.
<!-- prompt-version: 2.0 -->
<!-- applies-to: docker-compose.yml, server/.env.example, client/.env.example -->
<!-- validated-by: tools/validate-generation.mjs §validateRuntimeContractChecks -->
## Baseline runtime topology
Use this document during the **Preparation / Discovery** and **Auth / Runtime / Realm** stages defined in `prompts/general-prompt.md`.
- `server/` is the active backend target output path.
- `client/` is the active frontend target output path.
- Docker scope remains PostgreSQL only.
- Keycloak remains external to the repository runtime.
- The project remains LLM-first: markdown knowledge blocks in `prompts/` orchestrate generation, while active generated/maintained code lives in `server/` and `client/`.
## Purpose
## Required input and derived artifacts
Define the runtime topology, environment defaults, scaffold expectations, and bootstrap sequence for a buildable generated workspace.
- Source of truth:
- `domain/*.dsl`
- Required derived artifacts:
- `domain-summary.json`
- root-level `*-realm.json`
## Mandatory Inputs
`domain-summary.json` exists to stabilize generation and validation; it must be regenerated from the DSL and treated as non-authoritative.
- `prompts/general-prompt.md`
- `prompts/auth-rules.md` when runtime changes affect auth defaults or seams
- current repository runtime/auth defaults
## Output contract
`api-summary.json` is an auxiliary artifact only. Refresh it when validator/tooling requires freshness checks or when a compact inventory helps discovery. Do not treat it as the runtime source of truth.
The strengthened baseline must produce and keep aligned:
## Expected Outputs
- `server/prisma/schema.prisma`
- backend/frontend env examples
- backend/frontend auth seams
- root `.gitignore`, `server/.gitignore`, `client/.gitignore`
- `docker-compose.yml`
- `domain-summary.json`
- root-level realm import artifact
- `server/.env.example`
- `client/.env.example`
- a buildable NestJS workspace under `server/`
- a buildable Vite React TypeScript workspace under `client/`
- any validator-required auxiliary artifacts such as `api-summary.json`
## Concrete runtime examples
## Baseline Runtime Topology
Use these as the baseline examples for this project unless the prompt explicitly overrides them:
- `server/` is the backend output path
- `client/` is the frontend output path
- Docker scope stays PostgreSQL-only
- Keycloak remains external to repository runtime
- the project remains LLM-first and prompt-driven
- Backend:
- `PORT=3000`
- `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"`
- `CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"`
- `KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"`
- `KEYCLOAK_AUDIENCE="toir-backend"`
- Frontend:
- `VITE_API_URL=http://localhost:3000`
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
- `VITE_KEYCLOAK_REALM=toir`
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
## Concrete Runtime Defaults
These example values come from the already working runtime shape and are preferred over local-only Keycloak placeholders.
Backend:
## Runtime bootstrap
- `PORT=3000`
- `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"`
- `CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"`
- `KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"`
- `KEYCLOAK_AUDIENCE="toir-backend"`
1. Import the root-level realm artifact into Keycloak.
2. Start PostgreSQL with `docker compose up -d`.
3. From `server/` run:
- initialize or repair the workspace with official Nest CLI scaffolding if required before generating domain code
- `npm install`
- `npx prisma generate`
- `npx prisma migrate dev`
- `npx prisma db seed`
- `npm run build`
- `npm run start`
4. From `client/` run:
- initialize or repair the workspace with official Vite React TypeScript scaffolding if required before generating app code
- `npm install`
- `npm run build`
- `npm run dev`
Frontend:
## Recovery and completion rules
- `VITE_API_URL=http://localhost:3000`
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
- `VITE_KEYCLOAK_REALM=toir`
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
- Repair degraded framework workspaces before applying any new domain-derived generation changes.
- Do not mark generation complete while `server/` or `client/` remains non-buildable.
- If dependency installation has not happened yet, buildability may be reported as skipped, but it must never be reported as green without verification.
- Runtime/bootstrap instructions are reusable project baseline rules; TOiR names remain examples, not the only supported domain project.
## Scaffold Expectations
## Scaffold expectations
- new or repaired backend workspaces start from the official Nest CLI
- new or repaired frontend workspaces start from the official Vite React TypeScript CLI
- Prisma initialization uses the official Prisma CLI when relevant
- the LLM may customize generated code after scaffold creation, but must not replace official initialization with ad hoc file creation
- NestJS workspace creation should follow the official Nest CLI path for new applications and resource scaffolding.
- Vite frontend creation should follow the official Vite `create-vite` path for React TypeScript applications.
- The LLM may customize generated code after scaffold creation, but must not replace official workspace initialization with ad hoc file creation.
## Runtime Bootstrap
## Common generation failures to avoid
1. import `toir-realm.json` into Keycloak
2. start PostgreSQL with `docker compose up -d`
3. from `server/`:
- repair or create the workspace with official Nest CLI if needed
- install dependencies
- run Prisma commands required by the schema stage
- run `npm run build`
- run `npm run start`
4. from `client/`:
- repair or create the workspace with official Vite CLI if needed
- install dependencies
- run `npm run build`
- run `npm run dev`
- starting feature generation before scaffold repair
- leaving deleted framework config files unrepaired because the current diff looks smaller
- accepting a form-only validation pass while buildability is unknown
- binding runtime rules to one project-specific DSL filename instead of `domain/*.dsl`
## Completion Expectations
## Baseline intent
Runtime preparation is incomplete if any of the following is true:
- No new generator engine
- No compiler platform
- No planner/emitter/runtime redesign
- Only the current LLM-first pipeline, strengthened by summary, realm, and validation artifacts
- `server/` is missing or not buildable as a NestJS workspace
- `client/` is missing or not buildable as a Vite React TypeScript workspace
- framework scaffolding was hand-built instead of created or repaired from official CLIs
- shared env defaults drift from the repository auth/runtime contract
- runtime success is claimed without actual build verification

View File

@@ -1,98 +1,101 @@
# Validation Rules
Validation is now a lightweight automated gate instead of a prose-only checklist.
<!-- prompt-version: 2.0 -->
<!-- applies-to: tools/validate-generation.mjs and npm run eval:generation -->
<!-- validated-by: self -->
## Commands
Use this document during the **Verification / Success Gate** stage defined in `prompts/general-prompt.md`.
- `npm run generate:domain-summary`
- `npm run validate:generation`
- `npm run validate:generation:runtime`
## Purpose
Define the repository gates that convert a plausible generation run into a verified one.
## Primary Gates
- `node tools/validate-generation.mjs --artifacts-only`
- `npm run eval:generation`
## Auxiliary Freshness Prep
- `npm run generate:api-summary`
Run the freshness prep when the repository validator or supporting tooling expects `api-summary.json` to exist and match the current DSL. This artifact is auxiliary to validation and inventory, not the generation source of truth.
## Prompt-Gate Alignment Rule
- Every invariant described as required in the active prompt corpus must either be enforced by this gate or be called out explicitly as a manual/runtime-only check.
- Validation must not stay silent about a violation that the prompts describe as forbidden.
- Validation must not report green buildability when build verification was skipped.
- every invariant marked required in the active prompt corpus must either be enforced by a gate or called out as manual/runtime-only
- validation must not silently ignore a forbidden pattern
- build verification must not be reported as green when it was skipped
## Gate groups
## Gate Groups
### Build checks
### Build Checks
- at least one `domain/*.dsl` file exists
- required artifacts exist
- Prisma schema exists
- frontend/backend env contracts exist
- frontend/backend framework workspace files exist
- `domain-summary.json` matches the current DSL
- project `.env.example` files keep the working domain-based Keycloak examples unless explicitly overridden
- at least one `domain/*.api.dsl` file exists
- required artifacts exist:
- `server/prisma/schema.prisma`
- env examples
- required scaffold files
- auth/runtime/realm artifacts
- if the current validator policy checks `api-summary.json`, it exists and is fresh relative to the DSL
- `server/` remains a valid Nest workspace
- `client/` remains a valid Vite workspace
- generation must not pass validation if framework scaffolding files were deleted and replaced by a hand-written minimal skeleton
- if dependencies are installed, build verification runs for `server/` and `client/`
- if dependencies are installed, backend and frontend build verification runs
- if dependencies are missing, build verification is reported as skipped with reason instead of green
### Auth checks
### Auth Checks
- frontend auth seam files exist
- backend auth seam files exist
- `401` and `403` semantics stay split
- `401` and `403` semantics remain split
- auth code keeps the required Keycloak/JWT contracts
- JWKS resolution chain matches the contract:
- JWKS resolution order remains:
1. explicit `KEYCLOAK_JWKS_URL`
2. OIDC discovery
3. certs fallback
### Filter checks
### Filter And UI Checks
- list resources expose filter UI (including `FilterButton`)
- list resources expose filter UI including `FilterButton`
- reference filters use `ReferenceInput` + `AutocompleteInput` with `filterToQuery`
- data provider preserves repeated query params for array filters
- backend FK filters keep exact-match semantics
- enum repeated params are mapped to Prisma `in`
- typed form mapping is preserved:
- `integer` / `decimal` -> `NumberInput`
- `date` -> `DateInput`
- reference fields intended for navigation keep `ReferenceField link="show"`
- `dataProvider` preserves repeated query params for array filters
- backend FK filters remain exact-match
- repeated enum params map to Prisma `in`
- Create/Edit forms keep type-correct inputs
- navigable references keep `ReferenceField link="show"`
- resources keep `show={...}` registration in `App.tsx`
### Natural-key checks
### Natural-Key Checks
- response records expose `id`
- route/update contracts use the real primary key
- natural-key sort/update paths do not regress to a fake physical `id`
### Realm checks
### Realm Checks
- a root `*-realm.json` artifact exists
- realm roles exist
- audience delivery exists
- required claims are explicit
- SPA/backend client structure is explicit
- required roles, audience delivery, and claims remain explicit
- SPA and backend client structure remains explicit
### Runtime checks
### Runtime Checks
- compose topology stays PostgreSQL-only
- Prisma lifecycle scripts remain in place
- `/health` stays public
- backend can execute `npm run build` inside `server/`
- frontend can execute `npm run build` inside `client/` after dependencies are installed
- client/server `.env.example` stay aligned with the working runtime defaults:
- `https://sso.greact.ru`
- `toir`
- `toir-frontend`
- `toir-backend`
- `https://toir-frontend.greact.ru`
- optional runtime execution mode runs:
- `npx prisma generate`
- `npx prisma migrate dev`
- `npx prisma db seed`
- Docker topology remains PostgreSQL-only
- Prisma lifecycle commands remain available where required
- `/health` remains public
- backend build runs inside `server/`
- frontend build runs inside `client/`
- client/server `.env.example` stay aligned with repository defaults
### Scaffold checks
### Output Contract Checks
- backend initialization starts from official Nest CLI scaffolding
- frontend initialization starts from official Vite React TypeScript scaffolding
- feature generation happens after scaffold creation, not instead of scaffold creation
- repair happens before generation when workspace is degraded
- required framework configs and entry files must survive subsequent LLM edits
- every generated Create/Update DTO imports from `'class-validator'`
- DTO fields have type-correct decorators
- optional/nullable fields carry `@IsOptional()` before the type decorator
- controllers carry the required guards and roles
- React Admin components use correct input/field types
The automated gate is intentionally small. It enforces the critical reproducibility contract without turning the repository into a test platform or a generator engine.
### Eval Harness
- `npm run eval:generation` runs fixture-based semantic checks
- eval failures block completion
- prompt changes that break evals are regressions, not acceptable simplifications

View File

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

View File

@@ -3,5 +3,4 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"
CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"
KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"
KEYCLOAK_AUDIENCE="toir-backend"
# Optional: if omitted, backend uses OIDC discovery and then falls back to issuer + /protocol/openid-connect/certs
# KEYCLOAK_JWKS_URL="https://sso.greact.ru/realms/toir/protocol/openid-connect/certs"
KEYCLOAK_JWKS_URL=""

View File

@@ -1,25 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

33
server/.gitignore vendored
View File

@@ -1,32 +1,5 @@
# Dependencies
node_modules/
# Build
dist/
# Environment
node_modules
# Keep environment variables out of version control
.env
.env.local
.env.*.local
# Prisma
prisma/migrations/**/migration_lock.toml
# Logs
logs/
*.log
npm-debug.log*
# OS files
.DS_Store
Thumbs.db
# Editor / IDE
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/generated/prisma

View File

@@ -1,4 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
}

View File

@@ -2,19 +2,22 @@ FROM node:20-bookworm-slim AS build
WORKDIR /app
ARG DATABASE_URL=postgresql://postgres:postgres@localhost:5432/toir
ENV DATABASE_URL=$DATABASE_URL
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci
COPY nest-cli.json tsconfig*.json ./
COPY nest-cli.json tsconfig*.json prisma.config.ts ./
COPY src ./src
RUN npx prisma generate
RUN npm run build
FROM node:20-bookworm-slim AS runtime
@@ -24,14 +27,18 @@ WORKDIR /app
ENV NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& apt-get install -y --no-install-recommends openssl ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
COPY --from=build /app/dist ./dist
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main.js"]
CMD ["./docker-entrypoint.sh"]

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