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": [],
|
"enums": [],
|
||||||
"dtos": [
|
"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",
|
"name": "DTO.Equipment",
|
||||||
"description": "Полный response-объект для единицы оборудования",
|
"description": "Полный response-объект для единицы оборудования",
|
||||||
@@ -79,6 +145,18 @@
|
|||||||
"map": "Equipment.status",
|
"map": "Equipment.status",
|
||||||
"sync": false,
|
"sync": false,
|
||||||
"label": null
|
"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
|
"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 { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
|
||||||
|
import { EquipmentAttachmentInput } from './EquipmentAttachmentInput';
|
||||||
import { equipmentStatusChoices } from './shared';
|
import { equipmentStatusChoices } from './shared';
|
||||||
|
|
||||||
const equipmentLabels = {
|
const equipmentLabels = {
|
||||||
@@ -18,6 +19,7 @@ export function EquipmentEdit() {
|
|||||||
<RaTextInput source="name" label={equipmentLabels.name} />
|
<RaTextInput source="name" label={equipmentLabels.name} />
|
||||||
<RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} />
|
<RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||||
<SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} />
|
<SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} />
|
||||||
|
<EquipmentAttachmentInput />
|
||||||
</SimpleForm>
|
</SimpleForm>
|
||||||
</Edit>
|
</Edit>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Datagrid,
|
Datagrid,
|
||||||
DateField,
|
DateField,
|
||||||
FilterButton,
|
FilterButton,
|
||||||
|
FunctionField,
|
||||||
List,
|
List,
|
||||||
SelectArrayInput,
|
SelectArrayInput,
|
||||||
SelectField,
|
SelectField,
|
||||||
@@ -34,6 +35,10 @@ export function EquipmentList() {
|
|||||||
<DateField source="dateOfInspection" />
|
<DateField source="dateOfInspection" />
|
||||||
<DateField source="commissionedAt" />
|
<DateField source="commissionedAt" />
|
||||||
<SelectField source="status" choices={equipmentStatusChoices} />
|
<SelectField source="status" choices={equipmentStatusChoices} />
|
||||||
|
<FunctionField
|
||||||
|
label="Файл"
|
||||||
|
render={(record: { attachment?: { objectKey?: string } | null }) => (record?.attachment?.objectKey ? '✓' : '—')}
|
||||||
|
/>
|
||||||
</Datagrid>
|
</Datagrid>
|
||||||
</List>
|
</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';
|
import { equipmentStatusChoices } from './shared';
|
||||||
|
|
||||||
export function EquipmentShow() {
|
export function EquipmentShow() {
|
||||||
@@ -11,6 +11,18 @@ export function EquipmentShow() {
|
|||||||
<DateField source="dateOfInspection" />
|
<DateField source="dateOfInspection" />
|
||||||
<DateField source="commissionedAt" />
|
<DateField source="commissionedAt" />
|
||||||
<SelectField source="status" choices={equipmentStatusChoices} />
|
<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>
|
</SimpleShowLayout>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ services:
|
|||||||
KEYCLOAK_ISSUER_URL: ${KEYCLOAK_ISSUER_URL:-https://sso.greact.ru/realms/toir}
|
KEYCLOAK_ISSUER_URL: ${KEYCLOAK_ISSUER_URL:-https://sso.greact.ru/realms/toir}
|
||||||
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-toir-backend}
|
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-toir-backend}
|
||||||
KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL:-}
|
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:
|
healthcheck:
|
||||||
test:
|
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 {
|
dto DTO.Equipment {
|
||||||
description "Полный response-объект для единицы оборудования";
|
description "Полный response-объект для единицы оборудования";
|
||||||
attribute id {
|
attribute id {
|
||||||
@@ -31,6 +59,12 @@ dto DTO.Equipment {
|
|||||||
description "Текущий статус";
|
description "Текущий статус";
|
||||||
map Equipment.status;
|
map Equipment.status;
|
||||||
}
|
}
|
||||||
|
attribute attachment {
|
||||||
|
type DTO.FileAttachment;
|
||||||
|
is nullable;
|
||||||
|
description "Вложение (файл в MinIO/S3)";
|
||||||
|
map Equipment.attachment;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dto DTO.EquipmentCreate {
|
dto DTO.EquipmentCreate {
|
||||||
@@ -311,6 +345,22 @@ api API.Equipment {
|
|||||||
type uuid;
|
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 {
|
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_ISSUER_URL="https://sso.greact.ru/realms/toir"
|
||||||
KEYCLOAK_AUDIENCE="toir-backend"
|
KEYCLOAK_AUDIENCE="toir-backend"
|
||||||
KEYCLOAK_JWKS_URL=""
|
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"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1033.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1033.0",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
@@ -42,6 +45,7 @@
|
|||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/supertest": "^7.0.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?
|
dateOfInspection DateTime?
|
||||||
commissionedAt DateTime?
|
commissionedAt DateTime?
|
||||||
status EquipmentStatus @default(Active)
|
status EquipmentStatus @default(Active)
|
||||||
|
/// JSON: { objectKey, originalFileName, contentType, sizeBytes } — файл в MinIO/S3
|
||||||
|
attachment Json?
|
||||||
changeEquipmentStatuses ChangeEquipmentStatus[]
|
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,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import { memoryStorage } from 'multer';
|
||||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../../auth/guards/roles.guard';
|
import { RolesGuard } from '../../auth/guards/roles.guard';
|
||||||
@@ -29,6 +33,24 @@ export class EquipmentController {
|
|||||||
return this.equipmentService.findAll(query, response);
|
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')
|
@Roles('viewer', 'editor', 'admin')
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
|
import { StorageModule } from '../../storage/storage.module';
|
||||||
import { EquipmentController } from './equipment.controller';
|
import { EquipmentController } from './equipment.controller';
|
||||||
import { EquipmentService } from './equipment.service';
|
import { EquipmentService } from './equipment.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, StorageModule],
|
||||||
controllers: [EquipmentController],
|
controllers: [EquipmentController],
|
||||||
providers: [EquipmentService],
|
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 { EquipmentStatus, Prisma } from '@prisma/client';
|
||||||
|
import type { Equipment } from '@prisma/client';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import type { Express } from 'express';
|
||||||
import { setListHeaders } from '../../common/http';
|
import { setListHeaders } from '../../common/http';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
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 { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||||
|
import { isEquipmentAttachmentStored } from './attachment.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EquipmentService {
|
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) {
|
async findAll(query: Record<string, unknown>, response: Response) {
|
||||||
const start = Number(query._start ?? 0);
|
const start = Number(query._start ?? 0);
|
||||||
@@ -51,7 +64,7 @@ export class EquipmentService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
setListHeaders(response, total, start, end);
|
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) {
|
async findOne(id: string) {
|
||||||
@@ -94,19 +107,88 @@ export class EquipmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string) {
|
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 } });
|
const deleted = await this.prisma.equipment.delete({ where: { id } });
|
||||||
return this.toRecord(deleted);
|
return this.toRecord(deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toRecord(item: {
|
async uploadAttachment(id: string, file: Express.Multer.File | undefined) {
|
||||||
id: string;
|
if (!file?.buffer?.length) {
|
||||||
name: string;
|
throw new BadRequestException('File is required (multipart field "file").');
|
||||||
serialNumber: string;
|
}
|
||||||
dateOfInspection: Date | null;
|
if (!this.storage.isConfigured()) {
|
||||||
commissionedAt: Date | null;
|
throw new ServiceUnavailableException(
|
||||||
status: EquipmentStatus;
|
'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 {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
@@ -114,6 +196,31 @@ export class EquipmentService {
|
|||||||
dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
|
dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
|
||||||
commissionedAt: item.commissionedAt?.toISOString() ?? null,
|
commissionedAt: item.commissionedAt?.toISOString() ?? null,
|
||||||
status: item.status,
|
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