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:
106
api-summary.json
106
api-summary.json
@@ -4,6 +4,72 @@
|
||||
],
|
||||
"enums": [],
|
||||
"dtos": [
|
||||
{
|
||||
"name": "DTO.FileAttachment",
|
||||
"description": "Метаданные файла в объектном хранилище (MinIO/S3); бинарные данные в БД не хранятся",
|
||||
"fields": [
|
||||
{
|
||||
"name": "objectKey",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"primary": false,
|
||||
"description": "Ключ объекта в бакете",
|
||||
"map": null,
|
||||
"sync": false,
|
||||
"label": null
|
||||
},
|
||||
{
|
||||
"name": "originalFileName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"nullable": true,
|
||||
"unique": false,
|
||||
"primary": false,
|
||||
"description": "Исходное имя файла",
|
||||
"map": null,
|
||||
"sync": false,
|
||||
"label": null
|
||||
},
|
||||
{
|
||||
"name": "contentType",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"nullable": true,
|
||||
"unique": false,
|
||||
"primary": false,
|
||||
"description": "MIME-тип",
|
||||
"map": null,
|
||||
"sync": false,
|
||||
"label": null
|
||||
},
|
||||
{
|
||||
"name": "sizeBytes",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"nullable": true,
|
||||
"unique": false,
|
||||
"primary": false,
|
||||
"description": "Размер в байтах",
|
||||
"map": null,
|
||||
"sync": false,
|
||||
"label": null
|
||||
},
|
||||
{
|
||||
"name": "downloadUrl",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"nullable": true,
|
||||
"unique": false,
|
||||
"primary": false,
|
||||
"description": "Ссылка на скачивание (публичная или presigned)",
|
||||
"map": null,
|
||||
"sync": false,
|
||||
"label": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "DTO.Equipment",
|
||||
"description": "Полный response-объект для единицы оборудования",
|
||||
@@ -79,6 +145,18 @@
|
||||
"map": "Equipment.status",
|
||||
"sync": false,
|
||||
"label": null
|
||||
},
|
||||
{
|
||||
"name": "attachment",
|
||||
"type": "DTO.FileAttachment",
|
||||
"required": false,
|
||||
"nullable": true,
|
||||
"unique": false,
|
||||
"primary": false,
|
||||
"description": "Вложение (файл в MinIO/S3)",
|
||||
"map": "Equipment.attachment",
|
||||
"sync": false,
|
||||
"label": null
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -682,6 +760,34 @@
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "uploadEquipmentAttachment",
|
||||
"label": "POST /equipment/{id}/attachment",
|
||||
"method": "POST",
|
||||
"path": "/equipment/{id}/attachment",
|
||||
"description": "Загрузить файл-вложение (multipart/form-data, поле file)",
|
||||
"attributes": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "deleteEquipmentAttachment",
|
||||
"label": "DELETE /equipment/{id}/attachment",
|
||||
"method": "DELETE",
|
||||
"path": "/equipment/{id}/attachment",
|
||||
"description": "Удалить файл-вложение из хранилища и из записи",
|
||||
"attributes": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
11
client/src/lib/apiBase.ts
Normal file
11
client/src/lib/apiBase.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { env } from '../config/env';
|
||||
|
||||
/** Resolves VITE_API_URL like `/api` to an absolute origin URL. */
|
||||
export function resolveApiBaseUrl(): string {
|
||||
const base = env.apiUrl;
|
||||
if (base.startsWith('http://') || base.startsWith('https://')) {
|
||||
return base.replace(/\/$/, '');
|
||||
}
|
||||
const path = base.startsWith('/') ? base : `/${base}`;
|
||||
return `${window.location.origin.replace(/\/$/, '')}${path}`.replace(/\/$/, '');
|
||||
}
|
||||
109
client/src/resources/equipment/EquipmentAttachmentInput.tsx
Normal file
109
client/src/resources/equipment/EquipmentAttachmentInput.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Button, Stack, Typography } from '@mui/material';
|
||||
import { useNotify, useRecordContext, useRefresh } from 'react-admin';
|
||||
import { useCallback, useState, type ChangeEvent } from 'react';
|
||||
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
|
||||
import { resolveApiBaseUrl } from '../../lib/apiBase';
|
||||
|
||||
export type FileAttachmentValue = {
|
||||
objectKey?: string;
|
||||
originalFileName?: string | null;
|
||||
contentType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
downloadUrl?: string | null;
|
||||
} | null;
|
||||
|
||||
export function EquipmentAttachmentInput() {
|
||||
const record = useRecordContext();
|
||||
const notify = useNotify();
|
||||
const refresh = useRefresh();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const upload = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file || !record?.id) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await ensureFreshToken();
|
||||
const token = getAccessToken();
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const url = `${resolveApiBaseUrl()}/equipment/${record.id}/attachment`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
notify('Файл сохранён', { type: 'success' });
|
||||
refresh();
|
||||
} catch {
|
||||
notify('Не удалось загрузить файл', { type: 'warning' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[notify, refresh, record?.id],
|
||||
);
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
if (!record?.id) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await ensureFreshToken();
|
||||
const token = getAccessToken();
|
||||
const url = `${resolveApiBaseUrl()}/equipment/${record.id}/attachment`;
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { Authorization: `Bearer ${token}`, Accept: 'application/json' } : { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
notify('Вложение удалено', { type: 'success' });
|
||||
refresh();
|
||||
} catch {
|
||||
notify('Не удалось удалить файл', { type: 'warning' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [notify, refresh, record?.id]);
|
||||
|
||||
const att = (record as { attachment?: FileAttachmentValue })?.attachment;
|
||||
|
||||
return (
|
||||
<Stack spacing={1} sx={{ maxWidth: 480 }}>
|
||||
<Typography component="label" variant="body2">
|
||||
Вложение (файл в MinIO)
|
||||
</Typography>
|
||||
{att?.downloadUrl ? (
|
||||
<Typography variant="body2">
|
||||
<a href={att.downloadUrl} target="_blank" rel="noreferrer">
|
||||
{att.originalFileName || 'Скачать файл'}
|
||||
</a>
|
||||
{att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Файл не прикреплён
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Button variant="outlined" component="label" disabled={busy || !record?.id}>
|
||||
Выбрать файл…
|
||||
<input type="file" hidden onChange={upload} />
|
||||
</Button>
|
||||
<Button variant="text" color="error" disabled={busy || !att?.objectKey} onClick={remove}>
|
||||
Удалить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
|
||||
import { EquipmentAttachmentInput } from './EquipmentAttachmentInput';
|
||||
import { equipmentStatusChoices } from './shared';
|
||||
|
||||
const equipmentLabels = {
|
||||
@@ -18,6 +19,7 @@ export function EquipmentEdit() {
|
||||
<RaTextInput source="name" label={equipmentLabels.name} />
|
||||
<RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||
<SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} />
|
||||
<EquipmentAttachmentInput />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Datagrid,
|
||||
DateField,
|
||||
FilterButton,
|
||||
FunctionField,
|
||||
List,
|
||||
SelectArrayInput,
|
||||
SelectField,
|
||||
@@ -34,6 +35,10 @@ export function EquipmentList() {
|
||||
<DateField source="dateOfInspection" />
|
||||
<DateField source="commissionedAt" />
|
||||
<SelectField source="status" choices={equipmentStatusChoices} />
|
||||
<FunctionField
|
||||
label="Файл"
|
||||
render={(record: { attachment?: { objectKey?: string } | null }) => (record?.attachment?.objectKey ? '✓' : '—')}
|
||||
/>
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DateField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
|
||||
import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
|
||||
import { equipmentStatusChoices } from './shared';
|
||||
|
||||
export function EquipmentShow() {
|
||||
@@ -11,6 +11,18 @@ export function EquipmentShow() {
|
||||
<DateField source="dateOfInspection" />
|
||||
<DateField source="commissionedAt" />
|
||||
<SelectField source="status" choices={equipmentStatusChoices} />
|
||||
<FunctionField
|
||||
label="Вложение"
|
||||
render={(record: { attachment?: { downloadUrl?: string | null; originalFileName?: string | null } | null }) =>
|
||||
record?.attachment?.downloadUrl ? (
|
||||
<a href={record.attachment.downloadUrl} target="_blank" rel="noreferrer">
|
||||
{record.attachment.originalFileName || 'Скачать'}
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,14 @@ services:
|
||||
KEYCLOAK_ISSUER_URL: ${KEYCLOAK_ISSUER_URL:-https://sso.greact.ru/realms/toir}
|
||||
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-toir-backend}
|
||||
KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL:-}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||
S3_REGION: ${S3_REGION:-eu-central-1}
|
||||
S3_BUCKET: ${S3_BUCKET:-media}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-true}
|
||||
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
|
||||
S3_OBJECT_PREFIX: ${S3_OBJECT_PREFIX:-toir-light/equipment}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
dto DTO.FileAttachment {
|
||||
description "Метаданные файла в объектном хранилище (MinIO/S3); бинарные данные в БД не хранятся";
|
||||
attribute objectKey {
|
||||
type string;
|
||||
description "Ключ объекта в бакете";
|
||||
}
|
||||
attribute originalFileName {
|
||||
type string;
|
||||
is nullable;
|
||||
description "Исходное имя файла";
|
||||
}
|
||||
attribute contentType {
|
||||
type string;
|
||||
is nullable;
|
||||
description "MIME-тип";
|
||||
}
|
||||
attribute sizeBytes {
|
||||
type integer;
|
||||
is nullable;
|
||||
description "Размер в байтах";
|
||||
}
|
||||
attribute downloadUrl {
|
||||
type string;
|
||||
is nullable;
|
||||
description "Ссылка на скачивание (публичная или presigned)";
|
||||
}
|
||||
}
|
||||
|
||||
dto DTO.Equipment {
|
||||
description "Полный response-объект для единицы оборудования";
|
||||
attribute id {
|
||||
@@ -31,6 +59,12 @@ dto DTO.Equipment {
|
||||
description "Текущий статус";
|
||||
map Equipment.status;
|
||||
}
|
||||
attribute attachment {
|
||||
type DTO.FileAttachment;
|
||||
is nullable;
|
||||
description "Вложение (файл в MinIO/S3)";
|
||||
map Equipment.attachment;
|
||||
}
|
||||
}
|
||||
|
||||
dto DTO.EquipmentCreate {
|
||||
@@ -311,6 +345,22 @@ api API.Equipment {
|
||||
type uuid;
|
||||
}
|
||||
}
|
||||
|
||||
endpoint uploadEquipmentAttachment {
|
||||
label "POST /equipment/{id}/attachment";
|
||||
description "Загрузить файл-вложение (multipart/form-data, поле file)";
|
||||
attribute id {
|
||||
type uuid;
|
||||
}
|
||||
}
|
||||
|
||||
endpoint deleteEquipmentAttachment {
|
||||
label "DELETE /equipment/{id}/attachment";
|
||||
description "Удалить файл-вложение из хранилища и из записи";
|
||||
attribute id {
|
||||
type uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
api API.EquipmentStatusChange {
|
||||
|
||||
@@ -4,3 +4,16 @@ CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"
|
||||
KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"
|
||||
KEYCLOAK_AUDIENCE="toir-backend"
|
||||
KEYCLOAK_JWKS_URL=""
|
||||
|
||||
# MinIO / S3 (вложения оборудования)
|
||||
S3_ENDPOINT="http://localhost:9000"
|
||||
S3_REGION="eu-central-1"
|
||||
S3_BUCKET="media"
|
||||
S3_ACCESS_KEY_ID=""
|
||||
S3_SECRET_ACCESS_KEY=""
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
# Если бакет публичный: базовый URL для прямых ссылок (без presign)
|
||||
S3_PUBLIC_BASE_URL=""
|
||||
# Префикс ключей объектов в бакете
|
||||
S3_OBJECT_PREFIX="toir-light/equipment"
|
||||
S3_PRESIGN_EXPIRES_SECONDS="3600"
|
||||
|
||||
1711
server/package-lock.json
generated
1711
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1033.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1033.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -30,6 +32,7 @@
|
||||
"class-validator": "^0.15.1",
|
||||
"dotenv": "^17.4.0",
|
||||
"jose": "^6.2.2",
|
||||
"multer": "^2.1.1",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
@@ -42,6 +45,7 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Equipment" ADD COLUMN IF NOT EXISTS "attachment" JSONB;
|
||||
@@ -20,6 +20,8 @@ model Equipment {
|
||||
dateOfInspection DateTime?
|
||||
commissionedAt DateTime?
|
||||
status EquipmentStatus @default(Active)
|
||||
/// JSON: { objectKey, originalFileName, contentType, sizeBytes } — файл в MinIO/S3
|
||||
attachment Json?
|
||||
changeEquipmentStatuses ChangeEquipmentStatus[]
|
||||
}
|
||||
|
||||
|
||||
14
server/src/modules/equipment/attachment.types.ts
Normal file
14
server/src/modules/equipment/attachment.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type EquipmentAttachmentStored = {
|
||||
objectKey: string;
|
||||
originalFileName: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export function isEquipmentAttachmentStored(value: unknown): value is EquipmentAttachmentStored {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const o = value as Record<string, unknown>;
|
||||
return typeof o.objectKey === 'string' && o.objectKey.length > 0;
|
||||
}
|
||||
@@ -8,9 +8,13 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
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';
|
||||
@@ -29,6 +33,24 @@ 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')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { StorageModule } from '../../storage/storage.module';
|
||||
import { EquipmentController } from './equipment.controller';
|
||||
import { EquipmentService } from './equipment.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, StorageModule],
|
||||
controllers: [EquipmentController],
|
||||
providers: [EquipmentService],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
8
server/src/storage/storage.module.ts
Normal file
8
server/src/storage/storage.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
97
server/src/storage/storage.service.ts
Normal file
97
server/src/storage/storage.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type StoredAttachmentMeta = {
|
||||
objectKey: string;
|
||||
originalFileName: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly client: S3Client | null;
|
||||
private readonly bucket: string;
|
||||
private readonly publicBaseUrl: string | null;
|
||||
private readonly presignExpiresSeconds: number;
|
||||
|
||||
constructor() {
|
||||
const endpoint = process.env.S3_ENDPOINT?.trim();
|
||||
const region = process.env.S3_REGION?.trim() || 'us-east-1';
|
||||
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim();
|
||||
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim();
|
||||
this.bucket = process.env.S3_BUCKET?.trim() || '';
|
||||
const forcePathStyle = (process.env.S3_FORCE_PATH_STYLE ?? 'true').toLowerCase() === 'true';
|
||||
this.publicBaseUrl = process.env.S3_PUBLIC_BASE_URL?.trim() || null;
|
||||
this.presignExpiresSeconds = Number(process.env.S3_PRESIGN_EXPIRES_SECONDS ?? '3600');
|
||||
|
||||
if (!endpoint || !accessKeyId || !secretAccessKey || !this.bucket) {
|
||||
this.client = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
forcePathStyle,
|
||||
});
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return this.client !== null;
|
||||
}
|
||||
|
||||
assertConfigured() {
|
||||
if (!this.client) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Object storage is not configured (set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
|
||||
this.assertConfigured();
|
||||
await this.client!.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType || 'application/octet-stream',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
this.assertConfigured();
|
||||
await this.client!.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
}
|
||||
|
||||
async getDownloadUrl(objectKey: string): Promise<string> {
|
||||
this.assertConfigured();
|
||||
if (this.publicBaseUrl) {
|
||||
const base = this.publicBaseUrl.replace(/\/$/, '');
|
||||
const path = objectKey
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
return `${base}/${path}`;
|
||||
}
|
||||
|
||||
const command = new GetObjectCommand({ Bucket: this.bucket, Key: objectKey });
|
||||
return getSignedUrl(this.client!, command, { expiresIn: this.presignExpiresSeconds });
|
||||
}
|
||||
|
||||
buildEquipmentObjectKey(equipmentId: string, originalName: string): string {
|
||||
const prefix = (process.env.S3_OBJECT_PREFIX ?? 'toir-light/equipment').replace(/^\/+|\/+$/g, '');
|
||||
const safe = sanitizeFileName(originalName);
|
||||
return `${prefix}/${equipmentId}/${randomUUID()}-${safe}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
const base = name.replace(/[/\\]/g, '_').trim() || 'file';
|
||||
return base.slice(0, 200);
|
||||
}
|
||||
Reference in New Issue
Block a user