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/styled": "^11.14.1",
|
||||
"@mui/material": "^7.3.9",
|
||||
"keycloak-js": "^26.2.3",
|
||||
"ra-data-simple-rest": "^5.14.4",
|
||||
"react": "^18.2.0",
|
||||
"react-admin": "^5.14.4",
|
||||
@@ -3533,6 +3534,15 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@@ -13,6 +13,7 @@
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/material": "^7.3.9",
|
||||
"keycloak-js": "^26.2.3",
|
||||
"ra-data-simple-rest": "^5.14.4",
|
||||
"react": "^18.2.0",
|
||||
"react-admin": "^5.14.4",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Admin, Resource } from 'react-admin';
|
||||
import dataProvider from './dataProvider';
|
||||
import authProvider from './auth/authProvider';
|
||||
|
||||
import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList';
|
||||
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';
|
||||
|
||||
const App = () => (
|
||||
<Admin dataProvider={dataProvider}>
|
||||
<Admin dataProvider={dataProvider} authProvider={authProvider} requireAuth>
|
||||
<Resource
|
||||
name="equipment-types"
|
||||
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 { getValidAccessToken } from './auth/keycloak';
|
||||
import { env } from './config/env';
|
||||
|
||||
const apiUrl = 'http://localhost:3000';
|
||||
const httpClient = fetchUtils.fetchJson;
|
||||
const apiUrl = env.apiUrl;
|
||||
|
||||
const httpClient = async (url: string, options: fetchUtils.Options = {}) => {
|
||||
const token = await getValidAccessToken();
|
||||
const headers = new Headers(options.headers ?? { Accept: 'application/json' });
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
return fetchUtils.fetchJson(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
function buildQueryString(query: Record<string, unknown>) {
|
||||
const search = new URLSearchParams();
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { initKeycloak } from './auth/keycloak';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
const root = ReactDOM.createRoot(document.getElementById('root')!);
|
||||
|
||||
async function bootstrap() {
|
||||
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,
|
||||
SelectArrayInput,
|
||||
ReferenceInput,
|
||||
AutocompleteInput,
|
||||
} from "react-admin";
|
||||
AutocompleteInput
|
||||
} from 'react-admin';
|
||||
|
||||
const statusChoices = [
|
||||
{ id: "Active", name: "В эксплуатации" },
|
||||
{ id: "Repair", name: "В ремонте" },
|
||||
{ id: "Reserve", name: "В резерве" },
|
||||
{ id: "WriteOff", name: "Списано" },
|
||||
{ id: 'Active', name: 'В эксплуатации' },
|
||||
{ id: 'Repair', name: 'В ремонте' },
|
||||
{ id: 'Reserve', name: 'В резерве' },
|
||||
{ id: 'WriteOff', name: 'Списано' },
|
||||
];
|
||||
|
||||
const equipmentFilters = [
|
||||
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
|
||||
<TextInput
|
||||
key="inventoryNumber"
|
||||
source="inventoryNumber"
|
||||
label="Инвентарный номер"
|
||||
/>,
|
||||
<TextInput
|
||||
key="serialNumber"
|
||||
source="serialNumber"
|
||||
label="Заводской (серийный) номер"
|
||||
/>,
|
||||
<TextInput
|
||||
key="name"
|
||||
source="name"
|
||||
label="Наименование единицы оборудования"
|
||||
/>,
|
||||
<ReferenceInput
|
||||
key="equipmentTypeCode"
|
||||
source="equipmentTypeCode"
|
||||
reference="equipment-types"
|
||||
label="Вид оборудования"
|
||||
>
|
||||
<AutocompleteInput
|
||||
optionText={(record) =>
|
||||
record.code
|
||||
? `${record.code} — ${record.name ?? record.code}`
|
||||
: (record.name ?? record.id)
|
||||
}
|
||||
filterToQuery={(searchText) => ({ q: searchText })}
|
||||
/>
|
||||
<TextInput key="inventoryNumber" source="inventoryNumber" label="Инвентарный номер" />,
|
||||
<TextInput key="serialNumber" source="serialNumber" label="Заводской (серийный) номер" />,
|
||||
<TextInput key="name" source="name" label="Наименование единицы оборудования" />,
|
||||
<ReferenceInput key="equipmentTypeCode" source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования">
|
||||
<AutocompleteInput optionText={(record) => record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
|
||||
</ReferenceInput>,
|
||||
<SelectArrayInput
|
||||
key="status"
|
||||
source="status"
|
||||
label="Текущий статус"
|
||||
choices={statusChoices}
|
||||
/>,
|
||||
<TextInput
|
||||
key="location"
|
||||
source="location"
|
||||
label="Место эксплуатации / скважина / куст"
|
||||
/>,
|
||||
<TextInput key="notes" source="notes" label="Примечания" />,
|
||||
<SelectArrayInput key="status" source="status" label="Текущий статус" choices={statusChoices} />,
|
||||
<TextInput key="location" source="location" label="Место эксплуатации / скважина / куст" />,
|
||||
<TextInput key="notes" source="notes" label="Примечания" />
|
||||
];
|
||||
|
||||
const EquipmentListActions = () => (
|
||||
@@ -78,42 +45,20 @@ const EquipmentListActions = () => (
|
||||
);
|
||||
|
||||
export const EquipmentList = () => (
|
||||
<List
|
||||
actions={<EquipmentListActions />}
|
||||
filters={equipmentFilters}
|
||||
sort={{ field: "inventoryNumber", order: "ASC" }}
|
||||
>
|
||||
<List actions={<EquipmentListActions />} filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="id" label="id" />
|
||||
<TextField source="inventoryNumber" label="Инвентарный номер" />
|
||||
<TextField source="serialNumber" label="Заводской (серийный) номер" />
|
||||
<TextField source="name" label="Наименование единицы оборудования" />
|
||||
<ReferenceField
|
||||
source="equipmentTypeCode"
|
||||
reference="equipment-types"
|
||||
label="Вид оборудования"
|
||||
link="show"
|
||||
>
|
||||
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
|
||||
<TextField source="code" />
|
||||
</ReferenceField>
|
||||
<SelectField
|
||||
source="status"
|
||||
label="Текущий статус"
|
||||
choices={statusChoices}
|
||||
/>
|
||||
<TextField
|
||||
source="location"
|
||||
label="Место эксплуатации / скважина / куст"
|
||||
/>
|
||||
<SelectField source="status" label="Текущий статус" choices={statusChoices} />
|
||||
<TextField source="location" label="Место эксплуатации / скважина / куст" />
|
||||
<DateField source="commissionedAt" label="Дата ввода в эксплуатацию" />
|
||||
<NumberField
|
||||
source="totalEngineHours"
|
||||
label="Общая наработка, моточасов"
|
||||
/>
|
||||
<NumberField
|
||||
source="engineHoursSinceLastRepair"
|
||||
label="Наработка с последнего ремонта, моточасов"
|
||||
/>
|
||||
<NumberField source="totalEngineHours" label="Общая наработка, моточасов" />
|
||||
<NumberField source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
|
||||
<DateField source="lastRepairAt" label="Дата последнего ремонта" />
|
||||
<TextField source="notes" label="Примечания" />
|
||||
</Datagrid>
|
||||
|
||||
11
client/src/vite-env.d.ts
vendored
11
client/src/vite-env.d.ts
vendored
@@ -1 +1,12 @@
|
||||
/// <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:
|
||||
image: postgres:16
|
||||
container_name: toir-postgres
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
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 (Заявка на ремонт)
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Перечисления
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
enum EquipmentStatus {
|
||||
value Active {
|
||||
label "В эксплуатации";
|
||||
@@ -61,10 +57,6 @@ enum RepairOrderStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Справочник: Вид оборудования
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
entity EquipmentType {
|
||||
description "Вид (марка) оборудования — нормативный справочник НСИ";
|
||||
|
||||
@@ -87,7 +79,6 @@ entity EquipmentType {
|
||||
type string;
|
||||
}
|
||||
|
||||
// Нормативный межремонтный ресурс (моточасы)
|
||||
attribute maintenanceIntervalHours {
|
||||
description "Периодичность ТО, моточасов";
|
||||
type integer;
|
||||
@@ -99,11 +90,6 @@ entity EquipmentType {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Основная сущность: Оборудование
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
entity Equipment {
|
||||
description "Единица оборудования — объект ремонта и технического обслуживания";
|
||||
|
||||
@@ -130,7 +116,6 @@ entity Equipment {
|
||||
is required;
|
||||
}
|
||||
|
||||
// Связь с видом оборудования (справочник НСИ)
|
||||
attribute equipmentTypeCode {
|
||||
type string;
|
||||
key foreign {
|
||||
@@ -156,7 +141,6 @@ entity Equipment {
|
||||
type date;
|
||||
}
|
||||
|
||||
// Наработка фиксируется вручную или из производственной программы
|
||||
attribute totalEngineHours {
|
||||
description "Общая наработка, моточасов";
|
||||
type decimal;
|
||||
@@ -178,11 +162,6 @@ entity Equipment {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Заявка на ремонт
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
entity RepairOrder {
|
||||
description "Заявка на ремонт — формируется по ППР или по факту обнаруженного дефекта";
|
||||
|
||||
@@ -1,6 +1,46 @@
|
||||
# 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
|
||||
|
||||
- **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).
|
||||
|
||||
---
|
||||
@@ -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 → Prisma
|
||||
@@ -174,9 +187,9 @@ attribute Наименование {
|
||||
| entity | One module (e.g. equipment.module.ts) |
|
||||
| entity | Controller with CRUD endpoints |
|
||||
| entity | Service with Prisma CRUD |
|
||||
| DTO (Create) | create-{entity}.dto.ts |
|
||||
| DTO (Update) | update-{entity}.dto.ts |
|
||||
| DTO (Response) | Used for GET response shape |
|
||||
| entity + attribute metadata | create-{entity}.dto.ts |
|
||||
| entity + attribute metadata | update-{entity}.dto.ts |
|
||||
| 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`).
|
||||
|
||||
@@ -193,21 +206,12 @@ API paths are derived from entity name: PascalCase → kebab-case, pluralized (e
|
||||
| type date | DateInput, DateField |
|
||||
| enum | SelectInput with choices |
|
||||
| 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** — must not include generated primary keys (e.g. no `id` for uuid PK).
|
||||
- **Update DTO** — all fields optional (nullable) for partial updates.
|
||||
- **List response DTO** — must expose `data` (array) and `total` (integer) for React Admin compatibility.
|
||||
|
||||
---
|
||||
|
||||
# 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.
|
||||
- **Create DTO** — derived from domain attributes and must not include generated primary keys (for example no `id` for uuid PKs).
|
||||
- **Update DTO** — derived from the same domain attributes with all fields optional for partial updates.
|
||||
- **API response shape** — derived from domain attributes and must expose React Admin-compatible identifiers when needed.
|
||||
- **UI field mapping** — derived from attribute types, descriptions, enums, and foreign keys without a separate UI DSL.
|
||||
|
||||
@@ -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('}');
|
||||
|
||||
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
|
||||
.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});`)
|
||||
.join('\n')}\n\n const record = await this.prisma.${lowerFirst(className)}.update({ where: { ${pk}: id } as any, data });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n\n async remove(id: string) {\n const record = await this.prisma.${lowerFirst(className)}.delete({ where: { ${pk}: id } as any });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n}\n`;
|
||||
|
||||
let serviceContent = service
|
||||
.replace(
|
||||
`const sortField = query._sort || '${getBestSortField(entity, pk)}';\n const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';`,
|
||||
`const sortField = query._sort || '${getBestSortField(entity, pk)}';\n const prismaSortField = sortField === 'id' ? '${pk}' : sortField;\n const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';`
|
||||
)
|
||||
.replace('orderBy: { [sortField]: sortOrder }', 'orderBy: { [prismaSortField]: sortOrder }')
|
||||
.replace(
|
||||
`data.map((r: any) => ({ id: r.${pk}, ...serializeRecord(r) }))`,
|
||||
`data.map((item: any) => ({ id: item.${pk}, ...serializeRecord(item) }))`
|
||||
);
|
||||
|
||||
if (pk !== 'id') {
|
||||
serviceContent = serviceContent.replace(
|
||||
`const data: any = { ...(dto as any) };\n delete data.id;\n delete data.${pk};`,
|
||||
`const { id: _pk, ${pk}, ...rest } = (dto as any);\n const data: any = { ...rest };`
|
||||
);
|
||||
}
|
||||
|
||||
const mod = `import { Module } from '@nestjs/common';\nimport { ${controllerName} } from './${folder}.controller';\nimport { ${serviceName} } from './${folder}.service';\n\n@Module({\n controllers: [${controllerName}],\n providers: [${serviceName}],\n})\nexport class ${moduleName} {}\n`;
|
||||
|
||||
return {
|
||||
folder,
|
||||
files: {
|
||||
[`server/src/modules/${folder}/${folder}.controller.ts`]: controller,
|
||||
[`server/src/modules/${folder}/${folder}.service.ts`]: service,
|
||||
[`server/src/modules/${folder}/${folder}.service.ts`]: serviceContent,
|
||||
[`server/src/modules/${folder}/${folder}.module.ts`]: mod,
|
||||
[`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDtoLines.join('\n') + '\n',
|
||||
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDtoLines.join('\n') + '\n',
|
||||
@@ -621,7 +639,7 @@ function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const apply = args.includes('--apply');
|
||||
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 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"
|
||||
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/platform-express": "^10.0.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"jose": "^6.2.2",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -6675,6 +6676,15 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"test:cov": "jest --coverage",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"prisma": {
|
||||
@@ -30,6 +30,7 @@
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"jose": "^6.2.2",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { validateEnvironment } from './config/env.validation';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthModule } from './health/health.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';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true }),
|
||||
imports: [ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnvironment,
|
||||
}),
|
||||
AuthModule,
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
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 { Public } from '../auth/decorators/public.decorator';
|
||||
|
||||
@Public()
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
import { RuntimeEnvironment } from './config/env.validation';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const configService = app.get<ConfigService<RuntimeEnvironment, true>>(
|
||||
ConfigService,
|
||||
);
|
||||
|
||||
const allowedOrigins = configService
|
||||
.getOrThrow('CORS_ALLOWED_ORIGINS')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0);
|
||||
|
||||
app.enableCors({
|
||||
origin: 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'],
|
||||
credentials: false,
|
||||
});
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
|
||||
const port = configService.get('PORT', 3000);
|
||||
await app.listen(port);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||
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 { CreateEquipmentTypeDto } from './dto/create-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 {
|
||||
constructor(private readonly service: EquipmentTypeService) {}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get()
|
||||
async findAll(@Query() query: any, @Res() res: Response) {
|
||||
const result = await this.service.findAll(query);
|
||||
@@ -16,21 +19,25 @@ export class EquipmentTypeController {
|
||||
return res.json(result.data);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get(':code')
|
||||
findOne(@Param('code') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Post()
|
||||
create(@Body() dto: CreateEquipmentTypeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Patch(':code')
|
||||
update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Admin)
|
||||
@Delete(':code')
|
||||
remove(@Param('code') id: string) {
|
||||
return this.service.remove(id);
|
||||
|
||||
@@ -22,6 +22,7 @@ export class EquipmentTypeService {
|
||||
const take = end - start;
|
||||
const skip = start;
|
||||
const sortField = query._sort || 'code';
|
||||
const prismaSortField = sortField === 'id' ? 'code' : sortField;
|
||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const where: any = {};
|
||||
@@ -50,11 +51,11 @@ export class EquipmentTypeService {
|
||||
}
|
||||
|
||||
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 }),
|
||||
]);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -73,9 +74,8 @@ export class EquipmentTypeService {
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateEquipmentTypeDto) {
|
||||
const data: any = { ...(dto as any) };
|
||||
delete data.id;
|
||||
delete data.code;
|
||||
const { id: _pk, code, ...rest } = (dto as any);
|
||||
const data: any = { ...rest };
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||
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 { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||
@@ -8,6 +10,7 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||
export class EquipmentController {
|
||||
constructor(private readonly service: EquipmentService) {}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get()
|
||||
async findAll(@Query() query: any, @Res() res: Response) {
|
||||
const result = await this.service.findAll(query);
|
||||
@@ -16,21 +19,25 @@ export class EquipmentController {
|
||||
return res.json(result.data);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Post()
|
||||
create(@Body() dto: CreateEquipmentDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Admin)
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
|
||||
@@ -24,6 +24,7 @@ export class EquipmentService {
|
||||
const take = end - start;
|
||||
const skip = start;
|
||||
const sortField = query._sort || 'inventoryNumber';
|
||||
const prismaSortField = sortField === 'id' ? 'id' : sortField;
|
||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const where: any = {};
|
||||
@@ -57,7 +58,7 @@ export class EquipmentService {
|
||||
}
|
||||
|
||||
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 }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||
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 { CreateRepairOrderDto } from './dto/create-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 {
|
||||
constructor(private readonly service: RepairOrderService) {}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get()
|
||||
async findAll(@Query() query: any, @Res() res: Response) {
|
||||
const result = await this.service.findAll(query);
|
||||
@@ -16,21 +19,25 @@ export class RepairOrderController {
|
||||
return res.json(result.data);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Post()
|
||||
create(@Body() dto: CreateRepairOrderDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Admin)
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
|
||||
@@ -24,6 +24,7 @@ export class RepairOrderService {
|
||||
const take = end - start;
|
||||
const skip = start;
|
||||
const sortField = query._sort || 'number';
|
||||
const prismaSortField = sortField === 'id' ? 'id' : sortField;
|
||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const where: any = {};
|
||||
@@ -55,7 +56,7 @@ export class RepairOrderService {
|
||||
}
|
||||
|
||||
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 }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,24 +1,83 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
describe('Auth and Health (e2e)', () => {
|
||||
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({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
})
|
||||
.overrideProvider(AuthService)
|
||||
.useValue(authServiceMock)
|
||||
.overrideProvider(PrismaService)
|
||||
.useValue({})
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
authServiceMock.verifyAccessToken.mockReset();
|
||||
});
|
||||
|
||||
it('/health (GET) is public', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.get('/health')
|
||||
.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$",
|
||||
"transform": {
|
||||
"^.+\\.(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