(llm-first): context budget, validation, and eval harness, orchestration general-prompt

This commit is contained in:
MaKarin
2026-04-03 14:17:21 +03:00
parent 79c9589658
commit c42a88dff6
189 changed files with 15538 additions and 9109 deletions

View File

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

View File

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

View File

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

33
server/.gitignore vendored
View File

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

View File

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

View File

@@ -1,37 +0,0 @@
FROM node:20-bookworm-slim AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci
COPY nest-cli.json tsconfig*.json ./
COPY src ./src
RUN npm run build
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main.js"]

View File

@@ -11,7 +11,6 @@
<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>
@@ -65,7 +64,7 @@ When you're ready to deploy your NestJS application to production, there are som
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
$ npm install -g @nestjs/mau
$ mau deploy
```

35
server/eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

5733
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,53 +11,51 @@
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/src/main.js",
"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"
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"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",
"@prisma/client": "^6.19.0",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"jose": "^6.1.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@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",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"dotenv": "^17.2.3",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^6.19.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [

16
server/prisma.config.ts Normal file
View File

@@ -0,0 +1,16 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -1,68 +0,0 @@
-- 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,85 @@
-- CreateEnum
CREATE TYPE "EquipmentStatus" AS ENUM ('Active', 'Repair', 'Reserve', 'WriteOff');
-- CreateEnum
CREATE TYPE "laborOperation" AS ENUM ('Manual', 'MachineManual', 'Machine');
-- CreateEnum
CREATE TYPE "EnumPeriodicityTO" AS ENUM ('Ежедневное', 'Еженедельное', 'Ежемесячное', 'Полугодовое', 'Годовое');
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('Исполнитель', 'Подписант', 'Пользователь');
-- CreateEnum
CREATE TYPE "CategoryPart" AS ENUM ('Расходник', 'Запчасть', 'Инструмент', 'Спецодежда');
-- CreateEnum
CREATE TYPE "EquipmentType" AS ENUM ('Производственное', 'Энергетическое', 'Насосное', 'Компрессорное');
-- CreateTable
CREATE TABLE "Equipment" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"serialNumber" TEXT NOT NULL,
"inventoryNumber" TEXT NOT NULL,
"equipmentType" "EquipmentType" NOT NULL,
"dateOfInspection" TIMESTAMP(3),
"periodicityTO" "EnumPeriodicityTO" NOT NULL,
"location" TEXT,
"status" "EquipmentStatus" NOT NULL,
"commissionedAt" TIMESTAMP(3),
"totalEngineHours" DECIMAL(65,30),
"engineHoursSinceLastRepair" DECIMAL(65,30),
"lastRepairAt" TIMESTAMP(3),
"notes" TEXT,
"workAsPartOf" "laborOperation",
"fuelConsumed" DOUBLE PRECISION,
CONSTRAINT "Equipment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Employee" (
"code" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"role" "Role" NOT NULL,
"position" TEXT NOT NULL,
"bossCode" TEXT,
"price" DOUBLE PRECISION,
"phoneNumber" DOUBLE PRECISION,
CONSTRAINT "Employee_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "Part" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"categories" "CategoryPart",
"price" DOUBLE PRECISION,
"description" TEXT,
"serialNumber" TEXT,
CONSTRAINT "Part_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CategoryResource" (
"id" TEXT NOT NULL,
"partId" TEXT,
"employeeCode" TEXT,
CONSTRAINT "CategoryResource_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Equipment_inventoryNumber_key" ON "Equipment"("inventoryNumber");
-- AddForeignKey
ALTER TABLE "Employee" ADD CONSTRAINT "Employee_bossCode_fkey" FOREIGN KEY ("bossCode") REFERENCES "Employee"("code") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CategoryResource" ADD CONSTRAINT "CategoryResource_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CategoryResource" ADD CONSTRAINT "CategoryResource_employeeCode_fkey" FOREIGN KEY ("employeeCode") REFERENCES "Employee"("code") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -14,61 +14,86 @@ enum EquipmentStatus {
WriteOff
}
enum RepairKind {
TO
TR
TRE
KR
AR
MP
enum laborOperation {
Manual
MachineManual
Machine
}
enum RepairOrderStatus {
Draft
Approved
InWork
Done
Cancelled
enum EnumPeriodicityTO {
EZHEDNEVNOE @map("Ежедневное")
EZHENEDELNOE @map("Еженедельное")
EZHEMESYACHNOE @map("Ежемесячное")
POLUGODOVOE @map("Полугодовое")
GODOVOE @map("Годовое")
}
model EquipmentType {
code String @id
name String
manufacturer String?
maintenanceIntervalHours Int?
overhaulIntervalHours Int?
equipment Equipment[]
enum Role {
ISPOLNITEL @map("Исполнитель")
PODPISANT @map("Подписант")
POLZOVATEL @map("Пользователь")
}
enum CategoryPart {
RASKHODNIK @map("Расходник")
ZAPCHAST @map("Запчасть")
INSTRUMENT @map("Инструмент")
SPETSODEZHDA @map("Спецодежда")
}
enum EquipmentType {
PROIZVODSTVENNOE @map("Производственное")
ENERGETICHESKOE @map("Энергетическое")
NASOSNOE @map("Насосное")
KOMPRESSORNOE @map("Компрессорное")
}
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?
id String @id @default(uuid())
name String
serialNumber String
inventoryNumber String @unique
equipmentType EquipmentType
dateOfInspection DateTime?
periodicityTO EnumPeriodicityTO
location String?
status EquipmentStatus
commissionedAt DateTime?
totalEngineHours Decimal?
engineHoursSinceLastRepair Decimal?
lastRepairAt DateTime?
notes String?
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
repairOrders RepairOrder[]
lastRepairAt DateTime?
notes String?
workAsPartOf laborOperation?
fuelConsumed Float?
}
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])
model Employee {
code String @id
fullName String
role Role
position String
bossCode String?
boss Employee? @relation("EmployeeBoss", fields: [bossCode], references: [code])
subordinates Employee[] @relation("EmployeeBoss")
price Float?
phoneNumber Float?
categoryResources CategoryResource[]
}
model Part {
id String @id @default(uuid())
name String
categories CategoryPart?
price Float?
description String?
serialNumber String?
categoryResources CategoryResource[]
}
model CategoryResource {
id String @id @default(uuid())
partId String?
employeeCode String?
part Part? @relation(fields: [partId], references: [id])
employee Employee? @relation(fields: [employeeCode], references: [code])
}

View File

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

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

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

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

@@ -1,154 +0,0 @@
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);
}
}
}

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@@ -1,26 +1,23 @@
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 { HealthController } from './health/health.controller';
import { CategoryResourceModule } from './modules/category-resource/category-resource.module';
import { EmployeeModule } from './modules/employee/employee.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';
import { PartModule } from './modules/part/part.module';
import { PriceListModule } from './modules/price-list/price-list.module';
import { PrismaModule } from './prisma/prisma.module';
@Module({
imports: [ConfigModule.forRoot({
isGlobal: true,
validate: validateEnvironment,
}),
AuthModule,
imports: [
PrismaModule,
HealthModule,
AidExportModule,
EquipmentTypeModule,
AuthModule,
EquipmentModule,
RepairOrderModule,
EmployeeModule,
PartModule,
CategoryResourceModule,
PriceListModule,
],
controllers: [HealthController],
})
export class AppModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

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

View File

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

View File

@@ -1,129 +1,96 @@
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';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
export interface AuthenticatedUser {
sub: string;
username?: string;
email?: string;
name?: string;
realmRoles: string[];
payload: JWTPayload;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly issuerUrl: string;
private readonly audience: string;
private readonly explicitJwksUrl?: string;
private readonly KEYCLOAK_ISSUER_URL =
process.env.KEYCLOAK_ISSUER_URL ?? 'https://sso.greact.ru/realms/toir';
private jwksResolverPromise: Promise<ReturnType<typeof createRemoteJWKSet>> | null =
private readonly KEYCLOAK_AUDIENCE =
process.env.KEYCLOAK_AUDIENCE ?? 'toir-backend';
private readonly KEYCLOAK_JWKS_URL = process.env.KEYCLOAK_JWKS_URL;
private jwksPromise: 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> {
async verifyBearerToken(token: string): Promise<AuthenticatedUser> {
try {
const jwksResolver = await this.getJwksResolver();
const { payload } = await jwtVerify(token, jwksResolver, {
issuer: this.issuerUrl,
audience: this.audience,
const JWKS = await this.resolveJwks();
const { payload } = await jwtVerify(token, JWKS, {
issuer: this.KEYCLOAK_ISSUER_URL,
audience: this.KEYCLOAK_AUDIENCE,
});
return this.mapPayloadToUser(payload as KeycloakJwtPayload);
const realmAccess = payload.realm_access as
| { roles?: unknown }
| undefined;
const realmRoles = Array.isArray(realmAccess?.roles)
? realmAccess.roles.filter(
(role): role is string => typeof role === 'string',
)
: [];
return {
sub: String(payload.sub ?? ''),
username:
typeof payload.preferred_username === 'string'
? payload.preferred_username
: undefined,
email: typeof payload.email === 'string' ? payload.email : undefined,
name: typeof payload.name === 'string' ? payload.name : undefined,
realmRoles,
payload,
};
} catch (error) {
this.logger.warn(
`JWT verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
throw new UnauthorizedException(
error instanceof Error ? error.message : 'Invalid access token',
);
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;
private async resolveJwks(): Promise<ReturnType<typeof createRemoteJWKSet>> {
if (!this.jwksPromise) {
this.jwksPromise = (async () => {
if (this.KEYCLOAK_JWKS_URL) {
return createRemoteJWKSet(new URL(this.KEYCLOAK_JWKS_URL));
}
}
} catch (error) {
this.logger.warn(
`OIDC discovery failed at ${discoveryUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
const discoveryUrl = new URL(
`${this.KEYCLOAK_ISSUER_URL}/.well-known/openid-configuration`,
);
try {
const response = await fetch(discoveryUrl);
if (response.ok) {
const discovery = (await response.json()) as {
jwks_uri?: unknown;
};
if (typeof discovery.jwks_uri === 'string') {
return createRemoteJWKSet(new URL(discovery.jwks_uri));
}
}
} catch {
// Fall through to the Keycloak certs endpoint.
}
return createRemoteJWKSet(
new URL(
`${this.KEYCLOAK_ISSUER_URL}/protocol/openid-connect/certs`,
),
);
})();
}
return `${issuer}/protocol/openid-connect/certs`;
return this.jwksPromise;
}
}

View File

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

View File

@@ -1,6 +1,4 @@
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);
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -5,9 +5,13 @@ import {
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';
import type { Request } from 'express';
import { AuthService, type AuthenticatedUser } from '../auth.service';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
type AuthenticatedRequest = Request & {
user?: AuthenticatedUser;
};
@Injectable()
export class JwtAuthGuard implements CanActivate {
@@ -21,34 +25,18 @@ export class JwtAuthGuard implements CanActivate {
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractBearerToken(request);
if (!token) {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const header = request.headers.authorization;
if (!header?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing bearer token');
}
request.user = await this.authService.verifyAccessToken(token);
const token = header.slice('Bearer '.length).trim();
request.user = await this.authService.verifyBearerToken(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

@@ -1,49 +1,30 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { CanActivate, ExecutionContext, 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';
import type { Request } from 'express';
import { ROLES_KEY } from '../decorators/roles.decorator';
type RolesRequest = Request & {
user?: {
realmRoles?: string[];
};
};
@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),
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!hasRequiredRole) {
throw new ForbiddenException('Access denied: insufficient role');
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
return true;
const request = context.switchToHttp().getRequest<RolesRequest>();
const realmRoles = request.user?.realmRoles ?? [];
return requiredRoles.some((role) => realmRoles.includes(role));
}
}

View File

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

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

View File

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

View File

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

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

View File

@@ -1,35 +1,26 @@
import { ValidationPipe } from '@nestjs/common';
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')
const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS ?? 'http://localhost:5173,https://toir-frontend.greact.ru')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
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'],
origin: allowedOrigins,
exposedHeaders: ['Content-Range'],
credentials: false,
});
const port = configService.get('PORT', 3000);
await app.listen(port);
await app.listen(Number(process.env.PORT ?? 3000));
}
bootstrap();

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { Roles } from '../../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { CreateCategoryResourceDto } from './dto/create-category-resource.dto';
import { UpdateCategoryResourceDto } from './dto/update-category-resource.dto';
import { CategoryResourceService } from './category-resource.service';
@Controller('category-resources')
@UseGuards(JwtAuthGuard, RolesGuard)
export class CategoryResourceController {
constructor(private readonly categoryResourceService: CategoryResourceService) {}
@Roles('viewer', 'editor', 'admin')
@Get()
findAll(@Query() query: Record<string, unknown>, @Res({ passthrough: true }) res: Response) {
return this.categoryResourceService.findAll(query, res);
}
@Roles('viewer', 'editor', 'admin')
@Get(':id')
findOne(@Param('id') id: string) {
return this.categoryResourceService.findOne(id);
}
@Roles('editor', 'admin')
@Post()
create(@Body() dto: CreateCategoryResourceDto) {
return this.categoryResourceService.create(dto);
}
@Roles('editor', 'admin')
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateCategoryResourceDto) {
return this.categoryResourceService.update(id, dto);
}
@Roles('admin')
@Delete(':id')
remove(@Param('id') id: string) {
return this.categoryResourceService.remove(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CategoryResourceController } from './category-resource.controller';
import { CategoryResourceService } from './category-resource.service';
@Module({
controllers: [CategoryResourceController],
providers: [CategoryResourceService],
})
export class CategoryResourceModule {}

View File

@@ -0,0 +1,142 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Response } from 'express';
import { PrismaService } from '../../prisma/prisma.service';
import { containsInsensitive, getSingle, parseRange, setListHeaders, toSortOrder } from '../shared/query-utils';
import { mapCategoryResource } from '../shared/record-mappers';
import { CreateCategoryResourceDto } from './dto/create-category-resource.dto';
import { UpdateCategoryResourceDto } from './dto/update-category-resource.dto';
@Injectable()
export class CategoryResourceService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: Record<string, unknown>, res: Response) {
// React Admin list params: _start, _end, _sort, _order.
const { start, end, take } = parseRange(query);
const requestedSort = getSingle(query._sort) ?? 'id';
const sortField = requestedSort === 'id' ? 'id' : requestedSort;
const sortOrder = toSortOrder(getSingle(query._order));
const where: Prisma.CategoryResourceWhereInput = {};
const q = getSingle(query.q);
if (q) {
where.OR = [
{ part: { is: { name: containsInsensitive(q) } } },
{ employee: { is: { fullName: containsInsensitive(q) } } },
];
}
const partName = getSingle(query.partName);
if (partName) {
where.part = { is: { name: containsInsensitive(partName) } };
}
const employeeName = getSingle(query.employeeName);
if (employeeName) {
where.employee = { is: { fullName: containsInsensitive(employeeName) } };
}
let orderBy: Prisma.CategoryResourceOrderByWithRelationInput;
if (sortField === 'part') {
orderBy = { part: { name: sortOrder } };
} else if (sortField === 'employee') {
orderBy = { employee: { fullName: sortOrder } };
} else {
orderBy = { [sortField]: sortOrder } as Prisma.CategoryResourceOrderByWithRelationInput;
}
const [items, total] = await this.prisma.$transaction([
this.prisma.categoryResource.findMany({
where,
include: {
part: true,
employee: {
include: {
boss: true,
subordinates: true,
},
},
},
skip: start,
take: take,
orderBy,
}),
this.prisma.categoryResource.count({ where }),
]);
setListHeaders(res, 'category-resources', start, end, total);
return {
data: items.map((item) => mapCategoryResource(item)),
total: total,
};
}
async findOne(id: string) {
const item = await this.prisma.categoryResource.findUnique({
where: { id },
include: {
part: true,
employee: {
include: {
boss: true,
subordinates: true,
},
},
},
});
if (!item) {
throw new NotFoundException('Category resource not found');
}
return mapCategoryResource(item);
}
async create(dto: CreateCategoryResourceDto) {
const created = await this.prisma.categoryResource.create({
data: {
partId: dto.partId ?? null,
employeeCode: dto.employeeCode ?? null,
},
include: {
part: true,
employee: {
include: {
boss: true,
subordinates: true,
},
},
},
});
return mapCategoryResource(created);
}
async update(id: string, dto: UpdateCategoryResourceDto & { id?: string }) {
const { id: _id, ...mutableData } = dto;
const updated = await this.prisma.categoryResource.update({
where: { id },
data: {
...(mutableData.partId !== undefined ? { partId: mutableData.partId } : {}),
...(mutableData.employeeCode !== undefined ? { employeeCode: mutableData.employeeCode } : {}),
},
include: {
part: true,
employee: {
include: {
boss: true,
subordinates: true,
},
},
},
});
return mapCategoryResource(updated);
}
async remove(id: string) {
await this.prisma.categoryResource.delete({ where: { id } });
return { id };
}
}

View File

@@ -0,0 +1,15 @@
import {
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class CreateCategoryResourceDto {
@IsOptional()
@IsUUID()
partId?: string;
@IsOptional()
@IsString()
employeeCode?: string;
}

View File

@@ -0,0 +1,15 @@
import {
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class UpdateCategoryResourceDto {
@IsOptional()
@IsUUID()
partId?: string;
@IsOptional()
@IsString()
employeeCode?: string;
}

View File

@@ -0,0 +1,28 @@
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import { Role } from '../../shared/dsl-enums';
export class CreateEmployeeDto {
@IsString()
code!: string;
@IsString()
fullName!: string;
@IsEnum(Role)
role!: Role;
@IsString()
position!: string;
@IsOptional()
@IsString()
boss?: string;
@IsOptional()
@IsNumber()
price?: number;
@IsOptional()
@IsNumber()
phoneNumber?: number;
}

View File

@@ -0,0 +1,28 @@
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import { Role } from '../../shared/dsl-enums';
export class UpdateEmployeeDto {
@IsOptional()
@IsString()
fullName?: string;
@IsOptional()
@IsEnum(Role)
role?: Role;
@IsOptional()
@IsString()
position?: string;
@IsOptional()
@IsString()
boss?: string;
@IsOptional()
@IsNumber()
price?: number;
@IsOptional()
@IsNumber()
phoneNumber?: number;
}

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { Roles } from '../../auth/decorators/roles.decorator';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { UpdateEmployeeDto } from './dto/update-employee.dto';
import { EmployeeService } from './employee.service';
@Controller('employees')
@UseGuards(JwtAuthGuard, RolesGuard)
export class EmployeeController {
constructor(private readonly employeeService: EmployeeService) {}
@Get()
@Roles('viewer', 'editor', 'admin')
findAll(@Query() query: Record<string, string | string[] | undefined>, @Res({ passthrough: true }) response: Response) {
return this.employeeService.findAll(query, response);
}
@Get(':code')
@Roles('viewer', 'editor', 'admin')
findOne(@Param('code') code: string) {
return this.employeeService.findOne(code);
}
@Post()
@Roles('editor', 'admin')
create(@Body() dto: CreateEmployeeDto) {
return this.employeeService.create(dto);
}
@Patch(':code')
@Roles('editor', 'admin')
update(@Param('code') code: string, @Body() dto: UpdateEmployeeDto) {
return this.employeeService.update(code, dto);
}
@Delete(':code')
@Roles('admin')
remove(@Param('code') code: string) {
return this.employeeService.remove(code);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { EmployeeController } from './employee.controller';
import { EmployeeService } from './employee.service';
@Module({
imports: [PrismaModule],
controllers: [EmployeeController],
providers: [EmployeeService],
})
export class EmployeeModule {}

View File

@@ -0,0 +1,145 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { Response } from 'express';
import { PrismaService } from '../../prisma/prisma.service';
import { getFirst, setListHeaders, toArray, toDateValue, toDecimalValue, toNumberValue } from '../shared/query-utils';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { UpdateEmployeeDto } from './dto/update-employee.dto';
@Injectable()
export class EmployeeService {
constructor(private readonly prisma: PrismaService) {}
private toNested(item: any): Record<string, unknown> {
return {
id: item.code,
code: item.code,
fullName: item.fullName,
role: item.role,
position: item.position,
bossCode: item.bossCode,
price: item.price,
phoneNumber: item.phoneNumber,
};
}
private toRecord(item: any): Record<string, unknown> {
return {
id: item.code,
code: item.code,
fullName: item.fullName,
role: item.role,
position: item.position,
bossCode: item.bossCode,
boss: item.boss ? this.toNested(item.boss) : null,
subordinates: Array.isArray(item.subordinates) ? item.subordinates.map((subordinate: any) => this.toNested(subordinate)) : [],
price: item.price,
phoneNumber: item.phoneNumber,
};
}
async findAll(query: Record<string, string | string[] | undefined>, response: Response): Promise<Record<string, unknown>[]> {
const start = Number(getFirst(query._start) ?? 0);
const end = Number(getFirst(query._end) ?? start + 25);
const take = Math.max(end - start, 0) || 25;
const where: Record<string, unknown> = {};
const q = getFirst(query.q);
if (q) {
where.OR = [
{ code: { contains: q, mode: 'insensitive' } },
{ fullName: { contains: q, mode: 'insensitive' } },
{ position: { contains: q, mode: 'insensitive' } },
];
}
const code = getFirst(query.code);
if (code) where.code = { contains: code, mode: 'insensitive' };
const fullName = getFirst(query.fullName);
if (fullName) where.fullName = { contains: fullName, mode: 'insensitive' };
const position = getFirst(query.position);
if (position) where.position = { contains: position, mode: 'insensitive' };
const roleValues = toArray(query.role);
if (roleValues.length > 0) where.role = { in: Array.from(new Set(roleValues)) };
const boss = getFirst(query.boss);
if (boss) where.bossCode = boss;
const sortField = getFirst(query._sort) ?? 'code';
const prismaSortField = sortField === 'id' ? 'code' : sortField;
const sortOrder = (String(getFirst(query._order) ?? 'ASC').toLowerCase() === 'desc' ? 'desc' : 'asc') as Prisma.SortOrder;
const [items, total] = await this.prisma.$transaction([
this.prisma.employee.findMany({
where,
skip: start,
take,
orderBy: { [prismaSortField]: sortOrder },
include: {
boss: true,
subordinates: true,
},
}),
this.prisma.employee.count({ where }),
]);
setListHeaders(response, total, start, end, 'employees');
return items.map((item) => this.toRecord(item));
}
async findOne(code: string): Promise<Record<string, unknown>> {
const item = await this.prisma.employee.findUnique({ where: { code },
include: {
boss: true,
subordinates: true,
}, });
if (!item) throw new NotFoundException('Employee not found');
return this.toRecord(item);
}
async create(dto: CreateEmployeeDto): Promise<Record<string, unknown>> {
const created = await this.prisma.employee.create({ data: this.prepareCreateData(dto) as any,
include: {
boss: true,
subordinates: true,
}, });
return this.toRecord(created);
}
async update(code: string, dto: UpdateEmployeeDto): Promise<Record<string, unknown>> {
const updated = await this.prisma.employee.update({ where: { code }, data: this.prepareUpdateData(dto) as any,
include: {
boss: true,
subordinates: true,
}, });
return this.toRecord(updated);
}
async remove(code: string): Promise<Record<string, unknown>> {
const deleted = await this.prisma.employee.delete({ where: { code },
include: {
boss: true,
subordinates: true,
}, });
return this.toRecord(deleted);
}
private prepareCreateData(dto: CreateEmployeeDto): Record<string, unknown> {
return {
code: dto.code,
fullName: dto.fullName,
role: dto.role,
position: dto.position,
bossCode: dto.boss ?? null,
price: toNumberValue(dto.price),
phoneNumber: toNumberValue(dto.phoneNumber),
};
}
private prepareUpdateData(dto: UpdateEmployeeDto): Record<string, unknown> {
const { id, code: _pk, ...rest } = dto as Record<string, unknown> & { id?: string; code?: string };
return {
fullName: rest.fullName,
role: rest.role,
position: rest.position,
bossCode: rest.boss ?? null,
price: toNumberValue(rest.price),
phoneNumber: toNumberValue(rest.phoneNumber),
};
}
}

View File

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

View File

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

View File

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

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

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

@@ -1,13 +1,58 @@
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import { EquipmentType, EnumPeriodicityTO, EquipmentStatus, laborOperation } from '../../shared/dsl-enums';
export class CreateEquipmentDto {
inventoryNumber!: string;
serialNumber?: string;
@IsString()
name!: string;
equipmentTypeCode!: string;
status!: string;
@IsString()
serialNumber!: string;
@IsString()
inventoryNumber!: string;
@IsEnum(EquipmentType)
equipmentType!: EquipmentType;
@IsOptional()
@IsString()
dateOfInspection?: string;
@IsEnum(EnumPeriodicityTO)
periodicityTO!: EnumPeriodicityTO;
@IsOptional()
@IsString()
location?: string;
@IsEnum(EquipmentStatus)
status!: EquipmentStatus;
@IsOptional()
@IsString()
commissionedAt?: string;
@IsOptional()
@IsString()
totalEngineHours?: string;
@IsOptional()
@IsString()
engineHoursSinceLastRepair?: string;
@IsOptional()
@IsString()
lastRepairAt?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsEnum(laborOperation)
workAsPartOf?: laborOperation;
@IsOptional()
@IsNumber()
fuelConsumed?: number;
}

View File

@@ -1,14 +1,64 @@
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import { EquipmentType, EnumPeriodicityTO, EquipmentStatus, laborOperation } from '../../shared/dsl-enums';
export class UpdateEquipmentDto {
id?: string;
inventoryNumber?: string;
serialNumber?: string;
@IsOptional()
@IsString()
name?: string;
equipmentTypeCode?: string;
status?: string;
@IsOptional()
@IsString()
serialNumber?: string;
@IsOptional()
@IsString()
inventoryNumber?: string;
@IsOptional()
@IsEnum(EquipmentType)
equipmentType?: EquipmentType;
@IsOptional()
@IsString()
dateOfInspection?: string;
@IsOptional()
@IsEnum(EnumPeriodicityTO)
periodicityTO?: EnumPeriodicityTO;
@IsOptional()
@IsString()
location?: string;
@IsOptional()
@IsEnum(EquipmentStatus)
status?: EquipmentStatus;
@IsOptional()
@IsString()
commissionedAt?: string;
@IsOptional()
@IsString()
totalEngineHours?: string;
@IsOptional()
@IsString()
engineHoursSinceLastRepair?: string;
@IsOptional()
@IsString()
lastRepairAt?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsEnum(laborOperation)
workAsPartOf?: laborOperation;
@IsOptional()
@IsNumber()
fuelConsumed?: number;
}

View File

@@ -1,45 +1,44 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { Roles } from '../../auth/decorators/roles.decorator';
import { RealmRole } from '../../auth/roles/realm-role.enum';
import { EquipmentService } from './equipment.service';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
import { EquipmentService } from './equipment.service';
@Controller('equipment')
@Controller('equipments')
@UseGuards(JwtAuthGuard, RolesGuard)
export class EquipmentController {
constructor(private readonly service: EquipmentService) {}
constructor(private readonly equipmentService: 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('viewer', 'editor', 'admin')
findAll(@Query() query: Record<string, string | string[] | undefined>, @Res({ passthrough: true }) response: Response) {
return this.equipmentService.findAll(query, response);
}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':id')
@Roles('viewer', 'editor', 'admin')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
return this.equipmentService.findOne(id);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Post()
@Roles('editor', 'admin')
create(@Body() dto: CreateEquipmentDto) {
return this.service.create(dto);
return this.equipmentService.create(dto);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':id')
@Roles('editor', 'admin')
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
return this.service.update(id, dto);
return this.equipmentService.update(id, dto);
}
@Roles(RealmRole.Admin)
@Delete(':id')
@Roles('admin')
remove(@Param('id') id: string) {
return this.service.remove(id);
return this.equipmentService.remove(id);
}
}

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { EquipmentController } from './equipment.controller';
import { EquipmentService } from './equipment.service';
@Module({
imports: [PrismaModule],
controllers: [EquipmentController],
providers: [EquipmentService],
})

View File

@@ -1,102 +1,135 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { Response } from 'express';
import { PrismaService } from '../../prisma/prisma.service';
import { getFirst, setListHeaders, toArray, toDateValue, toDecimalValue, toNumberValue } from '../shared/query-utils';
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;
private toRecord(item: any): Record<string, unknown> {
return {
id: item.id,
name: item.name,
serialNumber: item.serialNumber,
inventoryNumber: item.inventoryNumber,
equipmentType: item.equipmentType,
dateOfInspection: item.dateOfInspection ? item.dateOfInspection.toISOString() : null,
periodicityTO: item.periodicityTO,
location: item.location,
status: item.status,
commissionedAt: item.commissionedAt ? item.commissionedAt.toISOString() : null,
totalEngineHours: item.totalEngineHours ? item.totalEngineHours.toString() : null,
engineHoursSinceLastRepair: item.engineHoursSinceLastRepair ? item.engineHoursSinceLastRepair.toString() : null,
lastRepairAt: item.lastRepairAt ? item.lastRepairAt.toISOString() : null,
notes: item.notes,
workAsPartOf: item.workAsPartOf,
fuelConsumed: item.fuelConsumed,
};
}
async findAll(query: Record<string, string | string[] | undefined>, response: Response): Promise<Record<string, unknown>[]> {
const start = Number(getFirst(query._start) ?? 0);
const end = Number(getFirst(query._end) ?? start + 25);
const take = Math.max(end - start, 0) || 25;
const where: Record<string, unknown> = {};
const q = getFirst(query.q);
if (q) {
where.OR = [
{ inventoryNumber: { contains: q, mode: 'insensitive' } },
{ serialNumber: { contains: q, mode: 'insensitive' } },
{ name: { contains: q, mode: 'insensitive' } },
{ location: { contains: q, mode: 'insensitive' } },
{ notes: { contains: q, mode: 'insensitive' } },
];
}
const inventoryNumber = getFirst(query.inventoryNumber);
if (inventoryNumber) where.inventoryNumber = { contains: inventoryNumber, mode: 'insensitive' };
const serialNumber = getFirst(query.serialNumber);
if (serialNumber) where.serialNumber = { contains: serialNumber, mode: 'insensitive' };
const name = getFirst(query.name);
if (name) where.name = { contains: name, mode: 'insensitive' };
const location = getFirst(query.location);
if (location) where.location = { contains: location, mode: 'insensitive' };
const statusValues = toArray(query.status);
if (statusValues.length > 0) where.status = { in: Array.from(new Set(statusValues)) };
const equipmentTypeValues = toArray(query.equipmentTypeCode ?? query.equipmentType);
if (equipmentTypeValues.length > 0) where.equipmentType = { in: Array.from(new Set(equipmentTypeValues)) };
const periodicityValues = toArray(query.equipmentPeriodicityTO ?? query.periodicityTO);
if (periodicityValues.length > 0) where.periodicityTO = { in: Array.from(new Set(periodicityValues)) };
const workAsPartOfValues = toArray(query.workAsPartOf);
if (workAsPartOfValues.length > 0) where.workAsPartOf = { in: Array.from(new Set(workAsPartOfValues)) };
const requestedSort = getFirst(query._sort) ?? 'inventoryNumber';
const sortField = requestedSort === 'id' ? 'id' : requestedSort;
const sortOrder = (String(getFirst(query._order) ?? 'ASC').toLowerCase() === 'desc' ? 'desc' : 'asc') as Prisma.SortOrder;
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 } }),
const [items, total] = await this.prisma.$transaction([
this.prisma.equipment.findMany({
where,
skip: start,
take,
orderBy: { [sortField]: sortOrder }
}),
this.prisma.equipment.count({ where }),
]);
const mapped = data.map(serializeRecord);
return { data: mapped, total };
setListHeaders(response, total, start, end, 'equipments');
return items.map((item) => this.toRecord(item));
}
async findOne(id: string) {
const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id: id } as any });
return serializeRecord(record);
async findOne(id: string): Promise<Record<string, unknown>> {
const item = await this.prisma.equipment.findUnique({ where: { id } });
if (!item) throw new NotFoundException('Equipment not found');
return this.toRecord(item);
}
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 create(dto: CreateEquipmentDto): Promise<Record<string, unknown>> {
const created = await this.prisma.equipment.create({ data: this.prepareCreateData(dto) as any });
return this.toRecord(created);
}
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 update(id: string, dto: UpdateEquipmentDto): Promise<Record<string, unknown>> {
const updated = await this.prisma.equipment.update({ where: { id }, data: this.prepareUpdateData(dto) as any });
return this.toRecord(updated);
}
async remove(id: string) {
const record = await this.prisma.equipment.delete({ where: { id: id } as any });
return serializeRecord(record);
async remove(id: string): Promise<Record<string, unknown>> {
const deleted = await this.prisma.equipment.delete({ where: { id } });
return this.toRecord(deleted);
}
private prepareCreateData(dto: CreateEquipmentDto): Record<string, unknown> {
return {
name: dto.name,
serialNumber: dto.serialNumber,
inventoryNumber: dto.inventoryNumber,
equipmentType: dto.equipmentType,
dateOfInspection: toDateValue(dto.dateOfInspection),
periodicityTO: dto.periodicityTO,
location: dto.location ?? null,
status: dto.status,
commissionedAt: toDateValue(dto.commissionedAt),
totalEngineHours: toDecimalValue(dto.totalEngineHours),
engineHoursSinceLastRepair: toDecimalValue(dto.engineHoursSinceLastRepair),
lastRepairAt: toDateValue(dto.lastRepairAt),
notes: dto.notes ?? null,
workAsPartOf: dto.workAsPartOf ?? null,
fuelConsumed: toNumberValue(dto.fuelConsumed),
};
}
private prepareUpdateData(dto: UpdateEquipmentDto): Record<string, unknown> {
const { id, ...rest } = dto as Record<string, unknown> & { id?: string };
return {
...rest,
dateOfInspection: toDateValue(rest.dateOfInspection),
commissionedAt: toDateValue(rest.commissionedAt),
totalEngineHours: toDecimalValue(rest.totalEngineHours),
engineHoursSinceLastRepair: toDecimalValue(rest.engineHoursSinceLastRepair),
lastRepairAt: toDateValue(rest.lastRepairAt),
fuelConsumed: toNumberValue(rest.fuelConsumed),
};
}
}

View File

@@ -0,0 +1,28 @@
import {
IsEnum,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { CategoryPart } from '../../shared/dsl-enums';
export class CreatePartDto {
@IsString()
name!: string;
@IsOptional()
@IsEnum(CategoryPart)
categories?: CategoryPart;
@IsOptional()
@IsNumber()
price?: number;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
serialNumber?: string;
}

View File

@@ -0,0 +1,29 @@
import {
IsEnum,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { CategoryPart } from '../../shared/dsl-enums';
export class UpdatePartDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsEnum(CategoryPart)
categories?: CategoryPart;
@IsOptional()
@IsNumber()
price?: number;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
serialNumber?: string;
}

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { Roles } from '../../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { CreatePartDto } from './dto/create-part.dto';
import { UpdatePartDto } from './dto/update-part.dto';
import { PartService } from './part.service';
@Controller('parts')
@UseGuards(JwtAuthGuard, RolesGuard)
export class PartController {
constructor(private readonly partService: PartService) {}
@Roles('viewer', 'editor', 'admin')
@Get()
findAll(@Query() query: Record<string, unknown>, @Res({ passthrough: true }) res: Response) {
return this.partService.findAll(query, res);
}
@Roles('viewer', 'editor', 'admin')
@Get(':id')
findOne(@Param('id') id: string) {
return this.partService.findOne(id);
}
@Roles('editor', 'admin')
@Post()
create(@Body() dto: CreatePartDto) {
return this.partService.create(dto);
}
@Roles('editor', 'admin')
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdatePartDto) {
return this.partService.update(id, dto);
}
@Roles('admin')
@Delete(':id')
remove(@Param('id') id: string) {
return this.partService.remove(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PartController } from './part.controller';
import { PartService } from './part.service';
@Module({
controllers: [PartController],
providers: [PartService],
})
export class PartModule {}

View File

@@ -0,0 +1,107 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Response } from 'express';
import { PrismaService } from '../../prisma/prisma.service';
import { containsInsensitive, getSingle, getStringArray, parseRange, setListHeaders, toSortOrder } from '../shared/query-utils';
import { mapPart } from '../shared/record-mappers';
import { CreatePartDto } from './dto/create-part.dto';
import { UpdatePartDto } from './dto/update-part.dto';
@Injectable()
export class PartService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: Record<string, unknown>, res: Response) {
const { start, end, take } = parseRange(query);
const requestedSort = getSingle(query._sort) ?? 'name';
const sortField = requestedSort === 'id' ? 'id' : requestedSort;
const sortOrder = toSortOrder(getSingle(query._order));
const where: Record<string, unknown> = {};
const q = getSingle(query.q);
if (q) {
where.OR = [
{ name: containsInsensitive(q) },
{ description: containsInsensitive(q) },
{ serialNumber: containsInsensitive(q) },
];
}
const name = getSingle(query.name);
if (name) {
where.name = containsInsensitive(name);
}
const serialNumber = getSingle(query.serialNumber);
if (serialNumber) {
where.serialNumber = containsInsensitive(serialNumber);
}
const categoryValues = getStringArray(query.categories);
if (categoryValues.length === 1) {
where.categories = categoryValues[0];
} else if (categoryValues.length > 1) {
where.categories = { in: Array.from(new Set(categoryValues)) };
}
const [items, total] = await this.prisma.$transaction([
this.prisma.part.findMany({
where: where as Prisma.PartWhereInput,
skip: start,
take: take,
orderBy: { [sortField]: sortOrder } as Prisma.PartOrderByWithRelationInput,
}),
this.prisma.part.count({ where: where as Prisma.PartWhereInput }),
]);
setListHeaders(res, 'parts', start, end, total);
return {
data: items.map((item) => mapPart(item)),
total: total,
};
}
async findOne(id: string) {
const item = await this.prisma.part.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException('Part not found');
}
return mapPart(item);
}
async create(dto: CreatePartDto) {
const created = await this.prisma.part.create({
data: {
name: dto.name,
categories: dto.categories ?? null,
price: dto.price ?? null,
description: dto.description ?? null,
serialNumber: dto.serialNumber ?? null,
} as any,
});
return mapPart(created);
}
async update(id: string, dto: UpdatePartDto & { id?: string }) {
const { id: _id, ...mutableData } = dto;
const updated = await this.prisma.part.update({
where: { id },
data: {
...(mutableData.name !== undefined ? { name: mutableData.name } : {}),
...(mutableData.categories !== undefined ? { categories: mutableData.categories } : {}),
...(mutableData.price !== undefined ? { price: mutableData.price } : {}),
...(mutableData.description !== undefined ? { description: mutableData.description } : {}),
...(mutableData.serialNumber !== undefined ? { serialNumber: mutableData.serialNumber } : {}),
} as any,
});
return mapPart(updated);
}
async remove(id: string) {
await this.prisma.part.delete({ where: { id } });
return { id };
}
}

View File

@@ -0,0 +1 @@
export class CreatePriceListDto {}

View File

@@ -0,0 +1 @@
export class UpdatePriceListDto {}

View File

@@ -0,0 +1,17 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Roles } from '../../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { PriceListService } from './price-list.service';
@Controller('price-list')
@UseGuards(JwtAuthGuard, RolesGuard)
export class PriceListController {
constructor(private readonly priceListService: PriceListService) {}
@Roles('viewer', 'editor', 'admin')
@Get()
getRecord() {
return this.priceListService.getRecord();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PriceListController } from './price-list.controller';
import { PriceListService } from './price-list.service';
@Module({
controllers: [PriceListController],
providers: [PriceListService],
})
export class PriceListModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { mapPriceList } from '../shared/record-mappers';
@Injectable()
export class PriceListService {
constructor(private readonly prisma: PrismaService) {}
async getRecord() {
const [employee, part] = await Promise.all([
this.prisma.employee.findFirst({
where: {
price: {
not: null,
},
},
orderBy: {
code: 'asc',
},
}),
this.prisma.part.findFirst({
where: {
price: {
not: null,
},
},
orderBy: {
name: 'asc',
},
}),
]);
return mapPriceList(employee?.price, part?.price);
}
}

View File

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

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

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

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

@@ -1,100 +0,0 @@
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,40 @@
export enum EquipmentStatus {
Active = 'Active',
Repair = 'Repair',
Reserve = 'Reserve',
WriteOff = 'WriteOff',
}
export enum laborOperation {
Manual = 'Manual',
MachineManual = 'MachineManual',
Machine = 'Machine',
}
export enum EnumPeriodicityTO {
EZHEDNEVNOE = 'Ежедневное',
EZHENEDELNOE = 'Еженедельное',
EZHEMESYACHNOE = 'Ежемесячное',
POLUGODOVOE = 'Полугодовое',
GODOVOE = 'Годовое',
}
export enum Role {
ISPOLNITEL = 'Исполнитель',
PODPISANT = 'Подписант',
POLZOVATEL = 'Пользователь',
}
export enum CategoryPart {
RASKHODNIK = 'Расходник',
ZAPCHAST = 'Запчасть',
INSTRUMENT = 'Инструмент',
SPETSODEZHDA = 'Спецодежда',
}
export enum EquipmentType {
PROIZVODSTVENNOE = 'Производственное',
ENERGETICHESKOE = 'Энергетическое',
NASOSNOE = 'Насосное',
KOMPRESSORNOE = 'Компрессорное',
}

View File

@@ -0,0 +1,103 @@
import type { Response } from 'express';
import { Prisma } from '@prisma/client';
export type QueryValue = unknown;
export function getSingle(value: QueryValue): string | undefined {
if (Array.isArray(value)) {
const first = value[0];
return typeof first === 'string' ? first : first === undefined || first === null ? undefined : String(first);
}
if (typeof value === 'string') {
return value;
}
if (value === undefined || value === null) {
return undefined;
}
return String(value);
}
export function getFirst(value: QueryValue): string | undefined {
return getSingle(value);
}
export function getStringArray(value: QueryValue): string[] {
if (Array.isArray(value)) {
return value
.filter((item) => item !== undefined && item !== null && item !== '')
.map((item) => String(item));
}
if (value === undefined || value === null || value === '') {
return [];
}
return [String(value)];
}
export function toArray(value: QueryValue): string[] {
return getStringArray(value);
}
export function parseRange(query: Record<string, QueryValue>): { start: number; end: number; take: number } {
const start = Number(getFirst(query._start) ?? 0);
const end = Number(getFirst(query._end) ?? start + 25);
const safeStart = Number.isFinite(start) && start >= 0 ? start : 0;
const safeEnd = Number.isFinite(end) && end > safeStart ? end : safeStart + 25;
return {
start: safeStart,
end: safeEnd,
take: safeEnd - safeStart,
};
}
export function toSortOrder(value: string | undefined): 'asc' | 'desc' {
return String(value ?? 'ASC').toLowerCase() === 'desc' ? 'desc' : 'asc';
}
export function containsInsensitive(value: string) {
return {
contains: value,
mode: 'insensitive' as const,
};
}
export function toNumberValue(value: unknown): number | null | undefined {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
const numeric = Number(value);
return Number.isNaN(numeric) ? undefined : numeric;
}
export function toDateValue(value: unknown): Date | null | undefined {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
return new Date(String(value));
}
export function toDecimalValue(value: unknown): Prisma.Decimal | null | undefined {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
return new Prisma.Decimal(String(value));
}
export function setListHeaders(
response: Response,
totalOrResource: number | string,
start: number,
end: number,
resourceOrTotal: string | number,
): void {
const total = typeof totalOrResource === 'number' ? totalOrResource : Number(resourceOrTotal);
const resource = typeof totalOrResource === 'string' ? totalOrResource : String(resourceOrTotal);
const safeStart = Number.isFinite(start) ? start : 0;
const safeEnd = Number.isFinite(end) ? end : safeStart;
const contentEnd = total === 0 ? safeStart : Math.min(Math.max(safeEnd - 1, safeStart), Math.max(total - 1, safeStart));
response.setHeader('Content-Range', `${resource} ${safeStart}-${contentEnd}/${total}`);
response.setHeader('Access-Control-Expose-Headers', 'Content-Range');
}

View File

@@ -0,0 +1,85 @@
function decimalToString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
return String(value);
}
function isoDate(value: unknown): string | null {
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
export function mapEquipment(record: any) {
return {
id: record.id,
name: record.name,
serialNumber: record.serialNumber,
inventoryNumber: record.inventoryNumber,
equipmentType: record.equipmentType,
dateOfInspection: isoDate(record.dateOfInspection),
periodicityTO: record.periodicityTO,
location: record.location ?? null,
status: record.status,
commissionedAt: isoDate(record.commissionedAt),
totalEngineHours: decimalToString(record.totalEngineHours),
engineHoursSinceLastRepair: decimalToString(record.engineHoursSinceLastRepair),
lastRepairAt: isoDate(record.lastRepairAt),
notes: record.notes ?? null,
workAsPartOf: record.workAsPartOf ?? null,
fuelConsumed: record.fuelConsumed ?? null,
};
}
export function mapEmployee(record: any, shallow = false): any {
return {
id: record.code,
code: record.code,
fullName: record.fullName,
role: record.role,
position: record.position,
bossCode: record.bossCode ?? record.boss?.code ?? null,
boss: shallow || !record.boss ? null : mapEmployee(record.boss, true),
subordinates: shallow
? []
: Array.isArray(record.subordinates)
? record.subordinates.map((item: any) => mapEmployee(item, true))
: [],
price: record.price ?? null,
phoneNumber: record.phoneNumber ?? null,
};
}
export function mapPart(record: any) {
return {
id: record.id,
name: record.name,
categories: record.categories ?? null,
price: record.price ?? null,
description: record.description ?? null,
serialNumber: record.serialNumber ?? null,
};
}
export function mapCategoryResource(record: any) {
return {
id: record.id,
partId: record.partId ?? record.part?.id ?? null,
employeeCode: record.employeeCode ?? record.employee?.code ?? null,
part: record.part ? mapPart(record.part) : null,
employee: record.employee ? mapEmployee(record.employee, true) : null,
};
}
export function mapPriceList(costOfWorkingHours: number | null | undefined, partPrice: number | null | undefined) {
return {
id: 'price-list',
costOfWorkingHours: costOfWorkingHours ?? 0,
partPrice: partPrice ?? 0,
};
}

View File

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

View File

@@ -3,7 +3,7 @@ import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
async onModuleInit(): Promise<void> {
await this.$connect();
}
}

View File

@@ -1,83 +1,29 @@
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 request from 'supertest';
import { App } from 'supertest/types';
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]>(),
};
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(AuthService)
.useValue(authServiceMock)
.overrideProvider(PrismaService)
.useValue({})
.compile();
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
afterEach(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);
});
});

View File

@@ -5,8 +5,5 @@
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^jose$": "<rootDir>/mocks/jose.ts"
}
}

View File

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

View File

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