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:
@@ -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;
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user