diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ad6e4e --- /dev/null +++ b/.gitignore @@ -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? diff --git a/README.md b/README.md index b50eeaa..4a99e51 100644 --- a/README.md +++ b/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. \ No newline at end of file +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. diff --git a/backend/architecture.md b/backend/architecture.md deleted file mode 100644 index 36b05a9..0000000 --- a/backend/architecture.md +++ /dev/null @@ -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**. diff --git a/backend/database-runtime.md b/backend/database-runtime.md deleted file mode 100644 index bff59c9..0000000 --- a/backend/database-runtime.md +++ /dev/null @@ -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 -``` diff --git a/backend/prisma-rules.md b/backend/prisma-rules.md deleted file mode 100644 index 46d2fd0..0000000 --- a/backend/prisma-rules.md +++ /dev/null @@ -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`. \ No newline at end of file diff --git a/backend/prisma-service.md b/backend/prisma-service.md deleted file mode 100644 index d06773b..0000000 --- a/backend/prisma-service.md +++ /dev/null @@ -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`. diff --git a/backend/runtime-rules.md b/backend/runtime-rules.md deleted file mode 100644 index 57762a0..0000000 --- a/backend/runtime-rules.md +++ /dev/null @@ -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 | diff --git a/backend/seed-rules.md b/backend/seed-rules.md deleted file mode 100644 index e3ceb4a..0000000 --- a/backend/seed-rules.md +++ /dev/null @@ -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. | diff --git a/backend/service-rules.md b/backend/service-rules.md deleted file mode 100644 index d7e76dd..0000000 --- a/backend/service-rules.md +++ /dev/null @@ -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; - 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`. | diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..d849548 --- /dev/null +++ b/client/.env.example @@ -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 + diff --git a/client/package-lock.json b/client/package-lock.json index 0bd6b54..e60c40f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 86bcda1..2154763 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/App.tsx b/client/src/App.tsx index ad3a1ea..91a825f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 = () => ( - + { + 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; + diff --git a/client/src/auth/keycloak.ts b/client/src/auth/keycloak.ts new file mode 100644 index 0000000..2103915 --- /dev/null +++ b/client/src/auth/keycloak.ts @@ -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 | null = null; +let refreshInFlight: Promise | 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 { + 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 }; +} diff --git a/client/src/config/env.ts b/client/src/config/env.ts new file mode 100644 index 0000000..818f12e --- /dev/null +++ b/client/src/config/env.ts @@ -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; + diff --git a/client/src/dataProvider.ts b/client/src/dataProvider.ts index fe35284..07952bd 100644 --- a/client/src/dataProvider.ts +++ b/client/src/dataProvider.ts @@ -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) { const search = new URLSearchParams(); diff --git a/client/src/main.tsx b/client/src/main.tsx index c018515..be3fe9e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -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( + + + , + ); +} + +bootstrap().catch((error) => { + console.error('Failed to initialize authentication', error); + + root.render( + +
Authentication initialization failed. Check your environment variables.
+
, + ); +}); diff --git a/client/src/resources/equipment/EquipmentList.tsx b/client/src/resources/equipment/EquipmentList.tsx index 5e70e88..62be3e7 100644 --- a/client/src/resources/equipment/EquipmentList.tsx +++ b/client/src/resources/equipment/EquipmentList.tsx @@ -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 = [ , - , - , - , - - - record.code - ? `${record.code} — ${record.name ?? record.code}` - : (record.name ?? record.id) - } - filterToQuery={(searchText) => ({ q: searchText })} - /> + , + , + , + + record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} /> , - , - , - , + , + , + ]; const EquipmentListActions = () => ( @@ -78,42 +45,20 @@ const EquipmentListActions = () => ( ); export const EquipmentList = () => ( - } - filters={equipmentFilters} - sort={{ field: "inventoryNumber", order: "ASC" }} - > + } filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}> - + - - + + - - + + diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts index 11f02fe..7de5aa4 100644 --- a/client/src/vite-env.d.ts +++ b/client/src/vite-env.d.ts @@ -1 +1,12 @@ /// + +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; +} diff --git a/docker-compose.yml b/docker-compose.yml index c591e3b..61fcc40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/repository-structure.md b/docs/repository-structure.md new file mode 100644 index 0000000..bc2fdbd --- /dev/null +++ b/docs/repository-structure.md @@ -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. diff --git a/domain-summary.json b/domain-summary.json new file mode 100644 index 0000000..cf1e7e0 --- /dev/null +++ b/domain-summary.json @@ -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": "Отменена" + } + ] + } + ] +} diff --git a/examples/TOiR.domain.dsl b/domain/TOiR.domain.dsl similarity index 74% rename from examples/TOiR.domain.dsl rename to domain/TOiR.domain.dsl index aca3ecd..c7002c8 100644 --- a/examples/TOiR.domain.dsl +++ b/domain/TOiR.domain.dsl @@ -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,14 +116,13 @@ entity Equipment { is required; } - // Связь с видом оборудования (справочник НСИ) - attribute equipmentTypeCode { - type string; - key foreign { - relates EquipmentType.code; - } - is required; + attribute equipmentTypeCode { + type string; + key foreign { + relates EquipmentType.code; } + is required; + } attribute status { description "Текущий статус"; @@ -156,7 +141,6 @@ entity Equipment { type date; } - // Наработка фиксируется вручную или из производственной программы attribute totalEngineHours { description "Общая наработка, моточасов"; type decimal; @@ -178,11 +162,6 @@ entity Equipment { } } - -// ───────────────────────────────────────────── -// Заявка на ремонт -// ───────────────────────────────────────────── - entity RepairOrder { description "Заявка на ремонт — формируется по ППР или по факту обнаруженного дефекта"; diff --git a/domain/dsl-spec.md b/domain/dsl-spec.md index 3ccc6c4..bfd4fed 100644 --- a/domain/dsl-spec.md +++ b/domain/dsl-spec.md @@ -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. diff --git a/examples/TOiR-ui.dsl b/examples/TOiR-ui.dsl deleted file mode 100644 index 36785de..0000000 --- a/examples/TOiR-ui.dsl +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/examples/TOiR.api.dsl b/examples/TOiR.api.dsl deleted file mode 100644 index f60fd99..0000000 --- a/examples/TOiR.api.dsl +++ /dev/null @@ -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; - } - } -} \ No newline at end of file diff --git a/examples/TOiR.dto.dsl b/examples/TOiR.dto.dsl deleted file mode 100644 index 8b00f56..0000000 --- a/examples/TOiR.dto.dsl +++ /dev/null @@ -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; - } -} diff --git a/frontend/architecture.md b/frontend/architecture.md deleted file mode 100644 index 42aefcb..0000000 --- a/frontend/architecture.md +++ /dev/null @@ -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: - - - ---- - -# 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: - - - ---- - -# 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 `` 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: -- `` -- `` -- `` \ No newline at end of file diff --git a/frontend/react-admin-rules.md b/frontend/react-admin-rules.md deleted file mode 100644 index e8de51f..0000000 --- a/frontend/react-admin-rules.md +++ /dev/null @@ -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 - - - ---- - -# Enum Example - -DSL - -attribute status { - type EquipmentStatus; -} - -React Admin - - - -## 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 - - - ---- - -# 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. \ No newline at end of file diff --git a/general-prompt.md b/general-prompt.md deleted file mode 100644 index df1eaa5..0000000 --- a/general-prompt.md +++ /dev/null @@ -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, , ...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 diff --git a/generation/backend-generation.md b/generation/backend-generation.md deleted file mode 100644 index 15b5321..0000000 --- a/generation/backend-generation.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/generation/dev-workflow.md b/generation/dev-workflow.md deleted file mode 100644 index d65ff35..0000000 --- a/generation/dev-workflow.md +++ /dev/null @@ -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. diff --git a/generation/frontend-generation.md b/generation/frontend-generation.md deleted file mode 100644 index 10f53ff..0000000 --- a/generation/frontend-generation.md +++ /dev/null @@ -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: - - \ No newline at end of file diff --git a/generation/generate.mjs b/generation/generate.mjs index 280b0cb..595797f 100644 --- a/generation/generate.mjs +++ b/generation/generate.mjs @@ -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); diff --git a/generation/post-generation-validation.md b/generation/post-generation-validation.md deleted file mode 100644 index 87cddd5..0000000 --- a/generation/post-generation-validation.md +++ /dev/null @@ -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:/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. diff --git a/generation/runtime-bootstrap.md b/generation/runtime-bootstrap.md deleted file mode 100644 index 34d208d..0000000 --- a/generation/runtime-bootstrap.md +++ /dev/null @@ -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. diff --git a/generation/scaffolding-rules.md b/generation/scaffolding-rules.md deleted file mode 100644 index 5b6b9e1..0000000 --- a/generation/scaffolding-rules.md +++ /dev/null @@ -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. diff --git a/generation/update-strategy.md b/generation/update-strategy.md deleted file mode 100644 index 6e15242..0000000 --- a/generation/update-strategy.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/overrides/api-overrides.dsl b/overrides/api-overrides.dsl new file mode 100644 index 0000000..0a02c8a --- /dev/null +++ b/overrides/api-overrides.dsl @@ -0,0 +1,2 @@ +// Optional overrides: +// resource EquipmentType path "equipment-types"; diff --git a/overrides/ui-overrides.dsl b/overrides/ui-overrides.dsl new file mode 100644 index 0000000..048f321 --- /dev/null +++ b/overrides/ui-overrides.dsl @@ -0,0 +1,2 @@ +// Optional overrides: +// field EquipmentType.code widget "text"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..56ef58c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/prompts/auth-rules.md b/prompts/auth-rules.md new file mode 100644 index 0000000..2d4be3c --- /dev/null +++ b/prompts/auth-rules.md @@ -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 diff --git a/prompts/backend-rules.md b/prompts/backend-rules.md new file mode 100644 index 0000000..af828ff --- /dev/null +++ b/prompts/backend-rules.md @@ -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` diff --git a/prompts/frontend-rules.md b/prompts/frontend-rules.md new file mode 100644 index 0000000..65071b5 --- /dev/null +++ b/prompts/frontend-rules.md @@ -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. diff --git a/prompts/general-prompt.md b/prompts/general-prompt.md new file mode 100644 index 0000000..7c6be55 --- /dev/null +++ b/prompts/general-prompt.md @@ -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`. diff --git a/prompts/runtime-rules.md b/prompts/runtime-rules.md new file mode 100644 index 0000000..6ff930e --- /dev/null +++ b/prompts/runtime-rules.md @@ -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 diff --git a/prompts/validation-rules.md b/prompts/validation-rules.md new file mode 100644 index 0000000..a26249d --- /dev/null +++ b/prompts/validation-rules.md @@ -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. diff --git a/server/.env.example b/server/.env.example index 43743fe..cd9c83f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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" diff --git a/server/package-lock.json b/server/package-lock.json index acaa367..16c0ade 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 9678d3b..b85909f 100644 --- a/server/package.json +++ b/server/package.json @@ -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" }, diff --git a/server/src/app.module.ts b/server/src/app.module.ts index a451c2b..b2bb498 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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, diff --git a/server/src/auth/auth.constants.ts b/server/src/auth/auth.constants.ts new file mode 100644 index 0000000..003c912 --- /dev/null +++ b/server/src/auth/auth.constants.ts @@ -0,0 +1,3 @@ +export const IS_PUBLIC_KEY = 'isPublic'; +export const ROLES_KEY = 'roles'; + diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts new file mode 100644 index 0000000..9611c7a --- /dev/null +++ b/server/src/auth/auth.module.ts @@ -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 {} + diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts new file mode 100644 index 0000000..b2d9b36 --- /dev/null +++ b/server/src/auth/auth.service.ts @@ -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> | null = + null; + private jwksResolver: ReturnType | null = null; + + constructor( + private readonly configService: ConfigService, + ) { + 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 { + 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 { + 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`; + } +} + diff --git a/server/src/auth/decorators/public.decorator.ts b/server/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..41d2571 --- /dev/null +++ b/server/src/auth/decorators/public.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { IS_PUBLIC_KEY } from '../auth.constants'; + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + diff --git a/server/src/auth/decorators/roles.decorator.ts b/server/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..0101ed5 --- /dev/null +++ b/server/src/auth/decorators/roles.decorator.ts @@ -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); + diff --git a/server/src/auth/guards/jwt-auth.guard.ts b/server/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..0e6a938 --- /dev/null +++ b/server/src/auth/guards/jwt-auth.guard.ts @@ -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 { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + 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; + } +} + diff --git a/server/src/auth/guards/roles.guard.ts b/server/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..94f6393 --- /dev/null +++ b/server/src/auth/guards/roles.guard.ts @@ -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(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const requiredRoles = + this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]) ?? []; + + if (requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const userRoles = request.user?.roles ?? []; + const hasRequiredRole = requiredRoles.some((role) => + userRoles.includes(role), + ); + + if (!hasRequiredRole) { + throw new ForbiddenException('Access denied: insufficient role'); + } + + return true; + } +} + diff --git a/server/src/auth/interfaces/authenticated-user.interface.ts b/server/src/auth/interfaces/authenticated-user.interface.ts new file mode 100644 index 0000000..30c4599 --- /dev/null +++ b/server/src/auth/interfaces/authenticated-user.interface.ts @@ -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; +} + diff --git a/server/src/auth/interfaces/express-request.interface.ts b/server/src/auth/interfaces/express-request.interface.ts new file mode 100644 index 0000000..696ab5a --- /dev/null +++ b/server/src/auth/interfaces/express-request.interface.ts @@ -0,0 +1,12 @@ +import { AuthenticatedUser } from './authenticated-user.interface'; + +declare global { + namespace Express { + interface Request { + user?: AuthenticatedUser; + } + } +} + +export {}; + diff --git a/server/src/auth/roles/realm-role.enum.ts b/server/src/auth/roles/realm-role.enum.ts new file mode 100644 index 0000000..e6856b9 --- /dev/null +++ b/server/src/auth/roles/realm-role.enum.ts @@ -0,0 +1,6 @@ +export enum RealmRole { + Admin = 'admin', + Editor = 'editor', + Viewer = 'viewer', +} + diff --git a/server/src/config/env.validation.ts b/server/src/config/env.validation.ts new file mode 100644 index 0000000..4fed5dc --- /dev/null +++ b/server/src/config/env.validation.ts @@ -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, + 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, + 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, +): 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'), + }; +} + diff --git a/server/src/health/health.controller.ts b/server/src/health/health.controller.ts index 4b0e136..24ddbb2 100644 --- a/server/src/health/health.controller.ts +++ b/server/src/health/health.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get } from '@nestjs/common'; +import { Public } from '../auth/decorators/public.decorator'; +@Public() @Controller('health') export class HealthController { @Get() diff --git a/server/src/main.ts b/server/src/main.ts index bfb39ea..2f858bb 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -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, + ); + + 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(); diff --git a/server/src/modules/equipment-type/equipment-type.controller.ts b/server/src/modules/equipment-type/equipment-type.controller.ts index a79b6ee..3de454e 100644 --- a/server/src/modules/equipment-type/equipment-type.controller.ts +++ b/server/src/modules/equipment-type/equipment-type.controller.ts @@ -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); diff --git a/server/src/modules/equipment-type/equipment-type.service.ts b/server/src/modules/equipment-type/equipment-type.service.ts index cd76f52..f6bcdbc 100644 --- a/server/src/modules/equipment-type/equipment-type.service.ts +++ b/server/src/modules/equipment-type/equipment-type.service.ts @@ -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 }; diff --git a/server/src/modules/equipment/equipment.controller.ts b/server/src/modules/equipment/equipment.controller.ts index e8c13aa..758ece3 100644 --- a/server/src/modules/equipment/equipment.controller.ts +++ b/server/src/modules/equipment/equipment.controller.ts @@ -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); diff --git a/server/src/modules/equipment/equipment.service.ts b/server/src/modules/equipment/equipment.service.ts index 9459f87..53a17b6 100644 --- a/server/src/modules/equipment/equipment.service.ts +++ b/server/src/modules/equipment/equipment.service.ts @@ -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 }), ]); diff --git a/server/src/modules/repair-order/repair-order.controller.ts b/server/src/modules/repair-order/repair-order.controller.ts index 30d282e..83250c0 100644 --- a/server/src/modules/repair-order/repair-order.controller.ts +++ b/server/src/modules/repair-order/repair-order.controller.ts @@ -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); diff --git a/server/src/modules/repair-order/repair-order.service.ts b/server/src/modules/repair-order/repair-order.service.ts index bcc0517..9d26718 100644 --- a/server/src/modules/repair-order/repair-order.service.ts +++ b/server/src/modules/repair-order/repair-order.service.ts @@ -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 }), ]); diff --git a/server/test/app.e2e-spec.ts b/server/test/app.e2e-spec.ts index 50cda62..ce1c8f7 100644 --- a/server/test/app.e2e-spec.ts +++ b/server/test/app.e2e-spec.ts @@ -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, [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, [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); }); }); diff --git a/server/test/jest-e2e.json b/server/test/jest-e2e.json index e9d912f..e9166a0 100644 --- a/server/test/jest-e2e.json +++ b/server/test/jest-e2e.json @@ -5,5 +5,8 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^jose$": "/mocks/jose.ts" } } diff --git a/server/test/mocks/jose.ts b/server/test/mocks/jose.ts new file mode 100644 index 0000000..97a9d97 --- /dev/null +++ b/server/test/mocks/jose.ts @@ -0,0 +1,8 @@ +export const createRemoteJWKSet = () => { + return async () => ({}) as never; +}; + +export const jwtVerify = async () => { + return { payload: {} }; +}; + diff --git a/toir-realm.json b/toir-realm.json new file mode 100644 index 0000000..bae772d --- /dev/null +++ b/toir-realm.json @@ -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 + } + ] +} diff --git a/tools/dsl-summary.mjs b/tools/dsl-summary.mjs new file mode 100644 index 0000000..823736b --- /dev/null +++ b/tools/dsl-summary.mjs @@ -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, + }; +} diff --git a/tools/generate-domain-summary.mjs b/tools/generate-domain-summary.mjs new file mode 100644 index 0000000..806015e --- /dev/null +++ b/tools/generate-domain-summary.mjs @@ -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)}`); diff --git a/tools/validate-generation.mjs b/tools/validate-generation.mjs new file mode 100644 index 0000000..e7209b0 --- /dev/null +++ b/tools/validate-generation.mjs @@ -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.');