Split archive journal to 2 sub-modules by status. Add possibility to upload file for equipment. Change date fields for equipment.
This commit is contained in:
@@ -20,13 +20,20 @@ else
|
||||
if [ "$MIGRATE_EXIT" -eq 0 ]; then
|
||||
cat "$MIGRATE_LOG"
|
||||
rm -f "$MIGRATE_LOG"
|
||||
elif grep -q P3005 "$MIGRATE_LOG"; then
|
||||
elif grep -q "P3005" "$MIGRATE_LOG"; then
|
||||
cat "$MIGRATE_LOG" >&2
|
||||
echo "" >&2
|
||||
echo "prisma migrate deploy failed with P3005: database already has schema but no migration history (typical after prisma db push)." >&2
|
||||
echo "Falling back to prisma db push so the schema stays in sync." >&2
|
||||
rm -f "$MIGRATE_LOG"
|
||||
run_db_push
|
||||
elif grep -q "P3009" "$MIGRATE_LOG" || grep -q "P3018" "$MIGRATE_LOG" || grep -q "42P01" "$MIGRATE_LOG"; then
|
||||
cat "$MIGRATE_LOG" >&2
|
||||
echo "" >&2
|
||||
echo "prisma migrate deploy failed due to an unrecoverable migration state for this environment (failed migration / missing relation)." >&2
|
||||
echo "Falling back to prisma db push to ensure the schema exists and matches schema.prisma." >&2
|
||||
rm -f "$MIGRATE_LOG"
|
||||
run_db_push
|
||||
else
|
||||
cat "$MIGRATE_LOG" >&2
|
||||
rm -f "$MIGRATE_LOG"
|
||||
|
||||
1910
server/package-lock.json
generated
1910
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
-- Add user-managed lifecycle dates for equipment list and cards.
|
||||
ALTER TABLE "Equipment" ADD COLUMN IF NOT EXISTS "installationDate" TIMESTAMP(3);
|
||||
ALTER TABLE "Equipment" ADD COLUMN IF NOT EXISTS "writeOffDate" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Allow equipment cards to store multiple file attachments, same format as status changes.
|
||||
ALTER TABLE "Equipment" ADD COLUMN IF NOT EXISTS "attachments" JSONB;
|
||||
@@ -19,7 +19,11 @@ model Equipment {
|
||||
serialNumber String
|
||||
dateOfInspection DateTime?
|
||||
commissionedAt DateTime?
|
||||
installationDate DateTime?
|
||||
writeOffDate DateTime?
|
||||
status EquipmentStatus @default(Active)
|
||||
/// JSON: [{ id, objectKey, originalFileName, contentType, sizeBytes }] — файлы в MinIO/S3
|
||||
attachments Json?
|
||||
changeEquipmentStatuses ChangeEquipmentStatus[]
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ export class CreateEquipmentDto {
|
||||
@IsString()
|
||||
commissionedAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
installationDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
writeOffDate?: string;
|
||||
|
||||
@IsEnum(EquipmentStatus)
|
||||
status!: EquipmentStatus;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ export class UpdateEquipmentDto {
|
||||
@IsString()
|
||||
commissionedAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
installationDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
writeOffDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(EquipmentStatus)
|
||||
status?: EquipmentStatus;
|
||||
|
||||
@@ -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';
|
||||
@@ -47,6 +52,40 @@ export class EquipmentController {
|
||||
return this.equipmentService.update(id, dto);
|
||||
}
|
||||
|
||||
@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.equipmentService.uploadAttachment(id, file);
|
||||
}
|
||||
|
||||
@Roles('editor', 'admin')
|
||||
@Delete(':id/attachments/:attachmentId')
|
||||
removeAttachment(@Param('id') id: string, @Param('attachmentId') attachmentId: string) {
|
||||
return this.equipmentService.removeAttachment(id, attachmentId);
|
||||
}
|
||||
|
||||
@Roles('viewer', 'editor', 'admin')
|
||||
@Get(':id/attachments/:attachmentId/download')
|
||||
async downloadAttachment(
|
||||
@Param('id') id: string,
|
||||
@Param('attachmentId') attachmentId: string,
|
||||
): Promise<StreamableFile> {
|
||||
const { stream, contentType, fileName, contentLength } =
|
||||
await this.equipmentService.getAttachmentDownloadStream(id, attachmentId);
|
||||
const encoded = encodeURIComponent(fileName);
|
||||
return new StreamableFile(stream, {
|
||||
type: contentType,
|
||||
disposition: `attachment; filename*=UTF-8''${encoded}`,
|
||||
length: contentLength,
|
||||
});
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
|
||||
@@ -8,10 +8,14 @@ import { EquipmentStatus, Prisma } from '@prisma/client';
|
||||
import type { Equipment } from '@prisma/client';
|
||||
import { Response } from 'express';
|
||||
import type { Express } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
|
||||
import { setListHeaders } from '../../common/http';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
import type { StoredAttachmentMeta } from './attachment.types';
|
||||
import { isStoredAttachmentMeta } from './attachment.types';
|
||||
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||
|
||||
@@ -82,6 +86,8 @@ export class EquipmentService {
|
||||
serialNumber: dto.serialNumber,
|
||||
dateOfInspection: dto.dateOfInspection ? new Date(dto.dateOfInspection) : undefined,
|
||||
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||
installationDate: dto.installationDate ? new Date(dto.installationDate) : undefined,
|
||||
writeOffDate: dto.writeOffDate ? new Date(dto.writeOffDate) : undefined,
|
||||
status: dto.status,
|
||||
},
|
||||
});
|
||||
@@ -99,6 +105,8 @@ export class EquipmentService {
|
||||
...payload,
|
||||
dateOfInspection: dto.dateOfInspection ? new Date(dto.dateOfInspection) : undefined,
|
||||
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||
installationDate: dto.installationDate ? new Date(dto.installationDate) : undefined,
|
||||
writeOffDate: dto.writeOffDate ? new Date(dto.writeOffDate) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -114,6 +122,113 @@ export class EquipmentService {
|
||||
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`);
|
||||
}
|
||||
|
||||
const decodedOriginalName = decodeUtf8FilenameFromMultipart(file.originalname);
|
||||
const objectKey = this.storage.buildEquipmentObjectKey(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.equipment.update({
|
||||
where: { id },
|
||||
data: { attachments: next as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
|
||||
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.equipment.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Equipment ${id} not found`);
|
||||
}
|
||||
|
||||
const current = this.parseAttachments(item.attachments);
|
||||
const found = current.find((a) => a.id === attachmentId);
|
||||
if (!found) {
|
||||
throw new NotFoundException(`Attachment ${attachmentId} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.storage.deleteObject(found.objectKey);
|
||||
} catch {
|
||||
}
|
||||
|
||||
const next = current.filter((a) => a.id !== attachmentId);
|
||||
const updated = await this.prisma.equipment.update({
|
||||
where: { id },
|
||||
data: { attachments: next.length ? (next as unknown as Prisma.InputJsonValue) : Prisma.JsonNull },
|
||||
});
|
||||
|
||||
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.equipment.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Equipment ${id} not found`);
|
||||
}
|
||||
|
||||
const current = this.parseAttachments(item.attachments);
|
||||
const found = current.find((a) => a.id === attachmentId);
|
||||
if (!found) {
|
||||
throw new NotFoundException(`Attachment ${attachmentId} not found`);
|
||||
}
|
||||
|
||||
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 async toRecord(item: Equipment) {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -121,7 +236,31 @@ export class EquipmentService {
|
||||
serialNumber: item.serialNumber,
|
||||
dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
|
||||
commissionedAt: item.commissionedAt?.toISOString() ?? null,
|
||||
installationDate: item.installationDate?.toISOString() ?? null,
|
||||
writeOffDate: item.writeOffDate?.toISOString() ?? null,
|
||||
status: item.status,
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ function interpretS3Failure(operation: string, err: unknown): never {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const message = formatS3ErrorMessage(err);
|
||||
const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
|
||||
const code = (err as { Code?: string; name?: string }).Code ?? (err as { name?: string }).name;
|
||||
|
||||
@@ -176,3 +176,28 @@ function interpretS3Failure(operation: string, err: unknown): never {
|
||||
|
||||
throw new ServiceUnavailableException(`S3 ${operation}: ${message}`);
|
||||
}
|
||||
|
||||
function formatS3ErrorMessage(err: unknown): string {
|
||||
const error = err as {
|
||||
Code?: string;
|
||||
code?: string;
|
||||
name?: string;
|
||||
message?: string;
|
||||
cause?: unknown;
|
||||
$metadata?: { httpStatusCode?: number; requestId?: string };
|
||||
};
|
||||
const parts = [
|
||||
error.name,
|
||||
error.Code ?? error.code,
|
||||
error.message,
|
||||
error.$metadata?.httpStatusCode ? `HTTP ${error.$metadata.httpStatusCode}` : undefined,
|
||||
error.$metadata?.requestId ? `requestId ${error.$metadata.requestId}` : undefined,
|
||||
error.cause instanceof Error
|
||||
? `${error.cause.name}: ${error.cause.message}`
|
||||
: error.cause
|
||||
? String(error.cause)
|
||||
: undefined,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
return parts.length ? parts.join(' | ') : String(err);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user