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