Initial commit
This commit is contained in:
87
server/src/aid-export/README.md
Normal file
87
server/src/aid-export/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# AID export: OpenAPI + генератор приложения
|
||||
|
||||
**Полное описание задачи, сценариев и CLI:** [docs/AID_EXPORT_README.md](../../../docs/AID_EXPORT_README.md)
|
||||
|
||||
Ниже — краткая справка по HTTP-эндпоинтам.
|
||||
|
||||
---
|
||||
|
||||
## 1. `api-format` → OpenAPI 3.0
|
||||
|
||||
`POST /aid/export/openapi`
|
||||
|
||||
Внутри: `tools/api-format-to-openapi/convert.mjs` (режимы `deterministic` и `llm`).
|
||||
|
||||
**Тело:**
|
||||
|
||||
```json
|
||||
{
|
||||
"apiFormat": { "apiFormatVersion": "1", "...": "..." },
|
||||
"mode": "deterministic"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ:** `{ "openapi": { ... } }`
|
||||
|
||||
---
|
||||
|
||||
## 2. DSL → сгенерированное приложение (бандл или запись на диск)
|
||||
|
||||
`POST /aid/export/app`
|
||||
|
||||
Внутри: `generation/generate.mjs`.
|
||||
|
||||
**Тело:**
|
||||
|
||||
```json
|
||||
{
|
||||
"dsl": "domain TOiR {\n ...\n}\n",
|
||||
"apply": false
|
||||
}
|
||||
```
|
||||
|
||||
- **`apply` по умолчанию `false` (рекомендуется для AID):** ответ содержит `files` — карта **путь от корня репозитория → текст файла** (Prisma, Nest-модули, React Admin и обновлённые `app.module.ts` / `App.tsx`). **На диск ничего не пишется.**
|
||||
- **`apply: true`:** выполняется тот же процесс, что и `npm run generate:from-dsl` с `--apply` — **перезапись файлов в рабочей копии** на машине, где запущен Nest. Включено только если в окружении задано **`AID_GENERATOR_ALLOW_APPLY=1`** (или `true`). Иначе `403 Forbidden`.
|
||||
|
||||
**Ответ (бандл):**
|
||||
|
||||
```json
|
||||
{
|
||||
"applied": false,
|
||||
"entityCount": 3,
|
||||
"enumCount": 3,
|
||||
"files": {
|
||||
"server/prisma/schema.prisma": "...",
|
||||
"server/src/modules/equipment/equipment.controller.ts": "...",
|
||||
"client/src/App.tsx": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ (apply):**
|
||||
|
||||
```json
|
||||
{
|
||||
"applied": true,
|
||||
"message": "Generated 3 entities from ..."
|
||||
}
|
||||
```
|
||||
|
||||
### CLI-аналог бандла (без Nest)
|
||||
|
||||
Из **корня репозитория**:
|
||||
|
||||
```bash
|
||||
node generation/generate.mjs --print-bundle-json --dsl examples/TOiR.domain.dsl > bundle.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Если в `.env` задан **`AID_EXPORT_API_KEY`**, для **обоих** эндпоинтов нужен заголовок **`X-AID-Export-Key`** с тем же значением.
|
||||
- Не включайте **`AID_GENERATOR_ALLOW_APPLY`** на публичных инстансах без понимания рисков.
|
||||
|
||||
## Требования
|
||||
|
||||
- Запуск Nest с **cwd = `server/`** относительно корня репо, чтобы находились `../generation/generate.mjs` и `../tools/...`.
|
||||
112
server/src/aid-export/aid-export.controller.ts
Normal file
112
server/src/aid-export/aid-export.controller.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AidExportService, OpenApiExportMode } from './aid-export.service';
|
||||
|
||||
/**
|
||||
* HTTP-экспортёры для AID: OpenAPI из api-format и генерация приложения из DSL.
|
||||
*/
|
||||
@Controller('aid/export')
|
||||
export class AidExportController {
|
||||
constructor(
|
||||
private readonly aidExport: AidExportService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
private assertExportKey(exportKey: string | undefined) {
|
||||
const requiredKey = this.config.get<string>('AID_EXPORT_API_KEY');
|
||||
if (requiredKey && exportKey !== requiredKey) {
|
||||
throw new UnauthorizedException('Invalid or missing X-AID-Export-Key');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /aid/export/openapi
|
||||
* Body: { "apiFormat": <объект api-format>, "mode"?: "deterministic" | "llm" }
|
||||
* Response: { "openapi": <OpenAPI 3.0.3 document> }
|
||||
*
|
||||
* mode=llm требует OPENAI_API_KEY в окружении сервера.
|
||||
*
|
||||
* Если задан AID_EXPORT_API_KEY, клиент должен передать заголовок X-AID-Export-Key с тем же значением.
|
||||
*/
|
||||
@Post('openapi')
|
||||
@HttpCode(200)
|
||||
async exportOpenApi(
|
||||
@Body()
|
||||
body: { apiFormat?: unknown; mode?: string },
|
||||
@Headers('x-aid-export-key') exportKey?: string,
|
||||
) {
|
||||
this.assertExportKey(exportKey);
|
||||
|
||||
if (body == null || typeof body !== 'object' || body.apiFormat == null) {
|
||||
throw new BadRequestException('Body must be a JSON object with an "apiFormat" property');
|
||||
}
|
||||
if (typeof body.apiFormat !== 'object' || Array.isArray(body.apiFormat)) {
|
||||
throw new BadRequestException('"apiFormat" must be a JSON object');
|
||||
}
|
||||
|
||||
const mode: OpenApiExportMode =
|
||||
body.mode === 'llm' ? 'llm' : 'deterministic';
|
||||
|
||||
const openapi = await this.aidExport.convertApiFormatToOpenApi(
|
||||
body.apiFormat,
|
||||
mode,
|
||||
);
|
||||
return { openapi };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /aid/export/app
|
||||
* Body: { "dsl": "<текст DSL как в examples/TOiR.domain.dsl>", "apply"?: boolean }
|
||||
*
|
||||
* По умолчанию `apply: false` — возвращается JSON с полем `files` (пути относительно корня репо → содержимое),
|
||||
* без записи на диск (безопасно для вызова из AID).
|
||||
*
|
||||
* `apply: true` перезаписывает файлы в **текущей** рабочей копии репозитория на машине, где крутится Nest.
|
||||
* Разрешено только если в окружении задано `AID_GENERATOR_ALLOW_APPLY=1` (или `true`).
|
||||
*/
|
||||
@Post('app')
|
||||
@HttpCode(200)
|
||||
async exportApp(
|
||||
@Body()
|
||||
body: { dsl?: string; apply?: boolean },
|
||||
@Headers('x-aid-export-key') exportKey?: string,
|
||||
) {
|
||||
this.assertExportKey(exportKey);
|
||||
|
||||
if (body == null || typeof body !== 'object') {
|
||||
throw new BadRequestException('Body must be a JSON object');
|
||||
}
|
||||
if (typeof body.dsl !== 'string' || !body.dsl.trim()) {
|
||||
throw new BadRequestException('Body must include a non-empty string "dsl"');
|
||||
}
|
||||
|
||||
const apply = body.apply === true;
|
||||
if (apply) {
|
||||
const allow = this.config.get<string>('AID_GENERATOR_ALLOW_APPLY');
|
||||
if (allow !== '1' && allow !== 'true') {
|
||||
throw new ForbiddenException(
|
||||
'apply=true is disabled. Set AID_GENERATOR_ALLOW_APPLY=1 on the server to allow writing generated files to disk.',
|
||||
);
|
||||
}
|
||||
const { message } = await this.aidExport.generateAppApply(body.dsl);
|
||||
return { applied: true, message };
|
||||
}
|
||||
|
||||
const bundle = await this.aidExport.generateAppBundle(body.dsl);
|
||||
return {
|
||||
applied: false,
|
||||
entityCount: bundle.entityCount,
|
||||
enumCount: bundle.enumCount,
|
||||
files: bundle.files,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
server/src/aid-export/aid-export.module.ts
Normal file
9
server/src/aid-export/aid-export.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AidExportController } from './aid-export.controller';
|
||||
import { AidExportService } from './aid-export.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AidExportController],
|
||||
providers: [AidExportService],
|
||||
})
|
||||
export class AidExportModule {}
|
||||
154
server/src/aid-export/aid-export.service.ts
Normal file
154
server/src/aid-export/aid-export.service.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { execFile } from 'child_process';
|
||||
import { access, readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const LARGE_BUFFER = 64 * 1024 * 1024;
|
||||
|
||||
export type OpenApiExportMode = 'deterministic' | 'llm';
|
||||
|
||||
export type AppGeneratorBundle = {
|
||||
entityCount: number;
|
||||
enumCount: number;
|
||||
files: Record<string, string>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AidExportService {
|
||||
/**
|
||||
* Путь к tools/api-format-to-openapi/convert.mjs относительно cwd процесса (обычно каталог server/).
|
||||
*/
|
||||
private resolveConvertScript(): string {
|
||||
return join(process.cwd(), '..', 'tools', 'api-format-to-openapi', 'convert.mjs');
|
||||
}
|
||||
|
||||
/** Путь к generation/generate.mjs относительно cwd = server/. */
|
||||
private resolveGenerateScript(): string {
|
||||
return join(process.cwd(), '..', 'generation', 'generate.mjs');
|
||||
}
|
||||
|
||||
async convertApiFormatToOpenApi(
|
||||
apiFormat: unknown,
|
||||
mode: OpenApiExportMode,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const script = this.resolveConvertScript();
|
||||
try {
|
||||
await access(script);
|
||||
} catch {
|
||||
throw new InternalServerErrorException(
|
||||
`Converter script not found at ${script}. Run the server with cwd = server/ from repo root.`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const inPath = join(tmpdir(), `api-format-${id}.json`);
|
||||
const outPath = join(tmpdir(), `openapi-${id}.json`);
|
||||
|
||||
try {
|
||||
await writeFile(inPath, JSON.stringify(apiFormat), 'utf8');
|
||||
const { stderr } = await execFileAsync(
|
||||
process.execPath,
|
||||
[script, '--in', inPath, '--out', outPath, '--mode', mode],
|
||||
{
|
||||
env: { ...process.env },
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (stderr?.trim()) {
|
||||
// convert.mjs пишет ошибки в stderr при падении; при успехе обычно пусто
|
||||
console.warn('[aid-export]', stderr);
|
||||
}
|
||||
const raw = await readFile(outPath, 'utf8');
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new InternalServerErrorException(`OpenAPI conversion failed: ${msg}`);
|
||||
} finally {
|
||||
await unlink(inPath).catch(() => undefined);
|
||||
await unlink(outPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DSL → снимок сгенерированных файлов (без записи в репозиторий).
|
||||
* Использует `generation/generate.mjs --print-bundle-json`.
|
||||
*/
|
||||
async generateAppBundle(dsl: string): Promise<AppGeneratorBundle> {
|
||||
const script = this.resolveGenerateScript();
|
||||
try {
|
||||
await access(script);
|
||||
} catch {
|
||||
throw new InternalServerErrorException(
|
||||
`Generator script not found at ${script}. Run the server with cwd = server/ from repo root.`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const dslPath = join(tmpdir(), `domain-${id}.dsl`);
|
||||
|
||||
try {
|
||||
await writeFile(dslPath, dsl, 'utf8');
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
process.execPath,
|
||||
[script, '--print-bundle-json', '--dsl', dslPath],
|
||||
{
|
||||
env: { ...process.env },
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
},
|
||||
);
|
||||
if (stderr?.trim()) console.warn('[aid-export][generate]', stderr);
|
||||
const bundle = JSON.parse(stdout) as AppGeneratorBundle;
|
||||
if (!bundle.files || typeof bundle.files !== 'object') {
|
||||
throw new Error('Invalid bundle: missing files');
|
||||
}
|
||||
return bundle;
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new InternalServerErrorException(`App generator (bundle) failed: ${msg}`);
|
||||
} finally {
|
||||
await unlink(dslPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DSL → запись сгенерированного кода в рабочую копию репозитория (`--apply`).
|
||||
* Опасно для публичных эндпоинтов; включать только осознанно.
|
||||
*/
|
||||
async generateAppApply(dsl: string): Promise<{ message: string }> {
|
||||
const script = this.resolveGenerateScript();
|
||||
try {
|
||||
await access(script);
|
||||
} catch {
|
||||
throw new InternalServerErrorException(
|
||||
`Generator script not found at ${script}. Run the server with cwd = server/ from repo root.`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const dslPath = join(tmpdir(), `domain-${id}.dsl`);
|
||||
|
||||
try {
|
||||
await writeFile(dslPath, dsl, 'utf8');
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
process.execPath,
|
||||
[script, '--apply', '--dsl', dslPath],
|
||||
{
|
||||
env: { ...process.env },
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
},
|
||||
);
|
||||
if (stderr?.trim()) console.warn('[aid-export][generate-apply]', stderr);
|
||||
return { message: (stdout || 'ok').trim() };
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new InternalServerErrorException(`App generator (apply) failed: ${msg}`);
|
||||
} finally {
|
||||
await unlink(dslPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
server/src/app.module.ts
Normal file
26
server/src/app.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { validateEnvironment } from './config/env.validation';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module';
|
||||
import { EquipmentModule } from './modules/equipment/equipment.module';
|
||||
import { RepairOrderModule } from './modules/repair-order/repair-order.module';
|
||||
import { AidExportModule } from './aid-export/aid-export.module';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnvironment,
|
||||
}),
|
||||
AuthModule,
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
AidExportModule,
|
||||
EquipmentTypeModule,
|
||||
EquipmentModule,
|
||||
RepairOrderModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
3
server/src/auth/auth.constants.ts
Normal file
3
server/src/auth/auth.constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
22
server/src/auth/auth.module.ts
Normal file
22
server/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RolesGuard,
|
||||
},
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
129
server/src/auth/auth.service.ts
Normal file
129
server/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { RuntimeEnvironment } from '../config/env.validation';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
KeycloakJwtPayload,
|
||||
} from './interfaces/authenticated-user.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
private readonly issuerUrl: string;
|
||||
private readonly audience: string;
|
||||
private readonly explicitJwksUrl?: string;
|
||||
|
||||
private jwksResolverPromise: Promise<ReturnType<typeof createRemoteJWKSet>> | null =
|
||||
null;
|
||||
private jwksResolver: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService<RuntimeEnvironment, true>,
|
||||
) {
|
||||
this.issuerUrl = this.configService.getOrThrow('KEYCLOAK_ISSUER_URL');
|
||||
this.audience = this.configService.getOrThrow('KEYCLOAK_AUDIENCE');
|
||||
this.explicitJwksUrl = this.configService.get('KEYCLOAK_JWKS_URL');
|
||||
}
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthenticatedUser> {
|
||||
try {
|
||||
const jwksResolver = await this.getJwksResolver();
|
||||
|
||||
const { payload } = await jwtVerify(token, jwksResolver, {
|
||||
issuer: this.issuerUrl,
|
||||
audience: this.audience,
|
||||
});
|
||||
|
||||
return this.mapPayloadToUser(payload as KeycloakJwtPayload);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`JWT verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
throw new UnauthorizedException('Invalid or expired access token');
|
||||
}
|
||||
}
|
||||
|
||||
private mapPayloadToUser(payload: KeycloakJwtPayload): AuthenticatedUser {
|
||||
if (!payload.sub) {
|
||||
throw new UnauthorizedException('Token subject is missing');
|
||||
}
|
||||
|
||||
const roles = Array.isArray(payload.realm_access?.roles)
|
||||
? payload.realm_access.roles.filter(
|
||||
(role): role is string => typeof role === 'string',
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
sub: payload.sub,
|
||||
username: payload.preferred_username,
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
roles,
|
||||
claims: payload,
|
||||
};
|
||||
}
|
||||
|
||||
private async getJwksResolver() {
|
||||
if (this.jwksResolver) {
|
||||
return this.jwksResolver;
|
||||
}
|
||||
|
||||
if (!this.jwksResolverPromise) {
|
||||
this.jwksResolverPromise = this.createJwksResolver()
|
||||
.then((resolver) => {
|
||||
this.jwksResolver = resolver;
|
||||
return resolver;
|
||||
})
|
||||
.finally(() => {
|
||||
this.jwksResolverPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return this.jwksResolverPromise;
|
||||
}
|
||||
|
||||
private async createJwksResolver() {
|
||||
const jwksUrl = await this.resolveJwksUrl();
|
||||
this.logger.log(`Using JWKS URL: ${jwksUrl}`);
|
||||
return createRemoteJWKSet(new URL(jwksUrl));
|
||||
}
|
||||
|
||||
private async resolveJwksUrl(): Promise<string> {
|
||||
if (this.explicitJwksUrl) {
|
||||
return this.explicitJwksUrl;
|
||||
}
|
||||
|
||||
const issuer = this.issuerUrl.replace(/\/+$/, '');
|
||||
const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
const discoveryResponse = await fetch(discoveryUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (discoveryResponse.ok) {
|
||||
const discoveryDocument = (await discoveryResponse.json()) as {
|
||||
jwks_uri?: string;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof discoveryDocument.jwks_uri === 'string' &&
|
||||
discoveryDocument.jwks_uri.trim().length > 0
|
||||
) {
|
||||
return discoveryDocument.jwks_uri;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`OIDC discovery failed at ${discoveryUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return `${issuer}/protocol/openid-connect/certs`;
|
||||
}
|
||||
}
|
||||
|
||||
5
server/src/auth/decorators/public.decorator.ts
Normal file
5
server/src/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { IS_PUBLIC_KEY } from '../auth.constants';
|
||||
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
6
server/src/auth/decorators/roles.decorator.ts
Normal file
6
server/src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { ROLES_KEY } from '../auth.constants';
|
||||
import { RealmRole } from '../roles/realm-role.enum';
|
||||
|
||||
export const Roles = (...roles: RealmRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
54
server/src/auth/guards/jwt-auth.guard.ts
Normal file
54
server/src/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { IS_PUBLIC_KEY } from '../auth.constants';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractBearerToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing bearer token');
|
||||
}
|
||||
|
||||
request.user = await this.authService.verifyAccessToken(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractBearerToken(request: Request): string | null {
|
||||
const authorization = request.headers.authorization;
|
||||
if (!authorization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [scheme, token] = authorization.split(' ');
|
||||
if (scheme?.toLowerCase() !== 'bearer' || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
49
server/src/auth/guards/roles.guard.ts
Normal file
49
server/src/auth/guards/roles.guard.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { IS_PUBLIC_KEY, ROLES_KEY } from '../auth.constants';
|
||||
import { RealmRole } from '../roles/realm-role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requiredRoles =
|
||||
this.reflector.getAllAndOverride<RealmRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]) ?? [];
|
||||
|
||||
if (requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const userRoles = request.user?.roles ?? [];
|
||||
const hasRequiredRole = requiredRoles.some((role) =>
|
||||
userRoles.includes(role),
|
||||
);
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
throw new ForbiddenException('Access denied: insufficient role');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
20
server/src/auth/interfaces/authenticated-user.interface.ts
Normal file
20
server/src/auth/interfaces/authenticated-user.interface.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { JWTPayload } from 'jose';
|
||||
|
||||
export interface KeycloakJwtPayload extends JWTPayload {
|
||||
preferred_username?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
realm_access?: {
|
||||
roles?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
sub: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
roles: string[];
|
||||
claims: KeycloakJwtPayload;
|
||||
}
|
||||
|
||||
12
server/src/auth/interfaces/express-request.interface.ts
Normal file
12
server/src/auth/interfaces/express-request.interface.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AuthenticatedUser } from './authenticated-user.interface';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
6
server/src/auth/roles/realm-role.enum.ts
Normal file
6
server/src/auth/roles/realm-role.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum RealmRole {
|
||||
Admin = 'admin',
|
||||
Editor = 'editor',
|
||||
Viewer = 'viewer',
|
||||
}
|
||||
|
||||
58
server/src/config/env.validation.ts
Normal file
58
server/src/config/env.validation.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export interface RuntimeEnvironment {
|
||||
PORT: number;
|
||||
DATABASE_URL: string;
|
||||
CORS_ALLOWED_ORIGINS: string;
|
||||
KEYCLOAK_ISSUER_URL: string;
|
||||
KEYCLOAK_AUDIENCE: string;
|
||||
KEYCLOAK_JWKS_URL?: string;
|
||||
}
|
||||
|
||||
function getRequiredString(
|
||||
config: Record<string, unknown>,
|
||||
key: keyof RuntimeEnvironment,
|
||||
): string {
|
||||
const value = config[key];
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function getOptionalString(
|
||||
config: Record<string, unknown>,
|
||||
key: keyof RuntimeEnvironment,
|
||||
): string | undefined {
|
||||
const value = config[key];
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function parsePort(value: unknown): number {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return 3000;
|
||||
}
|
||||
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('Environment variable PORT must be an integer between 1 and 65535');
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
export function validateEnvironment(
|
||||
config: Record<string, unknown>,
|
||||
): RuntimeEnvironment {
|
||||
return {
|
||||
PORT: parsePort(config.PORT),
|
||||
DATABASE_URL: getRequiredString(config, 'DATABASE_URL'),
|
||||
CORS_ALLOWED_ORIGINS: getRequiredString(config, 'CORS_ALLOWED_ORIGINS'),
|
||||
KEYCLOAK_ISSUER_URL: getRequiredString(config, 'KEYCLOAK_ISSUER_URL'),
|
||||
KEYCLOAK_AUDIENCE: getRequiredString(config, 'KEYCLOAK_AUDIENCE'),
|
||||
KEYCLOAK_JWKS_URL: getOptionalString(config, 'KEYCLOAK_JWKS_URL'),
|
||||
};
|
||||
}
|
||||
|
||||
11
server/src/health/health.controller.ts
Normal file
11
server/src/health/health.controller.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
|
||||
@Public()
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
getHealth() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
7
server/src/health/health.module.ts
Normal file
7
server/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
35
server/src/main.ts
Normal file
35
server/src/main.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
import { RuntimeEnvironment } from './config/env.validation';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const configService = app.get<ConfigService<RuntimeEnvironment, true>>(
|
||||
ConfigService,
|
||||
);
|
||||
|
||||
const allowedOrigins = configService
|
||||
.getOrThrow('CORS_ALLOWED_ORIGINS')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0);
|
||||
|
||||
app.enableCors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(new Error(`Origin ${origin} is not allowed by CORS`), false);
|
||||
},
|
||||
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Authorization', 'Content-Type'],
|
||||
exposedHeaders: ['Content-Range'],
|
||||
credentials: false,
|
||||
});
|
||||
|
||||
const port = configService.get('PORT', 3000);
|
||||
await app.listen(port);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,7 @@
|
||||
export class CreateEquipmentTypeDto {
|
||||
code?: string;
|
||||
name!: string;
|
||||
manufacturer?: string;
|
||||
maintenanceIntervalHours?: number;
|
||||
overhaulIntervalHours?: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class UpdateEquipmentTypeDto {
|
||||
id?: string;
|
||||
code?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
maintenanceIntervalHours?: number;
|
||||
overhaulIntervalHours?: number;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||
import { RealmRole } from '../../auth/roles/realm-role.enum';
|
||||
import { EquipmentTypeService } from './equipment-type.service';
|
||||
import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto';
|
||||
import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
|
||||
|
||||
@Controller('equipment-types')
|
||||
export class EquipmentTypeController {
|
||||
constructor(private readonly service: EquipmentTypeService) {}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get()
|
||||
async findAll(@Query() query: any, @Res() res: Response) {
|
||||
const result = await this.service.findAll(query);
|
||||
res.set('Content-Range', `equipment-types ${query._start || 0}-${query._end || result.total}/${result.total}`);
|
||||
res.set('Access-Control-Expose-Headers', 'Content-Range');
|
||||
return res.json(result.data);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get(':code')
|
||||
findOne(@Param('code') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Post()
|
||||
create(@Body() dto: CreateEquipmentTypeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Patch(':code')
|
||||
update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Admin)
|
||||
@Delete(':code')
|
||||
remove(@Param('code') id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EquipmentTypeController } from './equipment-type.controller';
|
||||
import { EquipmentTypeService } from './equipment-type.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EquipmentTypeController],
|
||||
providers: [EquipmentTypeService],
|
||||
})
|
||||
export class EquipmentTypeModule {}
|
||||
90
server/src/modules/equipment-type/equipment-type.service.ts
Normal file
90
server/src/modules/equipment-type/equipment-type.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto';
|
||||
import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
|
||||
|
||||
function serializeRecord(record: any) {
|
||||
return {
|
||||
...record,
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EquipmentTypeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {
|
||||
const start = parseInt(query._start) || 0;
|
||||
const end = parseInt(query._end) || 10;
|
||||
const take = end - start;
|
||||
const skip = start;
|
||||
const sortField = query._sort || 'code';
|
||||
const prismaSortField = sortField === 'id' ? 'code' : sortField;
|
||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (query.q) {
|
||||
const q = String(query.q);
|
||||
const ors: any[] = [];
|
||||
ors.push({ code: { contains: q, mode: 'insensitive' } });
|
||||
ors.push({ name: { contains: q, mode: 'insensitive' } });
|
||||
ors.push({ manufacturer: { contains: q, mode: 'insensitive' } });
|
||||
if (ors.length) where.OR = ors;
|
||||
}
|
||||
|
||||
if (query.code) where.code = { contains: query.code, mode: 'insensitive' };
|
||||
if (query.name) where.name = { contains: query.name, mode: 'insensitive' };
|
||||
if (query.manufacturer) where.manufacturer = { contains: query.manufacturer, mode: 'insensitive' };
|
||||
|
||||
|
||||
|
||||
// Enum multi-value support (e.g. status=A&status=B)
|
||||
|
||||
|
||||
if (query.id) {
|
||||
const ids = Array.isArray(query.id) ? query.id : [query.id];
|
||||
where.code = { in: ids };
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
|
||||
this.prisma.equipmentType.count({ where }),
|
||||
]);
|
||||
|
||||
const mapped = data.map((item: any) => ({ id: item.code, ...serializeRecord(item) }));
|
||||
return { data: mapped, total };
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const record = await this.prisma.equipmentType.findUniqueOrThrow({ where: { code: id } as any });
|
||||
return { id: (record as any).code, ...serializeRecord(record) };
|
||||
}
|
||||
|
||||
async create(dto: CreateEquipmentTypeDto) {
|
||||
const data: any = { ...(dto as any) };
|
||||
|
||||
|
||||
|
||||
const record = await this.prisma.equipmentType.create({ data });
|
||||
return { id: (record as any).code, ...serializeRecord(record) };
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateEquipmentTypeDto) {
|
||||
const { id: _pk, code, ...rest } = (dto as any);
|
||||
const data: any = { ...rest };
|
||||
|
||||
|
||||
|
||||
const record = await this.prisma.equipmentType.update({ where: { code: id } as any, data });
|
||||
return { id: (record as any).code, ...serializeRecord(record) };
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const record = await this.prisma.equipmentType.delete({ where: { code: id } as any });
|
||||
return { id: (record as any).code, ...serializeRecord(record) };
|
||||
}
|
||||
}
|
||||
13
server/src/modules/equipment/dto/create-equipment.dto.ts
Normal file
13
server/src/modules/equipment/dto/create-equipment.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export class CreateEquipmentDto {
|
||||
inventoryNumber!: string;
|
||||
serialNumber?: string;
|
||||
name!: string;
|
||||
equipmentTypeCode!: string;
|
||||
status!: string;
|
||||
location?: string;
|
||||
commissionedAt?: string;
|
||||
totalEngineHours?: string;
|
||||
engineHoursSinceLastRepair?: string;
|
||||
lastRepairAt?: string;
|
||||
notes?: string;
|
||||
}
|
||||
14
server/src/modules/equipment/dto/update-equipment.dto.ts
Normal file
14
server/src/modules/equipment/dto/update-equipment.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export class UpdateEquipmentDto {
|
||||
id?: string;
|
||||
inventoryNumber?: string;
|
||||
serialNumber?: string;
|
||||
name?: string;
|
||||
equipmentTypeCode?: string;
|
||||
status?: string;
|
||||
location?: string;
|
||||
commissionedAt?: string;
|
||||
totalEngineHours?: string;
|
||||
engineHoursSinceLastRepair?: string;
|
||||
lastRepairAt?: string;
|
||||
notes?: string;
|
||||
}
|
||||
45
server/src/modules/equipment/equipment.controller.ts
Normal file
45
server/src/modules/equipment/equipment.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||
import { RealmRole } from '../../auth/roles/realm-role.enum';
|
||||
import { EquipmentService } from './equipment.service';
|
||||
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||
|
||||
@Controller('equipment')
|
||||
export class EquipmentController {
|
||||
constructor(private readonly service: EquipmentService) {}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get()
|
||||
async findAll(@Query() query: any, @Res() res: Response) {
|
||||
const result = await this.service.findAll(query);
|
||||
res.set('Content-Range', `equipment ${query._start || 0}-${query._end || result.total}/${result.total}`);
|
||||
res.set('Access-Control-Expose-Headers', 'Content-Range');
|
||||
return res.json(result.data);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Post()
|
||||
create(@Body() dto: CreateEquipmentDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Admin)
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
9
server/src/modules/equipment/equipment.module.ts
Normal file
9
server/src/modules/equipment/equipment.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EquipmentController } from './equipment.controller';
|
||||
import { EquipmentService } from './equipment.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EquipmentController],
|
||||
providers: [EquipmentService],
|
||||
})
|
||||
export class EquipmentModule {}
|
||||
102
server/src/modules/equipment/equipment.service.ts
Normal file
102
server/src/modules/equipment/equipment.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||
|
||||
function serializeRecord(record: any) {
|
||||
return {
|
||||
...record,
|
||||
totalEngineHours: record.totalEngineHours?.toString() ?? null,
|
||||
engineHoursSinceLastRepair: record.engineHoursSinceLastRepair?.toString() ?? null,
|
||||
commissionedAt: record.commissionedAt?.toISOString() ?? null,
|
||||
lastRepairAt: record.lastRepairAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EquipmentService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {
|
||||
const start = parseInt(query._start) || 0;
|
||||
const end = parseInt(query._end) || 10;
|
||||
const take = end - start;
|
||||
const skip = start;
|
||||
const sortField = query._sort || 'inventoryNumber';
|
||||
const prismaSortField = sortField === 'id' ? 'id' : sortField;
|
||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (query.q) {
|
||||
const q = String(query.q);
|
||||
const ors: any[] = [];
|
||||
ors.push({ inventoryNumber: { contains: q, mode: 'insensitive' } });
|
||||
ors.push({ serialNumber: { contains: q, mode: 'insensitive' } });
|
||||
ors.push({ name: { contains: q, mode: 'insensitive' } });
|
||||
ors.push({ equipmentTypeCode: { contains: q, mode: 'insensitive' } });
|
||||
ors.push({ location: { contains: q, mode: 'insensitive' } });
|
||||
ors.push({ notes: { contains: q, mode: 'insensitive' } });
|
||||
if (ors.length) where.OR = ors;
|
||||
}
|
||||
|
||||
if (query.inventoryNumber) where.inventoryNumber = { contains: query.inventoryNumber, mode: 'insensitive' };
|
||||
if (query.serialNumber) where.serialNumber = { contains: query.serialNumber, mode: 'insensitive' };
|
||||
if (query.name) where.name = { contains: query.name, mode: 'insensitive' };
|
||||
if (query.location) where.location = { contains: query.location, mode: 'insensitive' };
|
||||
if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' };
|
||||
|
||||
if (query.equipmentTypeCode) where.equipmentTypeCode = query.equipmentTypeCode;
|
||||
|
||||
// Enum multi-value support (e.g. status=A&status=B)
|
||||
if (query.status) { const vals = Array.isArray(query.status) ? query.status : [query.status]; where.status = vals.length > 1 ? { in: vals } : vals[0]; }
|
||||
|
||||
if (query.id) {
|
||||
const ids = Array.isArray(query.id) ? query.id : [query.id];
|
||||
where.id = { in: ids };
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.equipment.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
|
||||
this.prisma.equipment.count({ where }),
|
||||
]);
|
||||
|
||||
const mapped = data.map(serializeRecord);
|
||||
return { data: mapped, total };
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id: id } as any });
|
||||
return serializeRecord(record);
|
||||
}
|
||||
|
||||
async create(dto: CreateEquipmentDto) {
|
||||
const data: any = { ...(dto as any) };
|
||||
if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt);
|
||||
if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt);
|
||||
if (data.totalEngineHours) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours);
|
||||
if (data.engineHoursSinceLastRepair) data.engineHoursSinceLastRepair = new Prisma.Decimal(data.engineHoursSinceLastRepair);
|
||||
|
||||
const record = await this.prisma.equipment.create({ data });
|
||||
return serializeRecord(record);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateEquipmentDto) {
|
||||
const data: any = { ...(dto as any) };
|
||||
delete data.id;
|
||||
delete data.id;
|
||||
if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt);
|
||||
if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt);
|
||||
if (data.totalEngineHours !== undefined && data.totalEngineHours !== null) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours);
|
||||
if (data.engineHoursSinceLastRepair !== undefined && data.engineHoursSinceLastRepair !== null) data.engineHoursSinceLastRepair = new Prisma.Decimal(data.engineHoursSinceLastRepair);
|
||||
|
||||
const record = await this.prisma.equipment.update({ where: { id: id } as any, data });
|
||||
return serializeRecord(record);
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const record = await this.prisma.equipment.delete({ where: { id: id } as any });
|
||||
return serializeRecord(record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class CreateRepairOrderDto {
|
||||
number!: string;
|
||||
equipmentId!: string;
|
||||
repairKind!: string;
|
||||
status!: string;
|
||||
plannedAt!: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
contractor?: string;
|
||||
engineHoursAtRepair?: string;
|
||||
description?: string;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export class UpdateRepairOrderDto {
|
||||
id?: string;
|
||||
number?: string;
|
||||
equipmentId?: string;
|
||||
repairKind?: string;
|
||||
status?: string;
|
||||
plannedAt?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
contractor?: string;
|
||||
engineHoursAtRepair?: string;
|
||||
description?: string;
|
||||
notes?: string;
|
||||
}
|
||||
45
server/src/modules/repair-order/repair-order.controller.ts
Normal file
45
server/src/modules/repair-order/repair-order.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||
import { RealmRole } from '../../auth/roles/realm-role.enum';
|
||||
import { RepairOrderService } from './repair-order.service';
|
||||
import { CreateRepairOrderDto } from './dto/create-repair-order.dto';
|
||||
import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
|
||||
|
||||
@Controller('repair-orders')
|
||||
export class RepairOrderController {
|
||||
constructor(private readonly service: RepairOrderService) {}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get()
|
||||
async findAll(@Query() query: any, @Res() res: Response) {
|
||||
const result = await this.service.findAll(query);
|
||||
res.set('Content-Range', `repair-orders ${query._start || 0}-${query._end || result.total}/${result.total}`);
|
||||
res.set('Access-Control-Expose-Headers', 'Content-Range');
|
||||
return res.json(result.data);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Post()
|
||||
create(@Body() dto: CreateRepairOrderDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Editor, RealmRole.Admin)
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RealmRole.Admin)
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
9
server/src/modules/repair-order/repair-order.module.ts
Normal file
9
server/src/modules/repair-order/repair-order.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RepairOrderController } from './repair-order.controller';
|
||||
import { RepairOrderService } from './repair-order.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RepairOrderController],
|
||||
providers: [RepairOrderService],
|
||||
})
|
||||
export class RepairOrderModule {}
|
||||
100
server/src/modules/repair-order/repair-order.service.ts
Normal file
100
server/src/modules/repair-order/repair-order.service.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateRepairOrderDto } from './dto/create-repair-order.dto';
|
||||
import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
|
||||
|
||||
function serializeRecord(record: any) {
|
||||
return {
|
||||
...record,
|
||||
engineHoursAtRepair: record.engineHoursAtRepair?.toString() ?? null,
|
||||
plannedAt: record.plannedAt?.toISOString() ?? null,
|
||||
startedAt: record.startedAt?.toISOString() ?? null,
|
||||
completedAt: record.completedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RepairOrderService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {
|
||||
const start = parseInt(query._start) || 0;
|
||||
const end = parseInt(query._end) || 10;
|
||||
const take = end - start;
|
||||
const skip = start;
|
||||
const sortField = query._sort || 'number';
|
||||
const prismaSortField = sortField === 'id' ? 'id' : sortField;
|
||||
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (query.q) {
|
||||
const q = String(query.q);
|
||||
const ors: any[] = [];
|
||||
ors.push({ number: { contains: q, mode: 'insensitive' } });
|
||||
ors.push({ contractor: { contains: q, mode: 'insensitive' } });
|
||||
ors.push({ description: { contains: q, mode: 'insensitive' } });
|
||||
ors.push({ notes: { contains: q, mode: 'insensitive' } });
|
||||
if (ors.length) where.OR = ors;
|
||||
}
|
||||
|
||||
if (query.number) where.number = { contains: query.number, mode: 'insensitive' };
|
||||
if (query.contractor) where.contractor = { contains: query.contractor, mode: 'insensitive' };
|
||||
if (query.description) where.description = { contains: query.description, mode: 'insensitive' };
|
||||
if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' };
|
||||
|
||||
if (query.equipmentId) where.equipmentId = query.equipmentId;
|
||||
|
||||
// Enum multi-value support (e.g. status=A&status=B)
|
||||
if (query.repairKind) { const vals = Array.isArray(query.repairKind) ? query.repairKind : [query.repairKind]; where.repairKind = vals.length > 1 ? { in: vals } : vals[0]; }
|
||||
if (query.status) { const vals = Array.isArray(query.status) ? query.status : [query.status]; where.status = vals.length > 1 ? { in: vals } : vals[0]; }
|
||||
|
||||
if (query.id) {
|
||||
const ids = Array.isArray(query.id) ? query.id : [query.id];
|
||||
where.id = { in: ids };
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
|
||||
this.prisma.repairOrder.count({ where }),
|
||||
]);
|
||||
|
||||
const mapped = data.map(serializeRecord);
|
||||
return { data: mapped, total };
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const record = await this.prisma.repairOrder.findUniqueOrThrow({ where: { id: id } as any });
|
||||
return serializeRecord(record);
|
||||
}
|
||||
|
||||
async create(dto: CreateRepairOrderDto) {
|
||||
const data: any = { ...(dto as any) };
|
||||
if (data.plannedAt) data.plannedAt = new Date(data.plannedAt);
|
||||
if (data.startedAt) data.startedAt = new Date(data.startedAt);
|
||||
if (data.completedAt) data.completedAt = new Date(data.completedAt);
|
||||
if (data.engineHoursAtRepair) data.engineHoursAtRepair = new Prisma.Decimal(data.engineHoursAtRepair);
|
||||
|
||||
const record = await this.prisma.repairOrder.create({ data });
|
||||
return serializeRecord(record);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateRepairOrderDto) {
|
||||
const data: any = { ...(dto as any) };
|
||||
delete data.id;
|
||||
delete data.id;
|
||||
if (data.plannedAt) data.plannedAt = new Date(data.plannedAt);
|
||||
if (data.startedAt) data.startedAt = new Date(data.startedAt);
|
||||
if (data.completedAt) data.completedAt = new Date(data.completedAt);
|
||||
if (data.engineHoursAtRepair !== undefined && data.engineHoursAtRepair !== null) data.engineHoursAtRepair = new Prisma.Decimal(data.engineHoursAtRepair);
|
||||
|
||||
const record = await this.prisma.repairOrder.update({ where: { id: id } as any, data });
|
||||
return serializeRecord(record);
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const record = await this.prisma.repairOrder.delete({ where: { id: id } as any });
|
||||
return serializeRecord(record);
|
||||
}
|
||||
}
|
||||
9
server/src/prisma/prisma.module.ts
Normal file
9
server/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
9
server/src/prisma/prisma.service.ts
Normal file
9
server/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user