SEO_iamge_renamer_starting_.../packages/api/src/images/images.service.ts

442 lines
12 KiB
TypeScript
Raw Normal View History

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