- Introduced a new utility function to decode filenames from multipart uploads that may be misencoded as ISO-8859-1. - Updated EquipmentService to utilize this function when processing file attachments, ensuring correct original file names are stored and returned.
229 lines
7.1 KiB
TypeScript
229 lines
7.1 KiB
TypeScript
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 { 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 { 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,
|
|
private readonly storage: StorageService,
|
|
) {}
|
|
|
|
async findAll(query: Record<string, unknown>, response: Response) {
|
|
const start = Number(query._start ?? 0);
|
|
const end = Number(query._end ?? start + 25);
|
|
const take = Math.max(end - start, 0);
|
|
const sortField = typeof query._sort === 'string' ? query._sort : 'id';
|
|
const sortOrder = query._order === 'DESC' ? 'desc' : 'asc';
|
|
const q = typeof query.q === 'string' ? query.q.trim() : '';
|
|
const rawStatus = query.status;
|
|
const statuses = Array.isArray(rawStatus)
|
|
? rawStatus.filter((value): value is EquipmentStatus => typeof value === 'string')
|
|
: typeof rawStatus === 'string'
|
|
? [rawStatus as EquipmentStatus]
|
|
: [];
|
|
|
|
const where: Prisma.EquipmentWhereInput = {};
|
|
|
|
if (q) {
|
|
where.OR = [
|
|
{ name: { contains: q, mode: 'insensitive' } },
|
|
{ serialNumber: { contains: q, mode: 'insensitive' } },
|
|
];
|
|
}
|
|
|
|
if (statuses.length === 1) {
|
|
where.status = statuses[0];
|
|
} else if (statuses.length > 1) {
|
|
where.status = { in: statuses };
|
|
}
|
|
|
|
const orderByField = sortField === 'id' ? 'id' : sortField;
|
|
const [total, items] = await this.prisma.$transaction([
|
|
this.prisma.equipment.count({ where }),
|
|
this.prisma.equipment.findMany({
|
|
where,
|
|
skip: start,
|
|
take,
|
|
orderBy: { [orderByField]: sortOrder },
|
|
}),
|
|
]);
|
|
|
|
setListHeaders(response, total, start, end);
|
|
return Promise.all(items.map((item) => this.toRecord(item)));
|
|
}
|
|
|
|
async findOne(id: string) {
|
|
const item = await this.prisma.equipment.findUnique({ where: { id } });
|
|
if (!item) {
|
|
throw new NotFoundException(`Equipment ${id} not found`);
|
|
}
|
|
|
|
return this.toRecord(item);
|
|
}
|
|
|
|
async create(dto: CreateEquipmentDto) {
|
|
const created = await this.prisma.equipment.create({
|
|
data: {
|
|
name: dto.name,
|
|
serialNumber: dto.serialNumber,
|
|
dateOfInspection: dto.dateOfInspection ? new Date(dto.dateOfInspection) : undefined,
|
|
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
|
status: dto.status,
|
|
},
|
|
});
|
|
|
|
return this.toRecord(created);
|
|
}
|
|
|
|
async update(id: string, dto: UpdateEquipmentDto) {
|
|
await this.findOne(id);
|
|
|
|
const { id: _id, ...payload } = dto as UpdateEquipmentDto & { id?: string };
|
|
const updated = await this.prisma.equipment.update({
|
|
where: { id },
|
|
data: {
|
|
...payload,
|
|
dateOfInspection: dto.dateOfInspection ? new Date(dto.dateOfInspection) : undefined,
|
|
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
|
},
|
|
});
|
|
|
|
return this.toRecord(updated);
|
|
}
|
|
|
|
async remove(id: string) {
|
|
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);
|
|
}
|
|
|
|
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 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,
|
|
serialNumber: item.serialNumber,
|
|
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,
|
|
};
|
|
}
|
|
}
|