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, 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, }; } }