Initial commit

This commit is contained in:
MaKarin
2026-03-25 21:01:31 +03:00
commit a46a860f4e
111 changed files with 21805 additions and 0 deletions

7
server/.env.example Normal file
View File

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

25
server/.eslintrc.js Normal file
View File

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

32
server/.gitignore vendored Normal file
View File

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

4
server/.prettierrc Normal file
View File

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

99
server/README.md Normal file
View File

@@ -0,0 +1,99 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

8
server/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

9815
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

79
server/package.json Normal file
View File

@@ -0,0 +1,79 @@
{
"name": "server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"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 domain/TOiR.domain.dsl",
"generate:bundle-json": "node ../generation/generate.mjs --print-bundle-json --dsl domain/TOiR.domain.dsl",
"postinstall": "prisma generate"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.3",
"@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"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.22.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,68 @@
-- CreateEnum
CREATE TYPE "EquipmentStatus" AS ENUM ('Active', 'Repair', 'Reserve', 'WriteOff');
-- CreateEnum
CREATE TYPE "RepairKind" AS ENUM ('TO', 'TR', 'TRE', 'KR', 'AR', 'MP');
-- CreateEnum
CREATE TYPE "RepairOrderStatus" AS ENUM ('Draft', 'Approved', 'InWork', 'Done', 'Cancelled');
-- CreateTable
CREATE TABLE "EquipmentType" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"manufacturer" TEXT,
"maintenanceIntervalHours" INTEGER,
"overhaulIntervalHours" INTEGER,
CONSTRAINT "EquipmentType_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "Equipment" (
"id" TEXT NOT NULL,
"inventoryNumber" TEXT NOT NULL,
"serialNumber" TEXT,
"name" TEXT NOT NULL,
"equipmentTypeCode" TEXT NOT NULL,
"status" "EquipmentStatus" NOT NULL DEFAULT 'Active',
"location" TEXT,
"commissionedAt" TIMESTAMP(3),
"totalEngineHours" DECIMAL(65,30),
"engineHoursSinceLastRepair" DECIMAL(65,30),
"lastRepairAt" TIMESTAMP(3),
"notes" TEXT,
CONSTRAINT "Equipment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RepairOrder" (
"id" TEXT NOT NULL,
"number" TEXT NOT NULL,
"equipmentId" TEXT NOT NULL,
"repairKind" "RepairKind" NOT NULL,
"status" "RepairOrderStatus" NOT NULL DEFAULT 'Draft',
"plannedAt" TIMESTAMP(3) NOT NULL,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"contractor" TEXT,
"engineHoursAtRepair" DECIMAL(65,30),
"description" TEXT,
"notes" TEXT,
CONSTRAINT "RepairOrder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Equipment_inventoryNumber_key" ON "Equipment"("inventoryNumber");
-- CreateIndex
CREATE UNIQUE INDEX "RepairOrder_number_key" ON "RepairOrder"("number");
-- AddForeignKey
ALTER TABLE "Equipment" ADD CONSTRAINT "Equipment_equipmentTypeCode_fkey" FOREIGN KEY ("equipmentTypeCode") REFERENCES "EquipmentType"("code") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RepairOrder" ADD CONSTRAINT "RepairOrder_equipmentId_fkey" FOREIGN KEY ("equipmentId") REFERENCES "Equipment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,74 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum EquipmentStatus {
Active
Repair
Reserve
WriteOff
}
enum RepairKind {
TO
TR
TRE
KR
AR
MP
}
enum RepairOrderStatus {
Draft
Approved
InWork
Done
Cancelled
}
model EquipmentType {
code String @id
name String
manufacturer String?
maintenanceIntervalHours Int?
overhaulIntervalHours Int?
equipment Equipment[]
}
model Equipment {
id String @id @default(uuid())
inventoryNumber String @unique
serialNumber String?
name String
equipmentTypeCode String
status EquipmentStatus @default(Active)
location String?
commissionedAt DateTime?
totalEngineHours Decimal?
engineHoursSinceLastRepair Decimal?
lastRepairAt DateTime?
notes String?
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
repairOrders RepairOrder[]
}
model RepairOrder {
id String @id @default(uuid())
number String @unique
equipmentId String
repairKind RepairKind
status RepairOrderStatus @default(Draft)
plannedAt DateTime
startedAt DateTime?
completedAt DateTime?
contractor String?
engineHoursAtRepair Decimal?
description String?
notes String?
equipment Equipment @relation(fields: [equipmentId], references: [id])
}

172
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,172 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const equipmentTypes = [
{
code: 'pump',
name: 'Насосный агрегат',
manufacturer: 'АО НасосПром',
maintenanceIntervalHours: 2000,
overhaulIntervalHours: 16000,
},
{
code: 'compressor',
name: 'Компрессорная установка',
manufacturer: 'ОАО Компрессормаш',
maintenanceIntervalHours: 1500,
overhaulIntervalHours: 12000,
},
{
code: 'generator',
name: 'Дизель-генератор',
manufacturer: 'АО ЭнергоМаш',
maintenanceIntervalHours: 500,
overhaulIntervalHours: 6000,
},
{
code: 'valve',
name: 'Запорная арматура',
manufacturer: 'ЗАО АрматурПром',
maintenanceIntervalHours: 1000,
overhaulIntervalHours: 10000,
},
{
code: 'sensor',
name: 'Датчик давления',
manufacturer: 'ООО ПриборСервис',
maintenanceIntervalHours: 800,
overhaulIntervalHours: 8000,
},
{
code: 'motor',
name: 'Электродвигатель',
manufacturer: 'ПАО ЭлектроМотор',
maintenanceIntervalHours: 1200,
overhaulIntervalHours: 14000,
},
{
code: 'fan',
name: 'Вентилятор',
manufacturer: 'АО ВентПром',
maintenanceIntervalHours: 700,
overhaulIntervalHours: 9000,
},
{
code: 'heat-exchanger',
name: 'Теплообменник',
manufacturer: 'ОАО ТеплоТех',
maintenanceIntervalHours: 1800,
overhaulIntervalHours: 15000,
},
{
code: 'filter',
name: 'Фильтровальная установка',
manufacturer: 'ООО ФильтрТех',
maintenanceIntervalHours: 600,
overhaulIntervalHours: 7000,
},
{
code: 'separator',
name: 'Сепаратор',
manufacturer: 'АО СепараторМаш',
maintenanceIntervalHours: 1600,
overhaulIntervalHours: 13000,
},
{
code: 'transformer',
name: 'Трансформатор',
manufacturer: 'ПАО ТрансЭнерго',
maintenanceIntervalHours: 2500,
overhaulIntervalHours: 20000,
},
] as const;
for (const type of equipmentTypes) {
await prisma.equipmentType.upsert({
where: { code: type.code },
update: { ...type },
create: { ...type },
});
}
const equipmentRecords: { id: string; inventoryNumber: string; name: string }[] = [];
for (let i = 1; i <= 11; i++) {
const type = equipmentTypes[(i - 1) % equipmentTypes.length];
const inventoryNumber = `INV-${String(i).padStart(3, '0')}`;
const serialNumber = `SN-2026-${String(i).padStart(4, '0')}`;
const record = await prisma.equipment.upsert({
where: { inventoryNumber },
update: {
serialNumber,
name: `${type.name} #${i}`,
equipmentTypeCode: type.code,
status: i % 5 === 0 ? 'Repair' : 'Active',
location: i % 2 === 0 ? `Площадка ${Math.ceil(i / 2)}` : `Цех ${Math.ceil(i / 3)}`,
commissionedAt: new Date(2022, (i - 1) % 12, 1 + ((i - 1) % 28)),
totalEngineHours: 1000 + i * 350,
engineHoursSinceLastRepair: 200 + i * 25,
},
create: {
inventoryNumber,
serialNumber,
name: `${type.name} #${i}`,
equipmentTypeCode: type.code,
status: i % 5 === 0 ? 'Repair' : 'Active',
location: i % 2 === 0 ? `Площадка ${Math.ceil(i / 2)}` : `Цех ${Math.ceil(i / 3)}`,
commissionedAt: new Date(2022, (i - 1) % 12, 1 + ((i - 1) % 28)),
totalEngineHours: 1000 + i * 350,
engineHoursSinceLastRepair: 200 + i * 25,
},
});
equipmentRecords.push({ id: record.id, inventoryNumber: record.inventoryNumber, name: record.name });
}
const repairKinds = ['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'] as const;
const statuses = ['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'] as const;
for (let i = 1; i <= 11; i++) {
const number = `RO-2026-${String(i).padStart(3, '0')}`;
const equipment = equipmentRecords[(i - 1) % equipmentRecords.length];
await prisma.repairOrder.upsert({
where: { number },
update: {
equipmentId: equipment.id,
repairKind: repairKinds[(i - 1) % repairKinds.length],
status: statuses[(i - 1) % statuses.length],
plannedAt: new Date(2026, ((i - 1) % 12), 1 + ((i - 1) % 28)),
startedAt: i % 4 === 0 ? new Date(2026, ((i - 1) % 12), 2 + ((i - 1) % 28)) : null,
completedAt: i % 5 === 0 ? new Date(2026, ((i - 1) % 12), 5 + ((i - 1) % 28)) : null,
contractor: i % 3 === 0 ? 'ООО СервисРемонт' : 'АО ТехПодряд',
engineHoursAtRepair: 1000 + i * 350,
description: `Заявка на ремонт ${equipment.inventoryNumber} (${equipment.name})`,
notes: i % 2 === 0 ? 'Тестовая заметка' : null,
},
create: {
number,
equipmentId: equipment.id,
repairKind: repairKinds[(i - 1) % repairKinds.length],
status: statuses[(i - 1) % statuses.length],
plannedAt: new Date(2026, ((i - 1) % 12), 1 + ((i - 1) % 28)),
startedAt: i % 4 === 0 ? new Date(2026, ((i - 1) % 12), 2 + ((i - 1) % 28)) : null,
completedAt: i % 5 === 0 ? new Date(2026, ((i - 1) % 12), 5 + ((i - 1) % 28)) : null,
contractor: i % 3 === 0 ? 'ООО СервисРемонт' : 'АО ТехПодряд',
engineHoursAtRepair: 1000 + i * 350,
description: `Заявка на ремонт ${equipment.inventoryNumber} (${equipment.name})`,
notes: i % 2 === 0 ? 'Тестовая заметка' : null,
},
});
}
console.log('Seed data created successfully');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,87 @@
# AID export: OpenAPI + генератор приложения
**Полное описание задачи, сценариев и CLI:** [docs/AID_EXPORT_README.md](../../../docs/AID_EXPORT_README.md)
Ниже — краткая справка по HTTP-эндпоинтам.
---
## 1. `api-format` → OpenAPI 3.0
`POST /aid/export/openapi`
Внутри: `tools/api-format-to-openapi/convert.mjs` (режимы `deterministic` и `llm`).
**Тело:**
```json
{
"apiFormat": { "apiFormatVersion": "1", "...": "..." },
"mode": "deterministic"
}
```
**Ответ:** `{ "openapi": { ... } }`
---
## 2. DSL → сгенерированное приложение (бандл или запись на диск)
`POST /aid/export/app`
Внутри: `generation/generate.mjs`.
**Тело:**
```json
{
"dsl": "domain TOiR {\n ...\n}\n",
"apply": false
}
```
- **`apply` по умолчанию `false` (рекомендуется для AID):** ответ содержит `files` — карта **путь от корня репозитория → текст файла** (Prisma, Nest-модули, React Admin и обновлённые `app.module.ts` / `App.tsx`). **На диск ничего не пишется.**
- **`apply: true`:** выполняется тот же процесс, что и `npm run generate:from-dsl` с `--apply`**перезапись файлов в рабочей копии** на машине, где запущен Nest. Включено только если в окружении задано **`AID_GENERATOR_ALLOW_APPLY=1`** (или `true`). Иначе `403 Forbidden`.
**Ответ (бандл):**
```json
{
"applied": false,
"entityCount": 3,
"enumCount": 3,
"files": {
"server/prisma/schema.prisma": "...",
"server/src/modules/equipment/equipment.controller.ts": "...",
"client/src/App.tsx": "..."
}
}
```
**Ответ (apply):**
```json
{
"applied": true,
"message": "Generated 3 entities from ..."
}
```
### CLI-аналог бандла (без Nest)
Из **корня репозитория**:
```bash
node generation/generate.mjs --print-bundle-json --dsl examples/TOiR.domain.dsl > bundle.json
```
---
## Безопасность
- Если в `.env` задан **`AID_EXPORT_API_KEY`**, для **обоих** эндпоинтов нужен заголовок **`X-AID-Export-Key`** с тем же значением.
- Не включайте **`AID_GENERATOR_ALLOW_APPLY`** на публичных инстансах без понимания рисков.
## Требования
- Запуск Nest с **cwd = `server/`** относительно корня репо, чтобы находились `../generation/generate.mjs` и `../tools/...`.

View File

@@ -0,0 +1,112 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Headers,
HttpCode,
Post,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AidExportService, OpenApiExportMode } from './aid-export.service';
/**
* HTTP-экспортёры для AID: OpenAPI из api-format и генерация приложения из DSL.
*/
@Controller('aid/export')
export class AidExportController {
constructor(
private readonly aidExport: AidExportService,
private readonly config: ConfigService,
) {}
private assertExportKey(exportKey: string | undefined) {
const requiredKey = this.config.get<string>('AID_EXPORT_API_KEY');
if (requiredKey && exportKey !== requiredKey) {
throw new UnauthorizedException('Invalid or missing X-AID-Export-Key');
}
}
/**
* POST /aid/export/openapi
* Body: { "apiFormat": <объект api-format>, "mode"?: "deterministic" | "llm" }
* Response: { "openapi": <OpenAPI 3.0.3 document> }
*
* mode=llm требует OPENAI_API_KEY в окружении сервера.
*
* Если задан AID_EXPORT_API_KEY, клиент должен передать заголовок X-AID-Export-Key с тем же значением.
*/
@Post('openapi')
@HttpCode(200)
async exportOpenApi(
@Body()
body: { apiFormat?: unknown; mode?: string },
@Headers('x-aid-export-key') exportKey?: string,
) {
this.assertExportKey(exportKey);
if (body == null || typeof body !== 'object' || body.apiFormat == null) {
throw new BadRequestException('Body must be a JSON object with an "apiFormat" property');
}
if (typeof body.apiFormat !== 'object' || Array.isArray(body.apiFormat)) {
throw new BadRequestException('"apiFormat" must be a JSON object');
}
const mode: OpenApiExportMode =
body.mode === 'llm' ? 'llm' : 'deterministic';
const openapi = await this.aidExport.convertApiFormatToOpenApi(
body.apiFormat,
mode,
);
return { openapi };
}
/**
* POST /aid/export/app
* Body: { "dsl": "<текст DSL как в examples/TOiR.domain.dsl>", "apply"?: boolean }
*
* По умолчанию `apply: false` — возвращается JSON с полем `files` (пути относительно корня репо → содержимое),
* без записи на диск (безопасно для вызова из AID).
*
* `apply: true` перезаписывает файлы в **текущей** рабочей копии репозитория на машине, где крутится Nest.
* Разрешено только если в окружении задано `AID_GENERATOR_ALLOW_APPLY=1` (или `true`).
*/
@Post('app')
@HttpCode(200)
async exportApp(
@Body()
body: { dsl?: string; apply?: boolean },
@Headers('x-aid-export-key') exportKey?: string,
) {
this.assertExportKey(exportKey);
if (body == null || typeof body !== 'object') {
throw new BadRequestException('Body must be a JSON object');
}
if (typeof body.dsl !== 'string' || !body.dsl.trim()) {
throw new BadRequestException('Body must include a non-empty string "dsl"');
}
const apply = body.apply === true;
if (apply) {
const allow = this.config.get<string>('AID_GENERATOR_ALLOW_APPLY');
if (allow !== '1' && allow !== 'true') {
throw new ForbiddenException(
'apply=true is disabled. Set AID_GENERATOR_ALLOW_APPLY=1 on the server to allow writing generated files to disk.',
);
}
const { message } = await this.aidExport.generateAppApply(body.dsl);
return { applied: true, message };
}
const bundle = await this.aidExport.generateAppBundle(body.dsl);
return {
applied: false,
entityCount: bundle.entityCount,
enumCount: bundle.enumCount,
files: bundle.files,
};
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AidExportController } from './aid-export.controller';
import { AidExportService } from './aid-export.service';
@Module({
controllers: [AidExportController],
providers: [AidExportService],
})
export class AidExportModule {}

View File

@@ -0,0 +1,154 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { execFile } from 'child_process';
import { access, readFile, unlink, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const LARGE_BUFFER = 64 * 1024 * 1024;
export type OpenApiExportMode = 'deterministic' | 'llm';
export type AppGeneratorBundle = {
entityCount: number;
enumCount: number;
files: Record<string, string>;
};
@Injectable()
export class AidExportService {
/**
* Путь к tools/api-format-to-openapi/convert.mjs относительно cwd процесса (обычно каталог server/).
*/
private resolveConvertScript(): string {
return join(process.cwd(), '..', 'tools', 'api-format-to-openapi', 'convert.mjs');
}
/** Путь к generation/generate.mjs относительно cwd = server/. */
private resolveGenerateScript(): string {
return join(process.cwd(), '..', 'generation', 'generate.mjs');
}
async convertApiFormatToOpenApi(
apiFormat: unknown,
mode: OpenApiExportMode,
): Promise<Record<string, unknown>> {
const script = this.resolveConvertScript();
try {
await access(script);
} catch {
throw new InternalServerErrorException(
`Converter script not found at ${script}. Run the server with cwd = server/ from repo root.`,
);
}
const id = randomUUID();
const inPath = join(tmpdir(), `api-format-${id}.json`);
const outPath = join(tmpdir(), `openapi-${id}.json`);
try {
await writeFile(inPath, JSON.stringify(apiFormat), 'utf8');
const { stderr } = await execFileAsync(
process.execPath,
[script, '--in', inPath, '--out', outPath, '--mode', mode],
{
env: { ...process.env },
maxBuffer: 16 * 1024 * 1024,
},
);
if (stderr?.trim()) {
// convert.mjs пишет ошибки в stderr при падении; при успехе обычно пусто
console.warn('[aid-export]', stderr);
}
const raw = await readFile(outPath, 'utf8');
return JSON.parse(raw) as Record<string, unknown>;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new InternalServerErrorException(`OpenAPI conversion failed: ${msg}`);
} finally {
await unlink(inPath).catch(() => undefined);
await unlink(outPath).catch(() => undefined);
}
}
/**
* DSL → снимок сгенерированных файлов (без записи в репозиторий).
* Использует `generation/generate.mjs --print-bundle-json`.
*/
async generateAppBundle(dsl: string): Promise<AppGeneratorBundle> {
const script = this.resolveGenerateScript();
try {
await access(script);
} catch {
throw new InternalServerErrorException(
`Generator script not found at ${script}. Run the server with cwd = server/ from repo root.`,
);
}
const id = randomUUID();
const dslPath = join(tmpdir(), `domain-${id}.dsl`);
try {
await writeFile(dslPath, dsl, 'utf8');
const { stdout, stderr } = await execFileAsync(
process.execPath,
[script, '--print-bundle-json', '--dsl', dslPath],
{
env: { ...process.env },
maxBuffer: LARGE_BUFFER,
},
);
if (stderr?.trim()) console.warn('[aid-export][generate]', stderr);
const bundle = JSON.parse(stdout) as AppGeneratorBundle;
if (!bundle.files || typeof bundle.files !== 'object') {
throw new Error('Invalid bundle: missing files');
}
return bundle;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new InternalServerErrorException(`App generator (bundle) failed: ${msg}`);
} finally {
await unlink(dslPath).catch(() => undefined);
}
}
/**
* DSL → запись сгенерированного кода в рабочую копию репозитория (`--apply`).
* Опасно для публичных эндпоинтов; включать только осознанно.
*/
async generateAppApply(dsl: string): Promise<{ message: string }> {
const script = this.resolveGenerateScript();
try {
await access(script);
} catch {
throw new InternalServerErrorException(
`Generator script not found at ${script}. Run the server with cwd = server/ from repo root.`,
);
}
const id = randomUUID();
const dslPath = join(tmpdir(), `domain-${id}.dsl`);
try {
await writeFile(dslPath, dsl, 'utf8');
const { stdout, stderr } = await execFileAsync(
process.execPath,
[script, '--apply', '--dsl', dslPath],
{
env: { ...process.env },
maxBuffer: LARGE_BUFFER,
},
);
if (stderr?.trim()) console.warn('[aid-export][generate-apply]', stderr);
return { message: (stdout || 'ok').trim() };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new InternalServerErrorException(`App generator (apply) failed: ${msg}`);
} finally {
await unlink(dslPath).catch(() => undefined);
}
}
}

26
server/src/app.module.ts Normal file
View File

@@ -0,0 +1,26 @@
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';
import { EquipmentModule } from './modules/equipment/equipment.module';
import { RepairOrderModule } from './modules/repair-order/repair-order.module';
import { AidExportModule } from './aid-export/aid-export.module';
@Module({
imports: [ConfigModule.forRoot({
isGlobal: true,
validate: validateEnvironment,
}),
AuthModule,
PrismaModule,
HealthModule,
AidExportModule,
EquipmentTypeModule,
EquipmentModule,
RepairOrderModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,3 @@
export const IS_PUBLIC_KEY = 'isPublic';
export const ROLES_KEY = 'roles';

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
@Module({
providers: [
AuthService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,129 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { RuntimeEnvironment } from '../config/env.validation';
import {
AuthenticatedUser,
KeycloakJwtPayload,
} from './interfaces/authenticated-user.interface';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly issuerUrl: string;
private readonly audience: string;
private readonly explicitJwksUrl?: string;
private jwksResolverPromise: Promise<ReturnType<typeof createRemoteJWKSet>> | null =
null;
private jwksResolver: ReturnType<typeof createRemoteJWKSet> | null = null;
constructor(
private readonly configService: ConfigService<RuntimeEnvironment, true>,
) {
this.issuerUrl = this.configService.getOrThrow('KEYCLOAK_ISSUER_URL');
this.audience = this.configService.getOrThrow('KEYCLOAK_AUDIENCE');
this.explicitJwksUrl = this.configService.get('KEYCLOAK_JWKS_URL');
}
async verifyAccessToken(token: string): Promise<AuthenticatedUser> {
try {
const jwksResolver = await this.getJwksResolver();
const { payload } = await jwtVerify(token, jwksResolver, {
issuer: this.issuerUrl,
audience: this.audience,
});
return this.mapPayloadToUser(payload as KeycloakJwtPayload);
} catch (error) {
this.logger.warn(
`JWT verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
throw new UnauthorizedException('Invalid or expired access token');
}
}
private mapPayloadToUser(payload: KeycloakJwtPayload): AuthenticatedUser {
if (!payload.sub) {
throw new UnauthorizedException('Token subject is missing');
}
const roles = Array.isArray(payload.realm_access?.roles)
? payload.realm_access.roles.filter(
(role): role is string => typeof role === 'string',
)
: [];
return {
sub: payload.sub,
username: payload.preferred_username,
name: payload.name,
email: payload.email,
roles,
claims: payload,
};
}
private async getJwksResolver() {
if (this.jwksResolver) {
return this.jwksResolver;
}
if (!this.jwksResolverPromise) {
this.jwksResolverPromise = this.createJwksResolver()
.then((resolver) => {
this.jwksResolver = resolver;
return resolver;
})
.finally(() => {
this.jwksResolverPromise = null;
});
}
return this.jwksResolverPromise;
}
private async createJwksResolver() {
const jwksUrl = await this.resolveJwksUrl();
this.logger.log(`Using JWKS URL: ${jwksUrl}`);
return createRemoteJWKSet(new URL(jwksUrl));
}
private async resolveJwksUrl(): Promise<string> {
if (this.explicitJwksUrl) {
return this.explicitJwksUrl;
}
const issuer = this.issuerUrl.replace(/\/+$/, '');
const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
try {
const discoveryResponse = await fetch(discoveryUrl, {
headers: {
Accept: 'application/json',
},
});
if (discoveryResponse.ok) {
const discoveryDocument = (await discoveryResponse.json()) as {
jwks_uri?: string;
};
if (
typeof discoveryDocument.jwks_uri === 'string' &&
discoveryDocument.jwks_uri.trim().length > 0
) {
return discoveryDocument.jwks_uri;
}
}
} catch (error) {
this.logger.warn(
`OIDC discovery failed at ${discoveryUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
return `${issuer}/protocol/openid-connect/certs`;
}
}

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { IS_PUBLIC_KEY } from '../auth.constants';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import { ROLES_KEY } from '../auth.constants';
import { RealmRole } from '../roles/realm-role.enum';
export const Roles = (...roles: RealmRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,54 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../auth.constants';
import { AuthService } from '../auth.service';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly authService: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractBearerToken(request);
if (!token) {
throw new UnauthorizedException('Missing bearer token');
}
request.user = await this.authService.verifyAccessToken(token);
return true;
}
private extractBearerToken(request: Request): string | null {
const authorization = request.headers.authorization;
if (!authorization) {
return null;
}
const [scheme, token] = authorization.split(' ');
if (scheme?.toLowerCase() !== 'bearer' || !token) {
return null;
}
return token;
}
}

View File

@@ -0,0 +1,49 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { IS_PUBLIC_KEY, ROLES_KEY } from '../auth.constants';
import { RealmRole } from '../roles/realm-role.enum';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const requiredRoles =
this.reflector.getAllAndOverride<RealmRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]) ?? [];
if (requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const userRoles = request.user?.roles ?? [];
const hasRequiredRole = requiredRoles.some((role) =>
userRoles.includes(role),
);
if (!hasRequiredRole) {
throw new ForbiddenException('Access denied: insufficient role');
}
return true;
}
}

View File

@@ -0,0 +1,20 @@
import { JWTPayload } from 'jose';
export interface KeycloakJwtPayload extends JWTPayload {
preferred_username?: string;
name?: string;
email?: string;
realm_access?: {
roles?: string[];
};
}
export interface AuthenticatedUser {
sub: string;
username?: string;
name?: string;
email?: string;
roles: string[];
claims: KeycloakJwtPayload;
}

View File

@@ -0,0 +1,12 @@
import { AuthenticatedUser } from './authenticated-user.interface';
declare global {
namespace Express {
interface Request {
user?: AuthenticatedUser;
}
}
}
export {};

View File

@@ -0,0 +1,6 @@
export enum RealmRole {
Admin = 'admin',
Editor = 'editor',
Viewer = 'viewer',
}

View File

@@ -0,0 +1,58 @@
export interface RuntimeEnvironment {
PORT: number;
DATABASE_URL: string;
CORS_ALLOWED_ORIGINS: string;
KEYCLOAK_ISSUER_URL: string;
KEYCLOAK_AUDIENCE: string;
KEYCLOAK_JWKS_URL?: string;
}
function getRequiredString(
config: Record<string, unknown>,
key: keyof RuntimeEnvironment,
): string {
const value = config[key];
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value.trim();
}
function getOptionalString(
config: Record<string, unknown>,
key: keyof RuntimeEnvironment,
): string | undefined {
const value = config[key];
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function parsePort(value: unknown): number {
if (value === undefined || value === null || value === '') {
return 3000;
}
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Environment variable PORT must be an integer between 1 and 65535');
}
return port;
}
export function validateEnvironment(
config: Record<string, unknown>,
): RuntimeEnvironment {
return {
PORT: parsePort(config.PORT),
DATABASE_URL: getRequiredString(config, 'DATABASE_URL'),
CORS_ALLOWED_ORIGINS: getRequiredString(config, 'CORS_ALLOWED_ORIGINS'),
KEYCLOAK_ISSUER_URL: getRequiredString(config, 'KEYCLOAK_ISSUER_URL'),
KEYCLOAK_AUDIENCE: getRequiredString(config, 'KEYCLOAK_AUDIENCE'),
KEYCLOAK_JWKS_URL: getOptionalString(config, 'KEYCLOAK_JWKS_URL'),
};
}

View File

@@ -0,0 +1,11 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/decorators/public.decorator';
@Public()
@Controller('health')
export class HealthController {
@Get()
getHealth() {
return { status: 'ok' };
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

35
server/src/main.ts Normal file
View File

@@ -0,0 +1,35 @@
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import { RuntimeEnvironment } from './config/env.validation';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get<ConfigService<RuntimeEnvironment, true>>(
ConfigService,
);
const allowedOrigins = configService
.getOrThrow('CORS_ALLOWED_ORIGINS')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.enableCors({
origin: (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,
});
const port = configService.get('PORT', 3000);
await app.listen(port);
}
bootstrap();

View File

@@ -0,0 +1,7 @@
export class CreateEquipmentTypeDto {
code?: string;
name!: string;
manufacturer?: string;
maintenanceIntervalHours?: number;
overhaulIntervalHours?: number;
}

View File

@@ -0,0 +1,8 @@
export class UpdateEquipmentTypeDto {
id?: string;
code?: string;
name?: string;
manufacturer?: string;
maintenanceIntervalHours?: number;
overhaulIntervalHours?: number;
}

View File

@@ -0,0 +1,45 @@
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';
@Controller('equipment-types')
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);
res.set('Content-Range', `equipment-types ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range');
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);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { EquipmentTypeController } from './equipment-type.controller';
import { EquipmentTypeService } from './equipment-type.service';
@Module({
controllers: [EquipmentTypeController],
providers: [EquipmentTypeService],
})
export class EquipmentTypeModule {}

View File

@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto';
import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
function serializeRecord(record: any) {
return {
...record,
};
}
@Injectable()
export class EquipmentTypeService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {
const start = parseInt(query._start) || 0;
const end = parseInt(query._end) || 10;
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 = {};
if (query.q) {
const q = String(query.q);
const ors: any[] = [];
ors.push({ code: { contains: q, mode: 'insensitive' } });
ors.push({ name: { contains: q, mode: 'insensitive' } });
ors.push({ manufacturer: { contains: q, mode: 'insensitive' } });
if (ors.length) where.OR = ors;
}
if (query.code) where.code = { contains: query.code, mode: 'insensitive' };
if (query.name) where.name = { contains: query.name, mode: 'insensitive' };
if (query.manufacturer) where.manufacturer = { contains: query.manufacturer, mode: 'insensitive' };
// Enum multi-value support (e.g. status=A&status=B)
if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id];
where.code = { in: ids };
}
const [data, total] = await Promise.all([
this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
this.prisma.equipmentType.count({ where }),
]);
const mapped = data.map((item: any) => ({ id: item.code, ...serializeRecord(item) }));
return { data: mapped, total };
}
async findOne(id: string) {
const record = await this.prisma.equipmentType.findUniqueOrThrow({ where: { code: id } as any });
return { id: (record as any).code, ...serializeRecord(record) };
}
async create(dto: CreateEquipmentTypeDto) {
const data: any = { ...(dto as any) };
const record = await this.prisma.equipmentType.create({ data });
return { id: (record as any).code, ...serializeRecord(record) };
}
async update(id: string, dto: UpdateEquipmentTypeDto) {
const { id: _pk, code, ...rest } = (dto as any);
const data: any = { ...rest };
const record = await this.prisma.equipmentType.update({ where: { code: id } as any, data });
return { id: (record as any).code, ...serializeRecord(record) };
}
async remove(id: string) {
const record = await this.prisma.equipmentType.delete({ where: { code: id } as any });
return { id: (record as any).code, ...serializeRecord(record) };
}
}

View File

@@ -0,0 +1,13 @@
export class CreateEquipmentDto {
inventoryNumber!: string;
serialNumber?: string;
name!: string;
equipmentTypeCode!: string;
status!: string;
location?: string;
commissionedAt?: string;
totalEngineHours?: string;
engineHoursSinceLastRepair?: string;
lastRepairAt?: string;
notes?: string;
}

View File

@@ -0,0 +1,14 @@
export class UpdateEquipmentDto {
id?: string;
inventoryNumber?: string;
serialNumber?: string;
name?: string;
equipmentTypeCode?: string;
status?: string;
location?: string;
commissionedAt?: string;
totalEngineHours?: string;
engineHoursSinceLastRepair?: string;
lastRepairAt?: string;
notes?: string;
}

View File

@@ -0,0 +1,45 @@
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';
@Controller('equipment')
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);
res.set('Content-Range', `equipment ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range');
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);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { EquipmentController } from './equipment.controller';
import { EquipmentService } from './equipment.service';
@Module({
controllers: [EquipmentController],
providers: [EquipmentService],
})
export class EquipmentModule {}

View File

@@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
function serializeRecord(record: any) {
return {
...record,
totalEngineHours: record.totalEngineHours?.toString() ?? null,
engineHoursSinceLastRepair: record.engineHoursSinceLastRepair?.toString() ?? null,
commissionedAt: record.commissionedAt?.toISOString() ?? null,
lastRepairAt: record.lastRepairAt?.toISOString() ?? null,
};
}
@Injectable()
export class EquipmentService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {
const start = parseInt(query._start) || 0;
const end = parseInt(query._end) || 10;
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 = {};
if (query.q) {
const q = String(query.q);
const ors: any[] = [];
ors.push({ inventoryNumber: { contains: q, mode: 'insensitive' } });
ors.push({ serialNumber: { contains: q, mode: 'insensitive' } });
ors.push({ name: { contains: q, mode: 'insensitive' } });
ors.push({ equipmentTypeCode: { contains: q, mode: 'insensitive' } });
ors.push({ location: { contains: q, mode: 'insensitive' } });
ors.push({ notes: { contains: q, mode: 'insensitive' } });
if (ors.length) where.OR = ors;
}
if (query.inventoryNumber) where.inventoryNumber = { contains: query.inventoryNumber, mode: 'insensitive' };
if (query.serialNumber) where.serialNumber = { contains: query.serialNumber, mode: 'insensitive' };
if (query.name) where.name = { contains: query.name, mode: 'insensitive' };
if (query.location) where.location = { contains: query.location, mode: 'insensitive' };
if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' };
if (query.equipmentTypeCode) where.equipmentTypeCode = query.equipmentTypeCode;
// Enum multi-value support (e.g. status=A&status=B)
if (query.status) { const vals = Array.isArray(query.status) ? query.status : [query.status]; where.status = vals.length > 1 ? { in: vals } : vals[0]; }
if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id];
where.id = { in: ids };
}
const [data, total] = await Promise.all([
this.prisma.equipment.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
this.prisma.equipment.count({ where }),
]);
const mapped = data.map(serializeRecord);
return { data: mapped, total };
}
async findOne(id: string) {
const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id: id } as any });
return serializeRecord(record);
}
async create(dto: CreateEquipmentDto) {
const data: any = { ...(dto as any) };
if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt);
if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt);
if (data.totalEngineHours) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours);
if (data.engineHoursSinceLastRepair) data.engineHoursSinceLastRepair = new Prisma.Decimal(data.engineHoursSinceLastRepair);
const record = await this.prisma.equipment.create({ data });
return serializeRecord(record);
}
async update(id: string, dto: UpdateEquipmentDto) {
const data: any = { ...(dto as any) };
delete data.id;
delete data.id;
if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt);
if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt);
if (data.totalEngineHours !== undefined && data.totalEngineHours !== null) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours);
if (data.engineHoursSinceLastRepair !== undefined && data.engineHoursSinceLastRepair !== null) data.engineHoursSinceLastRepair = new Prisma.Decimal(data.engineHoursSinceLastRepair);
const record = await this.prisma.equipment.update({ where: { id: id } as any, data });
return serializeRecord(record);
}
async remove(id: string) {
const record = await this.prisma.equipment.delete({ where: { id: id } as any });
return serializeRecord(record);
}
}

View File

@@ -0,0 +1,13 @@
export class CreateRepairOrderDto {
number!: string;
equipmentId!: string;
repairKind!: string;
status!: string;
plannedAt!: string;
startedAt?: string;
completedAt?: string;
contractor?: string;
engineHoursAtRepair?: string;
description?: string;
notes?: string;
}

View File

@@ -0,0 +1,14 @@
export class UpdateRepairOrderDto {
id?: string;
number?: string;
equipmentId?: string;
repairKind?: string;
status?: string;
plannedAt?: string;
startedAt?: string;
completedAt?: string;
contractor?: string;
engineHoursAtRepair?: string;
description?: string;
notes?: string;
}

View File

@@ -0,0 +1,45 @@
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';
@Controller('repair-orders')
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);
res.set('Content-Range', `repair-orders ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range');
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);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RepairOrderController } from './repair-order.controller';
import { RepairOrderService } from './repair-order.service';
@Module({
controllers: [RepairOrderController],
providers: [RepairOrderService],
})
export class RepairOrderModule {}

View File

@@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateRepairOrderDto } from './dto/create-repair-order.dto';
import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
function serializeRecord(record: any) {
return {
...record,
engineHoursAtRepair: record.engineHoursAtRepair?.toString() ?? null,
plannedAt: record.plannedAt?.toISOString() ?? null,
startedAt: record.startedAt?.toISOString() ?? null,
completedAt: record.completedAt?.toISOString() ?? null,
};
}
@Injectable()
export class RepairOrderService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {
const start = parseInt(query._start) || 0;
const end = parseInt(query._end) || 10;
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 = {};
if (query.q) {
const q = String(query.q);
const ors: any[] = [];
ors.push({ number: { contains: q, mode: 'insensitive' } });
ors.push({ contractor: { contains: q, mode: 'insensitive' } });
ors.push({ description: { contains: q, mode: 'insensitive' } });
ors.push({ notes: { contains: q, mode: 'insensitive' } });
if (ors.length) where.OR = ors;
}
if (query.number) where.number = { contains: query.number, mode: 'insensitive' };
if (query.contractor) where.contractor = { contains: query.contractor, mode: 'insensitive' };
if (query.description) where.description = { contains: query.description, mode: 'insensitive' };
if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' };
if (query.equipmentId) where.equipmentId = query.equipmentId;
// Enum multi-value support (e.g. status=A&status=B)
if (query.repairKind) { const vals = Array.isArray(query.repairKind) ? query.repairKind : [query.repairKind]; where.repairKind = vals.length > 1 ? { in: vals } : vals[0]; }
if (query.status) { const vals = Array.isArray(query.status) ? query.status : [query.status]; where.status = vals.length > 1 ? { in: vals } : vals[0]; }
if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id];
where.id = { in: ids };
}
const [data, total] = await Promise.all([
this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
this.prisma.repairOrder.count({ where }),
]);
const mapped = data.map(serializeRecord);
return { data: mapped, total };
}
async findOne(id: string) {
const record = await this.prisma.repairOrder.findUniqueOrThrow({ where: { id: id } as any });
return serializeRecord(record);
}
async create(dto: CreateRepairOrderDto) {
const data: any = { ...(dto as any) };
if (data.plannedAt) data.plannedAt = new Date(data.plannedAt);
if (data.startedAt) data.startedAt = new Date(data.startedAt);
if (data.completedAt) data.completedAt = new Date(data.completedAt);
if (data.engineHoursAtRepair) data.engineHoursAtRepair = new Prisma.Decimal(data.engineHoursAtRepair);
const record = await this.prisma.repairOrder.create({ data });
return serializeRecord(record);
}
async update(id: string, dto: UpdateRepairOrderDto) {
const data: any = { ...(dto as any) };
delete data.id;
delete data.id;
if (data.plannedAt) data.plannedAt = new Date(data.plannedAt);
if (data.startedAt) data.startedAt = new Date(data.startedAt);
if (data.completedAt) data.completedAt = new Date(data.completedAt);
if (data.engineHoursAtRepair !== undefined && data.engineHoursAtRepair !== null) data.engineHoursAtRepair = new Prisma.Decimal(data.engineHoursAtRepair);
const record = await this.prisma.repairOrder.update({ where: { id: id } as any, data });
return serializeRecord(record);
}
async remove(id: string) {
const record = await this.prisma.repairOrder.delete({ where: { id: id } as any });
return serializeRecord(record);
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,9 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}

View File

@@ -0,0 +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('Auth and Health (e2e)', () => {
let app: INestApplication;
let authServiceMock: {
verifyAccessToken: jest.Mock<Promise<AuthenticatedUser>, [string]>;
};
beforeAll(async () => {
process.env.PORT = '3000';
process.env.DATABASE_URL =
process.env.DATABASE_URL ??
'postgresql://postgres:postgres@localhost:5432/toir';
process.env.CORS_ALLOWED_ORIGINS =
process.env.CORS_ALLOWED_ORIGINS ??
'http://localhost:5173,https://toir-frontend.greact.ru';
process.env.KEYCLOAK_ISSUER_URL =
process.env.KEYCLOAK_ISSUER_URL ?? 'https://sso.greact.ru/realms/toir';
process.env.KEYCLOAK_AUDIENCE =
process.env.KEYCLOAK_AUDIENCE ?? 'toir-backend';
authServiceMock = {
verifyAccessToken: jest.fn<Promise<AuthenticatedUser>, [string]>(),
};
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(AuthService)
.useValue(authServiceMock)
.overrideProvider(PrismaService)
.useValue({})
.compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
authServiceMock.verifyAccessToken.mockReset();
});
it('/health (GET) is public', () => {
return request(app.getHttpServer())
.get('/health')
.expect(200)
.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);
});
});

12
server/test/jest-e2e.json Normal file
View File

@@ -0,0 +1,12 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^jose$": "<rootDir>/mocks/jose.ts"
}
}

View File

@@ -0,0 +1,8 @@
export const createRemoteJWKSet = () => {
return async () => ({}) as never;
};
export const jwtVerify = async () => {
return { payload: {} };
};

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
server/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}