Add file attachment functionality for equipment management

- Introduced DTO for file attachments with metadata (objectKey, originalFileName, contentType, sizeBytes, downloadUrl).
- Updated Equipment DTO to include an optional attachment field.
- Implemented endpoints for uploading and deleting equipment attachments.
- Enhanced Equipment service to handle file storage and retrieval using S3.
- Updated EquipmentEdit, EquipmentList, and EquipmentShow components to support file attachment input and display.
- Configured S3 settings in docker-compose and environment files.
This commit is contained in:
Первов Артем
2026-04-21 00:46:17 +03:00
parent 62446f2597
commit b60c4ee0ed
19 changed files with 2274 additions and 38 deletions

View File

@@ -4,3 +4,16 @@ CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"
KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"
KEYCLOAK_AUDIENCE="toir-backend"
KEYCLOAK_JWKS_URL=""
# MinIO / S3 (вложения оборудования)
S3_ENDPOINT="http://localhost:9000"
S3_REGION="eu-central-1"
S3_BUCKET="media"
S3_ACCESS_KEY_ID=""
S3_SECRET_ACCESS_KEY=""
S3_FORCE_PATH_STYLE="true"
# Если бакет публичный: базовый URL для прямых ссылок (без presign)
S3_PUBLIC_BASE_URL=""
# Префикс ключей объектов в бакете
S3_OBJECT_PREFIX="toir-light/equipment"
S3_PRESIGN_EXPIRES_SECONDS="3600"

1711
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1033.0",
"@aws-sdk/s3-request-presigner": "^3.1033.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
@@ -30,6 +32,7 @@
"class-validator": "^0.15.1",
"dotenv": "^17.4.0",
"jose": "^6.2.2",
"multer": "^2.1.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
@@ -42,6 +45,7 @@
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^24.0.0",
"@types/pg": "^8.20.0",
"@types/supertest": "^7.0.0",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Equipment" ADD COLUMN IF NOT EXISTS "attachment" JSONB;

View File

@@ -20,6 +20,8 @@ model Equipment {
dateOfInspection DateTime?
commissionedAt DateTime?
status EquipmentStatus @default(Active)
/// JSON: { objectKey, originalFileName, contentType, sizeBytes } — файл в MinIO/S3
attachment Json?
changeEquipmentStatuses ChangeEquipmentStatus[]
}

View File

@@ -0,0 +1,14 @@
export type EquipmentAttachmentStored = {
objectKey: string;
originalFileName: string;
contentType: string;
sizeBytes: number;
};
export function isEquipmentAttachmentStored(value: unknown): value is EquipmentAttachmentStored {
if (!value || typeof value !== 'object') {
return false;
}
const o = value as Record<string, unknown>;
return typeof o.objectKey === 'string' && o.objectKey.length > 0;
}

View File

@@ -8,9 +8,13 @@ import {
Post,
Query,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { memoryStorage } from 'multer';
import { Roles } from '../../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
@@ -29,6 +33,24 @@ export class EquipmentController {
return this.equipmentService.findAll(query, response);
}
@Roles('editor', 'admin')
@Post(':id/attachment')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 },
}),
)
uploadAttachment(@Param('id') id: string, @UploadedFile() file: Express.Multer.File) {
return this.equipmentService.uploadAttachment(id, file);
}
@Roles('editor', 'admin')
@Delete(':id/attachment')
removeAttachment(@Param('id') id: string) {
return this.equipmentService.removeAttachment(id);
}
@Roles('viewer', 'editor', 'admin')
@Get(':id')
findOne(@Param('id') id: string) {

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { StorageModule } from '../../storage/storage.module';
import { EquipmentController } from './equipment.controller';
import { EquipmentService } from './equipment.service';
@Module({
imports: [AuthModule],
imports: [AuthModule, StorageModule],
controllers: [EquipmentController],
providers: [EquipmentService],
})

View File

@@ -1,14 +1,27 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { EquipmentStatus, Prisma } from '@prisma/client';
import type { Equipment } from '@prisma/client';
import { Response } from 'express';
import type { Express } from 'express';
import { setListHeaders } from '../../common/http';
import { PrismaService } from '../../prisma/prisma.service';
import type { StoredAttachmentMeta } from '../../storage/storage.service';
import { StorageService } from '../../storage/storage.service';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
import { isEquipmentAttachmentStored } from './attachment.types';
@Injectable()
export class EquipmentService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
async findAll(query: Record<string, unknown>, response: Response) {
const start = Number(query._start ?? 0);
@@ -51,7 +64,7 @@ export class EquipmentService {
]);
setListHeaders(response, total, start, end);
return items.map((item) => this.toRecord(item));
return Promise.all(items.map((item) => this.toRecord(item)));
}
async findOne(id: string) {
@@ -94,19 +107,88 @@ export class EquipmentService {
}
async remove(id: string) {
await this.findOne(id);
const item = await this.prisma.equipment.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`Equipment ${id} not found`);
}
await this.deleteStoredAttachmentIfAny(item);
const deleted = await this.prisma.equipment.delete({ where: { id } });
return this.toRecord(deleted);
}
private toRecord(item: {
id: string;
name: string;
serialNumber: string;
dateOfInspection: Date | null;
commissionedAt: Date | null;
status: EquipmentStatus;
}) {
async uploadAttachment(id: string, file: Express.Multer.File | undefined) {
if (!file?.buffer?.length) {
throw new BadRequestException('File is required (multipart field "file").');
}
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.equipment.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`Equipment ${id} not found`);
}
await this.deleteStoredAttachmentIfAny(item);
const objectKey = this.storage.buildEquipmentObjectKey(id, file.originalname);
const meta: StoredAttachmentMeta = {
objectKey,
originalFileName: file.originalname,
contentType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
};
await this.storage.putObject(objectKey, file.buffer, meta.contentType);
const updated = await this.prisma.equipment.update({
where: { id },
data: { attachment: meta as unknown as Prisma.InputJsonValue },
});
return this.toRecord(updated);
}
async removeAttachment(id: string) {
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.equipment.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`Equipment ${id} not found`);
}
await this.deleteStoredAttachmentIfAny(item);
const updated = await this.prisma.equipment.update({
where: { id },
data: { attachment: Prisma.JsonNull },
});
return this.toRecord(updated);
}
private async deleteStoredAttachmentIfAny(item: Equipment) {
const raw = item.attachment;
if (!isEquipmentAttachmentStored(raw)) {
return;
}
if (!this.storage.isConfigured()) {
return;
}
try {
await this.storage.deleteObject(raw.objectKey);
} catch {
// best-effort: DB will still clear / overwrite
}
}
private async toRecord(item: Equipment) {
return {
id: item.id,
name: item.name,
@@ -114,6 +196,31 @@ export class EquipmentService {
dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
commissionedAt: item.commissionedAt?.toISOString() ?? null,
status: item.status,
attachment: await this.serializeAttachment(item.attachment),
};
}
private async serializeAttachment(raw: Prisma.JsonValue | null) {
if (raw === null || raw === undefined) {
return null;
}
if (!isEquipmentAttachmentStored(raw)) {
return null;
}
let downloadUrl: string | null = null;
if (this.storage.isConfigured()) {
try {
downloadUrl = await this.storage.getDownloadUrl(raw.objectKey);
} catch {
downloadUrl = null;
}
}
return {
objectKey: raw.objectKey,
originalFileName: raw.originalFileName,
contentType: raw.contentType,
sizeBytes: raw.sizeBytes,
downloadUrl,
};
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View File

@@ -0,0 +1,97 @@
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
export type StoredAttachmentMeta = {
objectKey: string;
originalFileName: string;
contentType: string;
sizeBytes: number;
};
@Injectable()
export class StorageService {
private readonly client: S3Client | null;
private readonly bucket: string;
private readonly publicBaseUrl: string | null;
private readonly presignExpiresSeconds: number;
constructor() {
const endpoint = process.env.S3_ENDPOINT?.trim();
const region = process.env.S3_REGION?.trim() || 'us-east-1';
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim();
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim();
this.bucket = process.env.S3_BUCKET?.trim() || '';
const forcePathStyle = (process.env.S3_FORCE_PATH_STYLE ?? 'true').toLowerCase() === 'true';
this.publicBaseUrl = process.env.S3_PUBLIC_BASE_URL?.trim() || null;
this.presignExpiresSeconds = Number(process.env.S3_PRESIGN_EXPIRES_SECONDS ?? '3600');
if (!endpoint || !accessKeyId || !secretAccessKey || !this.bucket) {
this.client = null;
return;
}
this.client = new S3Client({
region,
endpoint,
credentials: { accessKeyId, secretAccessKey },
forcePathStyle,
});
}
isConfigured(): boolean {
return this.client !== null;
}
assertConfigured() {
if (!this.client) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY).',
);
}
}
async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
this.assertConfigured();
await this.client!.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: body,
ContentType: contentType || 'application/octet-stream',
}),
);
}
async deleteObject(key: string): Promise<void> {
this.assertConfigured();
await this.client!.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
}
async getDownloadUrl(objectKey: string): Promise<string> {
this.assertConfigured();
if (this.publicBaseUrl) {
const base = this.publicBaseUrl.replace(/\/$/, '');
const path = objectKey
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
return `${base}/${path}`;
}
const command = new GetObjectCommand({ Bucket: this.bucket, Key: objectKey });
return getSignedUrl(this.client!, command, { expiresIn: this.presignExpiresSeconds });
}
buildEquipmentObjectKey(equipmentId: string, originalName: string): string {
const prefix = (process.env.S3_OBJECT_PREFIX ?? 'toir-light/equipment').replace(/^\/+|\/+$/g, '');
const safe = sanitizeFileName(originalName);
return `${prefix}/${equipmentId}/${randomUUID()}-${safe}`;
}
}
function sanitizeFileName(name: string): string {
const base = name.replace(/[/\\]/g, '_').trim() || 'file';
return base.slice(0, 200);
}