442 lines
12 KiB
TypeScript
442 lines
12 KiB
TypeScript
![]() |
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||
|
import { ImageStatus } from '@prisma/client';
|
||
|
import { PrismaService } from '../database/prisma.service';
|
||
|
import { StorageService } from '../storage/storage.service';
|
||
|
import { UpdateFilenameResponseDto } from './dto/update-filename.dto';
|
||
|
import { ImageResponseDto, BatchImagesResponseDto } from './dto/image-response.dto';
|
||
|
|
||
|
@Injectable()
|
||
|
export class ImagesService {
|
||
|
private readonly logger = new Logger(ImagesService.name);
|
||
|
|
||
|
constructor(
|
||
|
private readonly prisma: PrismaService,
|
||
|
private readonly storageService: StorageService,
|
||
|
) {}
|
||
|
|
||
|
/**
|
||
|
* Update image filename
|
||
|
*/
|
||
|
async updateFilename(
|
||
|
imageId: string,
|
||
|
userId: string,
|
||
|
newName: string,
|
||
|
): Promise<UpdateFilenameResponseDto> {
|
||
|
try {
|
||
|
// Find image and verify ownership
|
||
|
const image = await this.prisma.image.findFirst({
|
||
|
where: {
|
||
|
id: imageId,
|
||
|
batch: { userId },
|
||
|
},
|
||
|
include: {
|
||
|
batch: { select: { userId: true } },
|
||
|
},
|
||
|
});
|
||
|
|
||
|
if (!image) {
|
||
|
throw new NotFoundException('Image not found');
|
||
|
}
|
||
|
|
||
|
// Validate filename
|
||
|
if (!this.isValidFilename(newName)) {
|
||
|
throw new BadRequestException('Invalid filename format');
|
||
|
}
|
||
|
|
||
|
// Ensure filename has proper extension
|
||
|
if (!this.hasValidExtension(newName)) {
|
||
|
throw new BadRequestException('Filename must have a valid image extension');
|
||
|
}
|
||
|
|
||
|
// Update the proposed name
|
||
|
const updatedImage = await this.prisma.image.update({
|
||
|
where: { id: imageId },
|
||
|
data: {
|
||
|
proposedName: newName,
|
||
|
updatedAt: new Date(),
|
||
|
},
|
||
|
});
|
||
|
|
||
|
this.logger.log(`Updated filename for image: ${imageId} to: ${newName}`);
|
||
|
|
||
|
return {
|
||
|
id: updatedImage.id,
|
||
|
proposed_name: updatedImage.proposedName!,
|
||
|
original_name: updatedImage.originalName,
|
||
|
updated_at: updatedImage.updatedAt.toISOString(),
|
||
|
};
|
||
|
} catch (error) {
|
||
|
if (
|
||
|
error instanceof NotFoundException ||
|
||
|
error instanceof BadRequestException
|
||
|
) {
|
||
|
throw error;
|
||
|
}
|
||
|
this.logger.error(`Failed to update filename for image: ${imageId}`, error.stack);
|
||
|
throw new BadRequestException('Failed to update image filename');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get image details
|
||
|
*/
|
||
|
async getImage(imageId: string, userId: string): Promise<ImageResponseDto> {
|
||
|
try {
|
||
|
const image = await this.prisma.image.findFirst({
|
||
|
where: {
|
||
|
id: imageId,
|
||
|
batch: { userId },
|
||
|
},
|
||
|
});
|
||
|
|
||
|
if (!image) {
|
||
|
throw new NotFoundException('Image not found');
|
||
|
}
|
||
|
|
||
|
return this.mapImageToResponse(image);
|
||
|
} catch (error) {
|
||
|
if (error instanceof NotFoundException) {
|
||
|
throw error;
|
||
|
}
|
||
|
this.logger.error(`Failed to get image: ${imageId}`, error.stack);
|
||
|
throw new BadRequestException('Failed to get image details');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get all images in a batch
|
||
|
*/
|
||
|
async getBatchImages(batchId: string, userId: string): Promise<BatchImagesResponseDto> {
|
||
|
try {
|
||
|
// Verify batch ownership
|
||
|
const batch = await this.prisma.batch.findFirst({
|
||
|
where: {
|
||
|
id: batchId,
|
||
|
userId,
|
||
|
},
|
||
|
include: {
|
||
|
images: {
|
||
|
orderBy: { createdAt: 'asc' },
|
||
|
},
|
||
|
},
|
||
|
});
|
||
|
|
||
|
if (!batch) {
|
||
|
throw new NotFoundException('Batch not found');
|
||
|
}
|
||
|
|
||
|
// Calculate status summary
|
||
|
const statusSummary = {
|
||
|
pending: 0,
|
||
|
processing: 0,
|
||
|
completed: 0,
|
||
|
failed: 0,
|
||
|
};
|
||
|
|
||
|
batch.images.forEach((image) => {
|
||
|
switch (image.status) {
|
||
|
case ImageStatus.PENDING:
|
||
|
statusSummary.pending++;
|
||
|
break;
|
||
|
case ImageStatus.PROCESSING:
|
||
|
statusSummary.processing++;
|
||
|
break;
|
||
|
case ImageStatus.COMPLETED:
|
||
|
statusSummary.completed++;
|
||
|
break;
|
||
|
case ImageStatus.FAILED:
|
||
|
statusSummary.failed++;
|
||
|
break;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
return {
|
||
|
batch_id: batchId,
|
||
|
total_images: batch.images.length,
|
||
|
images: batch.images.map(this.mapImageToResponse),
|
||
|
status_summary: statusSummary,
|
||
|
};
|
||
|
} catch (error) {
|
||
|
if (error instanceof NotFoundException) {
|
||
|
throw error;
|
||
|
}
|
||
|
this.logger.error(`Failed to get batch images: ${batchId}`, error.stack);
|
||
|
throw new BadRequestException('Failed to get batch images');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get presigned download URL for image
|
||
|
*/
|
||
|
async getImageDownloadUrl(
|
||
|
imageId: string,
|
||
|
userId: string,
|
||
|
): Promise<{
|
||
|
download_url: string;
|
||
|
expires_at: string;
|
||
|
filename: string;
|
||
|
}> {
|
||
|
try {
|
||
|
const image = await this.prisma.image.findFirst({
|
||
|
where: {
|
||
|
id: imageId,
|
||
|
batch: { userId },
|
||
|
},
|
||
|
});
|
||
|
|
||
|
if (!image) {
|
||
|
throw new NotFoundException('Image not found');
|
||
|
}
|
||
|
|
||
|
if (!image.s3Key) {
|
||
|
throw new BadRequestException('Image file not available for download');
|
||
|
}
|
||
|
|
||
|
// Generate presigned URL (expires in 1 hour)
|
||
|
const downloadUrl = await this.storageService.getPresignedUrl(image.s3Key, 3600);
|
||
|
const expiresAt = new Date(Date.now() + 3600 * 1000);
|
||
|
|
||
|
// Use final name if available, otherwise proposed name, otherwise original name
|
||
|
const filename = image.finalName || image.proposedName || image.originalName;
|
||
|
|
||
|
this.logger.log(`Generated download URL for image: ${imageId}`);
|
||
|
|
||
|
return {
|
||
|
download_url: downloadUrl,
|
||
|
expires_at: expiresAt.toISOString(),
|
||
|
filename,
|
||
|
};
|
||
|
} catch (error) {
|
||
|
if (
|
||
|
error instanceof NotFoundException ||
|
||
|
error instanceof BadRequestException
|
||
|
) {
|
||
|
throw error;
|
||
|
}
|
||
|
this.logger.error(`Failed to generate download URL for image: ${imageId}`, error.stack);
|
||
|
throw new BadRequestException('Failed to generate download URL');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Approve the proposed filename as final
|
||
|
*/
|
||
|
async approveProposedFilename(
|
||
|
imageId: string,
|
||
|
userId: string,
|
||
|
): Promise<UpdateFilenameResponseDto> {
|
||
|
try {
|
||
|
const image = await this.prisma.image.findFirst({
|
||
|
where: {
|
||
|
id: imageId,
|
||
|
batch: { userId },
|
||
|
},
|
||
|
});
|
||
|
|
||
|
if (!image) {
|
||
|
throw new NotFoundException('Image not found');
|
||
|
}
|
||
|
|
||
|
if (!image.proposedName) {
|
||
|
throw new BadRequestException('No proposed filename to approve');
|
||
|
}
|
||
|
|
||
|
const updatedImage = await this.prisma.image.update({
|
||
|
where: { id: imageId },
|
||
|
data: {
|
||
|
finalName: image.proposedName,
|
||
|
updatedAt: new Date(),
|
||
|
},
|
||
|
});
|
||
|
|
||
|
this.logger.log(`Approved filename for image: ${imageId}`);
|
||
|
|
||
|
return {
|
||
|
id: updatedImage.id,
|
||
|
proposed_name: updatedImage.proposedName!,
|
||
|
original_name: updatedImage.originalName,
|
||
|
updated_at: updatedImage.updatedAt.toISOString(),
|
||
|
};
|
||
|
} catch (error) {
|
||
|
if (
|
||
|
error instanceof NotFoundException ||
|
||
|
error instanceof BadRequestException
|
||
|
) {
|
||
|
throw error;
|
||
|
}
|
||
|
this.logger.error(`Failed to approve filename for image: ${imageId}`, error.stack);
|
||
|
throw new BadRequestException('Failed to approve filename');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Revert to original filename
|
||
|
*/
|
||
|
async revertToOriginalFilename(
|
||
|
imageId: string,
|
||
|
userId: string,
|
||
|
): Promise<UpdateFilenameResponseDto> {
|
||
|
try {
|
||
|
const image = await this.prisma.image.findFirst({
|
||
|
where: {
|
||
|
id: imageId,
|
||
|
batch: { userId },
|
||
|
},
|
||
|
});
|
||
|
|
||
|
if (!image) {
|
||
|
throw new NotFoundException('Image not found');
|
||
|
}
|
||
|
|
||
|
const updatedImage = await this.prisma.image.update({
|
||
|
where: { id: imageId },
|
||
|
data: {
|
||
|
proposedName: image.originalName,
|
||
|
finalName: null,
|
||
|
updatedAt: new Date(),
|
||
|
},
|
||
|
});
|
||
|
|
||
|
this.logger.log(`Reverted filename for image: ${imageId} to original`);
|
||
|
|
||
|
return {
|
||
|
id: updatedImage.id,
|
||
|
proposed_name: updatedImage.proposedName!,
|
||
|
original_name: updatedImage.originalName,
|
||
|
updated_at: updatedImage.updatedAt.toISOString(),
|
||
|
};
|
||
|
} catch (error) {
|
||
|
if (error instanceof NotFoundException) {
|
||
|
throw error;
|
||
|
}
|
||
|
this.logger.error(`Failed to revert filename for image: ${imageId}`, error.stack);
|
||
|
throw new BadRequestException('Failed to revert filename');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Update image processing status (called by queue processors)
|
||
|
*/
|
||
|
async updateImageStatus(
|
||
|
imageId: string,
|
||
|
status: ImageStatus,
|
||
|
visionTags?: any,
|
||
|
proposedName?: string,
|
||
|
error?: string,
|
||
|
): Promise<void> {
|
||
|
try {
|
||
|
const updateData: any = {
|
||
|
status,
|
||
|
updatedAt: new Date(),
|
||
|
};
|
||
|
|
||
|
if (visionTags) {
|
||
|
updateData.visionTags = visionTags;
|
||
|
}
|
||
|
|
||
|
if (proposedName) {
|
||
|
updateData.proposedName = proposedName;
|
||
|
}
|
||
|
|
||
|
if (error) {
|
||
|
updateData.processingError = error;
|
||
|
}
|
||
|
|
||
|
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
|
||
|
updateData.processedAt = new Date();
|
||
|
}
|
||
|
|
||
|
await this.prisma.image.update({
|
||
|
where: { id: imageId },
|
||
|
data: updateData,
|
||
|
});
|
||
|
|
||
|
this.logger.debug(`Updated image status: ${imageId} to ${status}`);
|
||
|
} catch (error) {
|
||
|
this.logger.error(`Failed to update image status: ${imageId}`, error.stack);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get images by status (for queue processing)
|
||
|
*/
|
||
|
async getImagesByStatus(batchId: string, status: ImageStatus) {
|
||
|
try {
|
||
|
return await this.prisma.image.findMany({
|
||
|
where: {
|
||
|
batchId,
|
||
|
status,
|
||
|
},
|
||
|
select: {
|
||
|
id: true,
|
||
|
originalName: true,
|
||
|
s3Key: true,
|
||
|
},
|
||
|
});
|
||
|
} catch (error) {
|
||
|
this.logger.error(`Failed to get images by status: ${batchId}`, error.stack);
|
||
|
return [];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Map database image to response DTO
|
||
|
*/
|
||
|
private mapImageToResponse(image: any): ImageResponseDto {
|
||
|
return {
|
||
|
id: image.id,
|
||
|
batch_id: image.batchId,
|
||
|
original_name: image.originalName,
|
||
|
proposed_name: image.proposedName,
|
||
|
final_name: image.finalName,
|
||
|
status: image.status,
|
||
|
vision_tags: image.visionTags,
|
||
|
file_size: image.fileSize,
|
||
|
dimensions: image.dimensions,
|
||
|
mime_type: image.mimeType,
|
||
|
processing_error: image.processingError,
|
||
|
created_at: image.createdAt.toISOString(),
|
||
|
updated_at: image.updatedAt.toISOString(),
|
||
|
processed_at: image.processedAt?.toISOString(),
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Validate filename format
|
||
|
*/
|
||
|
private isValidFilename(filename: string): boolean {
|
||
|
// Check for invalid characters
|
||
|
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
|
||
|
if (invalidChars.test(filename)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// Check length
|
||
|
if (filename.length === 0 || filename.length > 255) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// Check for reserved names
|
||
|
const reservedNames = [
|
||
|
'CON', 'PRN', 'AUX', 'NUL',
|
||
|
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||
|
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
||
|
];
|
||
|
|
||
|
const nameWithoutExt = filename.split('.')[0].toUpperCase();
|
||
|
if (reservedNames.includes(nameWithoutExt)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Check if filename has valid image extension
|
||
|
*/
|
||
|
private hasValidExtension(filename: string): boolean {
|
||
|
const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'];
|
||
|
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||
|
return validExtensions.includes(extension);
|
||
|
}
|
||
|
}
|