Add support for file attachments in equipment status changes

- Introduced functionality for uploading and managing attachments in the ChangeEquipmentStatus module.
- Added new endpoints for uploading and deleting attachments, as well as for downloading them.
- Updated the ChangeEquipmentStatusService to handle attachment storage and retrieval using the new storage service methods.
- Enhanced the ChangeEquipmentStatusEdit and ChangeEquipmentStatusShow components to support attachment input and display.
- Removed deprecated attachment handling from Equipment module to streamline functionality.
- Updated Prisma schema to reflect changes in attachment management.
This commit is contained in:
Первов Артем
2026-04-21 12:19:49 +03:00
parent d572647772
commit b1aefae2fa
25 changed files with 669 additions and 430 deletions

View File

@@ -0,0 +1,4 @@
-- Move attachments from Equipment to ChangeEquipmentStatus and allow multiple files.
ALTER TABLE IF EXISTS "Equipment" DROP COLUMN IF EXISTS "attachment";
ALTER TABLE IF EXISTS "ChangeEquipmentStatus" ADD COLUMN IF NOT EXISTS "attachments" JSONB;

View File

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

View File

@@ -1,14 +1,29 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { EquipmentStatus, Prisma } from '@prisma/client';
import { Response } from 'express';
import type { Express } from 'express';
import { randomUUID } from 'node:crypto';
import type { Readable } from 'node:stream';
import { setListHeaders } from '../../common/http';
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
import { PrismaService } from '../../prisma/prisma.service';
import { StorageService } from '../../storage/storage.service';
import type { StoredAttachmentMeta } from '../equipment/attachment.types';
import { isStoredAttachmentMeta } from '../equipment/attachment.types';
import { CreateChangeEquipmentStatusDto } from './dto/create-change-equipment-status.dto';
import { UpdateChangeEquipmentStatusDto } from './dto/update-change-equipment-status.dto';
@Injectable()
export class ChangeEquipmentStatusService {
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);
@@ -135,6 +150,123 @@ export class ChangeEquipmentStatusService {
return this.toRecord(deleted);
}
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.changeEquipmentStatus.findUnique({
where: { id },
include: { equipment: true },
});
if (!item) {
throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`);
}
const decodedOriginalName = decodeUtf8FilenameFromMultipart(file.originalname);
const objectKey = this.storage.buildStatusChangeObjectKey(id, decodedOriginalName);
const meta: StoredAttachmentMeta = {
id: randomUUID(),
objectKey,
originalFileName: decodedOriginalName,
contentType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
};
await this.storage.putObject(objectKey, file.buffer, meta.contentType);
const current = this.parseAttachments(item.attachments);
const next = [...current, meta];
const updated = await this.prisma.changeEquipmentStatus.update({
where: { id },
data: { attachments: next as unknown as Prisma.InputJsonValue },
include: { equipment: true },
});
return this.toRecord(updated);
}
async removeAttachment(id: string, attachmentId: string) {
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.changeEquipmentStatus.findUnique({
where: { id },
include: { equipment: true },
});
if (!item) {
throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`);
}
const current = this.parseAttachments(item.attachments);
const found = current.find((a) => a.id === attachmentId);
if (!found) {
throw new NotFoundException(`Вложение ${attachmentId} не найдено`);
}
try {
await this.storage.deleteObject(found.objectKey);
} catch {
// best-effort
}
const next = current.filter((a) => a.id !== attachmentId);
const updated = await this.prisma.changeEquipmentStatus.update({
where: { id },
data: { attachments: next.length ? (next as unknown as Prisma.InputJsonValue) : Prisma.JsonNull },
include: { equipment: true },
});
return this.toRecord(updated);
}
async getAttachmentDownloadStream(
id: string,
attachmentId: string,
): Promise<{
stream: Readable;
contentType: string;
fileName: string;
contentLength?: number;
}> {
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.changeEquipmentStatus.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`);
}
const current = this.parseAttachments(item.attachments);
const found = current.find((a) => a.id === attachmentId);
if (!found) {
throw new NotFoundException(`Вложение ${attachmentId} не найдено`);
}
const fileName = decodeUtf8FilenameFromMultipart(found.originalFileName) || 'file';
const { stream, contentType, contentLength } = await this.storage.getObjectStream(found.objectKey);
const len = typeof contentLength === 'bigint' ? Number(contentLength) : contentLength;
return {
stream,
contentType: found.contentType || contentType,
fileName,
contentLength: len,
};
}
private toRecord(item: {
id: string;
equipmentId: string | null;
@@ -143,6 +275,7 @@ export class ChangeEquipmentStatusService {
date: Date;
responsible: string | null;
equipment?: { id: string; name: string } | null;
attachments?: Prisma.JsonValue | null;
}) {
return {
id: item.id,
@@ -157,6 +290,28 @@ export class ChangeEquipmentStatusService {
name: item.equipment.name,
}
: null,
attachments: this.serializeAttachments(item.attachments),
};
}
private parseAttachments(raw: Prisma.JsonValue | null | undefined): StoredAttachmentMeta[] {
if (!raw) {
return [];
}
if (!Array.isArray(raw)) {
return [];
}
return raw.filter(isStoredAttachmentMeta);
}
private serializeAttachments(raw: Prisma.JsonValue | null | undefined) {
const items = this.parseAttachments(raw);
return items.map((a) => ({
id: a.id,
originalFileName: decodeUtf8FilenameFromMultipart(a.originalFileName),
contentType: a.contentType,
sizeBytes: a.sizeBytes,
downloadUrl: null as string | null,
}));
}
}

View File

@@ -8,9 +8,14 @@ import {
Post,
Query,
Res,
StreamableFile,
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';
@@ -35,6 +40,40 @@ export class EquipmentStatusChangeController {
return this.equipmentStatusChangeService.findOne(id);
}
@Roles('editor', 'admin')
@Post(':id/attachments')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 },
}),
)
uploadAttachment(@Param('id') id: string, @UploadedFile() file: Express.Multer.File) {
return this.equipmentStatusChangeService.uploadAttachment(id, file);
}
@Roles('editor', 'admin')
@Delete(':id/attachments/:attachmentId')
removeAttachment(@Param('id') id: string, @Param('attachmentId') attachmentId: string) {
return this.equipmentStatusChangeService.removeAttachment(id, attachmentId);
}
@Roles('viewer', 'editor', 'admin')
@Get(':id/attachments/:attachmentId/download')
async downloadAttachment(
@Param('id') id: string,
@Param('attachmentId') attachmentId: string,
): Promise<StreamableFile> {
const { stream, contentType, fileName, contentLength } =
await this.equipmentStatusChangeService.getAttachmentDownloadStream(id, attachmentId);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: contentType,
disposition: `attachment; filename*=UTF-8''${encoded}`,
length: contentLength,
});
}
@Roles('editor', 'admin')
@Post()
create(@Body() dto: CreateChangeEquipmentStatusDto) {

View File

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

View File

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

View File

@@ -8,14 +8,9 @@ import {
Post,
Query,
Res,
StreamableFile,
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';
@@ -34,37 +29,6 @@ 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/attachment/download')
async downloadAttachment(@Param('id') id: string): Promise<StreamableFile> {
const { stream, contentType, fileName, contentLength } =
await this.equipmentService.getAttachmentDownloadStream(id);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: contentType,
disposition: `attachment; filename*=UTF-8''${encoded}`,
length: contentLength,
});
}
@Roles('viewer', 'editor', 'admin')
@Get(':id')
findOne(@Param('id') id: string) {

View File

@@ -11,12 +11,9 @@ import type { Express } from 'express';
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
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 type { Readable } from 'node:stream';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
import { isEquipmentAttachmentStored } from './attachment.types';
@Injectable()
export class EquipmentService {
@@ -113,118 +110,10 @@ export class EquipmentService {
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);
}
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 decodedOriginalName = decodeUtf8FilenameFromMultipart(file.originalname);
const objectKey = this.storage.buildEquipmentObjectKey(id, decodedOriginalName);
const meta: StoredAttachmentMeta = {
objectKey,
originalFileName: decodedOriginalName,
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 getAttachmentDownloadStream(id: string): Promise<{
stream: Readable;
contentType: string;
fileName: string;
contentLength?: number;
}> {
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`);
}
if (!isEquipmentAttachmentStored(item.attachment)) {
throw new NotFoundException(`Вложение для оборудования ${id} не найдено`);
}
const raw = item.attachment;
const fileName = decodeUtf8FilenameFromMultipart(raw.originalFileName) || 'file';
const { stream, contentType, contentLength } = await this.storage.getObjectStream(raw.objectKey);
const len =
typeof contentLength === 'bigint' ? Number(contentLength) : contentLength;
return {
stream,
contentType: raw.contentType || contentType,
fileName,
contentLength: len,
};
}
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,
@@ -233,31 +122,6 @@ 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: decodeUtf8FilenameFromMultipart(raw.originalFileName),
contentType: raw.contentType,
sizeBytes: raw.sizeBytes,
downloadUrl,
};
}
}

View File

@@ -126,6 +126,14 @@ export class StorageService {
const safe = sanitizeFileName(originalName);
return `${prefix}/${equipmentId}/${randomUUID()}-${safe}`;
}
buildStatusChangeObjectKey(statusChangeId: string, originalName: string): string {
const prefix = (process.env.S3_OBJECT_PREFIX ?? 'toir-light/equipment')
.replace(/^\/+|\/+$/g, '')
.replace(/equipment$/, 'status-changes');
const safe = sanitizeFileName(originalName);
return `${prefix}/${statusChangeId}/${randomUUID()}-${safe}`;
}
}
function sanitizeFileName(name: string): string {