(llm-first): context budget, validation, and eval harness, orchestration general-prompt
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user