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:
Первов Артем
2026-05-11 09:41:03 +03:00
parent 67f7d617be
commit 05ae32c030
33 changed files with 2753 additions and 1699 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -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;

View File

@@ -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[]
}

View File

@@ -16,6 +16,14 @@ export class CreateEquipmentDto {
@IsString()
commissionedAt?: string;
@IsOptional()
@IsString()
installationDate?: string;
@IsOptional()
@IsString()
writeOffDate?: string;
@IsEnum(EquipmentStatus)
status!: EquipmentStatus;
}

View File

@@ -18,6 +18,14 @@ export class UpdateEquipmentDto {
@IsString()
commissionedAt?: string;
@IsOptional()
@IsString()
installationDate?: string;
@IsOptional()
@IsString()
writeOffDate?: string;
@IsOptional()
@IsEnum(EquipmentStatus)
status?: EquipmentStatus;

View File

@@ -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) {

View File

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

View File

@@ -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);
}