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