From b1aefae2fa206ee6342389fcf3953a8a8d48743c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Tue, 21 Apr 2026 12:19:49 +0300 Subject: [PATCH] Add support for file attachments in equipment status changes - Introduced functionality for uploading and managing attachments in the ChangeEquipmentStatus module. - Added new endpoints for uploading and deleting attachments, as well as for downloading them. - Updated the ChangeEquipmentStatusService to handle attachment storage and retrieval using the new storage service methods. - Enhanced the ChangeEquipmentStatusEdit and ChangeEquipmentStatusShow components to support attachment input and display. - Removed deprecated attachment handling from Equipment module to streamline functionality. - Updated Prisma schema to reflect changes in attachment management. --- client/package-lock.json | 45 ++--- client/package.json | 2 + client/src/App.tsx | 25 ++- client/src/i18n/ru.ts | 44 +++++ .../src/pages/EmbeddedActiveEquipmentPage.tsx | 76 ++------ .../ChangeEquipmentStatusEdit.tsx | 2 + .../ChangeEquipmentStatusList.tsx | 8 + .../ChangeEquipmentStatusShow.tsx | 28 ++- .../StatusChangeAttachmentLink.tsx} | 11 +- .../StatusChangeAttachmentsInput.tsx | 179 ++++++++++++++++++ .../attachmentDownload.ts | 11 +- .../equipment/EquipmentAttachmentInput.tsx | 111 ----------- .../src/resources/equipment/EquipmentEdit.tsx | 2 - .../src/resources/equipment/EquipmentList.tsx | 18 -- .../src/resources/equipment/EquipmentShow.tsx | 19 +- client/src/theme/toirMuiTheme.ts | 117 ++++++++++++ .../migration.sql | 4 + server/prisma/schema.prisma | 4 +- .../change-equipment-status.service.ts | 159 +++++++++++++++- .../equipment-status-change.controller.ts | 39 ++++ .../equipment-status-change.module.ts | 3 +- .../src/modules/equipment/attachment.types.ts | 12 +- .../modules/equipment/equipment.controller.ts | 36 ---- .../modules/equipment/equipment.service.ts | 136 ------------- server/src/storage/storage.service.ts | 8 + 25 files changed, 669 insertions(+), 430 deletions(-) create mode 100644 client/src/i18n/ru.ts rename client/src/resources/{equipment/EquipmentAttachmentLink.tsx => change-equipment-status/StatusChangeAttachmentLink.tsx} (62%) create mode 100644 client/src/resources/change-equipment-status/StatusChangeAttachmentsInput.tsx rename client/src/resources/{equipment => change-equipment-status}/attachmentDownload.ts (74%) delete mode 100644 client/src/resources/equipment/EquipmentAttachmentInput.tsx create mode 100644 client/src/theme/toirMuiTheme.ts create mode 100644 server/prisma/migrations/20260421090000_move_attachments_to_status_changes/migration.sql diff --git a/client/package-lock.json b/client/package-lock.json index dc4e886..d0a0900 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,8 @@ "@emotion/styled": "^11.14.1", "@mui/material": "^7.3.9", "keycloak-js": "^26.2.3", + "ra-i18n-polyglot": "^5.14.5", + "ra-language-russian": "^5.4.5", "react": "^19.2.4", "react-admin": "^5.14.5", "react-dom": "^19.2.4", @@ -1081,9 +1083,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1101,9 +1100,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1121,9 +1117,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1141,9 +1134,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1161,9 +1151,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1181,9 +1168,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3099,9 +3083,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3123,9 +3104,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3147,9 +3125,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3171,9 +3146,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3655,6 +3627,15 @@ "ra-core": "^5.14.5" } }, + "node_modules/ra-language-russian": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/ra-language-russian/-/ra-language-russian-5.4.5.tgz", + "integrity": "sha512-hCr1KKpcfuIjKbxCbMspBwhEFcQDgjSISvOmCcFcQlLJ+LLni5BOwvDqToh87oOZ9A29wn6NGDqmpwl87PbzQA==", + "license": "MIT", + "dependencies": { + "ra-core": "^5.13.5" + } + }, "node_modules/ra-ui-materialui": { "version": "5.14.5", "resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-5.14.5.tgz", @@ -4335,8 +4316,10 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "extraneous": true, + "dev": true, "license": "ISC", + "optional": true, + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/client/package.json b/client/package.json index 9c465a2..ef1012c 100644 --- a/client/package.json +++ b/client/package.json @@ -14,6 +14,8 @@ "@emotion/styled": "^11.14.1", "@mui/material": "^7.3.9", "keycloak-js": "^26.2.3", + "ra-i18n-polyglot": "^5.14.5", + "ra-language-russian": "^5.4.5", "react": "^19.2.4", "react-admin": "^5.14.5", "react-dom": "^19.2.4", diff --git a/client/src/App.tsx b/client/src/App.tsx index f57cd22..5ca643d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,10 +1,12 @@ -import { createTheme } from '@mui/material/styles'; import { useMemo } from 'react'; import { Admin, Resource } from 'react-admin'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; import { authProvider } from './auth/authProvider'; import { dataProvider } from './dataProvider'; import { useEmbeddedParentTheme } from './embed/useEmbeddedParentTheme'; +import { messagesRu } from './i18n/ru'; import { EmbeddedActiveEquipmentPage } from './pages/EmbeddedActiveEquipmentPage'; +import { buildToirMuiTheme } from './theme/toirMuiTheme'; import { EquipmentCreate } from './resources/equipment/EquipmentCreate'; import { EquipmentEdit } from './resources/equipment/EquipmentEdit'; import { EquipmentList } from './resources/equipment/EquipmentList'; @@ -17,17 +19,27 @@ import { EquipmentStatusChangeShow } from './resources/equipment-status-change/E function ToirAdmin() { const paletteMode = useEmbeddedParentTheme(); const theme = useMemo( - () => - createTheme({ - palette: { mode: paletteMode }, - }), + () => buildToirMuiTheme(paletteMode), [paletteMode], ); + const i18nProvider = useMemo( + () => + polyglotI18nProvider(() => messagesRu, 'ru', { + allowMissing: true, + }), + [], + ); return ( - + - createTheme({ - palette: { mode: paletteMode }, - components: { - MuiPaper: { - styleOverrides: { - root: { - backgroundImage: 'none', - }, - }, - }, - }, - }), + () => buildToirMuiTheme(paletteMode), [paletteMode], ); @@ -132,13 +115,6 @@ export function EmbeddedActiveEquipmentPage() { }; }, []); - const pageBg = - paletteMode === 'dark' ? 'linear-gradient(180deg, #0d1117 0%, #0a0c10 100%)' : '#f3f6fa'; - const headerBg = paletteMode === 'dark' ? '#161b22' : '#fff'; - const headerBorder = paletteMode === 'dark' ? '1px solid #30363d' : '1px solid #d7e0ea'; - const titleColor = paletteMode === 'dark' ? '#e6edf3' : '#10233a'; - const subtitleColor = paletteMode === 'dark' ? '#8b949e' : '#5b7087'; - return ( @@ -147,23 +123,34 @@ export function EmbeddedActiveEquipmentPage() { minHeight: '100vh', boxSizing: 'border-box', p: { xs: 2, md: 3 }, - bgcolor: pageBg, + background: + paletteMode === 'dark' + ? 'radial-gradient(circle at 10% 10%, rgba(201, 122, 61, 0.18), transparent 40%), radial-gradient(circle at 90% 90%, rgba(212, 165, 116, 0.14), transparent 42%), #0a0d12' + : 'radial-gradient(circle at 12% 12%, rgba(182, 130, 81, 0.20), transparent 42%), radial-gradient(circle at 88% 88%, rgba(214, 188, 157, 0.26), transparent 44%), #f7f0e5', }} > - - + `1px solid ${theme.palette.divider}`, + background: + paletteMode === 'dark' + ? 'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.1))' + : 'linear-gradient(145deg, rgba(255,255,255,0.9), rgba(255,255,255,0.62))', + }} + > + Оборудование в эксплуатации - + Отображаются записи со статусом 'Active' {typeof total === 'number' ? `: ${total}` : ''} @@ -186,7 +173,6 @@ export function EmbeddedActiveEquipmentPage() { Заводской номер Дата изготовления Дата поверки - Файл @@ -196,26 +182,6 @@ export function EmbeddedActiveEquipmentPage() { {item.serialNumber} {formatDate(item.commissionedAt)} {formatDate(item.dateOfInspection)} - - {item?.attachment?.objectKey ? ( - { - e.preventDefault(); - e.stopPropagation(); - const label = item.attachment?.originalFileName?.trim() || 'файл'; - void downloadEquipmentAttachmentFile(item.id, label).catch(() => {}); - }} - sx={{ cursor: 'pointer', verticalAlign: 'inherit' }} - > - {item.attachment?.originalFileName?.trim() || 'Скачать'} - - ) : ( - '—' - )} - ))} diff --git a/client/src/resources/change-equipment-status/ChangeEquipmentStatusEdit.tsx b/client/src/resources/change-equipment-status/ChangeEquipmentStatusEdit.tsx index ed9bc8e..5094a0e 100644 --- a/client/src/resources/change-equipment-status/ChangeEquipmentStatusEdit.tsx +++ b/client/src/resources/change-equipment-status/ChangeEquipmentStatusEdit.tsx @@ -8,6 +8,7 @@ import { TextInput as RaTextInput, } from 'react-admin'; import { equipmentStatusChoices, equipmentOptionText } from '../equipment/shared'; +import { StatusChangeAttachmentsInput } from './StatusChangeAttachmentsInput'; export function ChangeEquipmentStatusEdit() { return ( @@ -24,6 +25,7 @@ export function ChangeEquipmentStatusEdit() { + ); diff --git a/client/src/resources/change-equipment-status/ChangeEquipmentStatusList.tsx b/client/src/resources/change-equipment-status/ChangeEquipmentStatusList.tsx index dbd9066..892971a 100644 --- a/client/src/resources/change-equipment-status/ChangeEquipmentStatusList.tsx +++ b/client/src/resources/change-equipment-status/ChangeEquipmentStatusList.tsx @@ -3,6 +3,7 @@ import { Datagrid, DateField, FilterButton, + FunctionField, List, ReferenceField, SelectArrayInput, @@ -38,6 +39,13 @@ export function ChangeEquipmentStatusList() { + { + const count = Array.isArray(record.attachments) ? record.attachments.length : 0; + return count ? String(count) : '—'; + }} + /> ); diff --git a/client/src/resources/change-equipment-status/ChangeEquipmentStatusShow.tsx b/client/src/resources/change-equipment-status/ChangeEquipmentStatusShow.tsx index 1914dde..e786ff0 100644 --- a/client/src/resources/change-equipment-status/ChangeEquipmentStatusShow.tsx +++ b/client/src/resources/change-equipment-status/ChangeEquipmentStatusShow.tsx @@ -1,5 +1,6 @@ -import { DateField, ReferenceField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'; +import { DateField, FunctionField, ReferenceField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'; import { equipmentStatusChoices } from '../equipment/shared'; +import { StatusChangeAttachmentLink } from './StatusChangeAttachmentLink'; export function ChangeEquipmentStatusShow() { return ( @@ -13,6 +14,31 @@ export function ChangeEquipmentStatusShow() { + { + const items = Array.isArray(record.attachments) ? record.attachments : []; + if (!items.length) { + return '—'; + } + return ( +
+ {items.map((att) => ( +
+ +
+ ))} +
+ ); + }} + /> ); diff --git a/client/src/resources/equipment/EquipmentAttachmentLink.tsx b/client/src/resources/change-equipment-status/StatusChangeAttachmentLink.tsx similarity index 62% rename from client/src/resources/equipment/EquipmentAttachmentLink.tsx rename to client/src/resources/change-equipment-status/StatusChangeAttachmentLink.tsx index d7d5aae..2cac13e 100644 --- a/client/src/resources/equipment/EquipmentAttachmentLink.tsx +++ b/client/src/resources/change-equipment-status/StatusChangeAttachmentLink.tsx @@ -1,14 +1,14 @@ import { Link } from '@mui/material'; import { useNotify } from 'react-admin'; -import { downloadEquipmentAttachmentFile } from './attachmentDownload'; +import { downloadStatusChangeAttachmentFile } from './attachmentDownload'; type Props = { - equipmentId: string; - /** Текст ссылки и имя при сохранении в браузере */ + statusChangeId: string; + attachmentId: string; fileName: string; }; -export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) { +export function StatusChangeAttachmentLink({ statusChangeId, attachmentId, fileName }: Props) { const notify = useNotify(); const label = fileName.trim() || 'Скачать'; @@ -19,7 +19,7 @@ export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - void downloadEquipmentAttachmentFile(equipmentId, label).catch(() => + void downloadStatusChangeAttachmentFile(statusChangeId, attachmentId, label).catch(() => notify('Не удалось скачать файл', { type: 'warning' }), ); }} @@ -29,3 +29,4 @@ export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) { ); } + diff --git a/client/src/resources/change-equipment-status/StatusChangeAttachmentsInput.tsx b/client/src/resources/change-equipment-status/StatusChangeAttachmentsInput.tsx new file mode 100644 index 0000000..45eefde --- /dev/null +++ b/client/src/resources/change-equipment-status/StatusChangeAttachmentsInput.tsx @@ -0,0 +1,179 @@ +import { Button, Chip, Stack, Typography } from '@mui/material'; +import { useNotify, useRecordContext, useRefresh } from 'react-admin'; +import { useCallback, useMemo, useState, type ChangeEvent } from 'react'; +import { ensureFreshToken, getAccessToken } from '../../auth/keycloak'; +import { resolveApiBaseUrl } from '../../lib/apiBase'; +import { StatusChangeAttachmentLink } from './StatusChangeAttachmentLink'; + +export type StatusChangeAttachmentValue = { + id: string; + originalFileName?: string | null; + contentType?: string | null; + sizeBytes?: number | null; +}[]; + +function formatBytes(bytes: number | null | undefined) { + if (!bytes || bytes <= 0) return null; + const kb = bytes / 1024; + if (kb < 1024) return `${Math.round(kb)} КБ`; + return `${(kb / 1024).toFixed(1)} МБ`; +} + +export function StatusChangeAttachmentsInput() { + const record = useRecordContext(); + const notify = useNotify(); + const refresh = useRefresh(); + const [busy, setBusy] = useState(false); + + const statusChangeId = record?.id ? String(record.id) : null; + const attachments = useMemo(() => { + const raw = (record as { attachments?: StatusChangeAttachmentValue | null })?.attachments; + return Array.isArray(raw) ? raw : []; + }, [record]); + + const upload = useCallback( + async (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ''; + if (!files.length || !statusChangeId) { + return; + } + + setBusy(true); + try { + await ensureFreshToken(); + const token = getAccessToken(); + const url = `${resolveApiBaseUrl()}/status-changes/${statusChangeId}/attachments`; + + for (const file of files) { + const form = new FormData(); + form.append('file', file); + 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(files.length === 1 ? 'Файл добавлен' : `Файлы добавлены: ${files.length}`, { + type: 'success', + }); + refresh(); + } catch { + notify('Не удалось загрузить файл', { type: 'warning' }); + } finally { + setBusy(false); + } + }, + [notify, refresh, statusChangeId], + ); + + const remove = useCallback( + async (attachmentId: string) => { + if (!statusChangeId) { + return; + } + setBusy(true); + try { + await ensureFreshToken(); + const token = getAccessToken(); + const url = `${resolveApiBaseUrl()}/status-changes/${statusChangeId}/attachments/${attachmentId}`; + 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, statusChangeId], + ); + + return ( + + + Вложения (файлы) + + + {!statusChangeId ? ( + + Сначала сохраните запись, затем можно будет прикреплять файлы. + + ) : attachments.length ? ( + + {attachments.map((att) => ( + `1px solid ${theme.palette.divider}`, + background: + 'linear-gradient(155deg, rgba(255,255,255,0.06), rgba(0,0,0,0.08))', + }} + > + + + + + + {att.contentType ? ( + + ) : null} + {formatBytes(att.sizeBytes) ? ( + + {formatBytes(att.sizeBytes)} + + ) : null} + + + + + + ))} + + ) : ( + + Файлы не прикреплены + + )} + + + + + + ); +} + diff --git a/client/src/resources/equipment/attachmentDownload.ts b/client/src/resources/change-equipment-status/attachmentDownload.ts similarity index 74% rename from client/src/resources/equipment/attachmentDownload.ts rename to client/src/resources/change-equipment-status/attachmentDownload.ts index d4aa2ba..f408a84 100644 --- a/client/src/resources/equipment/attachmentDownload.ts +++ b/client/src/resources/change-equipment-status/attachmentDownload.ts @@ -1,11 +1,15 @@ import { ensureFreshToken, getAccessToken } from '../../auth/keycloak'; import { resolveApiBaseUrl } from '../../lib/apiBase'; -/** Скачивание через API (JWT + Content-Disposition), без открытия URL MinIO в браузере. */ -export async function downloadEquipmentAttachmentFile(equipmentId: string, suggestedFileName: string): Promise { +/** Скачивание через API (JWT + Content-Disposition). */ +export async function downloadStatusChangeAttachmentFile( + statusChangeId: string, + attachmentId: string, + suggestedFileName: string, +): Promise { await ensureFreshToken(); const token = getAccessToken(); - const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/attachment/download`; + const url = `${resolveApiBaseUrl()}/status-changes/${statusChangeId}/attachments/${attachmentId}/download`; const response = await fetch(url, { method: 'GET', headers: token ? { Authorization: `Bearer ${token}`, Accept: '*/*' } : { Accept: '*/*' }, @@ -24,3 +28,4 @@ export async function downloadEquipmentAttachmentFile(equipmentId: string, sugge a.remove(); URL.revokeObjectURL(objectUrl); } + diff --git a/client/src/resources/equipment/EquipmentAttachmentInput.tsx b/client/src/resources/equipment/EquipmentAttachmentInput.tsx deleted file mode 100644 index 0c840f7..0000000 --- a/client/src/resources/equipment/EquipmentAttachmentInput.tsx +++ /dev/null @@ -1,111 +0,0 @@ -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'; -import { EquipmentAttachmentLink } from './EquipmentAttachmentLink'; - -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) => { - 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 ( - - - Вложение (файл в MinIO) - - {att?.objectKey ? ( - - - {att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null} - - ) : ( - - Файл не прикреплён - - )} - - - - - - ); -} diff --git a/client/src/resources/equipment/EquipmentEdit.tsx b/client/src/resources/equipment/EquipmentEdit.tsx index 2ba57e7..cd68308 100644 --- a/client/src/resources/equipment/EquipmentEdit.tsx +++ b/client/src/resources/equipment/EquipmentEdit.tsx @@ -1,5 +1,4 @@ import { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin'; -import { EquipmentAttachmentInput } from './EquipmentAttachmentInput'; import { equipmentStatusChoices } from './shared'; const equipmentLabels = { @@ -19,7 +18,6 @@ export function EquipmentEdit() { - ); diff --git a/client/src/resources/equipment/EquipmentList.tsx b/client/src/resources/equipment/EquipmentList.tsx index e795377..f8c6f79 100644 --- a/client/src/resources/equipment/EquipmentList.tsx +++ b/client/src/resources/equipment/EquipmentList.tsx @@ -3,7 +3,6 @@ import { Datagrid, DateField, FilterButton, - FunctionField, List, SelectArrayInput, SelectField, @@ -11,7 +10,6 @@ import { TextInput, TopToolbar, } from 'react-admin'; -import { EquipmentAttachmentLink } from './EquipmentAttachmentLink'; import { equipmentStatusChoices } from './shared'; const equipmentFilters = [ @@ -36,22 +34,6 @@ export function EquipmentList() { - - record?.attachment?.objectKey ? ( - - ) : ( - '—' - ) - } - /> ); diff --git a/client/src/resources/equipment/EquipmentShow.tsx b/client/src/resources/equipment/EquipmentShow.tsx index 076fa07..4bb5348 100644 --- a/client/src/resources/equipment/EquipmentShow.tsx +++ b/client/src/resources/equipment/EquipmentShow.tsx @@ -1,5 +1,4 @@ -import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'; -import { EquipmentAttachmentLink } from './EquipmentAttachmentLink'; +import { DateField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'; import { equipmentStatusChoices } from './shared'; export function EquipmentShow() { @@ -12,22 +11,6 @@ export function EquipmentShow() { - - record?.attachment?.objectKey ? ( - - ) : ( - '—' - ) - } - /> ); diff --git a/client/src/theme/toirMuiTheme.ts b/client/src/theme/toirMuiTheme.ts new file mode 100644 index 0000000..6b7852f --- /dev/null +++ b/client/src/theme/toirMuiTheme.ts @@ -0,0 +1,117 @@ +import { createTheme, type Theme } from '@mui/material/styles'; +import type { EmbedPaletteMode } from '../embed/useEmbeddedParentTheme'; + +type Mode = EmbedPaletteMode; + +function buildTokens(mode: Mode) { + if (mode === 'light') { + return { + textPrimary: '#2a1d14', + textSecondary: '#4a3526', + divider: 'rgba(76, 59, 45, 0.10)', + border: 'rgba(170, 126, 88, 0.28)', + bgDefault: '#f7f0e5', + bgPaper: 'rgba(255, 252, 247, 0.92)', + glass: 'rgba(255, 249, 242, 0.78)', + accent: '#bd8d64', + accentSoft: 'rgba(182, 130, 81, 0.22)', + shadow: '0 20px 48px rgba(42, 29, 20, 0.10)', + }; + } + + return { + textPrimary: '#f8fafc', + textSecondary: '#cbd5e1', + divider: 'rgba(255, 255, 255, 0.06)', + border: 'rgba(212, 165, 116, 0.18)', + bgDefault: '#0a0d12', + bgPaper: 'rgba(10, 13, 18, 0.72)', + glass: 'rgba(60, 40, 23, 0.55)', + accent: '#d4a574', + accentSoft: 'rgba(201, 122, 61, 0.24)', + shadow: '0 34px 86px rgba(0, 0, 0, 0.56)', + }; +} + +export function buildToirMuiTheme(mode: Mode): Theme { + const t = buildTokens(mode); + + return createTheme({ + palette: { + mode, + primary: { main: t.accent }, + secondary: { main: t.accent }, + divider: t.divider, + background: { + default: t.bgDefault, + paper: t.bgPaper, + }, + text: { + primary: t.textPrimary, + secondary: t.textSecondary, + }, + }, + shape: { + borderRadius: 14, + }, + typography: { + fontFamily: + '"Inter Variable","Inter",-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell",sans-serif', + }, + components: { + MuiCssBaseline: { + styleOverrides: { + body: { + background: t.bgDefault, + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: 'none', + border: `1px solid ${t.border}`, + background: `linear-gradient(155deg, ${t.glass}, ${t.bgPaper})`, + boxShadow: t.shadow, + backdropFilter: 'blur(26px) saturate(1.12)', + WebkitBackdropFilter: 'blur(26px) saturate(1.12)', + }, + }, + }, + MuiTableCell: { + styleOverrides: { + head: { + fontWeight: 700, + borderBottom: `1px solid ${t.border}`, + }, + root: { + borderBottom: `1px solid ${t.divider}`, + }, + }, + }, + MuiLink: { + styleOverrides: { + root: { + fontWeight: 500, + }, + }, + }, + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + borderRadius: 999, + }, + containedPrimary: { + backgroundImage: + mode === 'light' + ? 'linear-gradient(135deg, #cb9a6f 0%, #d9b48d 50%, #edd9be 100%)' + : 'linear-gradient(135deg, #c97a3d 0%, #d4a574 50%, #e8c9a0 100%)', + boxShadow: `0 18px 44px ${t.accentSoft}`, + }, + }, + }, + }, + }); +} + diff --git a/server/prisma/migrations/20260421090000_move_attachments_to_status_changes/migration.sql b/server/prisma/migrations/20260421090000_move_attachments_to_status_changes/migration.sql new file mode 100644 index 0000000..b689022 --- /dev/null +++ b/server/prisma/migrations/20260421090000_move_attachments_to_status_changes/migration.sql @@ -0,0 +1,4 @@ +-- Move attachments from Equipment to ChangeEquipmentStatus and allow multiple files. +ALTER TABLE IF EXISTS "Equipment" DROP COLUMN IF EXISTS "attachment"; +ALTER TABLE IF EXISTS "ChangeEquipmentStatus" ADD COLUMN IF NOT EXISTS "attachments" JSONB; + diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index dc07dda..b373683 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -20,8 +20,6 @@ model Equipment { dateOfInspection DateTime? commissionedAt DateTime? status EquipmentStatus @default(Active) - /// JSON: { objectKey, originalFileName, contentType, sizeBytes } — файл в MinIO/S3 - attachment Json? changeEquipmentStatuses ChangeEquipmentStatus[] } @@ -33,4 +31,6 @@ model ChangeEquipmentStatus { number String? date DateTime responsible String? + /// JSON: [{ id, objectKey, originalFileName, contentType, sizeBytes }] — файлы в MinIO/S3 + attachments Json? } diff --git a/server/src/modules/change-equipment-status/change-equipment-status.service.ts b/server/src/modules/change-equipment-status/change-equipment-status.service.ts index 3b42a9f..8767048 100644 --- a/server/src/modules/change-equipment-status/change-equipment-status.service.ts +++ b/server/src/modules/change-equipment-status/change-equipment-status.service.ts @@ -1,14 +1,29 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; import { EquipmentStatus, Prisma } from '@prisma/client'; import { Response } from 'express'; +import type { Express } from 'express'; +import { randomUUID } from 'node:crypto'; +import type { Readable } from 'node:stream'; import { setListHeaders } from '../../common/http'; +import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename'; import { PrismaService } from '../../prisma/prisma.service'; +import { StorageService } from '../../storage/storage.service'; +import type { StoredAttachmentMeta } from '../equipment/attachment.types'; +import { isStoredAttachmentMeta } from '../equipment/attachment.types'; import { CreateChangeEquipmentStatusDto } from './dto/create-change-equipment-status.dto'; import { UpdateChangeEquipmentStatusDto } from './dto/update-change-equipment-status.dto'; @Injectable() export class ChangeEquipmentStatusService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly storage: StorageService, + ) {} async findAll(query: Record, response: Response) { const start = Number(query._start ?? 0); @@ -135,6 +150,123 @@ export class ChangeEquipmentStatusService { 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.changeEquipmentStatus.findUnique({ + where: { id }, + include: { equipment: true }, + }); + if (!item) { + throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`); + } + + const decodedOriginalName = decodeUtf8FilenameFromMultipart(file.originalname); + const objectKey = this.storage.buildStatusChangeObjectKey(id, decodedOriginalName); + const meta: StoredAttachmentMeta = { + id: randomUUID(), + objectKey, + originalFileName: decodedOriginalName, + contentType: file.mimetype || 'application/octet-stream', + sizeBytes: file.size, + }; + + await this.storage.putObject(objectKey, file.buffer, meta.contentType); + + const current = this.parseAttachments(item.attachments); + const next = [...current, meta]; + + const updated = await this.prisma.changeEquipmentStatus.update({ + where: { id }, + data: { attachments: next as unknown as Prisma.InputJsonValue }, + include: { equipment: true }, + }); + + return this.toRecord(updated); + } + + async removeAttachment(id: string, attachmentId: string) { + if (!this.storage.isConfigured()) { + throw new ServiceUnavailableException( + 'Object storage is not configured (set S3_* environment variables).', + ); + } + + const item = await this.prisma.changeEquipmentStatus.findUnique({ + where: { id }, + include: { equipment: true }, + }); + if (!item) { + throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`); + } + + const current = this.parseAttachments(item.attachments); + const found = current.find((a) => a.id === attachmentId); + if (!found) { + throw new NotFoundException(`Вложение ${attachmentId} не найдено`); + } + + try { + await this.storage.deleteObject(found.objectKey); + } catch { + // best-effort + } + + const next = current.filter((a) => a.id !== attachmentId); + const updated = await this.prisma.changeEquipmentStatus.update({ + where: { id }, + data: { attachments: next.length ? (next as unknown as Prisma.InputJsonValue) : Prisma.JsonNull }, + include: { equipment: true }, + }); + + return this.toRecord(updated); + } + + async getAttachmentDownloadStream( + id: string, + attachmentId: string, + ): Promise<{ + stream: Readable; + contentType: string; + fileName: string; + contentLength?: number; + }> { + if (!this.storage.isConfigured()) { + throw new ServiceUnavailableException( + 'Object storage is not configured (set S3_* environment variables).', + ); + } + + const item = await this.prisma.changeEquipmentStatus.findUnique({ where: { id } }); + if (!item) { + throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`); + } + + const current = this.parseAttachments(item.attachments); + const found = current.find((a) => a.id === attachmentId); + if (!found) { + throw new NotFoundException(`Вложение ${attachmentId} не найдено`); + } + + const fileName = decodeUtf8FilenameFromMultipart(found.originalFileName) || 'file'; + const { stream, contentType, contentLength } = await this.storage.getObjectStream(found.objectKey); + const len = typeof contentLength === 'bigint' ? Number(contentLength) : contentLength; + + return { + stream, + contentType: found.contentType || contentType, + fileName, + contentLength: len, + }; + } + private toRecord(item: { id: string; equipmentId: string | null; @@ -143,6 +275,7 @@ export class ChangeEquipmentStatusService { date: Date; responsible: string | null; equipment?: { id: string; name: string } | null; + attachments?: Prisma.JsonValue | null; }) { return { id: item.id, @@ -157,6 +290,28 @@ export class ChangeEquipmentStatusService { name: item.equipment.name, } : null, + attachments: this.serializeAttachments(item.attachments), }; } + + private parseAttachments(raw: Prisma.JsonValue | null | undefined): StoredAttachmentMeta[] { + if (!raw) { + return []; + } + if (!Array.isArray(raw)) { + return []; + } + return raw.filter(isStoredAttachmentMeta); + } + + private serializeAttachments(raw: Prisma.JsonValue | null | undefined) { + const items = this.parseAttachments(raw); + return items.map((a) => ({ + id: a.id, + originalFileName: decodeUtf8FilenameFromMultipart(a.originalFileName), + contentType: a.contentType, + sizeBytes: a.sizeBytes, + downloadUrl: null as string | null, + })); + } } diff --git a/server/src/modules/equipment-status-change/equipment-status-change.controller.ts b/server/src/modules/equipment-status-change/equipment-status-change.controller.ts index ea079d3..1187a22 100644 --- a/server/src/modules/equipment-status-change/equipment-status-change.controller.ts +++ b/server/src/modules/equipment-status-change/equipment-status-change.controller.ts @@ -8,9 +8,14 @@ import { Post, Query, Res, + StreamableFile, + 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'; @@ -35,6 +40,40 @@ export class EquipmentStatusChangeController { return this.equipmentStatusChangeService.findOne(id); } + @Roles('editor', 'admin') + @Post(':id/attachments') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 50 * 1024 * 1024 }, + }), + ) + uploadAttachment(@Param('id') id: string, @UploadedFile() file: Express.Multer.File) { + return this.equipmentStatusChangeService.uploadAttachment(id, file); + } + + @Roles('editor', 'admin') + @Delete(':id/attachments/:attachmentId') + removeAttachment(@Param('id') id: string, @Param('attachmentId') attachmentId: string) { + return this.equipmentStatusChangeService.removeAttachment(id, attachmentId); + } + + @Roles('viewer', 'editor', 'admin') + @Get(':id/attachments/:attachmentId/download') + async downloadAttachment( + @Param('id') id: string, + @Param('attachmentId') attachmentId: string, + ): Promise { + const { stream, contentType, fileName, contentLength } = + await this.equipmentStatusChangeService.getAttachmentDownloadStream(id, attachmentId); + const encoded = encodeURIComponent(fileName); + return new StreamableFile(stream, { + type: contentType, + disposition: `attachment; filename*=UTF-8''${encoded}`, + length: contentLength, + }); + } + @Roles('editor', 'admin') @Post() create(@Body() dto: CreateChangeEquipmentStatusDto) { diff --git a/server/src/modules/equipment-status-change/equipment-status-change.module.ts b/server/src/modules/equipment-status-change/equipment-status-change.module.ts index dd8821c..51e4505 100644 --- a/server/src/modules/equipment-status-change/equipment-status-change.module.ts +++ b/server/src/modules/equipment-status-change/equipment-status-change.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../../auth/auth.module'; +import { StorageModule } from '../../storage/storage.module'; import { ChangeEquipmentStatusService } from '../change-equipment-status/change-equipment-status.service'; import { EquipmentStatusChangeController } from './equipment-status-change.controller'; @Module({ - imports: [AuthModule], + imports: [AuthModule, StorageModule], controllers: [EquipmentStatusChangeController], providers: [ChangeEquipmentStatusService], }) diff --git a/server/src/modules/equipment/attachment.types.ts b/server/src/modules/equipment/attachment.types.ts index 4e514de..4356a37 100644 --- a/server/src/modules/equipment/attachment.types.ts +++ b/server/src/modules/equipment/attachment.types.ts @@ -1,14 +1,20 @@ -export type EquipmentAttachmentStored = { +export type StoredAttachmentMeta = { + id: string; objectKey: string; originalFileName: string; contentType: string; sizeBytes: number; }; -export function isEquipmentAttachmentStored(value: unknown): value is EquipmentAttachmentStored { +export function isStoredAttachmentMeta(value: unknown): value is StoredAttachmentMeta { if (!value || typeof value !== 'object') { return false; } const o = value as Record; - return typeof o.objectKey === 'string' && o.objectKey.length > 0; + return ( + typeof o.id === 'string' && + o.id.length > 0 && + typeof o.objectKey === 'string' && + o.objectKey.length > 0 + ); } diff --git a/server/src/modules/equipment/equipment.controller.ts b/server/src/modules/equipment/equipment.controller.ts index e8c18fa..7667b80 100644 --- a/server/src/modules/equipment/equipment.controller.ts +++ b/server/src/modules/equipment/equipment.controller.ts @@ -8,14 +8,9 @@ import { Post, Query, Res, - StreamableFile, - 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'; @@ -34,37 +29,6 @@ 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/attachment/download') - async downloadAttachment(@Param('id') id: string): Promise { - const { stream, contentType, fileName, contentLength } = - await this.equipmentService.getAttachmentDownloadStream(id); - const encoded = encodeURIComponent(fileName); - return new StreamableFile(stream, { - type: contentType, - disposition: `attachment; filename*=UTF-8''${encoded}`, - length: contentLength, - }); - } - @Roles('viewer', 'editor', 'admin') @Get(':id') findOne(@Param('id') id: string) { diff --git a/server/src/modules/equipment/equipment.service.ts b/server/src/modules/equipment/equipment.service.ts index a43d04c..80472bb 100644 --- a/server/src/modules/equipment/equipment.service.ts +++ b/server/src/modules/equipment/equipment.service.ts @@ -11,12 +11,9 @@ 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 type { Readable } from 'node:stream'; import { CreateEquipmentDto } from './dto/create-equipment.dto'; import { UpdateEquipmentDto } from './dto/update-equipment.dto'; -import { isEquipmentAttachmentStored } from './attachment.types'; @Injectable() export class EquipmentService { @@ -113,118 +110,10 @@ export class EquipmentService { 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 getAttachmentDownloadStream(id: string): Promise<{ - stream: Readable; - contentType: string; - fileName: string; - contentLength?: number; - }> { - 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`); - } - if (!isEquipmentAttachmentStored(item.attachment)) { - throw new NotFoundException(`Вложение для оборудования ${id} не найдено`); - } - - const raw = item.attachment; - const fileName = decodeUtf8FilenameFromMultipart(raw.originalFileName) || 'file'; - const { stream, contentType, contentLength } = await this.storage.getObjectStream(raw.objectKey); - const len = - typeof contentLength === 'bigint' ? Number(contentLength) : contentLength; - - return { - stream, - contentType: raw.contentType || contentType, - fileName, - contentLength: len, - }; - } - - 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, @@ -233,31 +122,6 @@ 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: decodeUtf8FilenameFromMultipart(raw.originalFileName), - contentType: raw.contentType, - sizeBytes: raw.sizeBytes, - downloadUrl, }; } } diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts index bb44241..9307d4d 100644 --- a/server/src/storage/storage.service.ts +++ b/server/src/storage/storage.service.ts @@ -126,6 +126,14 @@ export class StorageService { const safe = sanitizeFileName(originalName); return `${prefix}/${equipmentId}/${randomUUID()}-${safe}`; } + + buildStatusChangeObjectKey(statusChangeId: string, originalName: string): string { + const prefix = (process.env.S3_OBJECT_PREFIX ?? 'toir-light/equipment') + .replace(/^\/+|\/+$/g, '') + .replace(/equipment$/, 'status-changes'); + const safe = sanitizeFileName(originalName); + return `${prefix}/${statusChangeId}/${randomUUID()}-${safe}`; + } } function sanitizeFileName(name: string): string {