(llm-first): context budget, validation, and eval harness, orchestration general-prompt
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
npm-debug.log*
|
||||
@@ -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=""
|
||||
|
||||
@@ -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
33
server/.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
35
server/eslint.config.mjs
Normal 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
5733
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
16
server/prisma.config.ts
Normal 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"),
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
85
server/prisma/migrations/20260403103310_init/migration.sql
Normal file
85
server/prisma/migrations/20260403103310_init/migration.sql
Normal 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;
|
||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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/...`.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
server/src/app.controller.spec.ts
Normal file
22
server/src/app.controller.spec.ts
Normal 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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
server/src/app.controller.ts
Normal file
12
server/src/app.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
8
server/src/app.service.ts
Normal file
8
server/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { AuthenticatedUser } from './authenticated-user.interface';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export enum RealmRole {
|
||||
Admin = 'admin',
|
||||
Editor = 'editor',
|
||||
Viewer = 'viewer',
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateCategoryResourceDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
partId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
employeeCode?: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateCategoryResourceDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
partId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
employeeCode?: string;
|
||||
}
|
||||
28
server/src/modules/employee/dto/create-employee.dto.ts
Normal file
28
server/src/modules/employee/dto/create-employee.dto.ts
Normal 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;
|
||||
}
|
||||
28
server/src/modules/employee/dto/update-employee.dto.ts
Normal file
28
server/src/modules/employee/dto/update-employee.dto.ts
Normal 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;
|
||||
}
|
||||
44
server/src/modules/employee/employee.controller.ts
Normal file
44
server/src/modules/employee/employee.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
server/src/modules/employee/employee.module.ts
Normal file
11
server/src/modules/employee/employee.module.ts
Normal 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 {}
|
||||
145
server/src/modules/employee/employee.service.ts
Normal file
145
server/src/modules/employee/employee.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export class CreateEquipmentTypeDto {
|
||||
code?: string;
|
||||
name!: string;
|
||||
manufacturer?: string;
|
||||
maintenanceIntervalHours?: number;
|
||||
overhaulIntervalHours?: number;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export class UpdateEquipmentTypeDto {
|
||||
id?: string;
|
||||
code?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
maintenanceIntervalHours?: number;
|
||||
overhaulIntervalHours?: number;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
28
server/src/modules/part/dto/create-part.dto.ts
Normal file
28
server/src/modules/part/dto/create-part.dto.ts
Normal 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;
|
||||
}
|
||||
29
server/src/modules/part/dto/update-part.dto.ts
Normal file
29
server/src/modules/part/dto/update-part.dto.ts
Normal 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;
|
||||
}
|
||||
44
server/src/modules/part/part.controller.ts
Normal file
44
server/src/modules/part/part.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
server/src/modules/part/part.module.ts
Normal file
9
server/src/modules/part/part.module.ts
Normal 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 {}
|
||||
107
server/src/modules/part/part.service.ts
Normal file
107
server/src/modules/part/part.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export class CreatePriceListDto {}
|
||||
@@ -0,0 +1 @@
|
||||
export class UpdatePriceListDto {}
|
||||
17
server/src/modules/price-list/price-list.controller.ts
Normal file
17
server/src/modules/price-list/price-list.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
server/src/modules/price-list/price-list.module.ts
Normal file
9
server/src/modules/price-list/price-list.module.ts
Normal 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 {}
|
||||
35
server/src/modules/price-list/price-list.service.ts
Normal file
35
server/src/modules/price-list/price-list.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
40
server/src/modules/shared/dsl-enums.ts
Normal file
40
server/src/modules/shared/dsl-enums.ts
Normal 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 = 'Компрессорное',
|
||||
}
|
||||
103
server/src/modules/shared/query-utils.ts
Normal file
103
server/src/modules/shared/query-utils.ts
Normal 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');
|
||||
}
|
||||
85
server/src/modules/shared/record-mappers.ts
Normal file
85
server/src/modules/shared/record-mappers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,5 @@
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^jose$": "<rootDir>/mocks/jose.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export const createRemoteJWKSet = () => {
|
||||
return async () => ({}) as never;
|
||||
};
|
||||
|
||||
export const jwtVerify = async () => {
|
||||
return { payload: {} };
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user