Merge feat/keycloak into add_filters with manual conflict resolution.
Preserve Keycloak auth/RBAC contracts while retaining filter behavior and generator consistency for future regenerations. Made-with: Cursor
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Dependencies
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
**/dist/
|
||||||
|
**/dist-ssr/
|
||||||
|
**/coverage/
|
||||||
|
**/.cache/
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
65
README.md
65
README.md
@@ -1,3 +1,64 @@
|
|||||||
This directory defines the AI generation context.
|
This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline.
|
||||||
|
|
||||||
All code generation must follow the rules described in these documents.
|
It is not a new generator engine and it is not a compiler platform. The repository remains:
|
||||||
|
|
||||||
|
- an AI generation context
|
||||||
|
- an active generated and maintained fullstack CRUD project
|
||||||
|
- `server/` as the active backend target output path
|
||||||
|
- `client/` as the active frontend target output path
|
||||||
|
- an LLM-first orchestration baseline with CLI-first framework bootstrap
|
||||||
|
- a compact rule set that strengthens the existing pipeline with:
|
||||||
|
- `domain-summary.json`
|
||||||
|
- a physical root-level realm artifact
|
||||||
|
- a lightweight automated validation gate
|
||||||
|
|
||||||
|
## Active knowledge blocks
|
||||||
|
|
||||||
|
The active prompt corpus is intentionally normalized to six stable blocks:
|
||||||
|
|
||||||
|
1. [prompts/general-prompt.md](prompts/general-prompt.md)
|
||||||
|
2. [prompts/auth-rules.md](prompts/auth-rules.md)
|
||||||
|
3. [prompts/backend-rules.md](prompts/backend-rules.md)
|
||||||
|
4. [prompts/frontend-rules.md](prompts/frontend-rules.md)
|
||||||
|
5. [prompts/runtime-rules.md](prompts/runtime-rules.md)
|
||||||
|
6. [prompts/validation-rules.md](prompts/validation-rules.md)
|
||||||
|
|
||||||
|
## Baseline contracts
|
||||||
|
|
||||||
|
- `domain/*.dsl` is the source of truth for the domain model.
|
||||||
|
- [domain-summary.json](domain-summary.json) is a derived artifact for LLM stabilization and validation.
|
||||||
|
- [toir-realm.json](toir-realm.json) is the physical Keycloak bootstrap artifact baseline.
|
||||||
|
- `server/` and `client/` are the active target output paths for this repository.
|
||||||
|
- `server/` must remain a valid NestJS workspace baseline.
|
||||||
|
- `client/` must remain a valid Vite React TypeScript workspace baseline.
|
||||||
|
|
||||||
|
## Scaffold baseline
|
||||||
|
|
||||||
|
- Generation remains LLM-first for orchestration and domain-derived feature code.
|
||||||
|
- Framework bootstrap is CLI-first:
|
||||||
|
- backend baseline starts from official Nest CLI conventions
|
||||||
|
- frontend baseline starts from official Vite React TypeScript conventions
|
||||||
|
- If `server/` or `client/` drift away from a valid workspace, repair the workspace baseline before generating more feature code.
|
||||||
|
- Do not replace the framework workspace with a hand-written minimal skeleton.
|
||||||
|
|
||||||
|
## Anti-regression contract
|
||||||
|
|
||||||
|
- The active prompts define forbidden generation patterns, required invariants, and recovery rules for future agents.
|
||||||
|
- Buildability is part of the baseline contract, not an optional follow-up.
|
||||||
|
- Validation targets `domain/*.dsl` as reusable source inputs, while TOiR names remain project defaults/examples.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
- [docs/repository-structure.md](docs/repository-structure.md) explains the normalized folder structure.
|
||||||
|
- Active prompts live in `prompts/`.
|
||||||
|
- Helper scripts live in `tools/`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate:domain-summary
|
||||||
|
npm run validate:generation
|
||||||
|
npm run validate:generation:runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run validate:generation` now checks both contract shape and workspace validity. When dependencies are installed, it also verifies `npm run build` in `server/` and `client/`. If dependencies are missing, it reports build verification as skipped instead of pretending the baseline is fully green.
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
# Backend Architecture
|
|
||||||
|
|
||||||
Backend stack:
|
|
||||||
|
|
||||||
- Node.js
|
|
||||||
- TypeScript
|
|
||||||
- NestJS
|
|
||||||
- Prisma ORM
|
|
||||||
- PostgreSQL
|
|
||||||
|
|
||||||
The backend is generated from the DSL specification.
|
|
||||||
|
|
||||||
Each DSL entity becomes:
|
|
||||||
|
|
||||||
- Prisma model
|
|
||||||
- NestJS module
|
|
||||||
- CRUD controller
|
|
||||||
- Service
|
|
||||||
- DTO definitions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project Structure
|
|
||||||
|
|
||||||
server/
|
|
||||||
package.json
|
|
||||||
|
|
||||||
prisma/
|
|
||||||
schema.prisma
|
|
||||||
|
|
||||||
src/
|
|
||||||
main.ts
|
|
||||||
app.module.ts
|
|
||||||
|
|
||||||
modules/
|
|
||||||
|
|
||||||
{entity}/
|
|
||||||
{entity}.module.ts
|
|
||||||
{entity}.controller.ts
|
|
||||||
{entity}.service.ts
|
|
||||||
|
|
||||||
dto/
|
|
||||||
create-{entity}.dto.ts
|
|
||||||
update-{entity}.dto.ts
|
|
||||||
{entity}.response.dto.ts
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Module Rules
|
|
||||||
|
|
||||||
Each entity generates exactly one NestJS module.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
Entity:
|
|
||||||
Equipment
|
|
||||||
|
|
||||||
Module:
|
|
||||||
|
|
||||||
modules/
|
|
||||||
equipment/
|
|
||||||
equipment.module.ts
|
|
||||||
equipment.controller.ts
|
|
||||||
equipment.service.ts
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Controller Rules
|
|
||||||
|
|
||||||
Each entity controller must expose these endpoints:
|
|
||||||
|
|
||||||
- GET /{resource} — list
|
|
||||||
- GET /{resource}/:pk — get one
|
|
||||||
- POST /{resource} — create
|
|
||||||
- PATCH /{resource}/:pk — update
|
|
||||||
- DELETE /{resource}/:pk — delete
|
|
||||||
|
|
||||||
The path parameter **:pk** must use the **primary key attribute name** from the DSL, not always `:id`.
|
|
||||||
|
|
||||||
## Path parameter by primary key
|
|
||||||
|
|
||||||
| Entity | Primary key (DSL) | Path parameter | Example routes |
|
|
||||||
| ------------- | ----------------- | -------------- | -------------------------------------------------------- |
|
|
||||||
| Equipment | id | :id | GET /equipment/:id, PATCH /equipment/:id |
|
|
||||||
| EquipmentType | code | :code | GET /equipment-types/:code, PATCH /equipment-types/:code |
|
|
||||||
| RepairOrder | id | :id | GET /repair-orders/:id, PATCH /repair-orders/:id |
|
|
||||||
|
|
||||||
**Rule:** Use the actual primary key name. For example, **EquipmentType** has PK `code`, so routes must be:
|
|
||||||
|
|
||||||
- GET /equipment-types/:code
|
|
||||||
- PATCH /equipment-types/:code
|
|
||||||
- DELETE /equipment-types/:code
|
|
||||||
|
|
||||||
**Do not** use `/equipment-types/:id` when the entity primary key is `code`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Health Endpoint
|
|
||||||
|
|
||||||
Every generated backend must expose a **health endpoint** so that runtime and orchestration can verify the API is up.
|
|
||||||
|
|
||||||
- **Path:** `GET /health`
|
|
||||||
- **Response:** JSON with a status indicator (e.g. `{ "status": "ok" }`).
|
|
||||||
|
|
||||||
## Controller example
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
@Controller("health")
|
|
||||||
export class HealthController {
|
|
||||||
@Get()
|
|
||||||
getHealth() {
|
|
||||||
return { status: "ok" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Register the health controller in the root app module (or a dedicated health module). No authentication required for this endpoint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# List Endpoint
|
|
||||||
|
|
||||||
List endpoint must support pagination and filters via query parameters.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
GET /equipment?page=0&size=10
|
|
||||||
|
|
||||||
Response format must follow React Admin requirements:
|
|
||||||
|
|
||||||
{
|
|
||||||
"data": [],
|
|
||||||
"total": number
|
|
||||||
}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Service Layer
|
|
||||||
|
|
||||||
Services implement CRUD operations using Prisma.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
findAll()
|
|
||||||
findOne(id)
|
|
||||||
create(data)
|
|
||||||
update(id, data)
|
|
||||||
remove(id)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# DTO Rules
|
|
||||||
|
|
||||||
Create DTO:
|
|
||||||
|
|
||||||
- contains required fields
|
|
||||||
- does NOT contain generated primary keys
|
|
||||||
|
|
||||||
Update DTO:
|
|
||||||
|
|
||||||
- all fields optional
|
|
||||||
|
|
||||||
Response DTO:
|
|
||||||
|
|
||||||
- mirrors domain entity attributes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Naming Conventions
|
|
||||||
|
|
||||||
## Entity naming
|
|
||||||
|
|
||||||
DSL entities use PascalCase. Generated backend artifacts use the same base name in lowercase for folders and file prefixes.
|
|
||||||
|
|
||||||
- **Equipment** → equipment module
|
|
||||||
- **EquipmentType** → equipment-type module (or equipment-type as path segment)
|
|
||||||
- **RepairOrder** → repair-order module
|
|
||||||
|
|
||||||
## Module naming
|
|
||||||
|
|
||||||
One entity = one module folder. Folder name = entity name in kebab-case (lowercase, hyphen-separated).
|
|
||||||
|
|
||||||
- Equipment → `modules/equipment/`
|
|
||||||
- EquipmentType → `modules/equipment-type/`
|
|
||||||
- RepairOrder → `modules/repair-order/`
|
|
||||||
|
|
||||||
## Controller naming
|
|
||||||
|
|
||||||
- File: `{entity-kebab}.controller.ts`
|
|
||||||
- Class: `EquipmentController`, `EquipmentTypeController`, `RepairOrderController`
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- `equipment.controller.ts`
|
|
||||||
- `equipment-type.controller.ts`
|
|
||||||
- `repair-order.controller.ts`
|
|
||||||
|
|
||||||
## Service naming
|
|
||||||
|
|
||||||
- File: `{entity-kebab}.service.ts`
|
|
||||||
- Class: `EquipmentService`, `EquipmentTypeService`, `RepairOrderService`
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- `equipment.service.ts`
|
|
||||||
- `equipment-type.service.ts`
|
|
||||||
- `repair-order.service.ts`
|
|
||||||
|
|
||||||
## DTO naming
|
|
||||||
|
|
||||||
- Create: `create-{entity-kebab}.dto.ts` (e.g. `create-equipment.dto.ts`, `create-repair-order.dto.ts`)
|
|
||||||
- Update: `update-{entity-kebab}.dto.ts` (e.g. `update-equipment.dto.ts`)
|
|
||||||
- Response: `{entity-kebab}.response.dto.ts` or use entity name for list/detail response types
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Resource Naming Rules
|
|
||||||
|
|
||||||
API resource paths are derived from the entity name:
|
|
||||||
|
|
||||||
1. **PascalCase → kebab-case:** Replace camelCase with lowercase hyphenated segments.
|
|
||||||
2. **Pluralize:** Use plural form for the resource path (list endpoint represents a collection).
|
|
||||||
|
|
||||||
| Entity (DSL) | API path (resource) |
|
|
||||||
| ------------- | ------------------- |
|
|
||||||
| Equipment | /equipment |
|
|
||||||
| EquipmentType | /equipment-types |
|
|
||||||
| RepairOrder | /repair-orders |
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- **Equipment** → `equipment` (already singular-looking; path is still `/equipment` for consistency with REST resource naming).
|
|
||||||
- **EquipmentType** → `equipment-types` (camelCase "EquipmentType" → "equipment-type", then plural → "equipment-types").
|
|
||||||
- **RepairOrder** → `repair-orders` ("RepairOrder" → "repair-order" → "repair-orders").
|
|
||||||
|
|
||||||
Standard endpoints per resource:
|
|
||||||
|
|
||||||
- GET /{resource} — list
|
|
||||||
- GET /{resource}/:pk — get one (pk = primary key name, e.g. :id or :code)
|
|
||||||
- POST /{resource} — create
|
|
||||||
- PATCH /{resource}/:pk — update
|
|
||||||
- DELETE /{resource}/:pk — delete
|
|
||||||
|
|
||||||
See **Controller Rules** above for the rule that :pk must match the entity's primary key attribute name.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Environment and runtime
|
|
||||||
|
|
||||||
- **Environment variables:** Backend requires at least `DATABASE_URL`. See **backend/runtime-rules.md**.
|
|
||||||
- **.env:** Generated project must include a `.env` (and `.env.example`) with `DATABASE_URL` so the app starts without runtime errors.
|
|
||||||
- **PrismaService:** Must follow **backend/prisma-service.md** (OnModuleInit, $connect; no beforeExit).
|
|
||||||
- **Prisma client:** Add `"postinstall": "prisma generate"` (or equivalent) to package.json so the client is generated after install.
|
|
||||||
- **Migrations:** Document or run `npx prisma migrate dev` after schema generation. See **backend/runtime-rules.md** and **generation/backend-generation.md**.
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Database Runtime
|
|
||||||
|
|
||||||
The generated project must include a **PostgreSQL development database** so the application can run immediately after generation without manual database setup.
|
|
||||||
|
|
||||||
Use **Docker** to provision the database.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# docker-compose.yml
|
|
||||||
|
|
||||||
The generator must create a `docker-compose.yml` file at the **project root** (same level as `server/` and `client/` directories).
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16
|
|
||||||
container_name: toir-postgres
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
POSTGRES_DB: toir
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Rules
|
|
||||||
|
|
||||||
- **Location:** Project root (e.g. `TOiR-generation/docker-compose.yml` or monorepo root).
|
|
||||||
- **Service name:** Can be `postgres` or project-specific (e.g. `toir-postgres` as container_name).
|
|
||||||
- **Credentials and DB name** must match the `DATABASE_URL` in `server/.env`:
|
|
||||||
- User: `postgres`
|
|
||||||
- Password: `postgres`
|
|
||||||
- Database: `toir`
|
|
||||||
- Host: `localhost`
|
|
||||||
- Port: `5432`
|
|
||||||
- **Volume:** Use a named volume so data persists across container restarts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Usage
|
|
||||||
|
|
||||||
Start the database before running the backend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Stop:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
Without this file and a running database, the backend will fail at runtime with errors such as:
|
|
||||||
|
|
||||||
```
|
|
||||||
PrismaClientInitializationError P1001: Can't reach database server at localhost:5432
|
|
||||||
```
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
# DSL → Prisma Mapping Rules
|
|
||||||
|
|
||||||
The domain DSL defines the database schema.
|
|
||||||
|
|
||||||
Each DSL entity becomes a Prisma model.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Type Mapping (Prisma schema)
|
|
||||||
|
|
||||||
| DSL Type | Prisma Type |
|
|
||||||
|--------|-------------|
|
|
||||||
| string | String |
|
|
||||||
| uuid | String @id @default(uuid()) |
|
|
||||||
| integer | Int |
|
|
||||||
| decimal | Decimal |
|
|
||||||
| date | DateTime |
|
|
||||||
| text | String |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# DTO Type Mapping (API / serialization)
|
|
||||||
|
|
||||||
For DTOs and API responses, use types that serialize to JSON without errors. Prisma's `Decimal` and `DateTime` do not always serialize predictably; map them as follows in generated DTOs:
|
|
||||||
|
|
||||||
| DSL Type | Prisma Type | DTO / API type | Notes |
|
|
||||||
|----------|-------------|----------------|-------|
|
|
||||||
| string | String | string | — |
|
|
||||||
| uuid | String | string | — |
|
|
||||||
| integer | Int | number | — |
|
|
||||||
| decimal | Decimal | **string** | Avoid Prisma Decimal in DTO; use string to prevent serialization issues. |
|
|
||||||
| date | DateTime | **string** | ISO 8601 date string (e.g. `"2025-03-08T00:00:00.000Z"`). |
|
|
||||||
| text | String | string | — |
|
|
||||||
| enum | enum | string | Enum value as string. |
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
- **decimal** → In create/update/response DTOs, use `string` (or a type that serializes to string). Convert to/from Prisma `Decimal` only in the service layer.
|
|
||||||
- **date** → In DTOs, use `string` (ISO date). Convert to/from Prisma `DateTime` in the service layer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Enum Mapping
|
|
||||||
|
|
||||||
DSL enum becomes Prisma enum.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
DSL
|
|
||||||
|
|
||||||
enum EquipmentStatus {
|
|
||||||
Active
|
|
||||||
Repair
|
|
||||||
}
|
|
||||||
|
|
||||||
Prisma
|
|
||||||
|
|
||||||
enum EquipmentStatus {
|
|
||||||
Active
|
|
||||||
Repair
|
|
||||||
}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Foreign Keys
|
|
||||||
|
|
||||||
DSL foreign keys become Prisma relations.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
DSL
|
|
||||||
|
|
||||||
attribute equipmentTypeCode {
|
|
||||||
type string;
|
|
||||||
key foreign {
|
|
||||||
relates EquipmentType.code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Prisma
|
|
||||||
|
|
||||||
equipmentTypeCode String
|
|
||||||
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Primary Keys
|
|
||||||
|
|
||||||
Primary keys defined in DSL must become Prisma model identifiers.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
key primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
Prisma
|
|
||||||
|
|
||||||
id String @id @default(uuid())
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Prisma Service Rules
|
|
||||||
|
|
||||||
Use Prisma v5 compatible service. See **backend/prisma-service.md** for full specification.
|
|
||||||
|
|
||||||
- PrismaService MUST implement `OnModuleInit` and call `await this.$connect()` in `onModuleInit()`.
|
|
||||||
- PrismaService MUST NOT use the deprecated `this.$on('beforeExit', ...)` hook (not supported in Prisma 5).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Prisma Lifecycle Rules
|
|
||||||
|
|
||||||
These lifecycle rules must be applied in generated projects so Prisma runtime is reliable after generation and after dependency installation.
|
|
||||||
|
|
||||||
## 1) Generate Prisma client after schema changes
|
|
||||||
|
|
||||||
Whenever `prisma/schema.prisma` is generated or updated, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma generate
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2) postinstall lifecycle
|
|
||||||
|
|
||||||
Generated `server/package.json` must include:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"scripts": {
|
|
||||||
"postinstall": "prisma generate"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This ensures Prisma client generation after `npm install`.
|
|
||||||
|
|
||||||
## 3) Migration lifecycle
|
|
||||||
|
|
||||||
Use development migration workflow:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma migrate dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Run this after database startup and before starting the backend server.
|
|
||||||
|
|
||||||
## 4) Seed lifecycle
|
|
||||||
|
|
||||||
If seed is configured (see `backend/seed-rules.md`), run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma db seed
|
|
||||||
```
|
|
||||||
|
|
||||||
Generated `server/package.json` should include:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"prisma": {
|
|
||||||
"seed": "ts-node prisma/seed.ts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `tsx prisma/seed.ts` if the project standard uses `tsx` instead of `ts-node`.
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# PrismaService Implementation
|
|
||||||
|
|
||||||
The generated backend must provide a NestJS injectable service that wraps PrismaClient. This document defines the **only** supported implementation for Prisma 5+.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Correct Implementation
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
|
||||||
async onModuleInit() {
|
|
||||||
await this.$connect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why this pattern
|
|
||||||
|
|
||||||
- **OnModuleInit:** NestJS lifecycle hook; `onModuleInit()` runs when the module is initialized, ensuring the database connection is established before handling requests.
|
|
||||||
- **$connect():** Explicitly connects the Prisma client. Required for reliable connection handling in serverless or long-running apps.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Deprecated: Do NOT Use
|
|
||||||
|
|
||||||
The following pattern is **deprecated** in Prisma 5 and must **not** be generated:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// WRONG — do not generate
|
|
||||||
this.$on("beforeExit", async () => {
|
|
||||||
await this.$disconnect();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- `beforeExit` is not supported in Prisma 5.
|
|
||||||
- Using it will cause runtime errors or warnings.
|
|
||||||
|
|
||||||
## Rule for generators
|
|
||||||
|
|
||||||
Generated `PrismaService` (or equivalent) must:
|
|
||||||
|
|
||||||
1. Extend `PrismaClient`.
|
|
||||||
2. Implement `OnModuleInit`.
|
|
||||||
3. Call `await this.$connect()` in `onModuleInit()`.
|
|
||||||
4. **Not** use `this.$on('beforeExit', ...)` or any `beforeExit` hook.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Module Registration
|
|
||||||
|
|
||||||
Register the service in a dedicated Prisma module (or in `AppModule`) so it can be injected into other services:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// prisma.module.ts (or app.module.ts)
|
|
||||||
import { Global, Module } from "@nestjs/common";
|
|
||||||
import { PrismaService } from "./prisma.service";
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [PrismaService],
|
|
||||||
exports: [PrismaService],
|
|
||||||
})
|
|
||||||
export class PrismaModule {}
|
|
||||||
```
|
|
||||||
|
|
||||||
Using `@Global()` allows injecting `PrismaService` in any module without re-importing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# File location
|
|
||||||
|
|
||||||
Generated file:
|
|
||||||
|
|
||||||
- `src/prisma.prisma.service.ts` or `src/prisma.service.ts`
|
|
||||||
|
|
||||||
Class name: `PrismaService`.
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# Backend Runtime Rules
|
|
||||||
|
|
||||||
This document defines runtime configuration requirements for the generated backend. Generators must produce a project that runs without errors when these rules are followed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Environment Variables
|
|
||||||
|
|
||||||
The backend **must** have a `.env` file at the project root (e.g. `server/.env` or backend package root). This file must **not** be committed with real credentials; provide an example instead (e.g. `.env.example`).
|
|
||||||
|
|
||||||
## Required variables
|
|
||||||
|
|
||||||
| Variable | Required | Description |
|
|
||||||
| ------------ | -------- | --------------------------------------- |
|
|
||||||
| DATABASE_URL | Yes | PostgreSQL connection string for Prisma |
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Generation requirement
|
|
||||||
|
|
||||||
When generating the backend, **always** create:
|
|
||||||
|
|
||||||
1. **`.env.example`** — with placeholder DATABASE_URL and instructions.
|
|
||||||
2. **`.env`** — with the same placeholder so the app can start; user replaces with real values.
|
|
||||||
|
|
||||||
If the generator does not create `.env`, the first run will fail with:
|
|
||||||
|
|
||||||
```
|
|
||||||
Environment variable not found: DATABASE_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Prisma Client Generation
|
|
||||||
|
|
||||||
After the Prisma schema is generated or modified, the Prisma client must be generated.
|
|
||||||
|
|
||||||
## Command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma generate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lifecycle rule
|
|
||||||
|
|
||||||
Add to generated `package.json` so that `prisma generate` runs after every `npm install`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"scripts": {
|
|
||||||
"postinstall": "prisma generate"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This ensures that after cloning or installing dependencies, the Prisma client is available without a manual step.
|
|
||||||
|
|
||||||
**Note:** Path may need to be adjusted if Prisma schema lives in a subfolder (e.g. `prisma generate` is typically run from the package root where `prisma/schema.prisma` exists).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Database Migration
|
|
||||||
|
|
||||||
The generation process must document and support database migration.
|
|
||||||
|
|
||||||
## Command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma migrate dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Run after:
|
|
||||||
|
|
||||||
1. Prisma schema has been generated or updated.
|
|
||||||
2. `npx prisma generate` has been run.
|
|
||||||
|
|
||||||
## Generation requirement
|
|
||||||
|
|
||||||
1. Include migration in the backend generation pipeline (see `generation/backend-generation.md`).
|
|
||||||
2. Document in README or post-generation validation that the user must run `npx prisma migrate dev` before first run (or provide a setup script that runs it).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
|
|
||||||
| Requirement | Action |
|
|
||||||
| --------------- | ------------------------------------------------------------- |
|
|
||||||
| DATABASE_URL | Create `.env` and `.env.example` with DATABASE_URL |
|
|
||||||
| Prisma client | Run `npx prisma generate`; add `postinstall` script |
|
|
||||||
| Database schema | Document/run `npx prisma migrate dev` after schema generation |
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# Seed Data Rules
|
|
||||||
|
|
||||||
The generator must create a **Prisma seed script** so the development database contains minimal sample data. This allows the frontend and API to be used immediately after migration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Seed Script Location
|
|
||||||
|
|
||||||
**File:** `server/prisma/seed.ts`
|
|
||||||
|
|
||||||
(Or `server/prisma/seed.js` if the project uses JavaScript; TypeScript is preferred and may require `ts-node` or `tsx` to run.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Seed Data Requirements
|
|
||||||
|
|
||||||
The seed script must create **minimal sample data** for at least one record per main entity, so that:
|
|
||||||
|
|
||||||
- List views show data.
|
|
||||||
- Reference fields (e.g. equipment type, equipment) have valid options.
|
|
||||||
- The app is demo-ready without manual data entry.
|
|
||||||
|
|
||||||
## Example scope (TOiR-style domain)
|
|
||||||
|
|
||||||
- **One EquipmentType** (e.g. code `"pump"`, name `"Pump"`).
|
|
||||||
- **One Equipment** (e.g. linked to that type, with required fields filled).
|
|
||||||
- **One RepairOrder** (e.g. linked to that equipment, with required fields filled).
|
|
||||||
|
|
||||||
Order matters: create EquipmentType first, then Equipment (references type), then RepairOrder (references equipment). Use Prisma `create` with the generated client; respect unique constraints and foreign keys.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Prisma Seed Configuration
|
|
||||||
|
|
||||||
The generator must add the following to **`server/package.json`**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"prisma": {
|
|
||||||
"seed": "ts-node prisma/seed.ts"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If the project uses a different runner (e.g. `tsx`), use that instead:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"prisma": {
|
|
||||||
"seed": "tsx prisma/seed.ts"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ensure the seed runner is installed (e.g. `ts-node` as dev dependency) so that:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma db seed
|
|
||||||
```
|
|
||||||
|
|
||||||
runs successfully.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Running the Seed
|
|
||||||
|
|
||||||
After migrations:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npx prisma migrate dev
|
|
||||||
npx prisma db seed
|
|
||||||
```
|
|
||||||
|
|
||||||
Or document that the user can run `npx prisma db seed` once after the first migration to populate sample data.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
|
|
||||||
| Requirement | Action |
|
|
||||||
|--------------------|--------|
|
|
||||||
| Seed script | Create `server/prisma/seed.ts` (or equivalent). |
|
|
||||||
| Sample data | At least one EquipmentType, one Equipment, one RepairOrder (or equivalent for the DSL). |
|
|
||||||
| package.json | Add `"prisma": { "seed": "ts-node prisma/seed.ts" }` (or tsx). |
|
|
||||||
| Seed runner | Ensure ts-node (or tsx) is available so `prisma db seed` works. |
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# Service Layer Rules
|
|
||||||
|
|
||||||
Generated NestJS services must follow these rules so that update operations work correctly with React Admin and Prisma.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Update Payload Sanitization
|
|
||||||
|
|
||||||
React Admin (and many REST clients) **always send `id`** in PATCH/PUT request bodies.
|
|
||||||
|
|
||||||
Example payload from React Admin:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "003",
|
|
||||||
"name": "Pump"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Some entities use a **different primary key** (e.g. `code`). The API response includes `id` (mapped from the PK) for React Admin compatibility, but the Prisma model may have no `id` field—only `code`. If the service passes the incoming DTO directly to Prisma:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// WRONG — causes runtime error
|
|
||||||
prisma.equipmentType.update({
|
|
||||||
where: { code },
|
|
||||||
data: dto // dto contains "id", which is not a Prisma field
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Prisma throws because `id` (and possibly other non-updatable fields) are not on the model or must not be written in `data`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Rules
|
|
||||||
|
|
||||||
1. **Update payload must be sanitized** before passing to the ORM. Do not pass the raw request body as `data` to `prisma.*.update()`.
|
|
||||||
|
|
||||||
2. **Remove `id`** from the update payload. React Admin sends `id` for identity; it must not be written to the database as a column (unless the entity actually has an `id` column and it is intended to be immutable on update).
|
|
||||||
|
|
||||||
3. **Remove the primary key** field from the update payload. The primary key is used in `where`; it must not appear in `data`. For example, remove `code` from `data` when updating by `code`.
|
|
||||||
|
|
||||||
4. **Remove readonly attributes** (e.g. created timestamps, server-generated fields) if they are present in the DTO, so they are not passed to Prisma `data`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Example Implementation
|
|
||||||
|
|
||||||
Destructure identity and primary key (and any other non-updatable fields) out of the DTO, then pass only the rest as `data`:
|
|
||||||
|
|
||||||
**Entity with primary key `code` (e.g. EquipmentType):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
update(code: string, dto: UpdateEquipmentTypeDto) {
|
|
||||||
const { id, code: _pk, ...data } = dto as any;
|
|
||||||
return this.prisma.equipmentType.update({
|
|
||||||
where: { code },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Or, if the DTO type does not include `id` or `code`, explicitly omit only the fields that must not be written:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
update(code: string, dto: UpdateEquipmentTypeDto) {
|
|
||||||
const { id, code: _pk, ...data } = dto as Record<string, unknown>;
|
|
||||||
return this.prisma.equipmentType.update({
|
|
||||||
where: { code },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Entity with primary key `id` (e.g. Equipment):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
update(id: string, dto: UpdateEquipmentDto) {
|
|
||||||
const { id: _pk, ...data } = dto as any;
|
|
||||||
return this.prisma.equipment.update({
|
|
||||||
where: { id },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This way, `id` (and the PK) are never passed into `data`, and Prisma does not receive unknown or read-only fields.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
|
|
||||||
| Rule | Action |
|
|
||||||
|------|--------|
|
|
||||||
| Sanitize update payload | Before `prisma.*.update()`, strip non-data fields from the DTO. |
|
|
||||||
| Remove `id` | Do not pass `id` in `data` unless the entity has an updatable `id` (rare). |
|
|
||||||
| Remove primary key | Use PK only in `where`; omit from `data`. |
|
|
||||||
| Remove readonly fields | Omit created_at, server-only fields, etc. from `data`. |
|
|
||||||
5
client/.env.example
Normal file
5
client/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
|
VITE_KEYCLOAK_URL=https://sso.greact.ru
|
||||||
|
VITE_KEYCLOAK_REALM=toir
|
||||||
|
VITE_KEYCLOAK_CLIENT_ID=toir-frontend
|
||||||
|
|
||||||
10
client/package-lock.json
generated
10
client/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/material": "^7.3.9",
|
"@mui/material": "^7.3.9",
|
||||||
|
"keycloak-js": "^26.2.3",
|
||||||
"ra-data-simple-rest": "^5.14.4",
|
"ra-data-simple-rest": "^5.14.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-admin": "^5.14.4",
|
"react-admin": "^5.14.4",
|
||||||
@@ -3533,6 +3534,15 @@
|
|||||||
"jsonexport": "bin/jsonexport.js"
|
"jsonexport": "bin/jsonexport.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/keycloak-js": {
|
||||||
|
"version": "26.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.3.tgz",
|
||||||
|
"integrity": "sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"workspaces": [
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/material": "^7.3.9",
|
"@mui/material": "^7.3.9",
|
||||||
|
"keycloak-js": "^26.2.3",
|
||||||
"ra-data-simple-rest": "^5.14.4",
|
"ra-data-simple-rest": "^5.14.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-admin": "^5.14.4",
|
"react-admin": "^5.14.4",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Admin, Resource } from 'react-admin';
|
import { Admin, Resource } from 'react-admin';
|
||||||
import dataProvider from './dataProvider';
|
import dataProvider from './dataProvider';
|
||||||
|
import authProvider from './auth/authProvider';
|
||||||
|
|
||||||
import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList';
|
import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList';
|
||||||
import { EquipmentTypeCreate } from './resources/equipment-type/EquipmentTypeCreate';
|
import { EquipmentTypeCreate } from './resources/equipment-type/EquipmentTypeCreate';
|
||||||
@@ -17,7 +18,7 @@ import { RepairOrderEdit } from './resources/repair-order/RepairOrderEdit';
|
|||||||
import { RepairOrderShow } from './resources/repair-order/RepairOrderShow';
|
import { RepairOrderShow } from './resources/repair-order/RepairOrderShow';
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<Admin dataProvider={dataProvider}>
|
<Admin dataProvider={dataProvider} authProvider={authProvider} requireAuth>
|
||||||
<Resource
|
<Resource
|
||||||
name="equipment-types"
|
name="equipment-types"
|
||||||
options={{ label: 'Виды оборудования' }}
|
options={{ label: 'Виды оборудования' }}
|
||||||
|
|||||||
45
client/src/auth/authProvider.ts
Normal file
45
client/src/auth/authProvider.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { AuthProvider } from 'react-admin';
|
||||||
|
import {
|
||||||
|
forceReauthentication,
|
||||||
|
getIdentity,
|
||||||
|
getRealmRoles,
|
||||||
|
getValidAccessToken,
|
||||||
|
initKeycloak,
|
||||||
|
logoutFromKeycloak,
|
||||||
|
} from './keycloak';
|
||||||
|
|
||||||
|
const authProvider: AuthProvider = {
|
||||||
|
login: async () => {
|
||||||
|
await initKeycloak();
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
await logoutFromKeycloak();
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAuth: async () => {
|
||||||
|
await getValidAccessToken();
|
||||||
|
},
|
||||||
|
|
||||||
|
checkError: async (error) => {
|
||||||
|
const status = error?.status;
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
await forceReauthentication();
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 403) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
getIdentity: async () => getIdentity(),
|
||||||
|
|
||||||
|
getPermissions: async () => getRealmRoles(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authProvider;
|
||||||
|
|
||||||
96
client/src/auth/keycloak.ts
Normal file
96
client/src/auth/keycloak.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import Keycloak, { KeycloakTokenParsed } 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;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
24
client/src/config/env.ts
Normal file
24
client/src/config/env.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const REQUIRED_ENV_KEYS = [
|
||||||
|
'VITE_API_URL',
|
||||||
|
'VITE_KEYCLOAK_URL',
|
||||||
|
'VITE_KEYCLOAK_REALM',
|
||||||
|
'VITE_KEYCLOAK_CLIENT_ID',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type RequiredEnvKey = (typeof REQUIRED_ENV_KEYS)[number];
|
||||||
|
|
||||||
|
function readRequiredEnv(key: RequiredEnvKey): string {
|
||||||
|
const value = import.meta.env[key];
|
||||||
|
if (!value || !value.trim()) {
|
||||||
|
throw new Error(`Missing required environment variable: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
apiUrl: readRequiredEnv('VITE_API_URL'),
|
||||||
|
keycloakUrl: readRequiredEnv('VITE_KEYCLOAK_URL'),
|
||||||
|
keycloakRealm: readRequiredEnv('VITE_KEYCLOAK_REALM'),
|
||||||
|
keycloakClientId: readRequiredEnv('VITE_KEYCLOAK_CLIENT_ID'),
|
||||||
|
} as const;
|
||||||
|
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import { DataProvider, fetchUtils } from 'react-admin';
|
import { DataProvider, fetchUtils } from 'react-admin';
|
||||||
|
import { getValidAccessToken } from './auth/keycloak';
|
||||||
|
import { env } from './config/env';
|
||||||
|
|
||||||
const apiUrl = 'http://localhost:3000';
|
const apiUrl = env.apiUrl;
|
||||||
const httpClient = fetchUtils.fetchJson;
|
|
||||||
|
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>) {
|
function buildQueryString(query: Record<string, unknown>) {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { initKeycloak } from './auth/keycloak';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
const root = ReactDOM.createRoot(document.getElementById('root')!);
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
async function bootstrap() {
|
||||||
</React.StrictMode>,
|
await initKeycloak();
|
||||||
);
|
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.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>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,60 +13,27 @@ import {
|
|||||||
ReferenceField,
|
ReferenceField,
|
||||||
SelectArrayInput,
|
SelectArrayInput,
|
||||||
ReferenceInput,
|
ReferenceInput,
|
||||||
AutocompleteInput,
|
AutocompleteInput
|
||||||
} from "react-admin";
|
} from 'react-admin';
|
||||||
|
|
||||||
const statusChoices = [
|
const statusChoices = [
|
||||||
{ id: "Active", name: "В эксплуатации" },
|
{ id: 'Active', name: 'В эксплуатации' },
|
||||||
{ id: "Repair", name: "В ремонте" },
|
{ id: 'Repair', name: 'В ремонте' },
|
||||||
{ id: "Reserve", name: "В резерве" },
|
{ id: 'Reserve', name: 'В резерве' },
|
||||||
{ id: "WriteOff", name: "Списано" },
|
{ id: 'WriteOff', name: 'Списано' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const equipmentFilters = [
|
const equipmentFilters = [
|
||||||
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
|
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
|
||||||
<TextInput
|
<TextInput key="inventoryNumber" source="inventoryNumber" label="Инвентарный номер" />,
|
||||||
key="inventoryNumber"
|
<TextInput key="serialNumber" source="serialNumber" label="Заводской (серийный) номер" />,
|
||||||
source="inventoryNumber"
|
<TextInput key="name" source="name" label="Наименование единицы оборудования" />,
|
||||||
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 })} />
|
||||||
<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>,
|
</ReferenceInput>,
|
||||||
<SelectArrayInput
|
<SelectArrayInput key="status" source="status" label="Текущий статус" choices={statusChoices} />,
|
||||||
key="status"
|
<TextInput key="location" source="location" label="Место эксплуатации / скважина / куст" />,
|
||||||
source="status"
|
<TextInput key="notes" source="notes" label="Примечания" />
|
||||||
label="Текущий статус"
|
|
||||||
choices={statusChoices}
|
|
||||||
/>,
|
|
||||||
<TextInput
|
|
||||||
key="location"
|
|
||||||
source="location"
|
|
||||||
label="Место эксплуатации / скважина / куст"
|
|
||||||
/>,
|
|
||||||
<TextInput key="notes" source="notes" label="Примечания" />,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const EquipmentListActions = () => (
|
const EquipmentListActions = () => (
|
||||||
@@ -78,42 +45,20 @@ const EquipmentListActions = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const EquipmentList = () => (
|
export const EquipmentList = () => (
|
||||||
<List
|
<List actions={<EquipmentListActions />} filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}>
|
||||||
actions={<EquipmentListActions />}
|
|
||||||
filters={equipmentFilters}
|
|
||||||
sort={{ field: "inventoryNumber", order: "ASC" }}
|
|
||||||
>
|
|
||||||
<Datagrid rowClick="show">
|
<Datagrid rowClick="show">
|
||||||
<TextField source="id" label="id" />
|
<TextField source="id" label="id" />
|
||||||
<TextField source="inventoryNumber" label="Инвентарный номер" />
|
<TextField source="inventoryNumber" label="Инвентарный номер" />
|
||||||
<TextField source="serialNumber" label="Заводской (серийный) номер" />
|
<TextField source="serialNumber" label="Заводской (серийный) номер" />
|
||||||
<TextField source="name" label="Наименование единицы оборудования" />
|
<TextField source="name" label="Наименование единицы оборудования" />
|
||||||
<ReferenceField
|
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
|
||||||
source="equipmentTypeCode"
|
|
||||||
reference="equipment-types"
|
|
||||||
label="Вид оборудования"
|
|
||||||
link="show"
|
|
||||||
>
|
|
||||||
<TextField source="code" />
|
<TextField source="code" />
|
||||||
</ReferenceField>
|
</ReferenceField>
|
||||||
<SelectField
|
<SelectField source="status" label="Текущий статус" choices={statusChoices} />
|
||||||
source="status"
|
<TextField source="location" label="Место эксплуатации / скважина / куст" />
|
||||||
label="Текущий статус"
|
|
||||||
choices={statusChoices}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
source="location"
|
|
||||||
label="Место эксплуатации / скважина / куст"
|
|
||||||
/>
|
|
||||||
<DateField source="commissionedAt" label="Дата ввода в эксплуатацию" />
|
<DateField source="commissionedAt" label="Дата ввода в эксплуатацию" />
|
||||||
<NumberField
|
<NumberField source="totalEngineHours" label="Общая наработка, моточасов" />
|
||||||
source="totalEngineHours"
|
<NumberField source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
|
||||||
label="Общая наработка, моточасов"
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
source="engineHoursSinceLastRepair"
|
|
||||||
label="Наработка с последнего ремонта, моточасов"
|
|
||||||
/>
|
|
||||||
<DateField source="lastRepairAt" label="Дата последнего ремонта" />
|
<DateField source="lastRepairAt" label="Дата последнего ремонта" />
|
||||||
<TextField source="notes" label="Примечания" />
|
<TextField source="notes" label="Примечания" />
|
||||||
</Datagrid>
|
</Datagrid>
|
||||||
|
|||||||
11
client/src/vite-env.d.ts
vendored
11
client/src/vite-env.d.ts
vendored
@@ -1 +1,12 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
readonly VITE_KEYCLOAK_URL: string;
|
||||||
|
readonly VITE_KEYCLOAK_REALM: string;
|
||||||
|
readonly VITE_KEYCLOAK_CLIENT_ID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
container_name: toir-postgres
|
container_name: toir-postgres
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
@@ -10,7 +10,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres-data:
|
||||||
|
|||||||
35
docs/repository-structure.md
Normal file
35
docs/repository-structure.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Repository Structure
|
||||||
|
|
||||||
|
`KIS-TOiR` keeps the existing LLM-first generation philosophy and organizes the repository by meaning:
|
||||||
|
|
||||||
|
- `domain/`
|
||||||
|
- canonical DSL inputs
|
||||||
|
- DSL specification
|
||||||
|
- `prompts/`
|
||||||
|
- active prompt corpus used to drive generation
|
||||||
|
- `docs/`
|
||||||
|
- overview and repository-level architecture notes
|
||||||
|
- `tools/`
|
||||||
|
- helper scripts for summary generation and validation
|
||||||
|
- `server/`
|
||||||
|
- active backend target output
|
||||||
|
- `client/`
|
||||||
|
- active frontend target output
|
||||||
|
|
||||||
|
The repository keeps LLM-first generation orchestration, but framework bootstrap is CLI-first:
|
||||||
|
|
||||||
|
- `server/` must remain a valid NestJS workspace baseline
|
||||||
|
- `client/` must remain a valid Vite React TypeScript workspace baseline
|
||||||
|
- repair a broken workspace before applying more domain-derived generation changes
|
||||||
|
- future agents must treat forbidden generation patterns in `prompts/` as contract violations, not suggestions
|
||||||
|
|
||||||
|
Root-level files stay limited to repository-level artifacts such as:
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `package.json`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `domain-summary.json`
|
||||||
|
- `toir-realm.json`
|
||||||
|
- `.gitignore`
|
||||||
|
|
||||||
|
The repository does not introduce a new generator engine or compiler platform. It keeps the current LLM-first pipeline and makes it cleaner, more explicit, and easier to navigate.
|
||||||
324
domain-summary.json
Normal file
324
domain-summary.json
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
{
|
||||||
|
"sourceFiles": [
|
||||||
|
"domain/TOiR.domain.dsl"
|
||||||
|
],
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"name": "EquipmentType",
|
||||||
|
"primaryKey": "code",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "code",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"unique": true,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "manufacturer",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maintenanceIntervalHours",
|
||||||
|
"type": "integer",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "overhaulIntervalHours",
|
||||||
|
"type": "integer",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Equipment",
|
||||||
|
"primaryKey": "id",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "inventoryNumber",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"unique": true,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "serialNumber",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "equipmentTypeCode",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"type": "EquipmentStatus",
|
||||||
|
"required": true,
|
||||||
|
"unique": false,
|
||||||
|
"default": "Active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "location",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "commissionedAt",
|
||||||
|
"type": "date",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "totalEngineHours",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engineHoursSinceLastRepair",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lastRepairAt",
|
||||||
|
"type": "date",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"field": "equipmentTypeCode",
|
||||||
|
"references": {
|
||||||
|
"entity": "EquipmentType",
|
||||||
|
"field": "code"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RepairOrder",
|
||||||
|
"primaryKey": "id",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "number",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"unique": true,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "equipmentId",
|
||||||
|
"type": "uuid",
|
||||||
|
"required": true,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "repairKind",
|
||||||
|
"type": "RepairKind",
|
||||||
|
"required": true,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"type": "RepairOrderStatus",
|
||||||
|
"required": true,
|
||||||
|
"unique": false,
|
||||||
|
"default": "Draft"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "plannedAt",
|
||||||
|
"type": "date",
|
||||||
|
"required": true,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "startedAt",
|
||||||
|
"type": "date",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "completedAt",
|
||||||
|
"type": "date",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "contractor",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engineHoursAtRepair",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"unique": false,
|
||||||
|
"default": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"field": "equipmentId",
|
||||||
|
"references": {
|
||||||
|
"entity": "Equipment",
|
||||||
|
"field": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"enums": [
|
||||||
|
{
|
||||||
|
"name": "EquipmentStatus",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"name": "Active",
|
||||||
|
"label": "В эксплуатации"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Repair",
|
||||||
|
"label": "В ремонте"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reserve",
|
||||||
|
"label": "В резерве"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WriteOff",
|
||||||
|
"label": "Списано"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RepairKind",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"name": "TO",
|
||||||
|
"label": "Техническое обслуживание"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TR",
|
||||||
|
"label": "Текущий ремонт"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TRE",
|
||||||
|
"label": "Текущий расширенный ремонт"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KR",
|
||||||
|
"label": "Капитальный ремонт"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AR",
|
||||||
|
"label": "Аварийный ремонт"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MP",
|
||||||
|
"label": "Метрологическая поверка"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RepairOrderStatus",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"name": "Draft",
|
||||||
|
"label": "Черновик"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Approved",
|
||||||
|
"label": "Утверждена"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "InWork",
|
||||||
|
"label": "В работе"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Done",
|
||||||
|
"label": "Выполнена"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cancelled",
|
||||||
|
"label": "Отменена"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,10 +3,6 @@
|
|||||||
Сущности: Equipment (Оборудование), EquipmentType (Вид оборудования), RepairOrder (Заявка на ремонт)
|
Сущности: Equipment (Оборудование), EquipmentType (Вид оборудования), RepairOrder (Заявка на ремонт)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Перечисления
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
enum EquipmentStatus {
|
enum EquipmentStatus {
|
||||||
value Active {
|
value Active {
|
||||||
label "В эксплуатации";
|
label "В эксплуатации";
|
||||||
@@ -61,10 +57,6 @@ enum RepairOrderStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Справочник: Вид оборудования
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
entity EquipmentType {
|
entity EquipmentType {
|
||||||
description "Вид (марка) оборудования — нормативный справочник НСИ";
|
description "Вид (марка) оборудования — нормативный справочник НСИ";
|
||||||
|
|
||||||
@@ -87,7 +79,6 @@ entity EquipmentType {
|
|||||||
type string;
|
type string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Нормативный межремонтный ресурс (моточасы)
|
|
||||||
attribute maintenanceIntervalHours {
|
attribute maintenanceIntervalHours {
|
||||||
description "Периодичность ТО, моточасов";
|
description "Периодичность ТО, моточасов";
|
||||||
type integer;
|
type integer;
|
||||||
@@ -99,11 +90,6 @@ entity EquipmentType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Основная сущность: Оборудование
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
entity Equipment {
|
entity Equipment {
|
||||||
description "Единица оборудования — объект ремонта и технического обслуживания";
|
description "Единица оборудования — объект ремонта и технического обслуживания";
|
||||||
|
|
||||||
@@ -130,14 +116,13 @@ entity Equipment {
|
|||||||
is required;
|
is required;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Связь с видом оборудования (справочник НСИ)
|
attribute equipmentTypeCode {
|
||||||
attribute equipmentTypeCode {
|
type string;
|
||||||
type string;
|
key foreign {
|
||||||
key foreign {
|
relates EquipmentType.code;
|
||||||
relates EquipmentType.code;
|
|
||||||
}
|
|
||||||
is required;
|
|
||||||
}
|
}
|
||||||
|
is required;
|
||||||
|
}
|
||||||
|
|
||||||
attribute status {
|
attribute status {
|
||||||
description "Текущий статус";
|
description "Текущий статус";
|
||||||
@@ -156,7 +141,6 @@ entity Equipment {
|
|||||||
type date;
|
type date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Наработка фиксируется вручную или из производственной программы
|
|
||||||
attribute totalEngineHours {
|
attribute totalEngineHours {
|
||||||
description "Общая наработка, моточасов";
|
description "Общая наработка, моточасов";
|
||||||
type decimal;
|
type decimal;
|
||||||
@@ -178,11 +162,6 @@ entity Equipment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Заявка на ремонт
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
entity RepairOrder {
|
entity RepairOrder {
|
||||||
description "Заявка на ремонт — формируется по ППР или по факту обнаруженного дефекта";
|
description "Заявка на ремонт — формируется по ППР или по факту обнаруженного дефекта";
|
||||||
|
|
||||||
@@ -1,6 +1,46 @@
|
|||||||
# DSL Language Specification
|
# DSL Language Specification
|
||||||
|
|
||||||
This document describes the DSL (Domain Specific Language) used to specify fullstack CRUD applications. The DSL has four layers: Domain, DTO, API, and UI.
|
This document describes the single DSL (Domain Specific Language) used to specify fullstack CRUD applications. The only required DSL input is `domain/*.dsl`.
|
||||||
|
|
||||||
|
`domain-summary.json` is a derived artifact generated from this DSL to stabilize LLM-first generation and feed the lightweight validation gate. It must never replace the DSL as the source of truth. The active prompt corpus that consumes this contract lives in `prompts/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DSL Responsibility
|
||||||
|
|
||||||
|
The domain DSL defines only:
|
||||||
|
|
||||||
|
- domain model
|
||||||
|
- relations
|
||||||
|
- enums
|
||||||
|
|
||||||
|
The domain DSL is the single source of truth for:
|
||||||
|
|
||||||
|
- entities
|
||||||
|
- attributes
|
||||||
|
- primary keys
|
||||||
|
- foreign keys
|
||||||
|
- enums
|
||||||
|
|
||||||
|
The following layers are always derived from the domain DSL and must not be authored as standalone authoritative DSL inputs:
|
||||||
|
|
||||||
|
- DTO
|
||||||
|
- API
|
||||||
|
- UI
|
||||||
|
|
||||||
|
Optional extension mechanism:
|
||||||
|
|
||||||
|
```text
|
||||||
|
overrides/
|
||||||
|
api-overrides.dsl
|
||||||
|
ui-overrides.dsl
|
||||||
|
```
|
||||||
|
|
||||||
|
Override rules:
|
||||||
|
|
||||||
|
- Overrides are optional.
|
||||||
|
- The generator must work without them.
|
||||||
|
- Overrides may refine derived API or UI behavior, but they must not duplicate or redefine entities, attributes, primary keys, foreign keys, relations, or enums.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,7 +148,7 @@ attribute equipmentTypeCode {
|
|||||||
|
|
||||||
## required
|
## required
|
||||||
|
|
||||||
- **is required** — attribute is non-nullable in domain and (unless overridden) in DTOs.
|
- **is required** — attribute is non-nullable in the domain model and drives requiredness in derived DTO/API/UI artifacts.
|
||||||
- Absence of `is required` means the attribute is optional (nullable).
|
- Absence of `is required` means the attribute is optional (nullable).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -120,33 +160,6 @@ attribute equipmentTypeCode {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## map
|
|
||||||
|
|
||||||
Used in **DTO** and **UI** layers to bind a DTO/UI field to a domain entity attribute.
|
|
||||||
|
|
||||||
**In DTO:**
|
|
||||||
|
|
||||||
```
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
map Equipment.name;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Ensures DTO attribute corresponds to an existing `Entity.attribute` and that types align.
|
|
||||||
|
|
||||||
**In UI:**
|
|
||||||
|
|
||||||
```
|
|
||||||
attribute Наименование {
|
|
||||||
map Equipment.name;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- UI label (e.g. "Наименование") maps to domain field `Equipment.name` for correct data binding and generation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# DSL → System Component Mapping
|
# DSL → System Component Mapping
|
||||||
|
|
||||||
## DSL → Prisma
|
## DSL → Prisma
|
||||||
@@ -174,9 +187,9 @@ attribute Наименование {
|
|||||||
| entity | One module (e.g. equipment.module.ts) |
|
| entity | One module (e.g. equipment.module.ts) |
|
||||||
| entity | Controller with CRUD endpoints |
|
| entity | Controller with CRUD endpoints |
|
||||||
| entity | Service with Prisma CRUD |
|
| entity | Service with Prisma CRUD |
|
||||||
| DTO (Create) | create-{entity}.dto.ts |
|
| entity + attribute metadata | create-{entity}.dto.ts |
|
||||||
| DTO (Update) | update-{entity}.dto.ts |
|
| entity + attribute metadata | update-{entity}.dto.ts |
|
||||||
| DTO (Response) | Used for GET response shape |
|
| entity + attribute metadata | Response DTO / API shape |
|
||||||
|
|
||||||
API paths are derived from entity name: PascalCase → kebab-case, pluralized (e.g. `Equipment` → `/equipment`, `RepairOrder` → `/repair-orders`).
|
API paths are derived from entity name: PascalCase → kebab-case, pluralized (e.g. `Equipment` → `/equipment`, `RepairOrder` → `/repair-orders`).
|
||||||
|
|
||||||
@@ -193,21 +206,12 @@ API paths are derived from entity name: PascalCase → kebab-case, pluralized (e
|
|||||||
| type date | DateInput, DateField |
|
| type date | DateInput, DateField |
|
||||||
| enum | SelectInput with choices |
|
| enum | SelectInput with choices |
|
||||||
| foreign key | ReferenceInput, ReferenceField |
|
| foreign key | ReferenceInput, ReferenceField |
|
||||||
| UI attribute with map | Field with correct source |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# DTO Mapping
|
# Derived Layer Mapping
|
||||||
|
|
||||||
- **map Entity.attribute** — DTO attribute corresponds to domain attribute; types must match.
|
- **Create DTO** — derived from domain attributes and must not include generated primary keys (for example no `id` for uuid PKs).
|
||||||
- **Create DTO** — must not include generated primary keys (e.g. no `id` for uuid PK).
|
- **Update DTO** — derived from the same domain attributes with all fields optional for partial updates.
|
||||||
- **Update DTO** — all fields optional (nullable) for partial updates.
|
- **API response shape** — derived from domain attributes and must expose React Admin-compatible identifiers when needed.
|
||||||
- **List response DTO** — must expose `data` (array) and `total` (integer) for React Admin compatibility.
|
- **UI field mapping** — derived from attribute types, descriptions, enums, and foreign keys without a separate UI DSL.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# UI Mapping
|
|
||||||
|
|
||||||
- Each UI attribute should have **map Entity.attribute** so it binds to a real domain field.
|
|
||||||
- UI attribute name is the label (e.g. "Наименование"); **source** in generated components is the domain attribute name (e.g. `name`).
|
|
||||||
- Enums → SelectInput; foreign keys → ReferenceInput/ReferenceField.
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import ./TOiR;
|
|
||||||
|
|
||||||
ui UI.Equipment {
|
|
||||||
offset 600;
|
|
||||||
|
|
||||||
description "Единица оборудования — объект ремонта и технического обслуживания";
|
|
||||||
|
|
||||||
attribute Код {
|
|
||||||
map Equipment.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute ИнвентарныйНомер {
|
|
||||||
map Equipment.inventoryNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute СерийныйНомер {
|
|
||||||
map Equipment.serialNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute Наименование {
|
|
||||||
map Equipment.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Связь с видом оборудования (справочник НСИ)
|
|
||||||
attribute Тип {
|
|
||||||
map Equipment.equipmentTypeCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute Статус {
|
|
||||||
map Equipment.status;
|
|
||||||
description "Текущий статус";
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute МестоТекущее {
|
|
||||||
map Equipment.location;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute ДатаВвода {
|
|
||||||
map Equipment.commissionedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute НаработкаВсего {
|
|
||||||
map Equipment.totalEngineHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute НаработкаТекущая {
|
|
||||||
map Equipment.engineHoursSinceLastRepair;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute Ремонт {
|
|
||||||
map Equipment.lastRepairAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute Примечания {
|
|
||||||
map Equipment.notes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,811 +0,0 @@
|
|||||||
// ─────────────────────────────────────────────
|
|
||||||
// DTO: Вид оборудования (EquipmentType)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
dto DTO.EquipmentType {
|
|
||||||
description "Вид оборудования — полный объект ответа";
|
|
||||||
|
|
||||||
attribute code {
|
|
||||||
type string;
|
|
||||||
description "Код вида оборудования";
|
|
||||||
map EquipmentType.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
description "Наименование вида";
|
|
||||||
map EquipmentType.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute manufacturer {
|
|
||||||
type string;
|
|
||||||
description "Производитель";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.manufacturer;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute maintenanceIntervalHours {
|
|
||||||
type integer;
|
|
||||||
description "Периодичность ТО, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.maintenanceIntervalHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute overhaulIntervalHours {
|
|
||||||
type integer;
|
|
||||||
description "Периодичность КР, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.overhaulIntervalHours;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentTypeCreate {
|
|
||||||
description "Вид оборудования — тело запроса на создание";
|
|
||||||
|
|
||||||
attribute code {
|
|
||||||
type string;
|
|
||||||
description "Код вида оборудования";
|
|
||||||
is required;
|
|
||||||
map EquipmentType.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
description "Наименование вида";
|
|
||||||
is required;
|
|
||||||
map EquipmentType.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute manufacturer {
|
|
||||||
type string;
|
|
||||||
description "Производитель";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.manufacturer;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute maintenanceIntervalHours {
|
|
||||||
type integer;
|
|
||||||
description "Периодичность ТО, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.maintenanceIntervalHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute overhaulIntervalHours {
|
|
||||||
type integer;
|
|
||||||
description "Периодичность КР, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.overhaulIntervalHours;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentTypeUpdate {
|
|
||||||
description "Вид оборудования — тело запроса на обновление (частичное)";
|
|
||||||
|
|
||||||
attribute code {
|
|
||||||
type string;
|
|
||||||
description "Код вида оборудования";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
description "Наименование вида";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute manufacturer {
|
|
||||||
type string;
|
|
||||||
description "Производитель";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.manufacturer;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute maintenanceIntervalHours {
|
|
||||||
type integer;
|
|
||||||
description "Периодичность ТО, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.maintenanceIntervalHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute overhaulIntervalHours {
|
|
||||||
type integer;
|
|
||||||
description "Периодичность КР, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.overhaulIntervalHours;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentTypeListResponse {
|
|
||||||
description "Список видов оборудования (формат React Admin)";
|
|
||||||
|
|
||||||
attribute data {
|
|
||||||
type DTO.EquipmentType[];
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute total {
|
|
||||||
type integer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// DTO: Оборудование (Equipment)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
dto DTO.Equipment {
|
|
||||||
description "Единица оборудования — полный объект ответа";
|
|
||||||
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
map Equipment.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute inventoryNumber {
|
|
||||||
type string;
|
|
||||||
description "Инвентарный номер";
|
|
||||||
map Equipment.inventoryNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute serialNumber {
|
|
||||||
type string;
|
|
||||||
description "Заводской (серийный) номер";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.serialNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
description "Наименование единицы оборудования";
|
|
||||||
map Equipment.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentTypeCode {
|
|
||||||
type string;
|
|
||||||
description "Код вида оборудования";
|
|
||||||
map Equipment.equipmentTypeCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type EquipmentStatus;
|
|
||||||
description "Текущий статус";
|
|
||||||
map Equipment.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute location {
|
|
||||||
type string;
|
|
||||||
description "Место эксплуатации / скважина / куст";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.location;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute commissionedAt {
|
|
||||||
type date;
|
|
||||||
description "Дата ввода в эксплуатацию";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.commissionedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute totalEngineHours {
|
|
||||||
type decimal;
|
|
||||||
description "Общая наработка, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.totalEngineHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursSinceLastRepair {
|
|
||||||
type decimal;
|
|
||||||
description "Наработка с последнего ремонта, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.engineHoursSinceLastRepair;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute lastRepairAt {
|
|
||||||
type date;
|
|
||||||
description "Дата последнего ремонта";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.lastRepairAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
type text;
|
|
||||||
description "Примечания";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.notes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentCreate {
|
|
||||||
description "Единица оборудования — тело запроса на создание";
|
|
||||||
|
|
||||||
attribute inventoryNumber {
|
|
||||||
type string;
|
|
||||||
description "Инвентарный номер";
|
|
||||||
is required;
|
|
||||||
map Equipment.inventoryNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute serialNumber {
|
|
||||||
type string;
|
|
||||||
description "Заводской (серийный) номер";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.serialNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
description "Наименование единицы оборудования";
|
|
||||||
is required;
|
|
||||||
map Equipment.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentTypeCode {
|
|
||||||
type string;
|
|
||||||
description "Код вида оборудования";
|
|
||||||
is required;
|
|
||||||
map Equipment.equipmentTypeCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type EquipmentStatus;
|
|
||||||
description "Текущий статус";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute location {
|
|
||||||
type string;
|
|
||||||
description "Место эксплуатации / скважина / куст";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.location;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute commissionedAt {
|
|
||||||
type date;
|
|
||||||
description "Дата ввода в эксплуатацию";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.commissionedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute totalEngineHours {
|
|
||||||
type decimal;
|
|
||||||
description "Общая наработка, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.totalEngineHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursSinceLastRepair {
|
|
||||||
type decimal;
|
|
||||||
description "Наработка с последнего ремонта, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.engineHoursSinceLastRepair;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute lastRepairAt {
|
|
||||||
type date;
|
|
||||||
description "Дата последнего ремонта";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.lastRepairAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
type text;
|
|
||||||
description "Примечания";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.notes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentUpdate {
|
|
||||||
description "Единица оборудования — тело запроса на обновление (частичное)";
|
|
||||||
|
|
||||||
attribute inventoryNumber {
|
|
||||||
type string;
|
|
||||||
description "Инвентарный номер";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.inventoryNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute serialNumber {
|
|
||||||
type string;
|
|
||||||
description "Заводской (серийный) номер";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.serialNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
description "Наименование единицы оборудования";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentTypeCode {
|
|
||||||
type string;
|
|
||||||
description "Код вида оборудования";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.equipmentTypeCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type EquipmentStatus;
|
|
||||||
description "Текущий статус";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute location {
|
|
||||||
type string;
|
|
||||||
description "Место эксплуатации / скважина / куст";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.location;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute commissionedAt {
|
|
||||||
type date;
|
|
||||||
description "Дата ввода в эксплуатацию";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.commissionedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute totalEngineHours {
|
|
||||||
type decimal;
|
|
||||||
description "Общая наработка, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.totalEngineHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursSinceLastRepair {
|
|
||||||
type decimal;
|
|
||||||
description "Наработка с последнего ремонта, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.engineHoursSinceLastRepair;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute lastRepairAt {
|
|
||||||
type date;
|
|
||||||
description "Дата последнего ремонта";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.lastRepairAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
type text;
|
|
||||||
description "Примечания";
|
|
||||||
is nullable;
|
|
||||||
map Equipment.notes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentListResponse {
|
|
||||||
description "Список оборудования (формат React Admin)";
|
|
||||||
|
|
||||||
attribute data {
|
|
||||||
type DTO.Equipment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute total {
|
|
||||||
type integer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// DTO: Заявка на ремонт (RepairOrder)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
dto DTO.RepairOrder {
|
|
||||||
description "Заявка на ремонт — полный объект ответа";
|
|
||||||
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
map RepairOrder.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute number {
|
|
||||||
type string;
|
|
||||||
description "Номер заявки";
|
|
||||||
map RepairOrder.number;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentId {
|
|
||||||
type uuid;
|
|
||||||
description "Идентификатор единицы оборудования";
|
|
||||||
map RepairOrder.equipmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute repairKind {
|
|
||||||
type RepairKind;
|
|
||||||
description "Вид ремонта";
|
|
||||||
map RepairOrder.repairKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type RepairOrderStatus;
|
|
||||||
description "Статус заявки";
|
|
||||||
map RepairOrder.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute plannedAt {
|
|
||||||
type date;
|
|
||||||
description "Плановая дата начала";
|
|
||||||
map RepairOrder.plannedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute startedAt {
|
|
||||||
type date;
|
|
||||||
description "Фактическая дата начала";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.startedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute completedAt {
|
|
||||||
type date;
|
|
||||||
description "Фактическая дата завершения";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.completedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute contractor {
|
|
||||||
type string;
|
|
||||||
description "Подрядная организация (если внешний ремонт)";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.contractor;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursAtRepair {
|
|
||||||
type decimal;
|
|
||||||
description "Наработка на момент ремонта, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.engineHoursAtRepair;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute description {
|
|
||||||
type text;
|
|
||||||
description "Описание работ / дефекта";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
type text;
|
|
||||||
description "Примечания";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.notes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.RepairOrderCreate {
|
|
||||||
description "Заявка на ремонт — тело запроса на создание";
|
|
||||||
|
|
||||||
attribute number {
|
|
||||||
type string;
|
|
||||||
description "Номер заявки";
|
|
||||||
is required;
|
|
||||||
map RepairOrder.number;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentId {
|
|
||||||
type uuid;
|
|
||||||
description "Идентификатор единицы оборудования";
|
|
||||||
is required;
|
|
||||||
map RepairOrder.equipmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute repairKind {
|
|
||||||
type RepairKind;
|
|
||||||
description "Вид ремонта";
|
|
||||||
is required;
|
|
||||||
map RepairOrder.repairKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type RepairOrderStatus;
|
|
||||||
description "Статус заявки";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute plannedAt {
|
|
||||||
type date;
|
|
||||||
description "Плановая дата начала";
|
|
||||||
is required;
|
|
||||||
map RepairOrder.plannedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute startedAt {
|
|
||||||
type date;
|
|
||||||
description "Фактическая дата начала";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.startedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute completedAt {
|
|
||||||
type date;
|
|
||||||
description "Фактическая дата завершения";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.completedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute contractor {
|
|
||||||
type string;
|
|
||||||
description "Подрядная организация (если внешний ремонт)";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.contractor;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursAtRepair {
|
|
||||||
type decimal;
|
|
||||||
description "Наработка на момент ремонта, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.engineHoursAtRepair;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute description {
|
|
||||||
type text;
|
|
||||||
description "Описание работ / дефекта";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
type text;
|
|
||||||
description "Примечания";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.notes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.RepairOrderUpdate {
|
|
||||||
description "Заявка на ремонт — тело запроса на обновление (частичное)";
|
|
||||||
|
|
||||||
attribute number {
|
|
||||||
type string;
|
|
||||||
description "Номер заявки";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.number;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentId {
|
|
||||||
type uuid;
|
|
||||||
description "Идентификатор единицы оборудования";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.equipmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute repairKind {
|
|
||||||
type RepairKind;
|
|
||||||
description "Вид ремонта";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.repairKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type RepairOrderStatus;
|
|
||||||
description "Статус заявки";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute plannedAt {
|
|
||||||
type date;
|
|
||||||
description "Плановая дата начала";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.plannedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute startedAt {
|
|
||||||
type date;
|
|
||||||
description "Фактическая дата начала";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.startedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute completedAt {
|
|
||||||
type date;
|
|
||||||
description "Фактическая дата завершения";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.completedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute contractor {
|
|
||||||
type string;
|
|
||||||
description "Подрядная организация (если внешний ремонт)";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.contractor;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursAtRepair {
|
|
||||||
type decimal;
|
|
||||||
description "Наработка на момент ремонта, моточасов";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.engineHoursAtRepair;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute description {
|
|
||||||
type text;
|
|
||||||
description "Описание работ / дефекта";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
type text;
|
|
||||||
description "Примечания";
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.notes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.RepairOrderListResponse {
|
|
||||||
description "Список заявок на ремонт (формат React Admin)";
|
|
||||||
|
|
||||||
attribute data {
|
|
||||||
type DTO.RepairOrder[];
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute total {
|
|
||||||
type integer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// API: Виды оборудования
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
api API.EquipmentTypes {
|
|
||||||
description "API управления справочником видов оборудования";
|
|
||||||
|
|
||||||
endpoint listEquipmentTypes {
|
|
||||||
label "GET /equipment-types";
|
|
||||||
description "Список видов оборудования (фильтры и пагинация — query-параметры)";
|
|
||||||
attribute response {
|
|
||||||
type DTO.EquipmentTypeListResponse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint getEquipmentType {
|
|
||||||
label "GET /equipment-types/{code}";
|
|
||||||
description "Получить вид оборудования по коду";
|
|
||||||
attribute code {
|
|
||||||
type string;
|
|
||||||
}
|
|
||||||
attribute response {
|
|
||||||
type DTO.EquipmentType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint createEquipmentType {
|
|
||||||
label "POST /equipment-types";
|
|
||||||
description "Создать вид оборудования";
|
|
||||||
attribute request {
|
|
||||||
type DTO.EquipmentTypeCreate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint updateEquipmentType {
|
|
||||||
label "PATCH /equipment-types/{code}";
|
|
||||||
description "Обновить вид оборудования";
|
|
||||||
attribute code {
|
|
||||||
type string;
|
|
||||||
}
|
|
||||||
attribute request {
|
|
||||||
type DTO.EquipmentTypeUpdate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint deleteEquipmentType {
|
|
||||||
label "DELETE /equipment-types/{code}";
|
|
||||||
description "Удалить вид оборудования";
|
|
||||||
attribute code {
|
|
||||||
type string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// API: Оборудование
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
api API.Equipment {
|
|
||||||
description "API управления оборудованием";
|
|
||||||
|
|
||||||
endpoint listEquipment {
|
|
||||||
label "GET /equipment";
|
|
||||||
description "Список оборудования (фильтры и пагинация — query-параметры)";
|
|
||||||
attribute response {
|
|
||||||
type DTO.EquipmentListResponse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint getEquipment {
|
|
||||||
label "GET /equipment/{id}";
|
|
||||||
description "Получить единицу оборудования по идентификатору";
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
}
|
|
||||||
attribute response {
|
|
||||||
type DTO.Equipment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint createEquipment {
|
|
||||||
label "POST /equipment";
|
|
||||||
description "Создать единицу оборудования";
|
|
||||||
attribute request {
|
|
||||||
type DTO.EquipmentCreate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint updateEquipment {
|
|
||||||
label "PATCH /equipment/{id}";
|
|
||||||
description "Обновить единицу оборудования";
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
}
|
|
||||||
attribute request {
|
|
||||||
type DTO.EquipmentUpdate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint deleteEquipment {
|
|
||||||
label "DELETE /equipment/{id}";
|
|
||||||
description "Удалить единицу оборудования";
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// API: Заявки на ремонт
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
api API.RepairOrders {
|
|
||||||
description "API управления заявками на ремонт";
|
|
||||||
|
|
||||||
endpoint listRepairOrders {
|
|
||||||
label "GET /repair-orders";
|
|
||||||
description "Список заявок на ремонт (фильтры и пагинация — query-параметры)";
|
|
||||||
attribute response {
|
|
||||||
type DTO.RepairOrderListResponse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint getRepairOrder {
|
|
||||||
label "GET /repair-orders/{id}";
|
|
||||||
description "Получить заявку на ремонт по идентификатору";
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
}
|
|
||||||
attribute response {
|
|
||||||
type DTO.RepairOrder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint createRepairOrder {
|
|
||||||
label "POST /repair-orders";
|
|
||||||
description "Создать заявку на ремонт";
|
|
||||||
attribute request {
|
|
||||||
type DTO.RepairOrderCreate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint updateRepairOrder {
|
|
||||||
label "PATCH /repair-orders/{id}";
|
|
||||||
description "Обновить заявку на ремонт";
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
}
|
|
||||||
attribute request {
|
|
||||||
type DTO.RepairOrderUpdate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint deleteRepairOrder {
|
|
||||||
label "DELETE /repair-orders/{id}";
|
|
||||||
description "Удалить заявку на ремонт";
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,753 +0,0 @@
|
|||||||
/*
|
|
||||||
КИС ТОиР — DTO
|
|
||||||
Структуры данных для обмена через API
|
|
||||||
*/
|
|
||||||
|
|
||||||
//import ./TOiR;
|
|
||||||
//only external;
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Общие
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
dto DTO.PageRequest {
|
|
||||||
description "Параметры постраничной выдачи";
|
|
||||||
|
|
||||||
attribute page {
|
|
||||||
description "Номер страницы (начиная с 0)";
|
|
||||||
type integer;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute size {
|
|
||||||
description "Размер страницы";
|
|
||||||
type integer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.Filter {
|
|
||||||
description "Элемент фильтра для запросов списка";
|
|
||||||
attribute field { type string; }
|
|
||||||
attribute operator { type string; }
|
|
||||||
attribute value { type string; }
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.PageInfo {
|
|
||||||
description "Метаданные постраничной выдачи";
|
|
||||||
attribute size { type integer; }
|
|
||||||
attribute number { type integer; }
|
|
||||||
attribute totalElements { type integer; }
|
|
||||||
attribute totalPages { type integer; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Вид оборудования (EquipmentType)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
dto DTO.EquipmentType {
|
|
||||||
description "Вид оборудования — response";
|
|
||||||
|
|
||||||
attribute code {
|
|
||||||
type string;
|
|
||||||
map EquipmentType.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
map EquipmentType.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute manufacturer {
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.manufacturer;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute maintenanceIntervalHours {
|
|
||||||
type integer;
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.maintenanceIntervalHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute overhaulIntervalHours {
|
|
||||||
type integer;
|
|
||||||
is nullable;
|
|
||||||
map EquipmentType.overhaulIntervalHours;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentTypeFilter {
|
|
||||||
description "Фильтры для списка видов оборудования";
|
|
||||||
|
|
||||||
attribute code {
|
|
||||||
description "Частичное совпадение по коду";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
description "Частичное совпадение по наименованию";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute manufacturer {
|
|
||||||
description "Частичное совпадение по производителю";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentTypeCreate {
|
|
||||||
description "Тело запроса на создание вида оборудования";
|
|
||||||
|
|
||||||
attribute code {
|
|
||||||
description "Код вида оборудования";
|
|
||||||
type string;
|
|
||||||
is required;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
description "Наименование вида";
|
|
||||||
type string;
|
|
||||||
is required;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute manufacturer {
|
|
||||||
description "Производитель";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute maintenanceIntervalHours {
|
|
||||||
description "Периодичность ТО, моточасов";
|
|
||||||
type integer;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute overhaulIntervalHours {
|
|
||||||
description "Периодичность КР, моточасов";
|
|
||||||
type integer;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentTypeUpdate {
|
|
||||||
description "Тело запроса на обновление вида оборудования";
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
description "Наименование вида";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute manufacturer {
|
|
||||||
description "Производитель";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute maintenanceIntervalHours {
|
|
||||||
description "Периодичность ТО, моточасов";
|
|
||||||
type integer;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute overhaulIntervalHours {
|
|
||||||
description "Периодичность КР, моточасов";
|
|
||||||
type integer;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Оборудование (Equipment)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
dto DTO.EquipmentListItem {
|
|
||||||
description "Строка списка оборудования";
|
|
||||||
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
map Equipment.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute inventoryNumber {
|
|
||||||
type string;
|
|
||||||
map Equipment.inventoryNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
map Equipment.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentTypeCode {
|
|
||||||
type string;
|
|
||||||
map Equipment.equipmentTypeCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type EquipmentStatus;
|
|
||||||
map Equipment.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute location {
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
map Equipment.location;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentDetail {
|
|
||||||
description "Полная информация об оборудовании";
|
|
||||||
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
map Equipment.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute inventoryNumber {
|
|
||||||
type string;
|
|
||||||
map Equipment.inventoryNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute serialNumber {
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
map Equipment.serialNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
map Equipment.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentTypeCode {
|
|
||||||
type string;
|
|
||||||
map Equipment.equipmentTypeCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type EquipmentStatus;
|
|
||||||
map Equipment.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute location {
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
map Equipment.location;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute commissionedAt {
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
map Equipment.commissionedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute totalEngineHours {
|
|
||||||
type decimal;
|
|
||||||
is nullable;
|
|
||||||
map Equipment.totalEngineHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursSinceLastRepair {
|
|
||||||
type decimal;
|
|
||||||
is nullable;
|
|
||||||
map Equipment.engineHoursSinceLastRepair;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute lastRepairAt {
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
map Equipment.lastRepairAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
type text;
|
|
||||||
is nullable;
|
|
||||||
map Equipment.notes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentFilter {
|
|
||||||
description "Фильтры для списка оборудования";
|
|
||||||
|
|
||||||
attribute inventoryNumber {
|
|
||||||
description "Частичное совпадение по инвентарному номеру";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute serialNumber {
|
|
||||||
description "Частичное совпадение по заводскому номеру";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
description "Частичное совпадение по наименованию";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentTypeCode {
|
|
||||||
description "Точное совпадение по коду вида оборудования";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
description "Фильтр по статусу";
|
|
||||||
type EquipmentStatus;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute location {
|
|
||||||
description "Частичное совпадение по месту эксплуатации";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentCreate {
|
|
||||||
description "Тело запроса на создание оборудования";
|
|
||||||
|
|
||||||
attribute inventoryNumber {
|
|
||||||
description "Инвентарный номер";
|
|
||||||
type string;
|
|
||||||
is required;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute serialNumber {
|
|
||||||
description "Заводской (серийный) номер";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
description "Наименование единицы оборудования";
|
|
||||||
type string;
|
|
||||||
is required;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentTypeCode {
|
|
||||||
description "Код вида оборудования";
|
|
||||||
type string;
|
|
||||||
is required;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
description "Текущий статус";
|
|
||||||
type EquipmentStatus;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute location {
|
|
||||||
description "Место эксплуатации / скважина / куст";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute commissionedAt {
|
|
||||||
description "Дата ввода в эксплуатацию";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute totalEngineHours {
|
|
||||||
description "Общая наработка, моточасов";
|
|
||||||
type decimal;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursSinceLastRepair {
|
|
||||||
description "Наработка с последнего ремонта, моточасов";
|
|
||||||
type decimal;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute lastRepairAt {
|
|
||||||
description "Дата последнего ремонта";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
description "Примечания";
|
|
||||||
type text;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.EquipmentUpdate {
|
|
||||||
description "Тело запроса на обновление оборудования";
|
|
||||||
|
|
||||||
attribute inventoryNumber {
|
|
||||||
description "Инвентарный номер";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute serialNumber {
|
|
||||||
description "Заводской (серийный) номер";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
description "Наименование единицы оборудования";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentTypeCode {
|
|
||||||
description "Код вида оборудования";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
description "Текущий статус";
|
|
||||||
type EquipmentStatus;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute location {
|
|
||||||
description "Место эксплуатации / скважина / куст";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute commissionedAt {
|
|
||||||
description "Дата ввода в эксплуатацию";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute totalEngineHours {
|
|
||||||
description "Общая наработка, моточасов";
|
|
||||||
type decimal;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursSinceLastRepair {
|
|
||||||
description "Наработка с последнего ремонта, моточасов";
|
|
||||||
type decimal;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute lastRepairAt {
|
|
||||||
description "Дата последнего ремонта";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
description "Примечания";
|
|
||||||
type text;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// Заявка на ремонт (RepairOrder)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
dto DTO.RepairOrderListItem {
|
|
||||||
description "Строка списка заявок на ремонт";
|
|
||||||
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
map RepairOrder.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute number {
|
|
||||||
type string;
|
|
||||||
map RepairOrder.number;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentId {
|
|
||||||
type uuid;
|
|
||||||
map RepairOrder.equipmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute repairKind {
|
|
||||||
type RepairKind;
|
|
||||||
map RepairOrder.repairKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type RepairOrderStatus;
|
|
||||||
map RepairOrder.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute plannedAt {
|
|
||||||
type date;
|
|
||||||
map RepairOrder.plannedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute contractor {
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.contractor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.RepairOrderDetail {
|
|
||||||
description "Полная информация о заявке на ремонт";
|
|
||||||
|
|
||||||
attribute id {
|
|
||||||
type uuid;
|
|
||||||
map RepairOrder.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute number {
|
|
||||||
type string;
|
|
||||||
map RepairOrder.number;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentId {
|
|
||||||
type uuid;
|
|
||||||
map RepairOrder.equipmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute repairKind {
|
|
||||||
type RepairKind;
|
|
||||||
map RepairOrder.repairKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type RepairOrderStatus;
|
|
||||||
map RepairOrder.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute plannedAt {
|
|
||||||
type date;
|
|
||||||
map RepairOrder.plannedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute startedAt {
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.startedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute completedAt {
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.completedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute contractor {
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.contractor;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursAtRepair {
|
|
||||||
type decimal;
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.engineHoursAtRepair;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute description {
|
|
||||||
type text;
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
type text;
|
|
||||||
is nullable;
|
|
||||||
map RepairOrder.notes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.RepairOrderFilter {
|
|
||||||
description "Фильтры для списка заявок на ремонт";
|
|
||||||
|
|
||||||
attribute number {
|
|
||||||
description "Частичное совпадение по номеру заявки";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentId {
|
|
||||||
description "Точное совпадение по идентификатору оборудования";
|
|
||||||
type uuid;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute repairKind {
|
|
||||||
description "Фильтр по виду ремонта";
|
|
||||||
type RepairKind;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
description "Фильтр по статусу заявки";
|
|
||||||
type RepairOrderStatus;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute plannedAtFrom {
|
|
||||||
description "Плановая дата начала ОТ";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute plannedAtTo {
|
|
||||||
description "Плановая дата начала ДО";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute contractor {
|
|
||||||
description "Частичное совпадение по подрядчику";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.RepairOrderCreate {
|
|
||||||
description "Тело запроса на создание заявки на ремонт";
|
|
||||||
|
|
||||||
attribute number {
|
|
||||||
description "Номер заявки";
|
|
||||||
type string;
|
|
||||||
is required;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentId {
|
|
||||||
description "Идентификатор оборудования";
|
|
||||||
type uuid;
|
|
||||||
is required;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute repairKind {
|
|
||||||
description "Вид ремонта";
|
|
||||||
type RepairKind;
|
|
||||||
is required;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
description "Статус заявки";
|
|
||||||
type RepairOrderStatus;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute plannedAt {
|
|
||||||
description "Плановая дата начала";
|
|
||||||
type date;
|
|
||||||
is required;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute startedAt {
|
|
||||||
description "Фактическая дата начала";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute completedAt {
|
|
||||||
description "Фактическая дата завершения";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute contractor {
|
|
||||||
description "Подрядная организация";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursAtRepair {
|
|
||||||
description "Наработка на момент ремонта, моточасов";
|
|
||||||
type decimal;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute description {
|
|
||||||
description "Описание работ / дефекта";
|
|
||||||
type text;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
description "Примечания";
|
|
||||||
type text;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dto DTO.RepairOrderUpdate {
|
|
||||||
description "Тело запроса на обновление заявки на ремонт";
|
|
||||||
|
|
||||||
attribute number {
|
|
||||||
description "Номер заявки";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute equipmentId {
|
|
||||||
description "Идентификатор оборудования";
|
|
||||||
type uuid;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute repairKind {
|
|
||||||
description "Вид ремонта";
|
|
||||||
type RepairKind;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
description "Статус заявки";
|
|
||||||
type RepairOrderStatus;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute plannedAt {
|
|
||||||
description "Плановая дата начала";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute startedAt {
|
|
||||||
description "Фактическая дата начала";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute completedAt {
|
|
||||||
description "Фактическая дата завершения";
|
|
||||||
type date;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute contractor {
|
|
||||||
description "Подрядная организация";
|
|
||||||
type string;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute engineHoursAtRepair {
|
|
||||||
description "Наработка на момент ремонта, моточасов";
|
|
||||||
type decimal;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute description {
|
|
||||||
description "Описание работ / дефекта";
|
|
||||||
type text;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
attribute notes {
|
|
||||||
description "Примечания";
|
|
||||||
type text;
|
|
||||||
is nullable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Frontend Architecture
|
|
||||||
|
|
||||||
Frontend stack:
|
|
||||||
|
|
||||||
- React
|
|
||||||
- TypeScript
|
|
||||||
- Vite
|
|
||||||
- React Admin
|
|
||||||
- shadcn/ui
|
|
||||||
|
|
||||||
The frontend is generated from the DSL and API specification.
|
|
||||||
|
|
||||||
Each entity becomes a React Admin resource.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project Structure
|
|
||||||
|
|
||||||
client/
|
|
||||||
src/
|
|
||||||
|
|
||||||
App.tsx
|
|
||||||
|
|
||||||
resources/
|
|
||||||
|
|
||||||
{entity}/
|
|
||||||
{entity}List.tsx
|
|
||||||
{entity}Create.tsx
|
|
||||||
{entity}Edit.tsx
|
|
||||||
{entity}Show.tsx
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Resource Registration
|
|
||||||
|
|
||||||
Each resource must be registered in App.tsx.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
<Resource
|
|
||||||
name="equipment"
|
|
||||||
list={EquipmentList}
|
|
||||||
create={EquipmentCreate}
|
|
||||||
edit={EquipmentEdit}
|
|
||||||
show={EquipmentShow}
|
|
||||||
/>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Data Provider
|
|
||||||
|
|
||||||
React Admin uses the standard REST provider.
|
|
||||||
|
|
||||||
API format must follow:
|
|
||||||
|
|
||||||
GET /resource
|
|
||||||
GET /resource/:id
|
|
||||||
POST /resource
|
|
||||||
PATCH /resource/:id
|
|
||||||
DELETE /resource/:id
|
|
||||||
|
|
||||||
List response format:
|
|
||||||
|
|
||||||
{
|
|
||||||
data: [],
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Foreign Keys
|
|
||||||
|
|
||||||
Foreign keys must use ReferenceInput and ReferenceField.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
<ReferenceInput source="equipmentTypeCode" reference="equipment-types" />
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Naming Conventions
|
|
||||||
|
|
||||||
## React component naming
|
|
||||||
|
|
||||||
Components are named after the entity in PascalCase. One entity = one resource with four main views.
|
|
||||||
|
|
||||||
- **List:** `{Entity}List.tsx` (e.g. `EquipmentList.tsx`, `RepairOrderList.tsx`)
|
|
||||||
- **Create:** `{Entity}Create.tsx` (e.g. `EquipmentCreate.tsx`)
|
|
||||||
- **Edit:** `{Entity}Edit.tsx` (e.g. `EquipmentEdit.tsx`)
|
|
||||||
- **Show:** `{Entity}Show.tsx` (e.g. `EquipmentShow.tsx`)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- Equipment → `EquipmentList.tsx`, `EquipmentCreate.tsx`, `EquipmentEdit.tsx`, `EquipmentShow.tsx`
|
|
||||||
- EquipmentType → `EquipmentTypeList.tsx`, `EquipmentTypeCreate.tsx`, `EquipmentTypeEdit.tsx`, `EquipmentTypeShow.tsx`
|
|
||||||
- RepairOrder → `RepairOrderList.tsx`, `RepairOrderCreate.tsx`, `RepairOrderEdit.tsx`, `RepairOrderShow.tsx`
|
|
||||||
|
|
||||||
## Resource folder naming
|
|
||||||
|
|
||||||
Folder under `resources/` uses kebab-case, matching the React Admin resource name:
|
|
||||||
|
|
||||||
- `resources/equipment/`
|
|
||||||
- `resources/equipment-type/`
|
|
||||||
- `resources/repair-order/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Resource Naming Rules
|
|
||||||
|
|
||||||
React Admin resource name (used in `<Resource name="..." />` and in `reference` for ReferenceInput) must match the API resource path (no leading slash, same segment string).
|
|
||||||
|
|
||||||
1. **Entity → resource name:** PascalCase entity name is converted to kebab-case and pluralized.
|
|
||||||
2. **Consistency with API:** The resource name must be the same as the backend path segment so that the data provider calls the correct URL.
|
|
||||||
|
|
||||||
| Entity (DSL) | Resource name (React Admin) | API path |
|
|
||||||
|----------------|-----------------------------|------------|
|
|
||||||
| Equipment | equipment | /equipment |
|
|
||||||
| EquipmentType | equipment-types | /equipment-types |
|
|
||||||
| RepairOrder | repair-orders | /repair-orders |
|
|
||||||
|
|
||||||
Examples in App.tsx:
|
|
||||||
- `<Resource name="equipment" list={EquipmentList} create={EquipmentCreate} edit={EquipmentEdit} show={EquipmentShow} />`
|
|
||||||
- `<Resource name="equipment-types" list={EquipmentTypeList} ... />`
|
|
||||||
- `<Resource name="repair-orders" list={RepairOrderList} ... />`
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# DSL → React Admin Mapping
|
|
||||||
|
|
||||||
Entity attributes determine UI fields.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Type Mapping
|
|
||||||
|
|
||||||
| DSL Type | React Admin Component |
|
|
||||||
|---------|-----------------------|
|
|
||||||
| string | TextInput / TextField |
|
|
||||||
| integer | NumberInput |
|
|
||||||
| decimal | NumberInput |
|
|
||||||
| date | DateInput |
|
|
||||||
| enum | SelectInput |
|
|
||||||
| foreign key | ReferenceInput |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Example
|
|
||||||
|
|
||||||
DSL
|
|
||||||
|
|
||||||
attribute name {
|
|
||||||
type string;
|
|
||||||
}
|
|
||||||
|
|
||||||
React Admin
|
|
||||||
|
|
||||||
<TextInput source="name" />
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Enum Example
|
|
||||||
|
|
||||||
DSL
|
|
||||||
|
|
||||||
attribute status {
|
|
||||||
type EquipmentStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
React Admin
|
|
||||||
|
|
||||||
<SelectInput source="status" choices={statusChoices} />
|
|
||||||
|
|
||||||
## List filtering UX rules
|
|
||||||
|
|
||||||
- Lists should provide a visible filter UX:
|
|
||||||
- Use `List` `filters` prop to define filter inputs.
|
|
||||||
- Use an actions toolbar that includes `FilterButton` so users can add/remove non-`alwaysOn` filters.
|
|
||||||
|
|
||||||
## Multi-select enum filters
|
|
||||||
|
|
||||||
When filtering by enum values where users often need multiple selections (e.g. `status`), use:
|
|
||||||
|
|
||||||
`SelectArrayInput` in list filters, and ensure the backend supports repeated query params (e.g. `status=A&status=B`).
|
|
||||||
|
|
||||||
## Reference selection UX rules
|
|
||||||
|
|
||||||
For foreign keys, prefer `ReferenceInput` + `AutocompleteInput` over `SelectInput`.
|
|
||||||
Autocomplete search should send `q` to backend using `filterToQuery={(searchText) => ({ q: searchText })}`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Foreign Key Example
|
|
||||||
|
|
||||||
DSL
|
|
||||||
|
|
||||||
attribute equipmentTypeCode {
|
|
||||||
type string;
|
|
||||||
}
|
|
||||||
|
|
||||||
React Admin
|
|
||||||
|
|
||||||
<ReferenceInput source="equipmentTypeCode" reference="equipment-types" />
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# React Admin ID Field Requirement
|
|
||||||
|
|
||||||
React Admin requires every record in list and detail responses to contain a field named **`id`**. It uses this field for resource identity, cache keys, and references.
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
|
|
||||||
1. Every record returned by the API must contain an **`id`** field.
|
|
||||||
2. If the DSL primary key is not named `id`, the generator must **map** the primary key value to an `id` field in the API response (backend) or in a frontend adapter.
|
|
||||||
3. The `id` field must contain the **value of the primary key** (e.g. uuid string, or `code` value for EquipmentType).
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
DSL entity with primary key `code`:
|
|
||||||
|
|
||||||
```
|
|
||||||
entity EquipmentType {
|
|
||||||
attribute code {
|
|
||||||
key primary;
|
|
||||||
type string;
|
|
||||||
}
|
|
||||||
attribute name { type string; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
API response must include `id` so React Admin can identify the record:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "pump",
|
|
||||||
"code": "pump",
|
|
||||||
"name": "Pump"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If the response only had `{ "code": "pump", "name": "Pump" }`, React Admin would not work correctly because it expects `id`. The backend or frontend adapter must therefore set `id: record.code` (or equivalent) when the primary key is not `id`.
|
|
||||||
|
|
||||||
This rule ensures compatibility with React Admin resource identity handling.
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
ROLE
|
|
||||||
|
|
||||||
You are a Staff-level Fullstack Platform Engineer.
|
|
||||||
|
|
||||||
Your task is to generate a fully runnable fullstack CRUD application from the DSL context of this repository.
|
|
||||||
|
|
||||||
Use context7.
|
|
||||||
|
|
||||||
Follow official best practices from:
|
|
||||||
|
|
||||||
NestJS documentation
|
|
||||||
|
|
||||||
Prisma documentation
|
|
||||||
|
|
||||||
React Admin documentation
|
|
||||||
|
|
||||||
Docker documentation
|
|
||||||
|
|
||||||
The generated application must run without manual fixes.
|
|
||||||
|
|
||||||
PROJECT CONTEXT
|
|
||||||
|
|
||||||
You must read the project documentation in the following strict order:
|
|
||||||
|
|
||||||
domain/dsl-spec.md
|
|
||||||
|
|
||||||
examples/\*.dsl
|
|
||||||
|
|
||||||
backend/architecture.md
|
|
||||||
|
|
||||||
backend/prisma-rules.md
|
|
||||||
|
|
||||||
backend/prisma-service.md
|
|
||||||
|
|
||||||
backend/service-rules.md
|
|
||||||
|
|
||||||
backend/runtime-rules.md
|
|
||||||
|
|
||||||
backend/database-runtime.md
|
|
||||||
|
|
||||||
backend/seed-rules.md
|
|
||||||
|
|
||||||
frontend/architecture.md
|
|
||||||
|
|
||||||
frontend/react-admin-rules.md
|
|
||||||
|
|
||||||
generation/scaffolding-rules.md
|
|
||||||
|
|
||||||
generation/backend-generation.md
|
|
||||||
|
|
||||||
generation/frontend-generation.md
|
|
||||||
|
|
||||||
generation/runtime-bootstrap.md
|
|
||||||
|
|
||||||
generation/post-generation-validation.md
|
|
||||||
|
|
||||||
Do not ignore any rules defined in these documents.
|
|
||||||
|
|
||||||
GOAL
|
|
||||||
|
|
||||||
Generate a DSL-driven fullstack CRUD system.
|
|
||||||
|
|
||||||
Stack:
|
|
||||||
|
|
||||||
Backend
|
|
||||||
|
|
||||||
Node.js
|
|
||||||
|
|
||||||
NestJS
|
|
||||||
|
|
||||||
Prisma ORM
|
|
||||||
|
|
||||||
PostgreSQL
|
|
||||||
|
|
||||||
Frontend
|
|
||||||
|
|
||||||
React
|
|
||||||
|
|
||||||
Vite
|
|
||||||
|
|
||||||
React Admin
|
|
||||||
|
|
||||||
MUI
|
|
||||||
|
|
||||||
shadcn/ui
|
|
||||||
|
|
||||||
PROJECT STRUCTURE
|
|
||||||
|
|
||||||
Root
|
|
||||||
docker-compose.yml
|
|
||||||
server/
|
|
||||||
client/
|
|
||||||
|
|
||||||
Backend
|
|
||||||
server/
|
|
||||||
src/
|
|
||||||
modules/{entity}/
|
|
||||||
prisma/schema.prisma
|
|
||||||
prisma/seed.ts
|
|
||||||
.env
|
|
||||||
.env.example
|
|
||||||
|
|
||||||
Frontend
|
|
||||||
client/src/resources/{entity}/
|
|
||||||
client/src/App.tsx
|
|
||||||
client/src/dataProvider.ts
|
|
||||||
|
|
||||||
STEP 1 — Parse DSL
|
|
||||||
|
|
||||||
Parse all DSL files and extract:
|
|
||||||
|
|
||||||
Entities
|
|
||||||
Attributes
|
|
||||||
Primary keys
|
|
||||||
Foreign keys
|
|
||||||
Enums
|
|
||||||
|
|
||||||
Respect the DSL specification.
|
|
||||||
|
|
||||||
STEP 2 — CLI scaffolding
|
|
||||||
|
|
||||||
Use official CLIs.
|
|
||||||
|
|
||||||
Backend
|
|
||||||
npx @nestjs/cli@10.3.2 new server --package-manager npm --skip-git
|
|
||||||
|
|
||||||
Frontend
|
|
||||||
npm create vite@5.2.0 client -- --template react-ts
|
|
||||||
|
|
||||||
STEP 3 — Install dependencies
|
|
||||||
|
|
||||||
Backend
|
|
||||||
|
|
||||||
@prisma/client
|
|
||||||
prisma
|
|
||||||
@nestjs/config
|
|
||||||
|
|
||||||
Frontend
|
|
||||||
|
|
||||||
react-admin
|
|
||||||
ra-data-simple-rest
|
|
||||||
@mui/material
|
|
||||||
@emotion/react
|
|
||||||
@emotion/styled
|
|
||||||
|
|
||||||
STEP 4 — Generate Prisma schema
|
|
||||||
|
|
||||||
From DSL domain generate:
|
|
||||||
|
|
||||||
models
|
|
||||||
|
|
||||||
enums
|
|
||||||
|
|
||||||
relations
|
|
||||||
|
|
||||||
primary keys
|
|
||||||
|
|
||||||
Type mapping
|
|
||||||
|
|
||||||
decimal → Decimal
|
|
||||||
date → DateTime
|
|
||||||
|
|
||||||
DTO mapping
|
|
||||||
|
|
||||||
decimal → string
|
|
||||||
date → ISO string
|
|
||||||
|
|
||||||
STEP 5 — Generate NestJS modules
|
|
||||||
|
|
||||||
Per entity generate:
|
|
||||||
|
|
||||||
module
|
|
||||||
controller
|
|
||||||
service
|
|
||||||
dto
|
|
||||||
|
|
||||||
Controller routes
|
|
||||||
|
|
||||||
GET /resource
|
|
||||||
GET /resource/:pk
|
|
||||||
POST /resource
|
|
||||||
PATCH /resource/:pk
|
|
||||||
DELETE /resource/:pk
|
|
||||||
|
|
||||||
Path parameter must match the DSL primary key name.
|
|
||||||
|
|
||||||
Examples
|
|
||||||
|
|
||||||
/equipment/:id
|
|
||||||
/equipment-types/:code
|
|
||||||
/repair-orders/:id
|
|
||||||
|
|
||||||
STEP 6 — Generate Service Layer
|
|
||||||
|
|
||||||
Service layer must follow backend/service-rules.md.
|
|
||||||
|
|
||||||
Important rule:
|
|
||||||
|
|
||||||
React Admin sends the id field in update payloads even when the primary key is not named id.
|
|
||||||
|
|
||||||
Therefore update payload must be sanitized before passing data to Prisma.
|
|
||||||
|
|
||||||
Services MUST NOT pass raw request DTO directly into Prisma.
|
|
||||||
|
|
||||||
Incorrect:
|
|
||||||
|
|
||||||
prisma.entity.update({
|
|
||||||
where,
|
|
||||||
data: dto
|
|
||||||
})
|
|
||||||
|
|
||||||
Correct pattern:
|
|
||||||
|
|
||||||
const { id, <primaryKey>, ...data } = dto
|
|
||||||
|
|
||||||
return prisma.entity.update({
|
|
||||||
where,
|
|
||||||
data
|
|
||||||
})
|
|
||||||
|
|
||||||
Example (PK = code)
|
|
||||||
|
|
||||||
const { id, code, ...data } = dto
|
|
||||||
|
|
||||||
return prisma.equipmentType.update({
|
|
||||||
where: { code },
|
|
||||||
data
|
|
||||||
})
|
|
||||||
|
|
||||||
Example (PK = id)
|
|
||||||
|
|
||||||
const { id: \_pk, ...data } = dto
|
|
||||||
|
|
||||||
return prisma.entity.update({
|
|
||||||
where: { id },
|
|
||||||
data
|
|
||||||
})
|
|
||||||
|
|
||||||
Rules
|
|
||||||
|
|
||||||
Update payload passed to Prisma must not contain:
|
|
||||||
|
|
||||||
id
|
|
||||||
primary key attribute
|
|
||||||
readonly attributes
|
|
||||||
|
|
||||||
STEP 7 — Generate PrismaService
|
|
||||||
|
|
||||||
Requirements
|
|
||||||
|
|
||||||
extends PrismaClient
|
|
||||||
implements OnModuleInit
|
|
||||||
await this.$connect()
|
|
||||||
|
|
||||||
Do NOT use
|
|
||||||
|
|
||||||
beforeExit
|
|
||||||
|
|
||||||
STEP 8 — Generate runtime infrastructure
|
|
||||||
|
|
||||||
Create
|
|
||||||
|
|
||||||
server/.env
|
|
||||||
server/.env.example
|
|
||||||
|
|
||||||
DATABASE_URL example
|
|
||||||
|
|
||||||
postgresql://postgres:postgres@localhost:5432/toir
|
|
||||||
|
|
||||||
Add to package.json
|
|
||||||
|
|
||||||
postinstall: prisma generate
|
|
||||||
|
|
||||||
STEP 9 — Database runtime
|
|
||||||
|
|
||||||
Generate root
|
|
||||||
|
|
||||||
docker-compose.yml
|
|
||||||
|
|
||||||
PostgreSQL container
|
|
||||||
|
|
||||||
postgres:16
|
|
||||||
port 5432
|
|
||||||
|
|
||||||
STEP 10 — Generate seed
|
|
||||||
|
|
||||||
Create
|
|
||||||
|
|
||||||
server/prisma/seed.ts
|
|
||||||
|
|
||||||
Seed minimal data for
|
|
||||||
|
|
||||||
EquipmentType
|
|
||||||
Equipment
|
|
||||||
RepairOrder
|
|
||||||
|
|
||||||
Add to package.json
|
|
||||||
|
|
||||||
prisma.seed
|
|
||||||
|
|
||||||
STEP 11 — Generate React Admin
|
|
||||||
|
|
||||||
For each entity generate
|
|
||||||
|
|
||||||
Field mapping
|
|
||||||
|
|
||||||
string → TextInput
|
|
||||||
number → NumberInput
|
|
||||||
date → DateInput
|
|
||||||
enum → SelectInput
|
|
||||||
FK → ReferenceInput
|
|
||||||
|
|
||||||
API responses MUST contain
|
|
||||||
|
|
||||||
If PK ≠ id, map primary key to id.
|
|
||||||
|
|
||||||
Example
|
|
||||||
|
|
||||||
{
|
|
||||||
id: record.code,
|
|
||||||
code: record.code
|
|
||||||
}
|
|
||||||
|
|
||||||
STEP 12 — Validation
|
|
||||||
|
|
||||||
Verify
|
|
||||||
|
|
||||||
docker-compose.yml exists
|
|
||||||
database container starts
|
|
||||||
prisma migrate dev works
|
|
||||||
prisma db seed works
|
|
||||||
API responds /health
|
|
||||||
React Admin receives id
|
|
||||||
update services sanitize payload before Prisma
|
|
||||||
|
|
||||||
OUTPUT
|
|
||||||
|
|
||||||
Provide
|
|
||||||
|
|
||||||
FULLSTACK GENERATION REPORT
|
|
||||||
|
|
||||||
Include
|
|
||||||
|
|
||||||
1 Parsed DSL
|
|
||||||
2 Prisma models
|
|
||||||
3 Backend modules
|
|
||||||
4 API endpoints
|
|
||||||
5 React Admin resources
|
|
||||||
6 Runtime configuration
|
|
||||||
7 Validation results
|
|
||||||
|
|
||||||
RUN INSTRUCTIONS
|
|
||||||
|
|
||||||
The generated application must run successfully with
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
npx prisma migrate dev
|
|
||||||
npm run start
|
|
||||||
|
|
||||||
cd client
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# Backend Generation Process
|
|
||||||
|
|
||||||
Backend generation follows a pipeline aligned with runtime and validation docs:
|
|
||||||
|
|
||||||
DSL
|
|
||||||
↓
|
|
||||||
CLI scaffolding
|
|
||||||
↓
|
|
||||||
code generation
|
|
||||||
↓
|
|
||||||
runtime infrastructure
|
|
||||||
↓
|
|
||||||
database runtime
|
|
||||||
↓
|
|
||||||
migration
|
|
||||||
↓
|
|
||||||
seed
|
|
||||||
↓
|
|
||||||
validation
|
|
||||||
|
|
||||||
Follow **backend/runtime-rules.md**, **backend/prisma-rules.md**, **backend/prisma-service.md**, **backend/database-runtime.md**, **backend/seed-rules.md**, and **backend/service-rules.md**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 1 — Parse DSL
|
|
||||||
|
|
||||||
Read DSL inputs and extract:
|
|
||||||
|
|
||||||
- entities
|
|
||||||
- attributes (including primary key attribute name per entity)
|
|
||||||
- enums
|
|
||||||
- foreign keys
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 2 — CLI scaffolding
|
|
||||||
|
|
||||||
Use official CLIs before generating backend code:
|
|
||||||
|
|
||||||
- NestJS project scaffold in `server/` (see `generation/scaffolding-rules.md`)
|
|
||||||
- Install backend dependencies (`@prisma/client`, `prisma`, `@nestjs/config`, and seed runner when needed)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 3 — Code generation
|
|
||||||
|
|
||||||
Generate backend source artifacts:
|
|
||||||
|
|
||||||
1. **Prisma schema** (`server/prisma/schema.prisma`) from domain DSL:
|
|
||||||
- attributes
|
|
||||||
- primary keys
|
|
||||||
- relations
|
|
||||||
- enums
|
|
||||||
2. **NestJS modules** per entity:
|
|
||||||
- module
|
|
||||||
- controller (path params use actual PK name: `:id`, `:code`, etc.)
|
|
||||||
- service (must sanitize update payload before Prisma — see **backend/service-rules.md**)
|
|
||||||
3. **DTO files**:
|
|
||||||
- `create-entity.dto.ts`
|
|
||||||
- `update-entity.dto.ts`
|
|
||||||
- `entity.response.dto.ts` (or equivalent)
|
|
||||||
4. **PrismaService**:
|
|
||||||
- `OnModuleInit` + `await this.$connect()`
|
|
||||||
- no `beforeExit` hook
|
|
||||||
5. **Service update methods**: Sanitize update payload before passing to Prisma (remove `id`, primary key, and readonly attributes from `data`). Do not pass the raw request body as `data` to `prisma.*.update()`.
|
|
||||||
|
|
||||||
Use mapping rules from `backend/prisma-rules.md`:
|
|
||||||
- DSL `decimal` -> DTO `string`
|
|
||||||
- DSL `date` -> DTO `string` (ISO)
|
|
||||||
|
|
||||||
## Filtering & search contract (must be generated)
|
|
||||||
|
|
||||||
React Admin uses query parameters for pagination, sorting, and filtering.
|
|
||||||
|
|
||||||
- **Pagination**: `_start`, `_end`
|
|
||||||
- **Sorting**: `_sort`, `_order`
|
|
||||||
- **Filtering**: arbitrary field keys in query string
|
|
||||||
|
|
||||||
Additionally, to support `AutocompleteInput` search for references, list endpoints must support:
|
|
||||||
|
|
||||||
- `q`: a generic search term that can be applied as an `OR` over a few human-meaningful fields (e.g. code/name/manufacturer, inventoryNumber/name, etc.)
|
|
||||||
|
|
||||||
### Multi-value filter support
|
|
||||||
|
|
||||||
For enum-like fields (e.g. `status`) the backend must accept both:
|
|
||||||
|
|
||||||
- `status=Active` (single value)
|
|
||||||
- `status=Active&status=Repair` (multiple values)
|
|
||||||
|
|
||||||
Services must treat repeated query params as arrays and translate them to Prisma `in` filters.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 4 — Runtime infrastructure
|
|
||||||
|
|
||||||
Generate runtime config files:
|
|
||||||
|
|
||||||
- `server/.env`
|
|
||||||
- `server/.env.example`
|
|
||||||
- `server/package.json` lifecycle:
|
|
||||||
- `"postinstall": "prisma generate"`
|
|
||||||
- `server/package.json` Prisma seed:
|
|
||||||
- `"prisma": { "seed": "ts-node prisma/seed.ts" }` (or `tsx` variant by project standard)
|
|
||||||
|
|
||||||
Commands that must be supported/documented:
|
|
||||||
|
|
||||||
- `npx prisma generate`
|
|
||||||
- `npx prisma migrate dev`
|
|
||||||
- `npx prisma db seed`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 5 — Database runtime
|
|
||||||
|
|
||||||
Generator must create `docker-compose.yml` at the **project root** with PostgreSQL.
|
|
||||||
|
|
||||||
Minimum required compose characteristics:
|
|
||||||
|
|
||||||
- `services.postgres`
|
|
||||||
- `image: postgres:16`
|
|
||||||
- `ports: ["5432:5432"]`
|
|
||||||
|
|
||||||
Credentials/database in compose must match `DATABASE_URL`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 6 — Migration
|
|
||||||
|
|
||||||
Apply schema to development database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npx prisma migrate dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 7 — Seed
|
|
||||||
|
|
||||||
Run development seed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npx prisma db seed
|
|
||||||
```
|
|
||||||
|
|
||||||
Seed file location: `server/prisma/seed.ts`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 8 — Validation
|
|
||||||
|
|
||||||
Run runtime and contract checks from `generation/post-generation-validation.md`, including:
|
|
||||||
|
|
||||||
- docker-compose exists and DB container starts
|
|
||||||
- Prisma lifecycle commands succeed
|
|
||||||
- seed runs
|
|
||||||
- `/health` responds
|
|
||||||
- React Admin receives `id` in every record
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# Developer Workflow
|
|
||||||
|
|
||||||
This document describes the **developer workflow** for running a generated fullstack application locally. The generator must produce a project that supports this workflow so the app is **fully runnable** after generation.
|
|
||||||
|
|
||||||
## Regenerating code from DSL
|
|
||||||
|
|
||||||
If the domain DSL changes (e.g. a new entity is added), regenerate backend + frontend artifacts from `examples/TOiR.domain.dsl`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npm run generate:from-dsl
|
|
||||||
```
|
|
||||||
|
|
||||||
Then apply the updated schema and seed data:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npx prisma db push
|
|
||||||
npx prisma db seed
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Prerequisites
|
|
||||||
|
|
||||||
- **Node.js** (LTS, e.g. 18+)
|
|
||||||
- **npm**
|
|
||||||
- **Docker** and **Docker Compose** (for the development database)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Workflow Steps
|
|
||||||
|
|
||||||
## 1. Start the database
|
|
||||||
|
|
||||||
From the **project root**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts the PostgreSQL container defined in `docker-compose.yml`. Wait a few seconds for the database to accept connections.
|
|
||||||
|
|
||||||
Verify (optional):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Backend setup and start
|
|
||||||
|
|
||||||
From the **server** directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
npx prisma generate
|
|
||||||
npx prisma migrate dev
|
|
||||||
npx prisma db seed
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
- `npm install` — installs dependencies and runs `postinstall` (for example `prisma generate`).
|
|
||||||
- `npx prisma generate` — explicitly generates Prisma client.
|
|
||||||
- `npx prisma migrate dev` — creates/applies migrations.
|
|
||||||
- `npx prisma db seed` — inserts minimal development data.
|
|
||||||
- `npm run start` — starts NestJS backend.
|
|
||||||
|
|
||||||
The API should be available at the configured port (e.g. `http://localhost:3000`). Verify with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3000/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `{ "status": "ok" }` (or equivalent).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Frontend setup and start
|
|
||||||
|
|
||||||
In a **separate terminal**, from the **project root**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd client
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
- `npm install` — installs frontend dependencies.
|
|
||||||
- `npm run dev` — starts the Vite dev server (e.g. `http://localhost:5173`).
|
|
||||||
|
|
||||||
Open the Vite URL in a browser; the React Admin app should load and use the backend API.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
|
|
||||||
| Step | Command / location |
|
|
||||||
|------|---------------------|
|
|
||||||
| Start database | From root: `docker compose up -d` |
|
|
||||||
| Backend setup/start | `cd server && npm install && npx prisma generate && npx prisma migrate dev && npx prisma db seed && npm run start` |
|
|
||||||
| Frontend setup/start | `cd client && npm install && npm run dev` |
|
|
||||||
|
|
||||||
The generator must produce all required artifacts (docker-compose, env, schema, migrations, seed, health endpoint) so that this workflow succeeds and the development environment is fully runnable.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Frontend Generation Process
|
|
||||||
|
|
||||||
Frontend generation uses the DSL and API specification.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 1 — Parse DSL
|
|
||||||
|
|
||||||
Extract entities and attributes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 2 — Generate React Admin Resources
|
|
||||||
|
|
||||||
For each entity create:
|
|
||||||
|
|
||||||
EntityList.tsx
|
|
||||||
EntityCreate.tsx
|
|
||||||
EntityEdit.tsx
|
|
||||||
EntityShow.tsx
|
|
||||||
|
|
||||||
## List UX requirements (must be generated)
|
|
||||||
|
|
||||||
- Lists must include **filtering UI** via `filters` prop on `List` and an explicit actions toolbar with:
|
|
||||||
- `FilterButton` (so non-`alwaysOn` filters are discoverable)
|
|
||||||
- `CreateButton`
|
|
||||||
- `ExportButton`
|
|
||||||
- Lists must include a **default sort** (`sort={{ field: "...", order: "ASC|DESC" }}`) appropriate for the entity.
|
|
||||||
|
|
||||||
## Reference selection UX (must be generated)
|
|
||||||
|
|
||||||
- For foreign keys (`ReferenceInput`) in Create/Edit forms, prefer `AutocompleteInput` over `SelectInput` to support search.
|
|
||||||
- Autocomplete must send search text to backend using `filterToQuery={(searchText) => ({ q: searchText })}`.
|
|
||||||
- Option text must include a **code** (or business identifier) and a name when available, e.g. `CODE — NAME`.
|
|
||||||
|
|
||||||
## Enum filters (must be generated)
|
|
||||||
|
|
||||||
- For enum fields in **list filters**, use:
|
|
||||||
- `SelectInput` for single-select filters
|
|
||||||
- `SelectArrayInput` for multi-select filters when users need to filter by multiple enum values (e.g. Status).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 3 — Map Fields
|
|
||||||
|
|
||||||
Map DSL attributes to React Admin components.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 4 — Register Resources
|
|
||||||
|
|
||||||
Register resources in App.tsx.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
<Resource name="equipment" ... />
|
|
||||||
@@ -332,7 +332,7 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
|||||||
}
|
}
|
||||||
updateDtoLines.push('}');
|
updateDtoLines.push('}');
|
||||||
|
|
||||||
const controller = `import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';\nimport { Response } from 'express';\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 @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 @Get(':${pk}')\n findOne(@Param('${pk}') id: string) {\n return this.service.findOne(id);\n }\n\n @Post()\n create(@Body() dto: Create${className}Dto) {\n return this.service.create(dto);\n }\n\n @Patch(':${pk}')\n update(@Param('${pk}') id: string, @Body() dto: Update${className}Dto) {\n return this.service.update(id, dto);\n }\n\n @Delete(':${pk}')\n remove(@Param('${pk}') id: string) {\n return this.service.remove(id);\n }\n}\n`;
|
const controller = `import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';\nimport { Response } from 'express';\nimport { Roles } from '../../auth/decorators/roles.decorator';\nimport { RealmRole } from '../../auth/roles/realm-role.enum';\nimport { ${serviceName} } from './${folder}.service';\nimport { Create${className}Dto } from './dto/create-${folder}.dto';\nimport { Update${className}Dto } from './dto/update-${folder}.dto';\n\n@Controller('${resourceName}')\nexport class ${controllerName} {\n constructor(private readonly service: ${serviceName}) {}\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get()\n async findAll(@Query() query: any, @Res() res: Response) {\n const result = await this.service.findAll(query);\n res.set('Content-Range', \`${resourceName} \${query._start || 0}-\${query._end || result.total}/\${result.total}\`);\n res.set('Access-Control-Expose-Headers', 'Content-Range');\n return res.json(result.data);\n }\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get(':${pk}')\n findOne(@Param('${pk}') id: string) {\n return this.service.findOne(id);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Post()\n create(@Body() dto: Create${className}Dto) {\n return this.service.create(dto);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Patch(':${pk}')\n update(@Param('${pk}') id: string, @Body() dto: Update${className}Dto) {\n return this.service.update(id, dto);\n }\n\n @Roles(RealmRole.Admin)\n @Delete(':${pk}')\n remove(@Param('${pk}') id: string) {\n return this.service.remove(id);\n }\n}\n`;
|
||||||
|
|
||||||
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
|
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')
|
.filter((a) => a.type === 'decimal')
|
||||||
@@ -367,13 +367,31 @@ function renderBackendModule(entityName, entity, resourceName, pk) {
|
|||||||
.map((a) => ` if (data.${a.name} !== undefined && data.${a.name} !== null) data.${a.name} = new Prisma.Decimal(data.${a.name});`)
|
.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`;
|
.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`;
|
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 {
|
return {
|
||||||
folder,
|
folder,
|
||||||
files: {
|
files: {
|
||||||
[`server/src/modules/${folder}/${folder}.controller.ts`]: controller,
|
[`server/src/modules/${folder}/${folder}.controller.ts`]: controller,
|
||||||
[`server/src/modules/${folder}/${folder}.service.ts`]: service,
|
[`server/src/modules/${folder}/${folder}.service.ts`]: serviceContent,
|
||||||
[`server/src/modules/${folder}/${folder}.module.ts`]: mod,
|
[`server/src/modules/${folder}/${folder}.module.ts`]: mod,
|
||||||
[`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDtoLines.join('\n') + '\n',
|
[`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDtoLines.join('\n') + '\n',
|
||||||
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDtoLines.join('\n') + '\n',
|
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDtoLines.join('\n') + '\n',
|
||||||
@@ -621,7 +639,7 @@ function main() {
|
|||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const apply = args.includes('--apply');
|
const apply = args.includes('--apply');
|
||||||
const dslArgIdx = args.indexOf('--dsl');
|
const dslArgIdx = args.indexOf('--dsl');
|
||||||
const dslPath = dslArgIdx >= 0 ? args[dslArgIdx + 1] : 'examples/TOiR.domain.dsl';
|
const dslPath = dslArgIdx >= 0 ? args[dslArgIdx + 1] : 'domain/TOiR.domain.dsl';
|
||||||
|
|
||||||
const absDsl = path.resolve(ROOT, dslPath);
|
const absDsl = path.resolve(ROOT, dslPath);
|
||||||
const dslText = readFile(absDsl);
|
const dslText = readFile(absDsl);
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
# Post-Generation Validation
|
|
||||||
|
|
||||||
After generating the backend or fullstack application, run these checks to ensure the project will run without runtime errors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Validation Checklist
|
|
||||||
|
|
||||||
## 1. Environment file
|
|
||||||
|
|
||||||
- [ ] **`.env` exists** in the backend root (e.g. `server/.env`).
|
|
||||||
- [ ] **`.env.example` exists** with at least `DATABASE_URL` and a placeholder value.
|
|
||||||
- [ ] **`DATABASE_URL`** is present in `.env` (or documented in `.env.example` so the user can copy and set it).
|
|
||||||
|
|
||||||
**Failure symptom:** `Environment variable not found: DATABASE_URL` at startup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. PrismaService implementation
|
|
||||||
|
|
||||||
- [ ] A **PrismaService** (or equivalent) class exists and extends `PrismaClient`.
|
|
||||||
- [ ] It implements **`OnModuleInit`** and calls **`await this.$connect()`** in `onModuleInit()`.
|
|
||||||
- [ ] It does **not** use **`this.$on('beforeExit', ...)`** or any `beforeExit` hook.
|
|
||||||
|
|
||||||
**Failure symptom:** Deprecation/runtime errors in Prisma 5 when using `beforeExit`.
|
|
||||||
|
|
||||||
**Reference:** `backend/prisma-service.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Prisma client lifecycle
|
|
||||||
|
|
||||||
- [ ] **`package.json`** includes a script that runs Prisma client generation:
|
|
||||||
- Either **`"postinstall": "prisma generate"`** (or `npx prisma generate`),
|
|
||||||
- Or clear documentation to run **`npx prisma generate`** after install.
|
|
||||||
- [ ] After schema generation or change, **`npx prisma generate`** has been run (or will run via postinstall).
|
|
||||||
|
|
||||||
**Failure symptom:** `Cannot find module '@prisma/client'` or missing types at build/run.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Database migration
|
|
||||||
|
|
||||||
- [ ] **Migration workflow** is documented (e.g. in README or generation docs).
|
|
||||||
- [ ] Instruction to run **`npx prisma migrate dev`** (or `prisma migrate deploy` for production) after first generation or schema change.
|
|
||||||
- [ ] Generation pipeline (see `generation/backend-generation.md`) includes or documents the migration step.
|
|
||||||
|
|
||||||
**Failure symptom:** Tables do not exist; Prisma errors on first query.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. REST route parameters
|
|
||||||
|
|
||||||
- [ ] For each entity, path parameters use the **correct primary key name** from the DSL.
|
|
||||||
- [ ] **Entity with PK `id` (uuid):** routes use **`/:id`** (e.g. `GET /equipment/:id`, `PATCH /equipment/:id`).
|
|
||||||
- [ ] **Entity with non-`id` primary key (e.g. `code`):** routes use **`/:code`** (or the actual PK attribute name), e.g. `GET /equipment-types/:code`, **not** `GET /equipment-types/:id`.
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
| Entity | Primary key | Correct path | Incorrect path |
|
|
||||||
| ------------- | ----------- | ---------------------- | -------------------- |
|
|
||||||
| Equipment | id | /equipment/:id | — |
|
|
||||||
| EquipmentType | code | /equipment-types/:code | /equipment-types/:id |
|
|
||||||
| RepairOrder | id | /repair-orders/:id | — |
|
|
||||||
|
|
||||||
**Failure symptom:** Controller expects `params.id` but route is defined with `:code`; or vice versa; 404 or wrong resource updated.
|
|
||||||
|
|
||||||
**Reference:** `backend/architecture.md` — API path rules for non-id primary keys.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. DTO type mapping (serialization)
|
|
||||||
|
|
||||||
- [ ] **DSL `decimal`** → In DTO/API response, use **`string`** (or a type that serializes to string), not Prisma `Decimal`, to avoid JSON serialization issues.
|
|
||||||
- [ ] **DSL `date`** → In DTO/API response, use **`string`** (ISO 8601) or ensure DateTime is serialized to string, so React Admin and JSON consumers receive a string.
|
|
||||||
|
|
||||||
**Reference:** `backend/prisma-rules.md` — DTO type mapping table.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. React Admin ID field in API responses
|
|
||||||
|
|
||||||
- [ ] **Every API response object** (list items and single-resource GET) contains a field named **`id`**.
|
|
||||||
- [ ] If the entity primary key is **not** named `id`, the response must **map** the primary key to `id`: e.g. `id: record.code` for EquipmentType, so the payload includes both `id` and `code` (or at least `id` with the PK value).
|
|
||||||
- [ ] The `id` field value must be the **primary key value** (string or uuid as appropriate).
|
|
||||||
|
|
||||||
**Failure symptom:** React Admin fails to identify records, breaks cache/references, or throws when expecting `record.id`.
|
|
||||||
|
|
||||||
**Reference:** `frontend/react-admin-rules.md` — React Admin ID Field Requirement.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Update payload sanitization (service layer)
|
|
||||||
|
|
||||||
- [ ] **Update endpoint must not pass `id` (or primary key) in Prisma `data`.** The service must sanitize the incoming DTO before calling `prisma.*.update({ where, data })`: remove `id`, remove the entity primary key field (e.g. `code`), and remove any readonly attributes. Only updatable fields should be passed as `data`.
|
|
||||||
- [ ] Generated update methods follow the pattern from **backend/service-rules.md** (e.g. `const { id, code, ...data } = dto` then pass `data` to Prisma).
|
|
||||||
|
|
||||||
**Failure symptom:** Prisma throws when `data` contains `id` or another field that is not on the model or not writable (e.g. entity with PK `code` receives body with `id` from React Admin).
|
|
||||||
|
|
||||||
**Reference:** `backend/service-rules.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Database runtime (docker-compose)
|
|
||||||
|
|
||||||
- [ ] **`docker-compose.yml` exists** at the project root (or documented location).
|
|
||||||
- [ ] It defines a **PostgreSQL** service with image (e.g. `postgres:16`), port `5432`, and credentials/DB name matching `DATABASE_URL` in `server/.env`.
|
|
||||||
- [ ] **Database container starts:** `docker compose up -d` runs without error and the container is reachable on the configured port.
|
|
||||||
|
|
||||||
**Failure symptom:** `PrismaClientInitializationError P1001: Can't reach database server at localhost:5432`.
|
|
||||||
|
|
||||||
**Reference:** `backend/database-runtime.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Migrations and seed
|
|
||||||
|
|
||||||
- [ ] **`npx prisma migrate dev`** runs successfully from `server/` when the database is up (schema is applied or created).
|
|
||||||
- [ ] **Seed script** exists at `server/prisma/seed.ts` (or equivalent) and creates minimal sample data (e.g. one EquipmentType, one Equipment, one RepairOrder).
|
|
||||||
- [ ] **`npx prisma db seed`** runs without error (package.json has `prisma.seed` configured and seed runner installed).
|
|
||||||
|
|
||||||
**Reference:** `backend/seed-rules.md`, `generation/runtime-bootstrap.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Health endpoint
|
|
||||||
|
|
||||||
- [ ] Backend exposes **GET /health** (or equivalent health route).
|
|
||||||
- [ ] **API responds to /health:** With backend running, `GET http://localhost:<port>/health` returns HTTP 200 and a body such as `{ "status": "ok" }`.
|
|
||||||
|
|
||||||
**Reference:** `backend/architecture.md` — Health Endpoint
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Summary Table
|
|
||||||
|
|
||||||
| Check | Required artifact / rule |
|
|
||||||
| ------------------- | ---------------------------------------------- |
|
|
||||||
| .env | File exists with DATABASE_URL |
|
|
||||||
| DATABASE_URL | Present in .env or .env.example |
|
|
||||||
| PrismaService | OnModuleInit + $connect(); no beforeExit |
|
|
||||||
| prisma generate | postinstall script or documented step |
|
|
||||||
| Migration | Documented step: prisma migrate dev |
|
|
||||||
| REST path params | Use entity PK name (:id or :code, etc.) |
|
|
||||||
| Decimal/Date in DTO | Map to string for serialization |
|
|
||||||
| API response `id` | Every record has `id`; if PK ≠ id, map PK → id |
|
|
||||||
| Update payload | Service strips `id`, PK, readonly from data before Prisma |
|
|
||||||
| docker-compose.yml | Exists at project root; PostgreSQL service |
|
|
||||||
| Database container | Starts with `docker compose up -d` |
|
|
||||||
| prisma migrate dev | Runs successfully from server/ |
|
|
||||||
| Seed script | Exists; prisma db seed runs |
|
|
||||||
| GET /health | Backend responds with 200 and status payload |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Integration with generation pipeline
|
|
||||||
|
|
||||||
1. **Backend generation** (see `generation/backend-generation.md`) should produce artifacts that satisfy the above by default.
|
|
||||||
2. After generation, run this checklist manually or via a script that parses generated code and config.
|
|
||||||
3. If any check fails, the AI context or generator should be updated so that future runs pass.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Runtime Bootstrap
|
|
||||||
|
|
||||||
After project generation, the following commands must work in order so that the application runs without manual database provisioning or ad-hoc steps.
|
|
||||||
|
|
||||||
The generator must produce a **runnable development environment**: backend, frontend, and database must all be startable via a documented sequence.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Bootstrap Sequence
|
|
||||||
|
|
||||||
## 1. Start the database
|
|
||||||
|
|
||||||
From the **project root**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts the PostgreSQL container. The backend will connect to it using `DATABASE_URL` from `server/.env`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Backend setup and start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
npx prisma generate
|
|
||||||
npx prisma migrate dev
|
|
||||||
npx prisma db seed
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
- `npm install` — installs dependencies and runs `postinstall` (e.g. `prisma generate`) if configured.
|
|
||||||
- `npx prisma generate` — ensures Prisma client is generated explicitly after install/schema changes.
|
|
||||||
- `npx prisma migrate dev` — creates/applies migrations and ensures the database schema exists.
|
|
||||||
- `npx prisma db seed` — populates minimal development data for immediate UI/API usage.
|
|
||||||
- `npm run start` — starts the NestJS server (default port e.g. 3000).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Frontend setup and start
|
|
||||||
|
|
||||||
In a separate terminal, from the **project root**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd client
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
- `npm run dev` — starts the Vite dev server (e.g. http://localhost:5173).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Success Criteria
|
|
||||||
|
|
||||||
After running the above:
|
|
||||||
|
|
||||||
- Database container is running; Prisma can connect.
|
|
||||||
- Backend responds (e.g. `GET /health` returns `{ "status": "ok" }`).
|
|
||||||
- Frontend loads and can call the backend API.
|
|
||||||
|
|
||||||
The generator is responsible for producing all artifacts (docker-compose, schema, migrations, seed, env, health endpoint) so that this sequence succeeds without additional manual setup.
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# Project Scaffolding Rules
|
|
||||||
|
|
||||||
The generator must use **official CLI tools** to create base project structures.
|
|
||||||
|
|
||||||
The AI must **not** manually generate the entire project skeleton (e.g. by writing all config files and folder structure by hand). Using the CLI reduces errors and ensures compatibility with current tool versions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Backend Scaffolding
|
|
||||||
|
|
||||||
Use **NestJS CLI**.
|
|
||||||
|
|
||||||
## Command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @nestjs/cli@10.3.2 new server --package-manager npm --skip-git
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- **Project directory** must be `server`.
|
|
||||||
- **TypeScript** must be used (default for Nest CLI).
|
|
||||||
- **npm** must be the package manager (`--package-manager npm`).
|
|
||||||
- **Git** initialization must be skipped (`--skip-git`).
|
|
||||||
|
|
||||||
## After scaffolding — install required dependencies
|
|
||||||
|
|
||||||
Run from the `server` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @prisma/client
|
|
||||||
npm install prisma --save-dev
|
|
||||||
npm install @nestjs/config
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Frontend Scaffolding
|
|
||||||
|
|
||||||
Use **Vite CLI**.
|
|
||||||
|
|
||||||
## Command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm create vite@5.2.0 client -- --template react-ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- **Project directory** must be `client`.
|
|
||||||
- **React + TypeScript** template must be used (`--template react-ts`).
|
|
||||||
|
|
||||||
## After scaffolding — install required dependencies
|
|
||||||
|
|
||||||
Run from the `client` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install react-admin
|
|
||||||
npm install ra-data-simple-rest
|
|
||||||
npm install @mui/material @emotion/react @emotion/styled
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Scaffolding Strategy
|
|
||||||
|
|
||||||
Generation pipeline order:
|
|
||||||
|
|
||||||
1. **Parse DSL** — Read domain, DTO, API, and UI DSL files.
|
|
||||||
2. **Run CLI scaffolding** — Create `server` with NestJS CLI and `client` with Vite CLI; install dependencies as above.
|
|
||||||
3. **Code generation** — Generate Prisma schema, NestJS modules/DTOs/PrismaService, and React Admin resources.
|
|
||||||
4. **Runtime infrastructure** — Generate `.env`, `.env.example`, package lifecycle scripts, and runtime config files.
|
|
||||||
5. **Database runtime** — Generate `docker-compose.yml` in project root with PostgreSQL service (`postgres`, image `postgres:16`, port `5432:5432`).
|
|
||||||
6. **Migration** — Apply schema with `npx prisma migrate dev`.
|
|
||||||
7. **Seed** — Populate minimal development data with `npx prisma db seed`.
|
|
||||||
8. **Validation** — Run checks from `generation/post-generation-validation.md`.
|
|
||||||
|
|
||||||
Scaffolding (steps 1–2) must be done with the CLI; steps 3–8 are generated from the DSL and project docs.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Update Strategy
|
|
||||||
|
|
||||||
When DSL changes:
|
|
||||||
|
|
||||||
1. Regenerate prisma.schema
|
|
||||||
2. Run prisma migrate dev
|
|
||||||
3. Regenerate Nest modules
|
|
||||||
4. Regenerate React Admin resources
|
|
||||||
2
overrides/api-overrides.dsl
Normal file
2
overrides/api-overrides.dsl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Optional overrides:
|
||||||
|
// resource EquipmentType path "equipment-types";
|
||||||
2
overrides/ui-overrides.dsl
Normal file
2
overrides/ui-overrides.dsl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Optional overrides:
|
||||||
|
// field EquipmentType.code widget "text";
|
||||||
10
package.json
Normal file
10
package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "toir-generation-context",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"generate:domain-summary": "node tools/generate-domain-summary.mjs",
|
||||||
|
"validate:generation": "node tools/validate-generation.mjs",
|
||||||
|
"validate:generation:runtime": "node tools/validate-generation.mjs --run-runtime",
|
||||||
|
"validate:generation:artifacts": "node tools/validate-generation.mjs --artifacts-only"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
prompts/auth-rules.md
Normal file
79
prompts/auth-rules.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Auth Rules
|
||||||
|
|
||||||
|
This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline.
|
||||||
|
|
||||||
|
- Auth is part of the default generation path, not a post-generation addon.
|
||||||
|
- `server/` is the active backend target output path.
|
||||||
|
- `client/` is the active frontend target output path.
|
||||||
|
- The generated runtime stays SPA + API + external Keycloak + PostgreSQL only.
|
||||||
|
|
||||||
|
## Frontend auth invariants
|
||||||
|
|
||||||
|
- Use `keycloak-js` with redirect-based login only.
|
||||||
|
- Initialize Keycloak before rendering the SPA.
|
||||||
|
- Use Authorization Code Flow + PKCE (`S256`).
|
||||||
|
- Keep `authProvider`, `dataProvider`, `getIdentity()`, `getPermissions()`, and `checkError()` as stable provider seams.
|
||||||
|
- Derive identity from token claims already present in the parsed token.
|
||||||
|
- Do not call `loadUserProfile()` and do not depend on `/account` for the baseline app.
|
||||||
|
- `401` must force re-authentication; `403` must stay an authorization error.
|
||||||
|
- Keep token handling in memory and refresh through one shared in-flight operation.
|
||||||
|
|
||||||
|
## Working runtime defaults
|
||||||
|
|
||||||
|
Use the already working project defaults unless a prompt explicitly overrides them.
|
||||||
|
|
||||||
|
- Frontend Keycloak base URL example:
|
||||||
|
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
|
||||||
|
- Frontend realm and client example:
|
||||||
|
- `VITE_KEYCLOAK_REALM=toir`
|
||||||
|
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
|
||||||
|
- Backend issuer and audience example:
|
||||||
|
- `KEYCLOAK_ISSUER_URL=https://sso.greact.ru/realms/toir`
|
||||||
|
- `KEYCLOAK_AUDIENCE=toir-backend`
|
||||||
|
- CORS example:
|
||||||
|
- `CORS_ALLOWED_ORIGINS=http://localhost:5173,https://toir-frontend.greact.ru`
|
||||||
|
|
||||||
|
Anti-regression rule:
|
||||||
|
|
||||||
|
- Do not switch the baseline Keycloak example back to `http://localhost:8080` in generated `.env.example` files unless the prompt explicitly asks for a local Keycloak runtime.
|
||||||
|
- Localhost Keycloak values may exist in private `.env.local` or `.env` overrides, but they are not the default project examples.
|
||||||
|
|
||||||
|
## Backend auth invariants
|
||||||
|
|
||||||
|
- Verify JWTs with `jose`.
|
||||||
|
- Validate issuer + audience + signature via JWKS.
|
||||||
|
- Resolve JWKS in this order:
|
||||||
|
1. `KEYCLOAK_JWKS_URL`
|
||||||
|
2. OIDC discovery at `/.well-known/openid-configuration`
|
||||||
|
3. `${issuer}/protocol/openid-connect/certs`
|
||||||
|
- Extract roles only from `realm_access.roles`.
|
||||||
|
- Keep `/health` public and all generated CRUD routes protected by default.
|
||||||
|
|
||||||
|
## Auth anti-regression invariants
|
||||||
|
|
||||||
|
- The accepted JWKS resolution chain above is the only baseline truth path. Do not document one order and implement another.
|
||||||
|
- If auth implementation changes, `prompts/auth-rules.md` and `prompts/validation-rules.md` must be updated in the same change.
|
||||||
|
- Do not skip OIDC discovery when no explicit `KEYCLOAK_JWKS_URL` is provided.
|
||||||
|
- Do not switch role extraction to alternative claims unless the prompt explicitly changes the baseline contract.
|
||||||
|
- Do not reintroduce localhost Keycloak defaults into shared baseline examples.
|
||||||
|
|
||||||
|
## Realm artifact contract
|
||||||
|
|
||||||
|
- A physical root-level `*-realm.json` artifact is mandatory output.
|
||||||
|
- The artifact must be importable, versioned, and aligned with generated backend/frontend env contracts.
|
||||||
|
- It must parameterize:
|
||||||
|
- realm name
|
||||||
|
- frontend client id
|
||||||
|
- backend client id / audience
|
||||||
|
- local and production frontend URLs
|
||||||
|
- artifact filename
|
||||||
|
- It must explicitly deliver:
|
||||||
|
- `sub`
|
||||||
|
- `aud`
|
||||||
|
- `realm_access.roles`
|
||||||
|
- It must define:
|
||||||
|
- realm roles `admin`, `editor`, `viewer`
|
||||||
|
- a public SPA client with PKCE S256
|
||||||
|
- a bearer-only backend client
|
||||||
|
- an explicit audience client scope
|
||||||
|
- explicit protocol mappers for baseline identity and role claims
|
||||||
88
prompts/backend-rules.md
Normal file
88
prompts/backend-rules.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Backend Rules
|
||||||
|
|
||||||
|
The backend remains derived from `domain/*.dsl` inside the existing LLM-first pipeline. No compiler platform or generator engine is introduced.
|
||||||
|
|
||||||
|
## Backend scaffold baseline
|
||||||
|
|
||||||
|
- Start backend initialization from the official NestJS CLI workspace, not from manually created files.
|
||||||
|
- The backend must remain compatible with standard Nest workspace tooling such as `nest build` and `nest start`.
|
||||||
|
- Preserve the core Nest workspace files generated by the CLI, especially:
|
||||||
|
- `server/tsconfig.json`
|
||||||
|
- `server/tsconfig.build.json`
|
||||||
|
- `server/nest-cli.json`
|
||||||
|
- `server/src/main.ts`
|
||||||
|
- `server/src/app.module.ts`
|
||||||
|
- For domain resources, prefer official Nest CLI generation patterns for modules/controllers/services/resources and then adapt the generated code to Prisma and auth requirements.
|
||||||
|
- Do not delete required Nest workspace files just because the LLM can inline a smaller custom structure.
|
||||||
|
|
||||||
|
## Forbidden backend generation patterns
|
||||||
|
|
||||||
|
- Do not bootstrap `server/` by hand-writing a pseudo-Nest project from memory.
|
||||||
|
- Do not remove `tsconfig.json`, `tsconfig.build.json`, or `nest-cli.json` after generation.
|
||||||
|
- Do not replace standard Nest package scripts with ad hoc commands that break `nest build` or `nest start`.
|
||||||
|
- Do not continue CRUD generation on top of a degraded backend workspace without repairing the workspace first.
|
||||||
|
|
||||||
|
## Domain-derived output
|
||||||
|
|
||||||
|
- `domain/*.dsl` is the source of truth for entities, fields, primary keys, foreign keys, and enums.
|
||||||
|
- `domain-summary.json` is a derived artifact used to stabilize LLM generation and validation. It must never replace the DSL as the source of truth.
|
||||||
|
- Each entity becomes:
|
||||||
|
- a Prisma model
|
||||||
|
- a NestJS module
|
||||||
|
- a controller
|
||||||
|
- a service
|
||||||
|
- create/update DTOs
|
||||||
|
|
||||||
|
## DTO and Prisma mapping
|
||||||
|
|
||||||
|
- `decimal` -> Prisma `Decimal`, DTO/API `string`
|
||||||
|
- `date` -> Prisma `DateTime`, DTO/API `string`
|
||||||
|
- Enums remain string-valued in DTO/API contracts
|
||||||
|
|
||||||
|
## CRUD and natural-key invariants
|
||||||
|
|
||||||
|
- CRUD routes use the real primary key name in the path.
|
||||||
|
- Every API record returned to React Admin must include `id`.
|
||||||
|
- For entities whose primary key is not `id`, the backend must map the real key to `id`.
|
||||||
|
- Natural-key list/sort logic must never build ORM `orderBy` against a fake physical `id`.
|
||||||
|
|
||||||
|
## Service invariants
|
||||||
|
|
||||||
|
- Never pass raw update DTOs into Prisma update `data`.
|
||||||
|
- Remove `id`, the real primary key, and readonly fields from update payloads before calling Prisma.
|
||||||
|
- Keep PrismaService lightweight:
|
||||||
|
- extend `PrismaClient`
|
||||||
|
- implement `OnModuleInit`
|
||||||
|
- call `$connect()`
|
||||||
|
- do not use `beforeExit`
|
||||||
|
|
||||||
|
## Filtering contract
|
||||||
|
|
||||||
|
- List endpoints must support React Admin query parameters:
|
||||||
|
- `_start`, `_end`, `_sort`, `_order`
|
||||||
|
- arbitrary field filters from query string
|
||||||
|
- `q` for reference autocomplete search
|
||||||
|
- String/text search filters may use `contains` with case-insensitive mode.
|
||||||
|
- Foreign key filters must use exact-match semantics (no `contains` for FK scalar keys).
|
||||||
|
- Enum filters must support both single and repeated query params:
|
||||||
|
- `status=Draft`
|
||||||
|
- `status=Draft&status=Approved`
|
||||||
|
- Repeated enum params must map to Prisma `{ in: [...] }`.
|
||||||
|
- Sorting must use real model scalar fields only; natural-key entities must not fallback to fake physical `id`.
|
||||||
|
|
||||||
|
## Reproducibility invariants
|
||||||
|
|
||||||
|
- A freshly generated backend must be bootstrappable with ordinary Nest + Prisma commands from `prompts/runtime-rules.md`.
|
||||||
|
- Missing TypeScript or Nest workspace config is a generation failure, not an acceptable simplification.
|
||||||
|
- The baseline backend should fail only on missing runtime dependencies or env values, not because the Nest workspace itself is incomplete.
|
||||||
|
|
||||||
|
## Recovery rule if backend workspace degraded
|
||||||
|
|
||||||
|
- If required Nest scaffold files are missing or broken, restore the official workspace baseline before editing Prisma models, modules, controllers, services, or DTOs.
|
||||||
|
- Treat workspace repair as higher priority than feature generation, because generated domain code on top of a broken workspace is invalid baseline output.
|
||||||
|
|
||||||
|
## Backend auth defaults
|
||||||
|
|
||||||
|
- `GET` -> `viewer | editor | admin`
|
||||||
|
- `POST`, `PATCH`, `PUT` -> `editor | admin`
|
||||||
|
- `DELETE` -> `admin`
|
||||||
63
prompts/frontend-rules.md
Normal file
63
prompts/frontend-rules.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Frontend Rules
|
||||||
|
|
||||||
|
The frontend stays a React Admin SPA generated from `domain/*.dsl` and anchored to the existing auth seams.
|
||||||
|
|
||||||
|
## Frontend scaffold baseline
|
||||||
|
|
||||||
|
- Start frontend initialization from the official Vite React TypeScript scaffold, not from manually assembled files.
|
||||||
|
- Preserve a valid Vite workspace baseline, including:
|
||||||
|
- `client/index.html`
|
||||||
|
- `client/tsconfig.json`
|
||||||
|
- `client/vite.config.*`
|
||||||
|
- `client/src/main.tsx`
|
||||||
|
- Add React Admin and auth seams on top of that baseline instead of replacing the workspace with a hand-written minimal shell.
|
||||||
|
- Do not delete required Vite entry/config files just because the LLM can write a shorter custom setup.
|
||||||
|
|
||||||
|
## Forbidden frontend generation patterns
|
||||||
|
|
||||||
|
- Do not bootstrap `client/` by hand-writing a pseudo-Vite project from memory.
|
||||||
|
- Do not remove `index.html`, `tsconfig*`, or `vite.config.*` after generation.
|
||||||
|
- Do not replace standard Vite package scripts with ad hoc commands that break `vite build`, `vite dev`, or `vite preview`.
|
||||||
|
- Do not continue React Admin resource generation on top of a degraded frontend workspace without repairing the workspace first.
|
||||||
|
|
||||||
|
## Resource generation
|
||||||
|
|
||||||
|
- Each entity becomes a React Admin resource with list/create/edit/show views.
|
||||||
|
- Resource names must stay aligned with backend path segments.
|
||||||
|
- Foreign keys must use `ReferenceInput` / `ReferenceField`.
|
||||||
|
- Lists must expose filters through `List` `filters` and an actions toolbar with `FilterButton`.
|
||||||
|
- For enum fields where multi-select is required (for example `status`), use `SelectArrayInput` in list filters.
|
||||||
|
- For foreign key filters and form selection use `ReferenceInput` + `AutocompleteInput` with `filterToQuery={(searchText) => ({ q: searchText })}`.
|
||||||
|
- Form mapping must stay type-safe:
|
||||||
|
- `integer` / `decimal` -> `NumberInput`
|
||||||
|
- `date` -> `DateInput`
|
||||||
|
|
||||||
|
## Provider seams
|
||||||
|
|
||||||
|
- `client/src/dataProvider.ts` is the single authenticated request seam.
|
||||||
|
- `client/src/auth/authProvider.ts` is the single React Admin auth seam.
|
||||||
|
- Auth logic must not leak into resource components.
|
||||||
|
|
||||||
|
## Identity and permissions
|
||||||
|
|
||||||
|
- `getIdentity()` must resolve from parsed token claims.
|
||||||
|
- `getPermissions()` may expose realm roles for UI awareness.
|
||||||
|
- Backend enforcement remains authoritative.
|
||||||
|
|
||||||
|
## React Admin compatibility
|
||||||
|
|
||||||
|
- Every resource record must include `id`.
|
||||||
|
- Natural-key resources must preserve route, update, and sort compatibility with React Admin contracts.
|
||||||
|
- Frontend requests must continue to work when the real primary key is not named `id`.
|
||||||
|
- `dataProvider` query serialization must preserve repeated query params for array filters (for example enum multi-select).
|
||||||
|
|
||||||
|
## Reproducibility invariants
|
||||||
|
|
||||||
|
- A freshly generated frontend must remain compatible with standard Vite commands such as `npm run dev` and `npm run build`.
|
||||||
|
- Missing Vite workspace files or missing local Vite executable wiring is a generation failure, not an acceptable simplification.
|
||||||
|
- The generated frontend should fail only on missing installation/env/runtime backend availability, not because the Vite app structure itself is incomplete.
|
||||||
|
|
||||||
|
## Recovery rule if frontend workspace degraded
|
||||||
|
|
||||||
|
- If required Vite scaffold files are missing or broken, restore the official workspace baseline before editing resources, auth seams, or UI code.
|
||||||
|
- Treat workspace repair as higher priority than feature generation, because generated React Admin code on top of a broken Vite workspace is invalid baseline output.
|
||||||
146
prompts/general-prompt.md
Normal file
146
prompts/general-prompt.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
ROLE
|
||||||
|
|
||||||
|
You are a Staff-level Fullstack Platform Engineer working inside the established LLM-first CRUD generation baseline.
|
||||||
|
|
||||||
|
Use context7 when official framework guidance is needed.
|
||||||
|
|
||||||
|
This repository is not a new generator engine. Do not redesign it into a planner/emitter/runtime platform.
|
||||||
|
|
||||||
|
GOAL
|
||||||
|
|
||||||
|
Strengthen and use the existing LLM-first CRUD generation pipeline.
|
||||||
|
|
||||||
|
- Keep `domain/*.dsl` as the source of truth for the domain model.
|
||||||
|
- Keep `server/` as the active backend target output path.
|
||||||
|
- Keep `client/` as the active frontend target output path.
|
||||||
|
- Keep Keycloak auth in the default generation path.
|
||||||
|
- Keep PostgreSQL as the only Dockerized runtime dependency.
|
||||||
|
- Generate and maintain:
|
||||||
|
- `domain-summary.json`
|
||||||
|
- a root-level `*-realm.json`
|
||||||
|
- backend/frontend auth seams
|
||||||
|
- runtime/env/bootstrap artifacts
|
||||||
|
- validation-gate-compatible output
|
||||||
|
|
||||||
|
Use the already working runtime defaults for this project unless the prompt explicitly overrides them:
|
||||||
|
|
||||||
|
- frontend Keycloak URL example: `https://sso.greact.ru`
|
||||||
|
- frontend realm/client examples: `toir`, `toir-frontend`
|
||||||
|
- backend issuer/audience examples: `https://sso.greact.ru/realms/toir`, `toir-backend`
|
||||||
|
- production frontend origin example: `https://toir-frontend.greact.ru`
|
||||||
|
|
||||||
|
Do not silently regress these examples to localhost Keycloak defaults.
|
||||||
|
|
||||||
|
ACTIVE KNOWLEDGE BLOCKS
|
||||||
|
|
||||||
|
Read in this order:
|
||||||
|
|
||||||
|
1. `domain/dsl-spec.md`
|
||||||
|
2. `domain/*.dsl`
|
||||||
|
3. `domain-summary.json` if present
|
||||||
|
4. `prompts/auth-rules.md`
|
||||||
|
5. `prompts/backend-rules.md`
|
||||||
|
6. `prompts/frontend-rules.md`
|
||||||
|
7. `prompts/runtime-rules.md`
|
||||||
|
8. `prompts/validation-rules.md`
|
||||||
|
|
||||||
|
Interpretation rules:
|
||||||
|
|
||||||
|
- `domain/*.dsl` is authoritative.
|
||||||
|
- `domain-summary.json` is derived. Regenerate or validate it against the DSL; never treat it as the source of truth.
|
||||||
|
INPUT CONTRACT
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- `domain/*.dsl`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `overrides/api-overrides.dsl`
|
||||||
|
- `overrides/ui-overrides.dsl`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Do not require DTO/API/UI DSL files.
|
||||||
|
- Do not resurrect multi-DSL source-of-truth behavior.
|
||||||
|
- Optional overrides may refine derived API/UI behavior but must not redefine the domain model.
|
||||||
|
|
||||||
|
PIPELINE CONTRACT
|
||||||
|
|
||||||
|
1. Parse `domain/*.dsl`.
|
||||||
|
2. Generate or refresh `domain-summary.json`.
|
||||||
|
3. Scaffold with official framework CLI conventions where scaffolding is needed.
|
||||||
|
4. Generate or maintain backend/frontend code in `server/` and `client/`.
|
||||||
|
5. Generate or maintain the root-level realm artifact.
|
||||||
|
6. Generate or maintain env/bootstrap/runtime artifacts.
|
||||||
|
7. Run the lightweight validation gate.
|
||||||
|
|
||||||
|
REPAIR-BEFORE-GENERATE ORDER
|
||||||
|
|
||||||
|
1. Inspect whether `server/` and `client/` are still valid framework workspaces.
|
||||||
|
2. If either workspace is degraded, repair the official scaffold baseline first.
|
||||||
|
3. Only after workspace repair, generate or update domain-derived feature code.
|
||||||
|
4. Only after generation, run validation and buildability checks.
|
||||||
|
|
||||||
|
CLI-FIRST SCAFFOLDING CONTRACT
|
||||||
|
|
||||||
|
- Never hand-write a fresh framework workspace from scratch when an official CLI exists for that framework.
|
||||||
|
- For `server/`, initialize the workspace from the official NestJS CLI baseline first, then generate/adapt modules/resources inside that workspace.
|
||||||
|
- For `client/`, initialize the workspace from the official Vite React TypeScript baseline first, then add React Admin, auth seams, and generated resources.
|
||||||
|
- Treat CLI scaffolding as the required baseline for compiler config, package scripts, workspace metadata, and default entry files.
|
||||||
|
- Manual creation is allowed only for domain-derived feature code after the official workspace exists.
|
||||||
|
- If a workspace already exists but is missing required CLI baseline files, repair it back to a valid official-style workspace before adding more generated code.
|
||||||
|
|
||||||
|
ANTI-REGRESSION FAILURES
|
||||||
|
|
||||||
|
Treat the following as baseline violations, not acceptable improvisations:
|
||||||
|
|
||||||
|
- creating a NestJS workspace by manually writing `package.json`, `tsconfig*`, `nest-cli.json`, and `src/*` from memory instead of starting from official CLI conventions
|
||||||
|
- creating a Vite React TypeScript workspace by manually writing `package.json`, `index.html`, `tsconfig*`, `vite.config.*`, and `src/*` from memory instead of starting from official scaffold conventions
|
||||||
|
- deleting required framework scaffold files after generation because the app appears to work with a smaller custom structure
|
||||||
|
- declaring generation successful when workspace validity or buildability is broken or unverified
|
||||||
|
- letting prompts promise an auth/runtime contract that validation does not enforce
|
||||||
|
- treating one project-specific DSL filename as the only allowed source instead of supporting `domain/*.dsl`
|
||||||
|
|
||||||
|
OUTPUT CONTRACT
|
||||||
|
|
||||||
|
The baseline output must include:
|
||||||
|
|
||||||
|
- `server/prisma/schema.prisma`
|
||||||
|
- `server/.env.example`
|
||||||
|
- `client/.env.example`
|
||||||
|
- `client/src/auth/keycloak.ts`
|
||||||
|
- `client/src/auth/authProvider.ts`
|
||||||
|
- `client/src/dataProvider.ts`
|
||||||
|
- `server/src/auth/*`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `domain-summary.json`
|
||||||
|
- root-level `*-realm.json`
|
||||||
|
|
||||||
|
The baseline output must also remain a real framework workspace, not a prompt-only file collection:
|
||||||
|
|
||||||
|
- `server/tsconfig.json`
|
||||||
|
- `server/tsconfig.build.json`
|
||||||
|
- `server/nest-cli.json`
|
||||||
|
- `client/index.html`
|
||||||
|
- `client/tsconfig.json`
|
||||||
|
- `client/vite.config.*`
|
||||||
|
|
||||||
|
NON-GOALS
|
||||||
|
|
||||||
|
- No new generator engine
|
||||||
|
- No compiler/IR platform
|
||||||
|
- No heavy codegen redesign
|
||||||
|
- No replacement of the old LLM-first architecture
|
||||||
|
|
||||||
|
COMPLETION INVARIANTS
|
||||||
|
|
||||||
|
- Generation is incomplete if `server/` is not a valid NestJS workspace.
|
||||||
|
- Generation is incomplete if `client/` is not a valid Vite React TypeScript workspace.
|
||||||
|
- Generation is incomplete if auth rules, runtime rules, and validation rules describe different truth paths.
|
||||||
|
- Generation is incomplete if buildability is broken.
|
||||||
|
- If buildability cannot be checked because dependencies are missing, report that state explicitly; do not report a green result for buildability.
|
||||||
|
|
||||||
|
VALIDATION
|
||||||
|
|
||||||
|
Before considering the output complete, satisfy `prompts/validation-rules.md`.
|
||||||
96
prompts/runtime-rules.md
Normal file
96
prompts/runtime-rules.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Runtime Rules
|
||||||
|
|
||||||
|
This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline and strengthens the existing pipeline instead of replacing it.
|
||||||
|
|
||||||
|
## Baseline runtime topology
|
||||||
|
|
||||||
|
- `server/` is the active backend target output path.
|
||||||
|
- `client/` is the active frontend target output path.
|
||||||
|
- Docker scope remains PostgreSQL only.
|
||||||
|
- Keycloak remains external to the repository runtime.
|
||||||
|
- The project remains LLM-first: markdown knowledge blocks in `prompts/` orchestrate generation, while active generated/maintained code lives in `server/` and `client/`.
|
||||||
|
|
||||||
|
## Required input and derived artifacts
|
||||||
|
|
||||||
|
- Source of truth:
|
||||||
|
- `domain/*.dsl`
|
||||||
|
- Required derived artifacts:
|
||||||
|
- `domain-summary.json`
|
||||||
|
- root-level `*-realm.json`
|
||||||
|
|
||||||
|
`domain-summary.json` exists to stabilize generation and validation; it must be regenerated from the DSL and treated as non-authoritative.
|
||||||
|
|
||||||
|
## Output contract
|
||||||
|
|
||||||
|
The strengthened baseline must produce and keep aligned:
|
||||||
|
|
||||||
|
- `server/prisma/schema.prisma`
|
||||||
|
- backend/frontend env examples
|
||||||
|
- backend/frontend auth seams
|
||||||
|
- root `.gitignore`, `server/.gitignore`, `client/.gitignore`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `domain-summary.json`
|
||||||
|
- root-level realm import artifact
|
||||||
|
|
||||||
|
## Concrete runtime examples
|
||||||
|
|
||||||
|
Use these as the baseline examples for this project unless the prompt explicitly overrides them:
|
||||||
|
|
||||||
|
- Backend:
|
||||||
|
- `PORT=3000`
|
||||||
|
- `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"`
|
||||||
|
- `CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"`
|
||||||
|
- `KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"`
|
||||||
|
- `KEYCLOAK_AUDIENCE="toir-backend"`
|
||||||
|
- Frontend:
|
||||||
|
- `VITE_API_URL=http://localhost:3000`
|
||||||
|
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
|
||||||
|
- `VITE_KEYCLOAK_REALM=toir`
|
||||||
|
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
|
||||||
|
|
||||||
|
These example values come from the already working runtime shape and are preferred over local-only Keycloak placeholders.
|
||||||
|
|
||||||
|
## Runtime bootstrap
|
||||||
|
|
||||||
|
1. Import the root-level realm artifact into Keycloak.
|
||||||
|
2. Start PostgreSQL with `docker compose up -d`.
|
||||||
|
3. From `server/` run:
|
||||||
|
- initialize or repair the workspace with official Nest CLI scaffolding if required before generating domain code
|
||||||
|
- `npm install`
|
||||||
|
- `npx prisma generate`
|
||||||
|
- `npx prisma migrate dev`
|
||||||
|
- `npx prisma db seed`
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run start`
|
||||||
|
4. From `client/` run:
|
||||||
|
- initialize or repair the workspace with official Vite React TypeScript scaffolding if required before generating app code
|
||||||
|
- `npm install`
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run dev`
|
||||||
|
|
||||||
|
## Recovery and completion rules
|
||||||
|
|
||||||
|
- Repair degraded framework workspaces before applying any new domain-derived generation changes.
|
||||||
|
- Do not mark generation complete while `server/` or `client/` remains non-buildable.
|
||||||
|
- If dependency installation has not happened yet, buildability may be reported as skipped, but it must never be reported as green without verification.
|
||||||
|
- Runtime/bootstrap instructions are reusable project baseline rules; TOiR names remain examples, not the only supported domain project.
|
||||||
|
|
||||||
|
## Scaffold expectations
|
||||||
|
|
||||||
|
- NestJS workspace creation should follow the official Nest CLI path for new applications and resource scaffolding.
|
||||||
|
- Vite frontend creation should follow the official Vite `create-vite` path for React TypeScript applications.
|
||||||
|
- The LLM may customize generated code after scaffold creation, but must not replace official workspace initialization with ad hoc file creation.
|
||||||
|
|
||||||
|
## Common generation failures to avoid
|
||||||
|
|
||||||
|
- starting feature generation before scaffold repair
|
||||||
|
- leaving deleted framework config files unrepaired because the current diff looks smaller
|
||||||
|
- accepting a form-only validation pass while buildability is unknown
|
||||||
|
- binding runtime rules to one project-specific DSL filename instead of `domain/*.dsl`
|
||||||
|
|
||||||
|
## Baseline intent
|
||||||
|
|
||||||
|
- No new generator engine
|
||||||
|
- No compiler platform
|
||||||
|
- No planner/emitter/runtime redesign
|
||||||
|
- Only the current LLM-first pipeline, strengthened by summary, realm, and validation artifacts
|
||||||
96
prompts/validation-rules.md
Normal file
96
prompts/validation-rules.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Validation Rules
|
||||||
|
|
||||||
|
Validation is now a lightweight automated gate instead of a prose-only checklist.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm run generate:domain-summary`
|
||||||
|
- `npm run validate:generation`
|
||||||
|
- `npm run validate:generation:runtime`
|
||||||
|
|
||||||
|
## Prompt-Gate Alignment Rule
|
||||||
|
|
||||||
|
- Every invariant described as required in the active prompt corpus must either be enforced by this gate or be called out explicitly as a manual/runtime-only check.
|
||||||
|
- Validation must not stay silent about a violation that the prompts describe as forbidden.
|
||||||
|
- Validation must not report green buildability when build verification was skipped.
|
||||||
|
|
||||||
|
## Gate groups
|
||||||
|
|
||||||
|
### Build checks
|
||||||
|
|
||||||
|
- at least one `domain/*.dsl` file exists
|
||||||
|
- required artifacts exist
|
||||||
|
- Prisma schema exists
|
||||||
|
- frontend/backend env contracts exist
|
||||||
|
- frontend/backend framework workspace files exist
|
||||||
|
- `domain-summary.json` matches the current DSL
|
||||||
|
- project `.env.example` files keep the working domain-based Keycloak examples unless explicitly overridden
|
||||||
|
- `server/` remains a valid Nest workspace
|
||||||
|
- `client/` remains a valid Vite workspace
|
||||||
|
- generation must not pass validation if framework scaffolding files were deleted and replaced by a hand-written minimal skeleton
|
||||||
|
- if dependencies are installed, build verification runs for `server/` and `client/`
|
||||||
|
- if dependencies are missing, build verification is reported as skipped with reason instead of green
|
||||||
|
|
||||||
|
### Auth checks
|
||||||
|
|
||||||
|
- frontend auth seam files exist
|
||||||
|
- backend auth seam files exist
|
||||||
|
- `401` and `403` semantics stay split
|
||||||
|
- auth code keeps the required Keycloak/JWT contracts
|
||||||
|
- JWKS resolution chain matches the contract:
|
||||||
|
1. explicit `KEYCLOAK_JWKS_URL`
|
||||||
|
2. OIDC discovery
|
||||||
|
3. certs fallback
|
||||||
|
|
||||||
|
### Filter checks
|
||||||
|
|
||||||
|
- list resources expose filter UI (including `FilterButton`)
|
||||||
|
- reference filters use `ReferenceInput` + `AutocompleteInput` with `filterToQuery`
|
||||||
|
- data provider preserves repeated query params for array filters
|
||||||
|
- backend FK filters keep exact-match semantics
|
||||||
|
- enum repeated params are mapped to Prisma `in`
|
||||||
|
- typed form mapping is preserved:
|
||||||
|
- `integer` / `decimal` -> `NumberInput`
|
||||||
|
- `date` -> `DateInput`
|
||||||
|
|
||||||
|
### Natural-key checks
|
||||||
|
|
||||||
|
- response records expose `id`
|
||||||
|
- route/update contracts use the real primary key
|
||||||
|
- natural-key sort/update paths do not regress to a fake physical `id`
|
||||||
|
|
||||||
|
### Realm checks
|
||||||
|
|
||||||
|
- a root `*-realm.json` artifact exists
|
||||||
|
- realm roles exist
|
||||||
|
- audience delivery exists
|
||||||
|
- required claims are explicit
|
||||||
|
- SPA/backend client structure is explicit
|
||||||
|
|
||||||
|
### Runtime checks
|
||||||
|
|
||||||
|
- compose topology stays PostgreSQL-only
|
||||||
|
- Prisma lifecycle scripts remain in place
|
||||||
|
- `/health` stays public
|
||||||
|
- backend can execute `npm run build` inside `server/`
|
||||||
|
- frontend can execute `npm run build` inside `client/` after dependencies are installed
|
||||||
|
- client/server `.env.example` stay aligned with the working runtime defaults:
|
||||||
|
- `https://sso.greact.ru`
|
||||||
|
- `toir`
|
||||||
|
- `toir-frontend`
|
||||||
|
- `toir-backend`
|
||||||
|
- `https://toir-frontend.greact.ru`
|
||||||
|
- optional runtime execution mode runs:
|
||||||
|
- `npx prisma generate`
|
||||||
|
- `npx prisma migrate dev`
|
||||||
|
- `npx prisma db seed`
|
||||||
|
|
||||||
|
### Scaffold checks
|
||||||
|
|
||||||
|
- backend initialization starts from official Nest CLI scaffolding
|
||||||
|
- frontend initialization starts from official Vite React TypeScript scaffolding
|
||||||
|
- feature generation happens after scaffold creation, not instead of scaffold creation
|
||||||
|
- repair happens before generation when workspace is degraded
|
||||||
|
- required framework configs and entry files must survive subsequent LLM edits
|
||||||
|
|
||||||
|
The automated gate is intentionally small. It enforces the critical reproducibility contract without turning the repository into a test platform or a generator engine.
|
||||||
@@ -1 +1,7 @@
|
|||||||
|
PORT=3000
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"
|
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"
|
||||||
|
|||||||
10
server/package-lock.json
generated
10
server/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"jose": "^6.2.2",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -6675,6 +6676,15 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"generate:from-dsl": "node ../generation/generate.mjs --apply --dsl examples/TOiR.domain.dsl",
|
"generate:from-dsl": "node ../generation/generate.mjs --apply --dsl domain/TOiR.domain.dsl",
|
||||||
"postinstall": "prisma generate"
|
"postinstall": "prisma generate"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"jose": "^6.2.2",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { validateEnvironment } from './config/env.validation';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module';
|
import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module';
|
||||||
@@ -7,7 +9,11 @@ import { EquipmentModule } from './modules/equipment/equipment.module';
|
|||||||
import { RepairOrderModule } from './modules/repair-order/repair-order.module';
|
import { RepairOrderModule } from './modules/repair-order/repair-order.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule.forRoot({ isGlobal: true }),
|
imports: [ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
validate: validateEnvironment,
|
||||||
|
}),
|
||||||
|
AuthModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
EquipmentTypeModule,
|
EquipmentTypeModule,
|
||||||
|
|||||||
3
server/src/auth/auth.constants.ts
Normal file
3
server/src/auth/auth.constants.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
22
server/src/auth/auth.module.ts
Normal file
22
server/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from './guards/roles.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthGuard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: RolesGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
|
|
||||||
129
server/src/auth/auth.service.ts
Normal file
129
server/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
|
import { RuntimeEnvironment } from '../config/env.validation';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
KeycloakJwtPayload,
|
||||||
|
} from './interfaces/authenticated-user.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
private readonly issuerUrl: string;
|
||||||
|
private readonly audience: string;
|
||||||
|
private readonly explicitJwksUrl?: string;
|
||||||
|
|
||||||
|
private jwksResolverPromise: Promise<ReturnType<typeof createRemoteJWKSet>> | null =
|
||||||
|
null;
|
||||||
|
private jwksResolver: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService<RuntimeEnvironment, true>,
|
||||||
|
) {
|
||||||
|
this.issuerUrl = this.configService.getOrThrow('KEYCLOAK_ISSUER_URL');
|
||||||
|
this.audience = this.configService.getOrThrow('KEYCLOAK_AUDIENCE');
|
||||||
|
this.explicitJwksUrl = this.configService.get('KEYCLOAK_JWKS_URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyAccessToken(token: string): Promise<AuthenticatedUser> {
|
||||||
|
try {
|
||||||
|
const jwksResolver = await this.getJwksResolver();
|
||||||
|
|
||||||
|
const { payload } = await jwtVerify(token, jwksResolver, {
|
||||||
|
issuer: this.issuerUrl,
|
||||||
|
audience: this.audience,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapPayloadToUser(payload as KeycloakJwtPayload);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`JWT verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
);
|
||||||
|
throw new UnauthorizedException('Invalid or expired access token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapPayloadToUser(payload: KeycloakJwtPayload): AuthenticatedUser {
|
||||||
|
if (!payload.sub) {
|
||||||
|
throw new UnauthorizedException('Token subject is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = Array.isArray(payload.realm_access?.roles)
|
||||||
|
? payload.realm_access.roles.filter(
|
||||||
|
(role): role is string => typeof role === 'string',
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sub: payload.sub,
|
||||||
|
username: payload.preferred_username,
|
||||||
|
name: payload.name,
|
||||||
|
email: payload.email,
|
||||||
|
roles,
|
||||||
|
claims: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getJwksResolver() {
|
||||||
|
if (this.jwksResolver) {
|
||||||
|
return this.jwksResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.jwksResolverPromise) {
|
||||||
|
this.jwksResolverPromise = this.createJwksResolver()
|
||||||
|
.then((resolver) => {
|
||||||
|
this.jwksResolver = resolver;
|
||||||
|
return resolver;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.jwksResolverPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jwksResolverPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createJwksResolver() {
|
||||||
|
const jwksUrl = await this.resolveJwksUrl();
|
||||||
|
this.logger.log(`Using JWKS URL: ${jwksUrl}`);
|
||||||
|
return createRemoteJWKSet(new URL(jwksUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveJwksUrl(): Promise<string> {
|
||||||
|
if (this.explicitJwksUrl) {
|
||||||
|
return this.explicitJwksUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuer = this.issuerUrl.replace(/\/+$/, '');
|
||||||
|
const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const discoveryResponse = await fetch(discoveryUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (discoveryResponse.ok) {
|
||||||
|
const discoveryDocument = (await discoveryResponse.json()) as {
|
||||||
|
jwks_uri?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof discoveryDocument.jwks_uri === 'string' &&
|
||||||
|
discoveryDocument.jwks_uri.trim().length > 0
|
||||||
|
) {
|
||||||
|
return discoveryDocument.jwks_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`OIDC discovery failed at ${discoveryUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${issuer}/protocol/openid-connect/certs`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
5
server/src/auth/decorators/public.decorator.ts
Normal file
5
server/src/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { IS_PUBLIC_KEY } from '../auth.constants';
|
||||||
|
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
|
|
||||||
6
server/src/auth/decorators/roles.decorator.ts
Normal file
6
server/src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { ROLES_KEY } from '../auth.constants';
|
||||||
|
import { RealmRole } from '../roles/realm-role.enum';
|
||||||
|
|
||||||
|
export const Roles = (...roles: RealmRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
|
|
||||||
54
server/src/auth/guards/jwt-auth.guard.ts
Normal file
54
server/src/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { IS_PUBLIC_KEY } from '../auth.constants';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = this.extractBearerToken(request);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('Missing bearer token');
|
||||||
|
}
|
||||||
|
|
||||||
|
request.user = await this.authService.verifyAccessToken(token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractBearerToken(request: Request): string | null {
|
||||||
|
const authorization = request.headers.authorization;
|
||||||
|
if (!authorization) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [scheme, token] = authorization.split(' ');
|
||||||
|
if (scheme?.toLowerCase() !== 'bearer' || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
49
server/src/auth/guards/roles.guard.ts
Normal file
49
server/src/auth/guards/roles.guard.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { IS_PUBLIC_KEY, ROLES_KEY } from '../auth.constants';
|
||||||
|
import { RealmRole } from '../roles/realm-role.enum';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredRoles =
|
||||||
|
this.reflector.getAllAndOverride<RealmRole[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]) ?? [];
|
||||||
|
|
||||||
|
if (requiredRoles.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const userRoles = request.user?.roles ?? [];
|
||||||
|
const hasRequiredRole = requiredRoles.some((role) =>
|
||||||
|
userRoles.includes(role),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRequiredRole) {
|
||||||
|
throw new ForbiddenException('Access denied: insufficient role');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
server/src/auth/interfaces/authenticated-user.interface.ts
Normal file
20
server/src/auth/interfaces/authenticated-user.interface.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { JWTPayload } from 'jose';
|
||||||
|
|
||||||
|
export interface KeycloakJwtPayload extends JWTPayload {
|
||||||
|
preferred_username?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
realm_access?: {
|
||||||
|
roles?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
sub: string;
|
||||||
|
username?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
roles: string[];
|
||||||
|
claims: KeycloakJwtPayload;
|
||||||
|
}
|
||||||
|
|
||||||
12
server/src/auth/interfaces/express-request.interface.ts
Normal file
12
server/src/auth/interfaces/express-request.interface.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { AuthenticatedUser } from './authenticated-user.interface';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: AuthenticatedUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
6
server/src/auth/roles/realm-role.enum.ts
Normal file
6
server/src/auth/roles/realm-role.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export enum RealmRole {
|
||||||
|
Admin = 'admin',
|
||||||
|
Editor = 'editor',
|
||||||
|
Viewer = 'viewer',
|
||||||
|
}
|
||||||
|
|
||||||
58
server/src/config/env.validation.ts
Normal file
58
server/src/config/env.validation.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export interface RuntimeEnvironment {
|
||||||
|
PORT: number;
|
||||||
|
DATABASE_URL: string;
|
||||||
|
CORS_ALLOWED_ORIGINS: string;
|
||||||
|
KEYCLOAK_ISSUER_URL: string;
|
||||||
|
KEYCLOAK_AUDIENCE: string;
|
||||||
|
KEYCLOAK_JWKS_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredString(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
key: keyof RuntimeEnvironment,
|
||||||
|
): string {
|
||||||
|
const value = config[key];
|
||||||
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
throw new Error(`Missing required environment variable: ${key}`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionalString(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
key: keyof RuntimeEnvironment,
|
||||||
|
): string | undefined {
|
||||||
|
const value = config[key];
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePort(value: unknown): number {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number(value);
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new Error('Environment variable PORT must be an integer between 1 and 65535');
|
||||||
|
}
|
||||||
|
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEnvironment(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): RuntimeEnvironment {
|
||||||
|
return {
|
||||||
|
PORT: parsePort(config.PORT),
|
||||||
|
DATABASE_URL: getRequiredString(config, 'DATABASE_URL'),
|
||||||
|
CORS_ALLOWED_ORIGINS: getRequiredString(config, 'CORS_ALLOWED_ORIGINS'),
|
||||||
|
KEYCLOAK_ISSUER_URL: getRequiredString(config, 'KEYCLOAK_ISSUER_URL'),
|
||||||
|
KEYCLOAK_AUDIENCE: getRequiredString(config, 'KEYCLOAK_AUDIENCE'),
|
||||||
|
KEYCLOAK_JWKS_URL: getOptionalString(config, 'KEYCLOAK_JWKS_URL'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
@Get()
|
@Get()
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { RuntimeEnvironment } from './config/env.validation';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
const configService = app.get<ConfigService<RuntimeEnvironment, true>>(
|
||||||
|
ConfigService,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowedOrigins = configService
|
||||||
|
.getOrThrow('CORS_ALLOWED_ORIGINS')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter((origin) => origin.length > 0);
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: true,
|
origin: (origin, callback) => {
|
||||||
|
if (!origin || allowedOrigins.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(new Error(`Origin ${origin} is not allowed by CORS`), false);
|
||||||
|
},
|
||||||
|
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Authorization', 'Content-Type'],
|
||||||
exposedHeaders: ['Content-Range'],
|
exposedHeaders: ['Content-Range'],
|
||||||
|
credentials: false,
|
||||||
});
|
});
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
|
||||||
|
const port = configService.get('PORT', 3000);
|
||||||
|
await app.listen(port);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||||
|
import { RealmRole } from '../../auth/roles/realm-role.enum';
|
||||||
import { EquipmentTypeService } from './equipment-type.service';
|
import { EquipmentTypeService } from './equipment-type.service';
|
||||||
import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto';
|
import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto';
|
||||||
import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
|
import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
|
||||||
@@ -8,6 +10,7 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
|
|||||||
export class EquipmentTypeController {
|
export class EquipmentTypeController {
|
||||||
constructor(private readonly service: EquipmentTypeService) {}
|
constructor(private readonly service: EquipmentTypeService) {}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(@Query() query: any, @Res() res: Response) {
|
async findAll(@Query() query: any, @Res() res: Response) {
|
||||||
const result = await this.service.findAll(query);
|
const result = await this.service.findAll(query);
|
||||||
@@ -16,21 +19,25 @@ export class EquipmentTypeController {
|
|||||||
return res.json(result.data);
|
return res.json(result.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||||
@Get(':code')
|
@Get(':code')
|
||||||
findOne(@Param('code') id: string) {
|
findOne(@Param('code') id: string) {
|
||||||
return this.service.findOne(id);
|
return this.service.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateEquipmentTypeDto) {
|
create(@Body() dto: CreateEquipmentTypeDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||||
@Patch(':code')
|
@Patch(':code')
|
||||||
update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) {
|
update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Admin)
|
||||||
@Delete(':code')
|
@Delete(':code')
|
||||||
remove(@Param('code') id: string) {
|
remove(@Param('code') id: string) {
|
||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class EquipmentTypeService {
|
|||||||
const take = end - start;
|
const take = end - start;
|
||||||
const skip = start;
|
const skip = start;
|
||||||
const sortField = query._sort || 'code';
|
const sortField = query._sort || 'code';
|
||||||
|
const prismaSortField = sortField === 'id' ? 'code' : sortField;
|
||||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
@@ -50,11 +51,11 @@ export class EquipmentTypeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
|
this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
|
||||||
this.prisma.equipmentType.count({ where }),
|
this.prisma.equipmentType.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mapped = data.map((r: any) => ({ id: r.code, ...serializeRecord(r) }));
|
const mapped = data.map((item: any) => ({ id: item.code, ...serializeRecord(item) }));
|
||||||
return { data: mapped, total };
|
return { data: mapped, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +74,8 @@ export class EquipmentTypeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateEquipmentTypeDto) {
|
async update(id: string, dto: UpdateEquipmentTypeDto) {
|
||||||
const data: any = { ...(dto as any) };
|
const { id: _pk, code, ...rest } = (dto as any);
|
||||||
delete data.id;
|
const data: any = { ...rest };
|
||||||
delete data.code;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||||
|
import { RealmRole } from '../../auth/roles/realm-role.enum';
|
||||||
import { EquipmentService } from './equipment.service';
|
import { EquipmentService } from './equipment.service';
|
||||||
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||||
@@ -8,6 +10,7 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
|||||||
export class EquipmentController {
|
export class EquipmentController {
|
||||||
constructor(private readonly service: EquipmentService) {}
|
constructor(private readonly service: EquipmentService) {}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(@Query() query: any, @Res() res: Response) {
|
async findAll(@Query() query: any, @Res() res: Response) {
|
||||||
const result = await this.service.findAll(query);
|
const result = await this.service.findAll(query);
|
||||||
@@ -16,21 +19,25 @@ export class EquipmentController {
|
|||||||
return res.json(result.data);
|
return res.json(result.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.service.findOne(id);
|
return this.service.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateEquipmentDto) {
|
create(@Body() dto: CreateEquipmentDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Admin)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export class EquipmentService {
|
|||||||
const take = end - start;
|
const take = end - start;
|
||||||
const skip = start;
|
const skip = start;
|
||||||
const sortField = query._sort || 'inventoryNumber';
|
const sortField = query._sort || 'inventoryNumber';
|
||||||
|
const prismaSortField = sortField === 'id' ? 'id' : sortField;
|
||||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
@@ -57,7 +58,7 @@ export class EquipmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.equipment.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
|
this.prisma.equipment.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
|
||||||
this.prisma.equipment.count({ where }),
|
this.prisma.equipment.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||||
|
import { RealmRole } from '../../auth/roles/realm-role.enum';
|
||||||
import { RepairOrderService } from './repair-order.service';
|
import { RepairOrderService } from './repair-order.service';
|
||||||
import { CreateRepairOrderDto } from './dto/create-repair-order.dto';
|
import { CreateRepairOrderDto } from './dto/create-repair-order.dto';
|
||||||
import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
|
import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
|
||||||
@@ -8,6 +10,7 @@ import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
|
|||||||
export class RepairOrderController {
|
export class RepairOrderController {
|
||||||
constructor(private readonly service: RepairOrderService) {}
|
constructor(private readonly service: RepairOrderService) {}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(@Query() query: any, @Res() res: Response) {
|
async findAll(@Query() query: any, @Res() res: Response) {
|
||||||
const result = await this.service.findAll(query);
|
const result = await this.service.findAll(query);
|
||||||
@@ -16,21 +19,25 @@ export class RepairOrderController {
|
|||||||
return res.json(result.data);
|
return res.json(result.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.service.findOne(id);
|
return this.service.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateRepairOrderDto) {
|
create(@Body() dto: CreateRepairOrderDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RealmRole.Admin)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export class RepairOrderService {
|
|||||||
const take = end - start;
|
const take = end - start;
|
||||||
const skip = start;
|
const skip = start;
|
||||||
const sortField = query._sort || 'number';
|
const sortField = query._sort || 'number';
|
||||||
|
const prismaSortField = sortField === 'id' ? 'id' : sortField;
|
||||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
@@ -55,7 +56,7 @@ export class RepairOrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
|
this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
|
||||||
this.prisma.repairOrder.count({ where }),
|
this.prisma.repairOrder.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,83 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import * as request from 'supertest';
|
import * as request from 'supertest';
|
||||||
|
import { AuthService } from '../src/auth/auth.service';
|
||||||
|
import { AuthenticatedUser } from '../src/auth/interfaces/authenticated-user.interface';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
import { AppModule } from './../src/app.module';
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
describe('AppController (e2e)', () => {
|
describe('Auth and Health (e2e)', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
let authServiceMock: {
|
||||||
|
verifyAccessToken: jest.Mock<Promise<AuthenticatedUser>, [string]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.PORT = '3000';
|
||||||
|
process.env.DATABASE_URL =
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
'postgresql://postgres:postgres@localhost:5432/toir';
|
||||||
|
process.env.CORS_ALLOWED_ORIGINS =
|
||||||
|
process.env.CORS_ALLOWED_ORIGINS ??
|
||||||
|
'http://localhost:5173,https://toir-frontend.greact.ru';
|
||||||
|
process.env.KEYCLOAK_ISSUER_URL =
|
||||||
|
process.env.KEYCLOAK_ISSUER_URL ?? 'https://sso.greact.ru/realms/toir';
|
||||||
|
process.env.KEYCLOAK_AUDIENCE =
|
||||||
|
process.env.KEYCLOAK_AUDIENCE ?? 'toir-backend';
|
||||||
|
|
||||||
|
authServiceMock = {
|
||||||
|
verifyAccessToken: jest.fn<Promise<AuthenticatedUser>, [string]>(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
}).compile();
|
})
|
||||||
|
.overrideProvider(AuthService)
|
||||||
|
.useValue(authServiceMock)
|
||||||
|
.overrideProvider(PrismaService)
|
||||||
|
.useValue({})
|
||||||
|
.compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/ (GET)', () => {
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
authServiceMock.verifyAccessToken.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/health (GET) is public', () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get('/')
|
.get('/health')
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect('Hello World!');
|
.expect({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/equipment (GET) requires authentication', () => {
|
||||||
|
return request(app.getHttpServer()).get('/equipment').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/equipment (POST) returns 403 for authenticated viewer role', async () => {
|
||||||
|
authServiceMock.verifyAccessToken.mockResolvedValue({
|
||||||
|
sub: 'viewer-user',
|
||||||
|
username: 'viewer-user',
|
||||||
|
roles: ['viewer'],
|
||||||
|
claims: {
|
||||||
|
sub: 'viewer-user',
|
||||||
|
realm_access: {
|
||||||
|
roles: ['viewer'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/equipment')
|
||||||
|
.set('Authorization', 'Bearer viewer-token')
|
||||||
|
.send({})
|
||||||
|
.expect(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,5 +5,8 @@
|
|||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^jose$": "<rootDir>/mocks/jose.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
server/test/mocks/jose.ts
Normal file
8
server/test/mocks/jose.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const createRemoteJWKSet = () => {
|
||||||
|
return async () => ({}) as never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jwtVerify = async () => {
|
||||||
|
return { payload: {} };
|
||||||
|
};
|
||||||
|
|
||||||
172
toir-realm.json
Normal file
172
toir-realm.json
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
{
|
||||||
|
"realm": "toir",
|
||||||
|
"enabled": true,
|
||||||
|
"displayName": "TOIR",
|
||||||
|
"sslRequired": "external",
|
||||||
|
"registrationAllowed": false,
|
||||||
|
"registrationEmailAsUsername": false,
|
||||||
|
"loginWithEmailAllowed": true,
|
||||||
|
"duplicateEmailsAllowed": false,
|
||||||
|
"resetPasswordAllowed": true,
|
||||||
|
"rememberMe": true,
|
||||||
|
"verifyEmail": false,
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"description": "Full administrative access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "editor",
|
||||||
|
"description": "Can create and modify data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "viewer",
|
||||||
|
"description": "Read-only access"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clientScopes": [
|
||||||
|
{
|
||||||
|
"name": "api-audience",
|
||||||
|
"description": "Adds backend audience to SPA access token",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"display.on.consent.screen": "false",
|
||||||
|
"include.in.token.scope": "false"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "aud-toir-backend",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.client.audience": "toir-backend",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"introspection.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "toir-frontend",
|
||||||
|
"name": "toir-frontend",
|
||||||
|
"description": "Frontend SPA client",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": true,
|
||||||
|
"bearerOnly": false,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"rootUrl": "https://toir-frontend.greact.ru",
|
||||||
|
"baseUrl": "https://toir-frontend.greact.ru",
|
||||||
|
"redirectUris": [
|
||||||
|
"https://toir-frontend.greact.ru/*",
|
||||||
|
"http://localhost:5173/*"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"https://toir-frontend.greact.ru",
|
||||||
|
"http://localhost:5173"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256"
|
||||||
|
},
|
||||||
|
"defaultClientScopes": [
|
||||||
|
"api-audience"
|
||||||
|
],
|
||||||
|
"optionalClientScopes": [
|
||||||
|
"offline_access"
|
||||||
|
],
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "sub",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "id",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "sub",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "preferred_username",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "username",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "preferred_username",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "email",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "email",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-full-name-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "realm roles",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"multivalued": "true",
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "realm_access.roles",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "toir-backend",
|
||||||
|
"name": "toir-backend",
|
||||||
|
"description": "Backend API resource server",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": false,
|
||||||
|
"bearerOnly": true,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"fullScopeAllowed": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
357
tools/dsl-summary.mjs
Normal file
357
tools/dsl-summary.mjs
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
function stripInlineComment(line) {
|
||||||
|
let inString = false;
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
const current = line[index];
|
||||||
|
const next = line[index + 1];
|
||||||
|
|
||||||
|
if (current === '"' && line[index - 1] !== '\\') {
|
||||||
|
inString = !inString;
|
||||||
|
result += current;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inString && current === '/' && next === '/') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDefaultValue(rawValue) {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^-?\d+$/.test(trimmed)) {
|
||||||
|
return Number(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDslFiles(rootDir) {
|
||||||
|
const domainDir = path.join(rootDir, 'domain');
|
||||||
|
|
||||||
|
return readdirSync(domainDir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.dsl'))
|
||||||
|
.map((entry) => path.join(domainDir, entry.name))
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverrideFiles(rootDir) {
|
||||||
|
const overridesDir = path.join(rootDir, 'overrides');
|
||||||
|
const files = ['api-overrides.dsl', 'ui-overrides.dsl']
|
||||||
|
.map((name) => path.join(overridesDir, name))
|
||||||
|
.filter((filePath) => {
|
||||||
|
try {
|
||||||
|
return readdirSync(path.dirname(filePath)).includes(path.basename(filePath));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDslFiles(rootDir) {
|
||||||
|
const dslFiles = getDslFiles(rootDir);
|
||||||
|
const enums = [];
|
||||||
|
const entities = [];
|
||||||
|
const stack = [];
|
||||||
|
|
||||||
|
for (const filePath of dslFiles) {
|
||||||
|
const contents = readFileSync(filePath, 'utf8');
|
||||||
|
const lines = contents.split(/\r?\n/);
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = stripInlineComment(rawLine);
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = stack.at(-1);
|
||||||
|
|
||||||
|
const enumMatch = line.match(/^enum\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||||
|
if (!top && enumMatch) {
|
||||||
|
const enumDefinition = { name: enumMatch[1], values: [] };
|
||||||
|
enums.push(enumDefinition);
|
||||||
|
stack.push({ type: 'enum', ref: enumDefinition });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityMatch = line.match(/^entity\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||||
|
if (!top && entityMatch) {
|
||||||
|
const entityDefinition = {
|
||||||
|
name: entityMatch[1],
|
||||||
|
primaryKey: null,
|
||||||
|
fields: [],
|
||||||
|
foreignKeys: [],
|
||||||
|
};
|
||||||
|
entities.push(entityDefinition);
|
||||||
|
stack.push({ type: 'entity', ref: entityDefinition });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueMatch = line.match(/^value\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||||
|
if (top?.type === 'enum' && valueMatch) {
|
||||||
|
const enumValue = { name: valueMatch[1] };
|
||||||
|
top.ref.values.push(enumValue);
|
||||||
|
stack.push({ type: 'enumValue', ref: enumValue });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributeMatch = line.match(/^attribute\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||||
|
if (top?.type === 'entity' && attributeMatch) {
|
||||||
|
const field = {
|
||||||
|
name: attributeMatch[1],
|
||||||
|
type: null,
|
||||||
|
required: false,
|
||||||
|
unique: false,
|
||||||
|
default: null,
|
||||||
|
};
|
||||||
|
top.ref.fields.push(field);
|
||||||
|
stack.push({ type: 'field', ref: field, entity: top.ref });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top?.type === 'field' && /^key\s+foreign\s*\{$/.test(line)) {
|
||||||
|
stack.push({ type: 'foreignKey', ref: top.ref, entity: top.entity });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top?.type === 'enumValue') {
|
||||||
|
const labelMatch = line.match(/^label\s+"(.*)"\s*;$/);
|
||||||
|
if (labelMatch) {
|
||||||
|
top.ref.label = labelMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top?.type === 'field') {
|
||||||
|
const typeMatch = line.match(/^type\s+([A-Za-z][A-Za-z0-9_]*)\s*;$/);
|
||||||
|
if (typeMatch) {
|
||||||
|
top.ref.type = typeMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^key\s+primary\s*;$/.test(line)) {
|
||||||
|
top.ref.primary = true;
|
||||||
|
top.entity.primaryKey = top.ref.name;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultMatch = line.match(/^default\s+(.+)\s*;$/);
|
||||||
|
if (defaultMatch) {
|
||||||
|
top.ref.default = parseDefaultValue(defaultMatch[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^is\s+required\s*;$/.test(line)) {
|
||||||
|
top.ref.required = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^is\s+unique\s*;$/.test(line)) {
|
||||||
|
top.ref.unique = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top?.type === 'foreignKey') {
|
||||||
|
const relatesMatch = line.match(
|
||||||
|
/^relates\s+([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\s*;$/,
|
||||||
|
);
|
||||||
|
if (relatesMatch) {
|
||||||
|
const foreignKey = {
|
||||||
|
field: top.ref.name,
|
||||||
|
references: {
|
||||||
|
entity: relatesMatch[1],
|
||||||
|
field: relatesMatch[2],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
top.ref.foreignKey = foreignKey.references;
|
||||||
|
top.entity.foreignKeys.push(foreignKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^}\s*;?$/.test(line)) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dslFiles, enums, entities };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoDomainRedefinition(line, filePath) {
|
||||||
|
const blocked = [/^\s*entity\s+/i, /^\s*enum\s+/i, /^\s*attribute\s+/i, /^\s*key\s+primary/i, /^\s*key\s+foreign/i];
|
||||||
|
for (const pattern of blocked) {
|
||||||
|
if (pattern.test(line)) {
|
||||||
|
throw new Error(
|
||||||
|
`Override file ${path.basename(filePath)} attempts to redefine domain structure: ${line.trim()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOverrides(rootDir, entities) {
|
||||||
|
const overrideFiles = getOverrideFiles(rootDir);
|
||||||
|
const entityNames = new Set(entities.map((entity) => entity.name));
|
||||||
|
const fieldNames = new Set(
|
||||||
|
entities.flatMap((entity) => entity.fields.map((field) => `${entity.name}.${field.name}`)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const api = { resources: {} };
|
||||||
|
const ui = { fields: {} };
|
||||||
|
|
||||||
|
for (const filePath of overrideFiles) {
|
||||||
|
const isApi = filePath.endsWith('api-overrides.dsl');
|
||||||
|
const isUi = filePath.endsWith('ui-overrides.dsl');
|
||||||
|
const lines = readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = stripInlineComment(rawLine);
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDomainRedefinition(line, filePath);
|
||||||
|
|
||||||
|
if (isApi) {
|
||||||
|
const match = line.match(/^resource\s+([A-Za-z][A-Za-z0-9_]*)\s+path\s+"([^"]+)"\s*;$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported API override syntax in ${path.basename(filePath)}: ${line}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [, entityName, resourcePath] = match;
|
||||||
|
if (!entityNames.has(entityName)) {
|
||||||
|
throw new Error(`API override references unknown entity ${entityName}`);
|
||||||
|
}
|
||||||
|
api.resources[entityName] = { path: resourcePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUi) {
|
||||||
|
const match = line.match(
|
||||||
|
/^field\s+([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\s+widget\s+"([^"]+)"\s*;$/,
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported UI override syntax in ${path.basename(filePath)}: ${line}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [, entityName, fieldName, widget] = match;
|
||||||
|
const key = `${entityName}.${fieldName}`;
|
||||||
|
if (!fieldNames.has(key)) {
|
||||||
|
throw new Error(`UI override references unknown field ${key}`);
|
||||||
|
}
|
||||||
|
ui.fields[key] = { widget };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { api, ui, sourceFiles: overrideFiles.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDomainSummary(rootDir) {
|
||||||
|
const { dslFiles, enums, entities } = parseDslFiles(rootDir);
|
||||||
|
parseOverrides(rootDir, entities);
|
||||||
|
const entityByName = new Map(entities.map((entity) => [entity.name, entity]));
|
||||||
|
const enumByName = new Map(enums.map((entry) => [entry.name, entry]));
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (!entity.primaryKey) {
|
||||||
|
throw new Error(`Entity ${entity.name} is missing a primary key`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryKeyField = entity.fields.find((field) => field.name === entity.primaryKey);
|
||||||
|
if (!primaryKeyField) {
|
||||||
|
throw new Error(
|
||||||
|
`Entity ${entity.name} declares primary key ${entity.primaryKey}, but the field is missing`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.fields.some((field) => !field.type)) {
|
||||||
|
throw new Error(`Entity ${entity.name} has attributes with missing type`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const foreignKey of entity.foreignKeys) {
|
||||||
|
const targetEntity = entityByName.get(foreignKey.references.entity);
|
||||||
|
if (!targetEntity) {
|
||||||
|
throw new Error(
|
||||||
|
`Foreign key ${entity.name}.${foreignKey.field} references missing entity ${foreignKey.references.entity}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetField = targetEntity.fields.find(
|
||||||
|
(field) => field.name === foreignKey.references.field,
|
||||||
|
);
|
||||||
|
if (!targetField) {
|
||||||
|
throw new Error(
|
||||||
|
`Foreign key ${entity.name}.${foreignKey.field} references missing field ${foreignKey.references.entity}.${foreignKey.references.field}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceField = entity.fields.find((field) => field.name === foreignKey.field);
|
||||||
|
if (sourceField && sourceField.type !== targetField.type) {
|
||||||
|
throw new Error(
|
||||||
|
`Foreign key ${entity.name}.${foreignKey.field} type ${sourceField.type} does not match ${foreignKey.references.entity}.${foreignKey.references.field} type ${targetField.type}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldNames = new Set();
|
||||||
|
for (const field of entity.fields) {
|
||||||
|
if (fieldNames.has(field.name)) {
|
||||||
|
throw new Error(`Entity ${entity.name} has duplicate field ${field.name}`);
|
||||||
|
}
|
||||||
|
fieldNames.add(field.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityNames = new Set();
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (entityNames.has(entity.name)) {
|
||||||
|
throw new Error(`Duplicate entity definition: ${entity.name}`);
|
||||||
|
}
|
||||||
|
entityNames.add(entity.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [enumName, enumDefinition] of enumByName.entries()) {
|
||||||
|
const valueNames = new Set();
|
||||||
|
for (const value of enumDefinition.values) {
|
||||||
|
if (valueNames.has(value.name)) {
|
||||||
|
throw new Error(`Enum ${enumName} has duplicate value ${value.name}`);
|
||||||
|
}
|
||||||
|
valueNames.add(value.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceFiles: dslFiles.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')),
|
||||||
|
entities: entities.map((entity) => ({
|
||||||
|
name: entity.name,
|
||||||
|
primaryKey: entity.primaryKey,
|
||||||
|
fields: entity.fields.map((field) => ({
|
||||||
|
name: field.name,
|
||||||
|
type: field.type,
|
||||||
|
required: field.required,
|
||||||
|
unique: field.unique,
|
||||||
|
default: field.default,
|
||||||
|
})),
|
||||||
|
foreignKeys: entity.foreignKeys,
|
||||||
|
})),
|
||||||
|
enums,
|
||||||
|
};
|
||||||
|
}
|
||||||
13
tools/generate-domain-summary.mjs
Normal file
13
tools/generate-domain-summary.mjs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { buildDomainSummary } from './dsl-summary.mjs';
|
||||||
|
|
||||||
|
const rootDir = process.cwd();
|
||||||
|
const outputPath = path.join(rootDir, 'domain-summary.json');
|
||||||
|
|
||||||
|
mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
|
|
||||||
|
const summary = buildDomainSummary(rootDir);
|
||||||
|
writeFileSync(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
console.log(`Generated ${path.relative(rootDir, outputPath)}`);
|
||||||
513
tools/validate-generation.mjs
Normal file
513
tools/validate-generation.mjs
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
buildDomainSummary,
|
||||||
|
getDslFiles,
|
||||||
|
parseDslFiles,
|
||||||
|
parseOverrides,
|
||||||
|
} from './dsl-summary.mjs';
|
||||||
|
|
||||||
|
const rootDir = process.cwd();
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
const artifactsOnly = args.has('--artifacts-only');
|
||||||
|
const runRuntime = args.has('--run-runtime');
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
function assertCondition(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
failures.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function warn(message) {
|
||||||
|
warnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return readFileSync(path.join(rootDir, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIfExists(relativePath) {
|
||||||
|
const filePath = path.join(rootDir, relativePath);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileSync(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFile(relativePath) {
|
||||||
|
assertCondition(existsSync(path.join(rootDir, relativePath)), `Missing file: ${relativePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFiles(relativePaths) {
|
||||||
|
relativePaths.forEach(requireFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireContent(relativePath, pattern, message) {
|
||||||
|
const contents = readIfExists(relativePath);
|
||||||
|
assertCondition(Boolean(contents), `Missing file: ${relativePath}`);
|
||||||
|
if (!contents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCondition(pattern.test(contents), `${message} (${relativePath})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(relativePath) {
|
||||||
|
const raw = readIfExists(relativePath);
|
||||||
|
if (!raw) {
|
||||||
|
failures.push(`Missing file: ${relativePath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`Invalid JSON in ${relativePath}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kebabCase(value) {
|
||||||
|
return value
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRealmArtifactPath() {
|
||||||
|
const rootFiles = readdirSync(rootDir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile())
|
||||||
|
.map((entry) => entry.name);
|
||||||
|
|
||||||
|
const realmArtifacts = rootFiles.filter((entry) => /-realm\.json$/i.test(entry));
|
||||||
|
assertCondition(realmArtifacts.length === 1, 'Expected exactly one root-level *-realm.json artifact');
|
||||||
|
|
||||||
|
return realmArtifacts[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceInfo() {
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
dir: path.join(rootDir, 'server'),
|
||||||
|
packagePath: 'server/package.json',
|
||||||
|
scaffoldFiles: [
|
||||||
|
'server/package.json',
|
||||||
|
'server/tsconfig.json',
|
||||||
|
'server/tsconfig.build.json',
|
||||||
|
'server/nest-cli.json',
|
||||||
|
'server/src/main.ts',
|
||||||
|
'server/src/app.module.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
dir: path.join(rootDir, 'client'),
|
||||||
|
packagePath: 'client/package.json',
|
||||||
|
scaffoldFiles: [
|
||||||
|
'client/package.json',
|
||||||
|
'client/index.html',
|
||||||
|
'client/tsconfig.json',
|
||||||
|
'client/tsconfig.node.json',
|
||||||
|
'client/vite.config.ts',
|
||||||
|
'client/src/main.tsx',
|
||||||
|
'client/src/vite-env.d.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBuildChecks() {
|
||||||
|
requireFiles([
|
||||||
|
'README.md',
|
||||||
|
'package.json',
|
||||||
|
'domain/dsl-spec.md',
|
||||||
|
'domain-summary.json',
|
||||||
|
'server/prisma/schema.prisma',
|
||||||
|
'server/.env.example',
|
||||||
|
'client/.env.example',
|
||||||
|
'prompts/general-prompt.md',
|
||||||
|
'prompts/auth-rules.md',
|
||||||
|
'prompts/backend-rules.md',
|
||||||
|
'prompts/frontend-rules.md',
|
||||||
|
'prompts/runtime-rules.md',
|
||||||
|
'prompts/validation-rules.md',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dslFiles = getDslFiles(rootDir).map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/'));
|
||||||
|
assertCondition(dslFiles.length > 0, 'Expected at least one domain/*.dsl file');
|
||||||
|
|
||||||
|
const actualSummaryRaw = readIfExists('domain-summary.json');
|
||||||
|
if (actualSummaryRaw) {
|
||||||
|
const expectedSummary = JSON.stringify(buildDomainSummary(rootDir), null, 2);
|
||||||
|
assertCondition(
|
||||||
|
actualSummaryRaw.trim() === expectedSummary,
|
||||||
|
'domain-summary.json is out of date. Run `npm run generate:domain-summary`.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { entities } = parseDslFiles(rootDir);
|
||||||
|
parseOverrides(rootDir, entities);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`Override validation failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { server, client } = getWorkspaceInfo();
|
||||||
|
requireFiles(server.scaffoldFiles);
|
||||||
|
requireFiles(client.scaffoldFiles);
|
||||||
|
|
||||||
|
const serverPackage = parseJson(server.packagePath);
|
||||||
|
if (serverPackage) {
|
||||||
|
assertCondition(serverPackage.scripts?.build === 'nest build', 'server/package.json must keep `build = nest build`');
|
||||||
|
assertCondition(serverPackage.scripts?.start === 'nest start', 'server/package.json must keep `start = nest start`');
|
||||||
|
assertCondition(serverPackage.scripts?.['start:dev'] === 'nest start --watch', 'server/package.json must keep `start:dev = nest start --watch`');
|
||||||
|
assertCondition(Boolean(serverPackage.scripts?.['start:prod']), 'server/package.json must define a start:prod script');
|
||||||
|
assertCondition(Boolean(serverPackage.dependencies?.['@nestjs/core']), 'server/package.json must keep Nest runtime dependencies');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientPackage = parseJson(client.packagePath);
|
||||||
|
if (clientPackage) {
|
||||||
|
assertCondition(clientPackage.scripts?.dev === 'vite', 'client/package.json must keep `dev = vite`');
|
||||||
|
assertCondition(clientPackage.scripts?.build === 'vite build', 'client/package.json must keep `build = vite build`');
|
||||||
|
assertCondition(clientPackage.scripts?.preview === 'vite preview', 'client/package.json must keep `preview = vite preview`');
|
||||||
|
assertCondition(Boolean(clientPackage.devDependencies?.vite), 'client/package.json must keep Vite as a dev dependency');
|
||||||
|
assertCondition(Boolean(clientPackage.devDependencies?.['@vitejs/plugin-react']), 'client/package.json must keep @vitejs/plugin-react as a dev dependency');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAuthChecks() {
|
||||||
|
requireFiles([
|
||||||
|
'client/src/auth/keycloak.ts',
|
||||||
|
'client/src/auth/authProvider.ts',
|
||||||
|
'client/src/dataProvider.ts',
|
||||||
|
'client/src/config/env.ts',
|
||||||
|
'client/src/main.tsx',
|
||||||
|
'client/src/App.tsx',
|
||||||
|
'server/src/auth/auth.module.ts',
|
||||||
|
'server/src/auth/auth.service.ts',
|
||||||
|
'server/src/auth/guards/jwt-auth.guard.ts',
|
||||||
|
'server/src/auth/guards/roles.guard.ts',
|
||||||
|
'server/src/auth/decorators/public.decorator.ts',
|
||||||
|
'server/src/auth/decorators/roles.decorator.ts',
|
||||||
|
]);
|
||||||
|
|
||||||
|
requireContent(
|
||||||
|
'client/src/auth/keycloak.ts',
|
||||||
|
/onLoad:\s*'login-required'/,
|
||||||
|
'Frontend auth must initialize Keycloak with login-required',
|
||||||
|
);
|
||||||
|
requireContent(
|
||||||
|
'client/src/auth/keycloak.ts',
|
||||||
|
/pkceMethod:\s*'S256'/,
|
||||||
|
'Frontend auth must use PKCE S256',
|
||||||
|
);
|
||||||
|
requireContent(
|
||||||
|
'client/src/auth/keycloak.ts',
|
||||||
|
/updateToken\(/,
|
||||||
|
'Frontend auth must refresh access tokens through the Keycloak adapter',
|
||||||
|
);
|
||||||
|
|
||||||
|
const keycloakSource = readIfExists('client/src/auth/keycloak.ts') ?? '';
|
||||||
|
assertCondition(
|
||||||
|
!/loadUserProfile\(/.test(keycloakSource),
|
||||||
|
'Frontend auth must not call keycloak.loadUserProfile()',
|
||||||
|
);
|
||||||
|
|
||||||
|
requireContent(
|
||||||
|
'client/src/dataProvider.ts',
|
||||||
|
/Authorization', `Bearer \$\{token\}`/,
|
||||||
|
'dataProvider must attach bearer tokens in the shared request seam',
|
||||||
|
);
|
||||||
|
|
||||||
|
const authProvider = readIfExists('client/src/auth/authProvider.ts') ?? '';
|
||||||
|
assertCondition(
|
||||||
|
/status === 401/.test(authProvider) && /status === 403/.test(authProvider),
|
||||||
|
'authProvider must distinguish 401 and 403 semantics',
|
||||||
|
);
|
||||||
|
|
||||||
|
const authService = readIfExists('server/src/auth/auth.service.ts') ?? '';
|
||||||
|
assertCondition(
|
||||||
|
/jwtVerify/.test(authService) && /KEYCLOAK_ISSUER_URL/.test(authService) && /KEYCLOAK_AUDIENCE/.test(authService),
|
||||||
|
'Backend auth must verify JWTs with issuer and audience',
|
||||||
|
);
|
||||||
|
assertCondition(/realm_access/.test(authService), 'Backend auth must extract roles from realm_access.roles');
|
||||||
|
assertCondition(/KEYCLOAK_JWKS_URL/.test(authService), 'Backend auth must support explicit KEYCLOAK_JWKS_URL');
|
||||||
|
assertCondition(/\.well-known\/openid-configuration/.test(authService), 'Backend auth must try OIDC discovery before fallback certs');
|
||||||
|
assertCondition(/protocol\/openid-connect\/certs/.test(authService), 'Backend auth must keep Keycloak certs fallback resolution');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNaturalKeyChecks() {
|
||||||
|
const summary = parseJson('domain-summary.json');
|
||||||
|
if (!summary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const naturalKeyEntities = summary.entities.filter((entity) => entity.primaryKey !== 'id');
|
||||||
|
|
||||||
|
for (const entity of naturalKeyEntities) {
|
||||||
|
const moduleName = kebabCase(entity.name);
|
||||||
|
const controllerPath = `server/src/modules/${moduleName}/${moduleName}.controller.ts`;
|
||||||
|
const servicePath = `server/src/modules/${moduleName}/${moduleName}.service.ts`;
|
||||||
|
const controller = readIfExists(controllerPath) ?? '';
|
||||||
|
const service = readIfExists(servicePath) ?? '';
|
||||||
|
|
||||||
|
assertCondition(Boolean(controller), `Missing file: ${controllerPath}`);
|
||||||
|
assertCondition(Boolean(service), `Missing file: ${servicePath}`);
|
||||||
|
if (!controller || !service) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCondition(
|
||||||
|
controller.includes(`@Get(':${entity.primaryKey}')`) &&
|
||||||
|
controller.includes(`@Patch(':${entity.primaryKey}')`) &&
|
||||||
|
controller.includes(`@Delete(':${entity.primaryKey}')`),
|
||||||
|
`${entity.name} controller must use :${entity.primaryKey} route params`,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertCondition(
|
||||||
|
service.includes(`id: item.${entity.primaryKey}`) || service.includes(`id: record.${entity.primaryKey}`),
|
||||||
|
`${entity.name} service must map the natural key to React Admin id`,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertCondition(
|
||||||
|
service.includes(`const { id, ${entity.primaryKey}: _pk`) || service.includes(`const { id: _pk, ${entity.primaryKey}`),
|
||||||
|
`${entity.name} update path must sanitize id and primary key from Prisma update data`,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertCondition(
|
||||||
|
/sortField\s*===\s*'id'/.test(service) || /sortField\s*===\s*"id"/.test(service),
|
||||||
|
`${entity.name} natural-key sort must map React Admin id sorting back to the real primary key`,
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
!service.includes("query._sort || 'id'"),
|
||||||
|
`${entity.name} natural-key sort must not fall back to the physical id field`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRealmChecks() {
|
||||||
|
const realmArtifactName = getRealmArtifactPath();
|
||||||
|
if (!realmArtifactName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifact = parseJson(realmArtifactName);
|
||||||
|
if (!artifact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realmRoles = artifact.roles?.realm?.map((role) => role.name) ?? [];
|
||||||
|
const frontendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-frontend'));
|
||||||
|
const backendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-backend'));
|
||||||
|
const audienceScope = artifact.clientScopes?.find((scope) => scope.name === 'api-audience');
|
||||||
|
|
||||||
|
['admin', 'editor', 'viewer'].forEach((role) => {
|
||||||
|
assertCondition(realmRoles.includes(role), `Realm artifact must define realm role ${role}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertCondition(Boolean(frontendClient), 'Realm artifact must define the frontend SPA client');
|
||||||
|
assertCondition(Boolean(backendClient), 'Realm artifact must define the backend resource client');
|
||||||
|
assertCondition(Boolean(audienceScope), 'Realm artifact must define the api-audience client scope');
|
||||||
|
|
||||||
|
if (frontendClient) {
|
||||||
|
assertCondition(frontendClient.publicClient === true, 'Frontend realm client must be public');
|
||||||
|
assertCondition(
|
||||||
|
frontendClient.standardFlowEnabled === true &&
|
||||||
|
frontendClient.implicitFlowEnabled === false &&
|
||||||
|
frontendClient.directAccessGrantsEnabled === false,
|
||||||
|
'Frontend realm client must use standard flow only',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
frontendClient.attributes?.['pkce.code.challenge.method'] === 'S256',
|
||||||
|
'Frontend realm client must enforce PKCE S256',
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapperNames = new Set((frontendClient.protocolMappers ?? []).map((mapper) => mapper.name));
|
||||||
|
['sub', 'preferred_username', 'email', 'name', 'realm roles'].forEach((mapperName) => {
|
||||||
|
assertCondition(mapperNames.has(mapperName), `Frontend realm client must include protocol mapper ${mapperName}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backendClient && audienceScope) {
|
||||||
|
const audienceMapper = (audienceScope.protocolMappers ?? []).find(
|
||||||
|
(mapper) => mapper.protocolMapper === 'oidc-audience-mapper',
|
||||||
|
);
|
||||||
|
assertCondition(Boolean(audienceMapper), 'api-audience scope must include an audience mapper');
|
||||||
|
assertCondition(
|
||||||
|
audienceMapper?.config?.['included.client.audience'] === backendClient.clientId,
|
||||||
|
'api-audience scope must deliver the backend audience/client id',
|
||||||
|
);
|
||||||
|
assertCondition(backendClient.bearerOnly === true, 'Backend realm client must be bearer-only');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRuntimeContractChecks() {
|
||||||
|
requireFile('docker-compose.yml');
|
||||||
|
const compose = readIfExists('docker-compose.yml') ?? '';
|
||||||
|
assertCondition(/image:\s*postgres:16/.test(compose), 'docker-compose must provision postgres:16');
|
||||||
|
assertCondition(!/keycloak/i.test(compose), 'docker-compose must remain PostgreSQL-only');
|
||||||
|
|
||||||
|
const serverEnvExample = readIfExists('server/.env.example') ?? '';
|
||||||
|
assertCondition(
|
||||||
|
/KEYCLOAK_ISSUER_URL="https:\/\/sso\.greact\.ru\/realms\/toir"/.test(serverEnvExample),
|
||||||
|
'server/.env.example must keep the working Keycloak issuer example',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
/KEYCLOAK_AUDIENCE="?toir-backend"?/.test(serverEnvExample),
|
||||||
|
'server/.env.example must keep the working backend audience example',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
/CORS_ALLOWED_ORIGINS="http:\/\/localhost:5173,https:\/\/toir-frontend\.greact\.ru"/.test(serverEnvExample),
|
||||||
|
'server/.env.example must keep the working CORS example with the production frontend domain',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
!/KEYCLOAK_ISSUER_URL=http:\/\/localhost:8080\/realms\/toir/.test(serverEnvExample),
|
||||||
|
'server/.env.example must not regress to localhost Keycloak as the baseline issuer example',
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientEnvExample = readIfExists('client/.env.example') ?? '';
|
||||||
|
assertCondition(
|
||||||
|
/VITE_KEYCLOAK_URL=https:\/\/sso\.greact\.ru/.test(clientEnvExample),
|
||||||
|
'client/.env.example must keep the working domain-based Keycloak URL example',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
/VITE_KEYCLOAK_REALM=toir/.test(clientEnvExample) && /VITE_KEYCLOAK_CLIENT_ID=toir-frontend/.test(clientEnvExample),
|
||||||
|
'client/.env.example must keep the working realm and frontend client examples',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
!/VITE_KEYCLOAK_URL=http:\/\/localhost:8080/.test(clientEnvExample),
|
||||||
|
'client/.env.example must not regress to localhost Keycloak as the baseline example',
|
||||||
|
);
|
||||||
|
|
||||||
|
const healthController = readIfExists('server/src/health/health.controller.ts') ?? '';
|
||||||
|
assertCondition(Boolean(healthController), 'Missing file: server/src/health/health.controller.ts');
|
||||||
|
if (healthController) {
|
||||||
|
assertCondition(
|
||||||
|
/@Public\(\)/.test(healthController) && /@Controller\('health'\)/.test(healthController),
|
||||||
|
'/health must stay public',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, commandArgs, workdir, failureLabel) {
|
||||||
|
const runtimeEnv = { ...process.env };
|
||||||
|
const envExamplePath = path.join(workdir, '.env.example');
|
||||||
|
if (existsSync(envExamplePath)) {
|
||||||
|
const envExample = readFileSync(envExamplePath, 'utf8');
|
||||||
|
for (const line of envExample.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = trimmed.indexOf('=');
|
||||||
|
if (separator <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, separator).trim();
|
||||||
|
const value = trimmed.slice(separator + 1).trim().replace(/^"|"$/g, '');
|
||||||
|
if (!(key in runtimeEnv)) {
|
||||||
|
runtimeEnv[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandLine = [command, ...commandArgs].join(' ');
|
||||||
|
const result = spawnSync(commandLine, {
|
||||||
|
cwd: workdir,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
shell: true,
|
||||||
|
env: runtimeEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
failures.push(`${failureLabel}: ${commandLine}\n${result.error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.trim();
|
||||||
|
const stdout = result.stdout?.trim();
|
||||||
|
failures.push(
|
||||||
|
`${failureLabel}: ${commandLine}${stderr ? `\n${stderr}` : stdout ? `\n${stdout}` : ''}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeValidateWorkspaceBuild(relativeDir) {
|
||||||
|
const workspaceDir = path.join(rootDir, relativeDir);
|
||||||
|
if (!existsSync(path.join(workspaceDir, 'package.json'))) {
|
||||||
|
failures.push(`Missing file: ${relativeDir}/package.json`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(path.join(workspaceDir, 'node_modules'))) {
|
||||||
|
warn(`Skipped build verification for ${relativeDir}: install dependencies in ${relativeDir}/ to validate workspace buildability.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runCommand('npm', ['run', 'build'], workspaceDir, `Build verification failed in ${relativeDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBuildExecutionChecks() {
|
||||||
|
maybeValidateWorkspaceBuild('server');
|
||||||
|
maybeValidateWorkspaceBuild('client');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRuntimeExecutionChecks() {
|
||||||
|
const serverDir = path.join(rootDir, 'server');
|
||||||
|
if (!existsSync(path.join(serverDir, 'node_modules'))) {
|
||||||
|
failures.push(
|
||||||
|
'Runtime validation requires installed backend dependencies. Run `npm install` in server/ before `npm run validate:generation:runtime`.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runCommand('npx', ['prisma', 'generate'], serverDir, 'Prisma generate failed');
|
||||||
|
runCommand(
|
||||||
|
'npx',
|
||||||
|
['prisma', 'migrate', 'dev', '--name', 'baseline', '--skip-generate'],
|
||||||
|
serverDir,
|
||||||
|
'Prisma migrate failed',
|
||||||
|
);
|
||||||
|
runCommand('npx', ['prisma', 'db', 'seed'], serverDir, 'Prisma seed failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
validateBuildChecks();
|
||||||
|
validateAuthChecks();
|
||||||
|
validateNaturalKeyChecks();
|
||||||
|
validateRealmChecks();
|
||||||
|
validateRuntimeContractChecks();
|
||||||
|
|
||||||
|
if (!artifactsOnly) {
|
||||||
|
validateBuildExecutionChecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!artifactsOnly && runRuntime) {
|
||||||
|
validateRuntimeExecutionChecks();
|
||||||
|
} else if (!artifactsOnly) {
|
||||||
|
warn('Runtime command execution skipped. Use --run-runtime after installing dependencies and starting the local database.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const warning of warnings) {
|
||||||
|
console.warn(`WARN: ${warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error('Generation validation failed:');
|
||||||
|
for (const failure of failures) {
|
||||||
|
console.error(`- ${failure}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Generation validation passed.');
|
||||||
Reference in New Issue
Block a user