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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,23 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
import { validateEnvironment } from './config/env.validation';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module';
import { HealthController } from './health/health.controller';
import { CategoryResourceModule } from './modules/category-resource/category-resource.module';
import { EmployeeModule } from './modules/employee/employee.module';
import { EquipmentModule } from './modules/equipment/equipment.module';
import { RepairOrderModule } from './modules/repair-order/repair-order.module';
import { AidExportModule } from './aid-export/aid-export.module';
import { PartModule } from './modules/part/part.module';
import { PriceListModule } from './modules/price-list/price-list.module';
import { PrismaModule } from './prisma/prisma.module';
@Module({
imports: [ConfigModule.forRoot({
isGlobal: true,
validate: validateEnvironment,
}),
AuthModule,
imports: [
PrismaModule,
HealthModule,
AidExportModule,
EquipmentTypeModule,
AuthModule,
EquipmentModule,
RepairOrderModule,
EmployeeModule,
PartModule,
CategoryResourceModule,
PriceListModule,
],
controllers: [HealthController],
})
export class AppModule {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,26 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import { RuntimeEnvironment } from './config/env.validation';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get<ConfigService<RuntimeEnvironment, true>>(
ConfigService,
);
const allowedOrigins = configService
.getOrThrow('CORS_ALLOWED_ORIGINS')
const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS ?? 'http://localhost:5173,https://toir-frontend.greact.ru')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
app.enableCors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(new Error(`Origin ${origin} is not allowed by CORS`), false);
},
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type'],
origin: allowedOrigins,
exposedHeaders: ['Content-Range'],
credentials: false,
});
const port = configService.get('PORT', 3000);
await app.listen(port);
await app.listen(Number(process.env.PORT ?? 3000));
}
bootstrap();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { Roles } from '../../auth/decorators/roles.decorator';
import { RealmRole } from '../../auth/roles/realm-role.enum';
import { EquipmentTypeService } from './equipment-type.service';
import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto';
import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
@Controller('equipment-types')
export class EquipmentTypeController {
constructor(private readonly service: EquipmentTypeService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get()
async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.service.findAll(query);
res.set('Content-Range', `equipment-types ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data);
}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':code')
findOne(@Param('code') id: string) {
return this.service.findOne(id);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Post()
create(@Body() dto: CreateEquipmentTypeDto) {
return this.service.create(dto);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':code')
update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) {
return this.service.update(id, dto);
}
@Roles(RealmRole.Admin)
@Delete(':code')
remove(@Param('code') id: string) {
return this.service.remove(id);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,45 +1,44 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { Roles } from '../../auth/decorators/roles.decorator';
import { RealmRole } from '../../auth/roles/realm-role.enum';
import { EquipmentService } from './equipment.service';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
import { EquipmentService } from './equipment.service';
@Controller('equipment')
@Controller('equipments')
@UseGuards(JwtAuthGuard, RolesGuard)
export class EquipmentController {
constructor(private readonly service: EquipmentService) {}
constructor(private readonly equipmentService: EquipmentService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get()
async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.service.findAll(query);
res.set('Content-Range', `equipment ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data);
@Roles('viewer', 'editor', 'admin')
findAll(@Query() query: Record<string, string | string[] | undefined>, @Res({ passthrough: true }) response: Response) {
return this.equipmentService.findAll(query, response);
}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':id')
@Roles('viewer', 'editor', 'admin')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
return this.equipmentService.findOne(id);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Post()
@Roles('editor', 'admin')
create(@Body() dto: CreateEquipmentDto) {
return this.service.create(dto);
return this.equipmentService.create(dto);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':id')
@Roles('editor', 'admin')
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
return this.service.update(id, dto);
return this.equipmentService.update(id, dto);
}
@Roles(RealmRole.Admin)
@Delete(':id')
@Roles('admin')
remove(@Param('id') id: string) {
return this.service.remove(id);
return this.equipmentService.remove(id);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { Roles } from '../../auth/decorators/roles.decorator';
import { RealmRole } from '../../auth/roles/realm-role.enum';
import { RepairOrderService } from './repair-order.service';
import { CreateRepairOrderDto } from './dto/create-repair-order.dto';
import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
@Controller('repair-orders')
export class RepairOrderController {
constructor(private readonly service: RepairOrderService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get()
async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.service.findAll(query);
res.set('Content-Range', `repair-orders ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data);
}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Post()
create(@Body() dto: CreateRepairOrderDto) {
return this.service.create(dto);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
return this.service.update(id, dto);
}
@Roles(RealmRole.Admin)
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.remove(id);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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