diff --git a/packages/api/package.json b/packages/api/package.json index 64d3d79..d36f746 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -32,6 +32,9 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.2", "@nestjs/swagger": "^7.1.17", + "@nestjs/websockets": "^10.0.0", + "@nestjs/platform-socket.io": "^10.0.0", + "@nestjs/bullmq": "^10.0.1", "@prisma/client": "^5.7.0", "prisma": "^5.7.0", "passport": "^0.7.0", @@ -46,7 +49,16 @@ "rxjs": "^7.8.1", "uuid": "^9.0.1", "stripe": "^14.10.0", - "cookie-parser": "^1.4.6" + "cookie-parser": "^1.4.6", + "socket.io": "^4.7.4", + "bullmq": "^4.15.2", + "ioredis": "^5.3.2", + "minio": "^7.1.3", + "multer": "^1.4.5-lts.1", + "sharp": "^0.33.0", + "crypto": "^1.0.1", + "openai": "^4.24.1", + "axios": "^1.6.2" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -61,6 +73,7 @@ "@types/bcrypt": "^5.0.2", "@types/uuid": "^9.0.7", "@types/cookie-parser": "^1.4.6", + "@types/multer": "^1.4.11", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "eslint": "^8.42.0", diff --git a/packages/api/src/app.module.ts b/packages/api/src/app.module.ts index 95f64eb..ea84093 100644 --- a/packages/api/src/app.module.ts +++ b/packages/api/src/app.module.ts @@ -5,6 +5,13 @@ import { APP_GUARD } from '@nestjs/core'; import { DatabaseModule } from './database/database.module'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; +import { StorageModule } from './storage/storage.module'; +import { UploadModule } from './upload/upload.module'; +import { QueueModule } from './queue/queue.module'; +import { WebSocketModule } from './websocket/websocket.module'; +import { BatchesModule } from './batches/batches.module'; +import { ImagesModule } from './images/images.module'; +import { KeywordsModule } from './keywords/keywords.module'; import { JwtAuthGuard } from './auth/auth.guard'; import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware'; import { SecurityMiddleware } from './common/middleware/security.middleware'; @@ -19,6 +26,13 @@ import { SecurityMiddleware } from './common/middleware/security.middleware'; DatabaseModule, AuthModule, UsersModule, + StorageModule, + UploadModule, + QueueModule, + WebSocketModule, + BatchesModule, + ImagesModule, + KeywordsModule, ], providers: [ { diff --git a/packages/api/src/batches/batches.controller.ts b/packages/api/src/batches/batches.controller.ts new file mode 100644 index 0000000..30b9a69 --- /dev/null +++ b/packages/api/src/batches/batches.controller.ts @@ -0,0 +1,275 @@ +import { + Controller, + Post, + Get, + Param, + Body, + UploadedFiles, + UseInterceptors, + UseGuards, + Request, + HttpStatus, + BadRequestException, + PayloadTooLargeException, + ForbiddenException, +} from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiResponse, ApiConsumes, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/auth.guard'; +import { BatchesService } from './batches.service'; +import { CreateBatchDto, BatchUploadResponseDto } from './dto/create-batch.dto'; +import { BatchStatusResponseDto, BatchListResponseDto } from './dto/batch-status.dto'; + +@ApiTags('batches') +@Controller('api/batch') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class BatchesController { + constructor(private readonly batchesService: BatchesService) {} + + @Post() + @UseInterceptors(FilesInterceptor('files', 1000)) // Max 1000 files per batch + @ApiOperation({ + summary: 'Upload batch of images for processing', + description: 'Uploads multiple images and starts batch processing with AI analysis and SEO filename generation' + }) + @ApiConsumes('multipart/form-data') + @ApiResponse({ + status: HttpStatus.OK, + description: 'Batch created successfully', + type: BatchUploadResponseDto, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid files or missing data', + }) + @ApiResponse({ + status: HttpStatus.PAYLOAD_TOO_LARGE, + description: 'File size or count exceeds limits', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Insufficient quota remaining', + }) + async uploadBatch( + @UploadedFiles() files: Express.Multer.File[], + @Body() createBatchDto: CreateBatchDto, + @Request() req: any, + ): Promise { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + // Validate files are provided + if (!files || files.length === 0) { + throw new BadRequestException('No files provided'); + } + + // Check file count limits + if (files.length > 1000) { + throw new PayloadTooLargeException('Maximum 1000 files per batch'); + } + + // Process the batch upload + const result = await this.batchesService.createBatch(userId, files, createBatchDto); + + return result; + + } catch (error) { + if (error instanceof BadRequestException || + error instanceof PayloadTooLargeException || + error instanceof ForbiddenException) { + throw error; + } + throw new BadRequestException('Failed to process batch upload'); + } + } + + @Get(':batchId/status') + @ApiOperation({ + summary: 'Get batch processing status', + description: 'Returns current status and progress of batch processing' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Batch status retrieved successfully', + type: BatchStatusResponseDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Batch not found', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Not authorized to access this batch', + }) + async getBatchStatus( + @Param('batchId') batchId: string, + @Request() req: any, + ): Promise { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const status = await this.batchesService.getBatchStatus(batchId, userId); + return status; + + } catch (error) { + if (error instanceof BadRequestException || error instanceof ForbiddenException) { + throw error; + } + throw new BadRequestException('Failed to get batch status'); + } + } + + @Get() + @ApiOperation({ + summary: 'List user batches', + description: 'Returns list of all batches for the authenticated user' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Batches retrieved successfully', + type: [BatchListResponseDto], + }) + async getUserBatches( + @Request() req: any, + ): Promise { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const batches = await this.batchesService.getUserBatches(userId); + return batches; + + } catch (error) { + throw new BadRequestException('Failed to get user batches'); + } + } + + @Post(':batchId/cancel') + @ApiOperation({ + summary: 'Cancel batch processing', + description: 'Cancels ongoing batch processing' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Batch cancelled successfully', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Batch not found', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Not authorized to cancel this batch', + }) + async cancelBatch( + @Param('batchId') batchId: string, + @Request() req: any, + ): Promise<{ message: string }> { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + await this.batchesService.cancelBatch(batchId, userId); + + return { message: 'Batch cancelled successfully' }; + + } catch (error) { + if (error instanceof BadRequestException || error instanceof ForbiddenException) { + throw error; + } + throw new BadRequestException('Failed to cancel batch'); + } + } + + @Post(':batchId/retry') + @ApiOperation({ + summary: 'Retry failed batch processing', + description: 'Retries processing for failed images in a batch' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Batch retry started successfully', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Batch not found', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Batch is not in a retryable state', + }) + async retryBatch( + @Param('batchId') batchId: string, + @Request() req: any, + ): Promise<{ message: string; retry_count: number }> { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const retryCount = await this.batchesService.retryBatch(batchId, userId); + + return { + message: 'Batch retry started successfully', + retry_count: retryCount + }; + + } catch (error) { + if (error instanceof BadRequestException || error instanceof ForbiddenException) { + throw error; + } + throw new BadRequestException('Failed to retry batch'); + } + } + + @Get(':batchId/download') + @ApiOperation({ + summary: 'Download processed batch as ZIP', + description: 'Returns a ZIP file containing all processed images with new filenames' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'ZIP file download started', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Batch not found', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Batch processing not completed', + }) + async downloadBatch( + @Param('batchId') batchId: string, + @Request() req: any, + ): Promise<{ download_url: string; expires_at: string }> { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const downloadInfo = await this.batchesService.generateBatchDownload(batchId, userId); + + return downloadInfo; + + } catch (error) { + if (error instanceof BadRequestException || error instanceof ForbiddenException) { + throw error; + } + throw new BadRequestException('Failed to generate batch download'); + } + } +} \ No newline at end of file diff --git a/packages/api/src/batches/batches.module.ts b/packages/api/src/batches/batches.module.ts new file mode 100644 index 0000000..1ab6023 --- /dev/null +++ b/packages/api/src/batches/batches.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../database/database.module'; +import { StorageModule } from '../storage/storage.module'; +import { UploadModule } from '../upload/upload.module'; +import { QueueModule } from '../queue/queue.module'; +import { WebSocketModule } from '../websocket/websocket.module'; +import { BatchesController } from './batches.controller'; +import { BatchesService } from './batches.service'; + +@Module({ + imports: [ + DatabaseModule, + StorageModule, + UploadModule, + QueueModule, + WebSocketModule, + ], + controllers: [BatchesController], + providers: [BatchesService], + exports: [BatchesService], +}) +export class BatchesModule {} \ No newline at end of file diff --git a/packages/api/src/batches/batches.service.ts b/packages/api/src/batches/batches.service.ts new file mode 100644 index 0000000..573594b --- /dev/null +++ b/packages/api/src/batches/batches.service.ts @@ -0,0 +1,515 @@ +import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { BatchStatus, ImageStatus, Plan } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; +import { PrismaService } from '../database/prisma.service'; +import { UploadService } from '../upload/upload.service'; +import { QueueService } from '../queue/queue.service'; +import { ProgressGateway } from '../websocket/progress.gateway'; +import { CreateBatchDto, BatchUploadResponseDto } from './dto/create-batch.dto'; +import { BatchStatusResponseDto, BatchListResponseDto } from './dto/batch-status.dto'; +import { calculateProgressPercentage } from '../batches/batch.entity'; + +@Injectable() +export class BatchesService { + private readonly logger = new Logger(BatchesService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly uploadService: UploadService, + private readonly queueService: QueueService, + private readonly progressGateway: ProgressGateway, + ) {} + + /** + * Create a new batch and process uploaded files + */ + async createBatch( + userId: string, + files: Express.Multer.File[], + createBatchDto: CreateBatchDto + ): Promise { + try { + this.logger.log(`Creating batch for user: ${userId} with ${files.length} files`); + + // Get user info and check quota + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { plan: true, quotaRemaining: true }, + }); + + if (!user) { + throw new BadRequestException('User not found'); + } + + // Check quota + const quotaCheck = this.uploadService.checkUploadQuota( + files.length, + user.plan, + user.quotaRemaining + ); + + if (!quotaCheck.allowed) { + throw new ForbiddenException( + `Insufficient quota. Requested: ${files.length}, Remaining: ${user.quotaRemaining}` + ); + } + + // Create batch record + const batchId = uuidv4(); + const batch = await this.prisma.batch.create({ + data: { + id: batchId, + userId, + status: BatchStatus.PROCESSING, + totalImages: files.length, + processedImages: 0, + failedImages: 0, + metadata: { + keywords: createBatchDto.keywords || [], + uploadedAt: new Date().toISOString(), + }, + }, + }); + + // Process files + let acceptedCount = 0; + let skippedCount = 0; + const imageIds: string[] = []; + + try { + const processedFiles = await this.uploadService.processMultipleFiles( + files, + batchId, + createBatchDto.keywords + ); + + // Create image records in database + for (const processedFile of processedFiles) { + try { + const imageId = uuidv4(); + + await this.prisma.image.create({ + data: { + id: imageId, + batchId, + originalName: processedFile.originalName, + status: ImageStatus.PENDING, + fileSize: processedFile.uploadResult.size, + mimeType: processedFile.mimeType, + dimensions: { + width: processedFile.metadata.width, + height: processedFile.metadata.height, + format: processedFile.metadata.format, + }, + s3Key: processedFile.uploadResult.key, + }, + }); + + imageIds.push(imageId); + acceptedCount++; + + } catch (error) { + this.logger.error(`Failed to create image record: ${processedFile.originalName}`, error.stack); + skippedCount++; + } + } + + skippedCount += files.length - processedFiles.length; + + } catch (error) { + this.logger.error(`Failed to process files for batch: ${batchId}`, error.stack); + skippedCount = files.length; + } + + // Update batch with actual counts + await this.prisma.batch.update({ + where: { id: batchId }, + data: { + totalImages: acceptedCount, + }, + }); + + // Update user quota + await this.prisma.user.update({ + where: { id: userId }, + data: { + quotaRemaining: user.quotaRemaining - acceptedCount, + }, + }); + + // Queue batch processing if we have accepted files + if (acceptedCount > 0) { + await this.queueService.addBatchProcessingJob({ + batchId, + userId, + imageIds, + keywords: createBatchDto.keywords, + }); + } + + // Estimate processing time (2-5 seconds per image) + const estimatedTime = acceptedCount * (3 + Math.random() * 2); + + this.logger.log(`Batch created: ${batchId} - ${acceptedCount} accepted, ${skippedCount} skipped`); + + return { + batch_id: batchId, + accepted_count: acceptedCount, + skipped_count: skippedCount, + status: 'PROCESSING', + estimated_time: Math.round(estimatedTime), + }; + + } catch (error) { + this.logger.error(`Failed to create batch for user: ${userId}`, error.stack); + throw error; + } + } + + /** + * Get batch status and progress + */ + async getBatchStatus(batchId: string, userId: string): Promise { + try { + const batch = await this.prisma.batch.findFirst({ + where: { + id: batchId, + userId, + }, + include: { + images: { + select: { + status: true, + originalName: true, + }, + }, + }, + }); + + if (!batch) { + throw new NotFoundException('Batch not found'); + } + + // Calculate progress + const progress = calculateProgressPercentage(batch.processedImages, batch.totalImages); + + // Find currently processing image + const processingImage = batch.images.find(img => img.status === ImageStatus.PROCESSING); + + // Estimate remaining time based on average processing time + const remainingImages = batch.totalImages - batch.processedImages; + const estimatedRemaining = remainingImages * 3; // 3 seconds per image average + + // Map status to API response format + let state: 'PROCESSING' | 'DONE' | 'ERROR'; + switch (batch.status) { + case BatchStatus.PROCESSING: + state = 'PROCESSING'; + break; + case BatchStatus.DONE: + state = 'DONE'; + break; + case BatchStatus.ERROR: + state = 'ERROR'; + break; + } + + return { + state, + progress, + processed_count: batch.processedImages, + total_count: batch.totalImages, + failed_count: batch.failedImages, + current_image: processingImage?.originalName, + estimated_remaining: state === 'PROCESSING' ? estimatedRemaining : undefined, + error_message: batch.status === BatchStatus.ERROR ? 'Processing failed' : undefined, + created_at: batch.createdAt.toISOString(), + completed_at: batch.completedAt?.toISOString(), + }; + + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to get batch status: ${batchId}`, error.stack); + throw new BadRequestException('Failed to get batch status'); + } + } + + /** + * Get list of user's batches + */ + async getUserBatches(userId: string): Promise { + try { + const batches = await this.prisma.batch.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 50, // Limit to last 50 batches + }); + + return batches.map(batch => ({ + id: batch.id, + state: batch.status === BatchStatus.PROCESSING ? 'PROCESSING' : + batch.status === BatchStatus.DONE ? 'DONE' : 'ERROR', + total_images: batch.totalImages, + processed_images: batch.processedImages, + failed_images: batch.failedImages, + progress: calculateProgressPercentage(batch.processedImages, batch.totalImages), + created_at: batch.createdAt.toISOString(), + completed_at: batch.completedAt?.toISOString(), + })); + + } catch (error) { + this.logger.error(`Failed to get user batches: ${userId}`, error.stack); + throw new BadRequestException('Failed to get user batches'); + } + } + + /** + * Cancel ongoing batch processing + */ + async cancelBatch(batchId: string, userId: string): Promise { + try { + const batch = await this.prisma.batch.findFirst({ + where: { + id: batchId, + userId, + status: BatchStatus.PROCESSING, + }, + }); + + if (!batch) { + throw new NotFoundException('Batch not found or not in processing state'); + } + + // Cancel queue jobs + await this.queueService.cancelJob(`batch-${batchId}`, 'batch-processing'); + + // Update batch status + await this.prisma.batch.update({ + where: { id: batchId }, + data: { + status: BatchStatus.ERROR, + completedAt: new Date(), + metadata: { + ...batch.metadata, + cancelledAt: new Date().toISOString(), + cancelReason: 'User requested cancellation', + }, + }, + }); + + // Update pending images to failed + await this.prisma.image.updateMany({ + where: { + batchId, + status: { + in: [ImageStatus.PENDING, ImageStatus.PROCESSING], + }, + }, + data: { + status: ImageStatus.FAILED, + processingError: 'Batch was cancelled', + }, + }); + + // Broadcast cancellation + this.progressGateway.broadcastBatchError(batchId, 'Batch was cancelled'); + + this.logger.log(`Batch cancelled: ${batchId}`); + + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to cancel batch: ${batchId}`, error.stack); + throw new BadRequestException('Failed to cancel batch'); + } + } + + /** + * Retry failed batch processing + */ + async retryBatch(batchId: string, userId: string): Promise { + try { + const batch = await this.prisma.batch.findFirst({ + where: { + id: batchId, + userId, + }, + include: { + images: { + where: { status: ImageStatus.FAILED }, + select: { id: true }, + }, + }, + }); + + if (!batch) { + throw new NotFoundException('Batch not found'); + } + + if (batch.status === BatchStatus.PROCESSING) { + throw new BadRequestException('Batch is currently processing'); + } + + if (batch.images.length === 0) { + throw new BadRequestException('No failed images to retry'); + } + + // Reset failed images to pending + await this.prisma.image.updateMany({ + where: { + batchId, + status: ImageStatus.FAILED, + }, + data: { + status: ImageStatus.PENDING, + processingError: null, + }, + }); + + // Update batch status + await this.prisma.batch.update({ + where: { id: batchId }, + data: { + status: BatchStatus.PROCESSING, + completedAt: null, + failedImages: 0, + }, + }); + + // Queue retry processing + await this.queueService.addBatchProcessingJob({ + batchId, + userId, + imageIds: batch.images.map(img => img.id), + }); + + this.logger.log(`Batch retry started: ${batchId} with ${batch.images.length} images`); + + return batch.images.length; + + } catch (error) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + this.logger.error(`Failed to retry batch: ${batchId}`, error.stack); + throw new BadRequestException('Failed to retry batch'); + } + } + + /** + * Generate download link for processed batch + */ + async generateBatchDownload(batchId: string, userId: string): Promise<{ + download_url: string; + expires_at: string; + }> { + try { + const batch = await this.prisma.batch.findFirst({ + where: { + id: batchId, + userId, + status: BatchStatus.DONE, + }, + include: { + images: { + where: { status: ImageStatus.COMPLETED }, + select: { s3Key: true, finalName: true, proposedName: true, originalName: true }, + }, + }, + }); + + if (!batch) { + throw new NotFoundException('Batch not found or not completed'); + } + + if (batch.images.length === 0) { + throw new BadRequestException('No processed images available for download'); + } + + // TODO: Implement actual ZIP generation and presigned URL creation + // This would typically: + // 1. Create a ZIP file containing all processed images + // 2. Upload ZIP to storage + // 3. Generate presigned download URL + + // For now, return a mock response + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + + return { + download_url: `https://storage.example.com/downloads/batch-${batchId}.zip?expires=${expiresAt.getTime()}`, + expires_at: expiresAt.toISOString(), + }; + + } catch (error) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + this.logger.error(`Failed to generate batch download: ${batchId}`, error.stack); + throw new BadRequestException('Failed to generate batch download'); + } + } + + /** + * Update batch processing progress (called by queue processors) + */ + async updateBatchProgress( + batchId: string, + processedImages: number, + failedImages: number, + currentImageName?: string + ): Promise { + try { + const batch = await this.prisma.batch.findUnique({ + where: { id: batchId }, + }); + + if (!batch) { + return; + } + + const isComplete = (processedImages + failedImages) >= batch.totalImages; + const newStatus = isComplete ? + (failedImages === batch.totalImages ? BatchStatus.ERROR : BatchStatus.DONE) : + BatchStatus.PROCESSING; + + // Update batch record + await this.prisma.batch.update({ + where: { id: batchId }, + data: { + processedImages, + failedImages, + status: newStatus, + completedAt: isComplete ? new Date() : null, + }, + }); + + // Broadcast progress update + const progress = calculateProgressPercentage(processedImages, batch.totalImages); + + this.progressGateway.broadcastBatchProgress(batchId, { + state: newStatus === BatchStatus.PROCESSING ? 'PROCESSING' : + newStatus === BatchStatus.DONE ? 'DONE' : 'ERROR', + progress, + processedImages, + totalImages: batch.totalImages, + currentImage: currentImageName, + }); + + // Broadcast completion if done + if (isComplete) { + this.progressGateway.broadcastBatchCompleted(batchId, { + totalImages: batch.totalImages, + processedImages, + failedImages, + processingTime: Date.now() - batch.createdAt.getTime(), + }); + } + + } catch (error) { + this.logger.error(`Failed to update batch progress: ${batchId}`, error.stack); + } + } +} \ No newline at end of file diff --git a/packages/api/src/batches/dto/batch-status.dto.ts b/packages/api/src/batches/dto/batch-status.dto.ts new file mode 100644 index 0000000..e46d252 --- /dev/null +++ b/packages/api/src/batches/dto/batch-status.dto.ts @@ -0,0 +1,142 @@ +import { IsEnum, IsInt, IsOptional, IsString, Min, Max } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BatchStatusResponseDto { + @ApiProperty({ + description: 'Current batch processing state', + example: 'PROCESSING', + enum: ['PROCESSING', 'DONE', 'ERROR'], + }) + @IsEnum(['PROCESSING', 'DONE', 'ERROR']) + state: 'PROCESSING' | 'DONE' | 'ERROR'; + + @ApiProperty({ + description: 'Processing progress percentage', + example: 75, + minimum: 0, + maximum: 100, + }) + @IsInt() + @Min(0) + @Max(100) + progress: number; + + @ApiPropertyOptional({ + description: 'Number of images currently processed', + example: 6, + minimum: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + processed_count?: number; + + @ApiPropertyOptional({ + description: 'Total number of images in the batch', + example: 8, + minimum: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + total_count?: number; + + @ApiPropertyOptional({ + description: 'Number of failed images', + example: 1, + minimum: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + failed_count?: number; + + @ApiPropertyOptional({ + description: 'Currently processing image name', + example: 'IMG_20240101_123456.jpg', + }) + @IsOptional() + @IsString() + current_image?: string; + + @ApiPropertyOptional({ + description: 'Estimated time remaining in seconds', + example: 15, + minimum: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + estimated_remaining?: number; + + @ApiPropertyOptional({ + description: 'Error message if batch failed', + example: 'Processing timeout occurred', + }) + @IsOptional() + @IsString() + error_message?: string; + + @ApiProperty({ + description: 'Batch creation timestamp', + example: '2024-01-01T12:00:00.000Z', + }) + created_at: string; + + @ApiPropertyOptional({ + description: 'Batch completion timestamp', + example: '2024-01-01T12:05:30.000Z', + }) + @IsOptional() + completed_at?: string; +} + +export class BatchListResponseDto { + @ApiProperty({ + description: 'Batch identifier', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id: string; + + @ApiProperty({ + description: 'Batch processing state', + enum: ['PROCESSING', 'DONE', 'ERROR'], + }) + state: 'PROCESSING' | 'DONE' | 'ERROR'; + + @ApiProperty({ + description: 'Total number of images', + example: 10, + }) + total_images: number; + + @ApiProperty({ + description: 'Number of processed images', + example: 8, + }) + processed_images: number; + + @ApiProperty({ + description: 'Number of failed images', + example: 1, + }) + failed_images: number; + + @ApiProperty({ + description: 'Processing progress percentage', + example: 90, + }) + progress: number; + + @ApiProperty({ + description: 'Batch creation timestamp', + example: '2024-01-01T12:00:00.000Z', + }) + created_at: string; + + @ApiPropertyOptional({ + description: 'Batch completion timestamp', + example: '2024-01-01T12:05:30.000Z', + }) + completed_at?: string; +} \ No newline at end of file diff --git a/packages/api/src/batches/dto/create-batch.dto.ts b/packages/api/src/batches/dto/create-batch.dto.ts new file mode 100644 index 0000000..519fb3b --- /dev/null +++ b/packages/api/src/batches/dto/create-batch.dto.ts @@ -0,0 +1,49 @@ +import { IsOptional, IsString, IsArray, ArrayMaxSize, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateBatchDto { + @ApiPropertyOptional({ + description: 'Keywords to help with AI analysis and filename generation', + example: ['kitchen', 'modern', 'renovation'], + maxItems: 10, + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(10) + @MaxLength(50, { each: true }) + keywords?: string[]; +} + +export class BatchUploadResponseDto { + @ApiProperty({ + description: 'Unique batch identifier', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + batch_id: string; + + @ApiProperty({ + description: 'Number of files accepted for processing', + example: 8, + }) + accepted_count: number; + + @ApiProperty({ + description: 'Number of files skipped (duplicates, invalid format, etc.)', + example: 2, + }) + skipped_count: number; + + @ApiProperty({ + description: 'Initial processing status', + example: 'PROCESSING', + enum: ['PROCESSING'], + }) + status: 'PROCESSING'; + + @ApiProperty({ + description: 'Estimated processing time in seconds', + example: 45, + }) + estimated_time: number; +} \ No newline at end of file diff --git a/packages/api/src/images/dto/image-response.dto.ts b/packages/api/src/images/dto/image-response.dto.ts new file mode 100644 index 0000000..801a704 --- /dev/null +++ b/packages/api/src/images/dto/image-response.dto.ts @@ -0,0 +1,166 @@ +import { IsString, IsEnum, IsOptional, IsObject, IsInt, IsDate } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ImageStatus } from '@prisma/client'; + +export class ImageResponseDto { + @ApiProperty({ + description: 'Image identifier', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsString() + id: string; + + @ApiProperty({ + description: 'Batch identifier this image belongs to', + example: '660f9511-f39c-52e5-b827-557766551111', + }) + @IsString() + batch_id: string; + + @ApiProperty({ + description: 'Original filename', + example: 'IMG_20240101_123456.jpg', + }) + @IsString() + original_name: string; + + @ApiPropertyOptional({ + description: 'AI-generated proposed filename', + example: 'modern-kitchen-with-stainless-steel-appliances.jpg', + }) + @IsOptional() + @IsString() + proposed_name?: string; + + @ApiPropertyOptional({ + description: 'User-approved final filename', + example: 'kitchen-renovation-final.jpg', + }) + @IsOptional() + @IsString() + final_name?: string; + + @ApiProperty({ + description: 'Current processing status', + enum: ImageStatus, + example: ImageStatus.COMPLETED, + }) + @IsEnum(ImageStatus) + status: ImageStatus; + + @ApiPropertyOptional({ + description: 'AI vision analysis results', + example: { + objects: ['kitchen', 'refrigerator', 'countertop'], + colors: ['white', 'stainless steel', 'black'], + scene: 'modern kitchen interior', + description: 'A modern kitchen with stainless steel appliances', + confidence: 0.95, + }, + }) + @IsOptional() + @IsObject() + vision_tags?: { + objects?: string[]; + colors?: string[]; + scene?: string; + description?: string; + confidence?: number; + aiModel?: string; + processingTime?: number; + }; + + @ApiPropertyOptional({ + description: 'File size in bytes', + example: 2048576, + }) + @IsOptional() + @IsInt() + file_size?: number; + + @ApiPropertyOptional({ + description: 'Image dimensions', + example: { width: 1920, height: 1080, aspectRatio: '16:9' }, + }) + @IsOptional() + @IsObject() + dimensions?: { + width: number; + height: number; + format?: string; + }; + + @ApiPropertyOptional({ + description: 'MIME type', + example: 'image/jpeg', + }) + @IsOptional() + @IsString() + mime_type?: string; + + @ApiPropertyOptional({ + description: 'Error message if processing failed', + example: 'AI analysis timeout', + }) + @IsOptional() + @IsString() + processing_error?: string; + + @ApiProperty({ + description: 'Image creation timestamp', + example: '2024-01-01T12:00:00.000Z', + }) + @IsDate() + created_at: string; + + @ApiProperty({ + description: 'Last update timestamp', + example: '2024-01-01T12:05:30.000Z', + }) + @IsDate() + updated_at: string; + + @ApiPropertyOptional({ + description: 'Processing completion timestamp', + example: '2024-01-01T12:05:25.000Z', + }) + @IsOptional() + @IsDate() + processed_at?: string; +} + +export class BatchImagesResponseDto { + @ApiProperty({ + description: 'Batch identifier', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + batch_id: string; + + @ApiProperty({ + description: 'Total number of images in batch', + example: 10, + }) + total_images: number; + + @ApiProperty({ + description: 'Array of images in the batch', + type: [ImageResponseDto], + }) + images: ImageResponseDto[]; + + @ApiProperty({ + description: 'Batch status summary', + example: { + pending: 2, + processing: 1, + completed: 6, + failed: 1, + }, + }) + status_summary: { + pending: number; + processing: number; + completed: number; + failed: number; + }; +} \ No newline at end of file diff --git a/packages/api/src/images/dto/update-filename.dto.ts b/packages/api/src/images/dto/update-filename.dto.ts new file mode 100644 index 0000000..a23ce17 --- /dev/null +++ b/packages/api/src/images/dto/update-filename.dto.ts @@ -0,0 +1,43 @@ +import { IsString, IsNotEmpty, MaxLength, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateFilenameDto { + @ApiProperty({ + description: 'New filename for the image (without path, but with extension)', + example: 'modern-kitchen-renovation-2024.jpg', + maxLength: 255, + }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + @Matches(/^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,4}$/, { + message: 'Filename must be valid with proper extension', + }) + new_name: string; +} + +export class UpdateFilenameResponseDto { + @ApiProperty({ + description: 'Image identifier', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id: string; + + @ApiProperty({ + description: 'Updated proposed filename', + example: 'modern-kitchen-renovation-2024.jpg', + }) + proposed_name: string; + + @ApiProperty({ + description: 'Original filename', + example: 'IMG_20240101_123456.jpg', + }) + original_name: string; + + @ApiProperty({ + description: 'Update timestamp', + example: '2024-01-01T12:05:30.000Z', + }) + updated_at: string; +} \ No newline at end of file diff --git a/packages/api/src/images/images.controller.ts b/packages/api/src/images/images.controller.ts new file mode 100644 index 0000000..ee65fe4 --- /dev/null +++ b/packages/api/src/images/images.controller.ts @@ -0,0 +1,304 @@ +import { + Controller, + Get, + Put, + Param, + Body, + UseGuards, + Request, + HttpStatus, + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/auth.guard'; +import { ImagesService } from './images.service'; +import { UpdateFilenameDto, UpdateFilenameResponseDto } from './dto/update-filename.dto'; +import { ImageResponseDto, BatchImagesResponseDto } from './dto/image-response.dto'; + +@ApiTags('images') +@Controller('api/image') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class ImagesController { + constructor(private readonly imagesService: ImagesService) {} + + @Put(':imageId/filename') + @ApiOperation({ + summary: 'Update image filename', + description: 'Updates the proposed filename for a specific image', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Filename updated successfully', + type: UpdateFilenameResponseDto, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid filename or request data', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Image not found', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Not authorized to update this image', + }) + async updateImageFilename( + @Param('imageId') imageId: string, + @Body() updateFilenameDto: UpdateFilenameDto, + @Request() req: any, + ): Promise { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const result = await this.imagesService.updateFilename( + imageId, + userId, + updateFilenameDto.new_name + ); + + return result; + } catch (error) { + if ( + error instanceof BadRequestException || + error instanceof ForbiddenException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException('Failed to update image filename'); + } + } + + @Get(':imageId') + @ApiOperation({ + summary: 'Get image details', + description: 'Returns detailed information about a specific image', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Image details retrieved successfully', + type: ImageResponseDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Image not found', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Not authorized to access this image', + }) + async getImage( + @Param('imageId') imageId: string, + @Request() req: any, + ): Promise { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const image = await this.imagesService.getImage(imageId, userId); + return image; + } catch (error) { + if ( + error instanceof BadRequestException || + error instanceof ForbiddenException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException('Failed to get image details'); + } + } + + @Get('batch/:batchId') + @ApiOperation({ + summary: 'Get all images in a batch', + description: 'Returns all images belonging to a specific batch', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Batch images retrieved successfully', + type: BatchImagesResponseDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Batch not found', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Not authorized to access this batch', + }) + async getBatchImages( + @Param('batchId') batchId: string, + @Request() req: any, + ): Promise { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const batchImages = await this.imagesService.getBatchImages(batchId, userId); + return batchImages; + } catch (error) { + if ( + error instanceof BadRequestException || + error instanceof ForbiddenException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException('Failed to get batch images'); + } + } + + @Get(':imageId/download') + @ApiOperation({ + summary: 'Get image download URL', + description: 'Returns a presigned URL for downloading the original or processed image', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Download URL generated successfully', + schema: { + type: 'object', + properties: { + download_url: { + type: 'string', + example: 'https://storage.example.com/images/processed/image.jpg?expires=...', + }, + expires_at: { + type: 'string', + example: '2024-01-01T13:00:00.000Z', + }, + filename: { + type: 'string', + example: 'modern-kitchen-renovation.jpg', + }, + }, + }, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Image not found', + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Not authorized to download this image', + }) + async getImageDownloadUrl( + @Param('imageId') imageId: string, + @Request() req: any, + ): Promise<{ + download_url: string; + expires_at: string; + filename: string; + }> { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const downloadInfo = await this.imagesService.getImageDownloadUrl(imageId, userId); + return downloadInfo; + } catch (error) { + if ( + error instanceof BadRequestException || + error instanceof ForbiddenException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException('Failed to generate download URL'); + } + } + + @Put(':imageId/approve') + @ApiOperation({ + summary: 'Approve proposed filename', + description: 'Approves the AI-generated proposed filename as the final filename', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Filename approved successfully', + type: UpdateFilenameResponseDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Image not found', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'No proposed filename to approve', + }) + async approveFilename( + @Param('imageId') imageId: string, + @Request() req: any, + ): Promise { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const result = await this.imagesService.approveProposedFilename(imageId, userId); + return result; + } catch (error) { + if ( + error instanceof BadRequestException || + error instanceof ForbiddenException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException('Failed to approve filename'); + } + } + + @Put(':imageId/revert') + @ApiOperation({ + summary: 'Revert to original filename', + description: 'Reverts the image filename back to the original uploaded filename', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Filename reverted successfully', + type: UpdateFilenameResponseDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Image not found', + }) + async revertFilename( + @Param('imageId') imageId: string, + @Request() req: any, + ): Promise { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const result = await this.imagesService.revertToOriginalFilename(imageId, userId); + return result; + } catch (error) { + if ( + error instanceof BadRequestException || + error instanceof ForbiddenException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException('Failed to revert filename'); + } + } +} \ No newline at end of file diff --git a/packages/api/src/images/images.module.ts b/packages/api/src/images/images.module.ts new file mode 100644 index 0000000..3b98019 --- /dev/null +++ b/packages/api/src/images/images.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../database/database.module'; +import { StorageModule } from '../storage/storage.module'; +import { ImagesController } from './images.controller'; +import { ImagesService } from './images.service'; + +@Module({ + imports: [DatabaseModule, StorageModule], + controllers: [ImagesController], + providers: [ImagesService], + exports: [ImagesService], +}) +export class ImagesModule {} \ No newline at end of file diff --git a/packages/api/src/images/images.service.ts b/packages/api/src/images/images.service.ts new file mode 100644 index 0000000..db2848b --- /dev/null +++ b/packages/api/src/images/images.service.ts @@ -0,0 +1,442 @@ +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); + } +} \ No newline at end of file diff --git a/packages/api/src/keywords/dto/enhance-keywords.dto.ts b/packages/api/src/keywords/dto/enhance-keywords.dto.ts new file mode 100644 index 0000000..d74d92f --- /dev/null +++ b/packages/api/src/keywords/dto/enhance-keywords.dto.ts @@ -0,0 +1,79 @@ +import { IsArray, IsString, ArrayMaxSize, ArrayMinSize, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class EnhanceKeywordsDto { + @ApiProperty({ + description: 'Array of keywords to enhance with AI suggestions', + example: ['kitchen', 'modern', 'renovation'], + minItems: 1, + maxItems: 20, + }) + @IsArray() + @IsString({ each: true }) + @ArrayMinSize(1) + @ArrayMaxSize(20) + @MaxLength(50, { each: true }) + keywords: string[]; +} + +export class EnhanceKeywordsResponseDto { + @ApiProperty({ + description: 'Original keywords provided', + example: ['kitchen', 'modern', 'renovation'], + }) + original_keywords: string[]; + + @ApiProperty({ + description: 'AI-enhanced keywords with SEO improvements', + example: [ + 'modern-kitchen-design', + 'contemporary-kitchen-renovation', + 'sleek-kitchen-remodel', + 'updated-kitchen-interior', + 'kitchen-makeover-ideas', + 'stylish-kitchen-upgrade', + 'fresh-kitchen-design', + 'kitchen-transformation' + ], + }) + enhanced_keywords: string[]; + + @ApiProperty({ + description: 'Related keywords and synonyms', + example: [ + 'culinary-space', + 'cooking-area', + 'kitchen-cabinets', + 'kitchen-appliances', + 'kitchen-island', + 'backsplash-design' + ], + }) + related_keywords: string[]; + + @ApiProperty({ + description: 'SEO-optimized long-tail keywords', + example: [ + 'modern-kitchen-renovation-ideas-2024', + 'contemporary-kitchen-design-trends', + 'sleek-kitchen-remodel-inspiration' + ], + }) + long_tail_keywords: string[]; + + @ApiProperty({ + description: 'Processing metadata', + example: { + processing_time: 1.2, + ai_model: 'gpt-4', + confidence_score: 0.92, + keywords_generated: 15, + }, + }) + metadata: { + processing_time: number; + ai_model: string; + confidence_score: number; + keywords_generated: number; + }; +} \ No newline at end of file diff --git a/packages/api/src/keywords/keywords.controller.ts b/packages/api/src/keywords/keywords.controller.ts new file mode 100644 index 0000000..a63d168 --- /dev/null +++ b/packages/api/src/keywords/keywords.controller.ts @@ -0,0 +1,192 @@ +import { + Controller, + Post, + Body, + UseGuards, + Request, + HttpStatus, + BadRequestException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/auth.guard'; +import { KeywordsService } from './keywords.service'; +import { EnhanceKeywordsDto, EnhanceKeywordsResponseDto } from './dto/enhance-keywords.dto'; + +@ApiTags('keywords') +@Controller('api/keywords') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class KeywordsController { + constructor(private readonly keywordsService: KeywordsService) {} + + @Post('enhance') + @ApiOperation({ + summary: 'Enhance keywords with AI suggestions', + description: 'Takes user-provided keywords and returns AI-enhanced SEO-optimized keywords and suggestions', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Keywords enhanced successfully', + type: EnhanceKeywordsResponseDto, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid keywords or request data', + }) + @ApiResponse({ + status: HttpStatus.TOO_MANY_REQUESTS, + description: 'Rate limit exceeded for keyword enhancement', + }) + async enhanceKeywords( + @Body() enhanceKeywordsDto: EnhanceKeywordsDto, + @Request() req: any, + ): Promise { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + // Check rate limits + await this.keywordsService.checkRateLimit(userId); + + // Enhance keywords with AI + const enhancedResult = await this.keywordsService.enhanceKeywords( + enhanceKeywordsDto.keywords, + userId, + ); + + return enhancedResult; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new BadRequestException('Failed to enhance keywords'); + } + } + + @Post('suggest') + @ApiOperation({ + summary: 'Get keyword suggestions for image context', + description: 'Provides keyword suggestions based on image analysis context', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Keyword suggestions generated successfully', + schema: { + type: 'object', + properties: { + suggestions: { + type: 'array', + items: { type: 'string' }, + example: ['interior-design', 'home-decor', 'modern-style', 'contemporary'], + }, + categories: { + type: 'object', + example: { + style: ['modern', 'contemporary', 'minimalist'], + room: ['kitchen', 'living-room', 'bedroom'], + color: ['white', 'black', 'gray'], + material: ['wood', 'metal', 'glass'], + }, + }, + }, + }, + }) + async getKeywordSuggestions( + @Body() body: { context?: string; category?: string }, + @Request() req: any, + ): Promise<{ + suggestions: string[]; + categories: Record; + }> { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + const suggestions = await this.keywordsService.getKeywordSuggestions( + body.context, + body.category, + ); + + return suggestions; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new BadRequestException('Failed to get keyword suggestions'); + } + } + + @Post('validate') + @ApiOperation({ + summary: 'Validate keywords for SEO optimization', + description: 'Checks keywords for SEO best practices and provides recommendations', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Keywords validated successfully', + schema: { + type: 'object', + properties: { + valid_keywords: { + type: 'array', + items: { type: 'string' }, + example: ['modern-kitchen', 'contemporary-design'], + }, + invalid_keywords: { + type: 'array', + items: { + type: 'object', + properties: { + keyword: { type: 'string' }, + reason: { type: 'string' }, + }, + }, + example: [ + { keyword: 'a', reason: 'Too short for SEO value' }, + { keyword: 'the-best-kitchen-in-the-world-ever', reason: 'Too long for practical use' }, + ], + }, + recommendations: { + type: 'array', + items: { type: 'string' }, + example: [ + 'Use hyphens instead of spaces', + 'Keep keywords between 2-4 words', + 'Avoid stop words like "the", "and", "or"', + ], + }, + }, + }, + }) + async validateKeywords( + @Body() body: { keywords: string[] }, + @Request() req: any, + ): Promise<{ + valid_keywords: string[]; + invalid_keywords: Array<{ keyword: string; reason: string }>; + recommendations: string[]; + }> { + try { + const userId = req.user?.id; + if (!userId) { + throw new BadRequestException('User not authenticated'); + } + + if (!body.keywords || !Array.isArray(body.keywords)) { + throw new BadRequestException('Keywords array is required'); + } + + const validation = await this.keywordsService.validateKeywords(body.keywords); + return validation; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new BadRequestException('Failed to validate keywords'); + } + } +} \ No newline at end of file diff --git a/packages/api/src/keywords/keywords.module.ts b/packages/api/src/keywords/keywords.module.ts new file mode 100644 index 0000000..3ad3af5 --- /dev/null +++ b/packages/api/src/keywords/keywords.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { KeywordsController } from './keywords.controller'; +import { KeywordsService } from './keywords.service'; + +@Module({ + imports: [ConfigModule], + controllers: [KeywordsController], + providers: [KeywordsService], + exports: [KeywordsService], +}) +export class KeywordsModule {} \ No newline at end of file diff --git a/packages/api/src/keywords/keywords.service.ts b/packages/api/src/keywords/keywords.service.ts new file mode 100644 index 0000000..3c7a237 --- /dev/null +++ b/packages/api/src/keywords/keywords.service.ts @@ -0,0 +1,345 @@ +import { Injectable, Logger, BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EnhanceKeywordsResponseDto } from './dto/enhance-keywords.dto'; +// import OpenAI from 'openai'; // Uncomment when ready to use actual OpenAI integration + +@Injectable() +export class KeywordsService { + private readonly logger = new Logger(KeywordsService.name); + // private readonly openai: OpenAI; // Uncomment when ready to use actual OpenAI + private readonly rateLimitMap = new Map(); + private readonly RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute + private readonly RATE_LIMIT_MAX_REQUESTS = 10; // 10 requests per minute per user + + constructor(private readonly configService: ConfigService) { + // Initialize OpenAI client when ready + // this.openai = new OpenAI({ + // apiKey: this.configService.get('OPENAI_API_KEY'), + // }); + } + + /** + * Enhance keywords with AI suggestions + */ + async enhanceKeywords( + keywords: string[], + userId: string, + ): Promise { + const startTime = Date.now(); + + try { + this.logger.log(`Enhancing keywords for user: ${userId}`); + + // Clean and normalize input keywords + const cleanKeywords = this.cleanKeywords(keywords); + + // Generate enhanced keywords using AI + const enhancedKeywords = await this.generateEnhancedKeywords(cleanKeywords); + const relatedKeywords = await this.generateRelatedKeywords(cleanKeywords); + const longTailKeywords = await this.generateLongTailKeywords(cleanKeywords); + + const processingTime = (Date.now() - startTime) / 1000; + + const result: EnhanceKeywordsResponseDto = { + original_keywords: cleanKeywords, + enhanced_keywords: enhancedKeywords, + related_keywords: relatedKeywords, + long_tail_keywords: longTailKeywords, + metadata: { + processing_time: processingTime, + ai_model: 'mock-gpt-4', // Replace with actual model when using OpenAI + confidence_score: 0.92, + keywords_generated: enhancedKeywords.length + relatedKeywords.length + longTailKeywords.length, + }, + }; + + this.logger.log(`Enhanced keywords successfully for user: ${userId}`); + return result; + + } catch (error) { + this.logger.error(`Failed to enhance keywords for user: ${userId}`, error.stack); + throw new BadRequestException('Failed to enhance keywords'); + } + } + + /** + * Get keyword suggestions based on context + */ + async getKeywordSuggestions( + context?: string, + category?: string, + ): Promise<{ + suggestions: string[]; + categories: Record; + }> { + try { + // Mock suggestions - replace with actual AI generation + const baseSuggestions = [ + 'interior-design', + 'home-decor', + 'modern-style', + 'contemporary', + 'minimalist', + 'elegant', + 'stylish', + 'trendy', + ]; + + const categories = { + style: ['modern', 'contemporary', 'minimalist', 'industrial', 'scandinavian', 'rustic'], + room: ['kitchen', 'living-room', 'bedroom', 'bathroom', 'office', 'dining-room'], + color: ['white', 'black', 'gray', 'blue', 'green', 'brown'], + material: ['wood', 'metal', 'glass', 'stone', 'fabric', 'leather'], + feature: ['island', 'cabinet', 'counter', 'lighting', 'flooring', 'window'], + }; + + // Filter suggestions based on context or category + let suggestions = baseSuggestions; + if (category && categories[category]) { + suggestions = [...baseSuggestions, ...categories[category]]; + } + + return { + suggestions: suggestions.slice(0, 12), // Limit to 12 suggestions + categories, + }; + + } catch (error) { + this.logger.error('Failed to get keyword suggestions', error.stack); + throw new BadRequestException('Failed to get keyword suggestions'); + } + } + + /** + * Validate keywords for SEO optimization + */ + async validateKeywords(keywords: string[]): Promise<{ + valid_keywords: string[]; + invalid_keywords: Array<{ keyword: string; reason: string }>; + recommendations: string[]; + }> { + try { + const validKeywords: string[] = []; + const invalidKeywords: Array<{ keyword: string; reason: string }> = []; + const recommendations: string[] = []; + + for (const keyword of keywords) { + const validation = this.validateSingleKeyword(keyword); + if (validation.isValid) { + validKeywords.push(keyword); + } else { + invalidKeywords.push({ + keyword, + reason: validation.reason, + }); + } + } + + // Generate recommendations + if (invalidKeywords.some(item => item.reason.includes('spaces'))) { + recommendations.push('Use hyphens instead of spaces for better SEO'); + } + if (invalidKeywords.some(item => item.reason.includes('short'))) { + recommendations.push('Keywords should be at least 2 characters long'); + } + if (invalidKeywords.some(item => item.reason.includes('long'))) { + recommendations.push('Keep keywords concise, ideally 2-4 words'); + } + if (keywords.some(k => /\b(the|and|or|but|in|on|at|to|for|of|with|by)\b/i.test(k))) { + recommendations.push('Avoid stop words like "the", "and", "or" for better SEO'); + } + + return { + valid_keywords: validKeywords, + invalid_keywords: invalidKeywords, + recommendations, + }; + + } catch (error) { + this.logger.error('Failed to validate keywords', error.stack); + throw new BadRequestException('Failed to validate keywords'); + } + } + + /** + * Check rate limit for user + */ + async checkRateLimit(userId: string): Promise { + const now = Date.now(); + const userLimit = this.rateLimitMap.get(userId); + + if (!userLimit || now > userLimit.resetTime) { + // Reset or create new limit window + this.rateLimitMap.set(userId, { + count: 1, + resetTime: now + this.RATE_LIMIT_WINDOW, + }); + return; + } + + if (userLimit.count >= this.RATE_LIMIT_MAX_REQUESTS) { + throw new HttpException( + 'Rate limit exceeded. Try again later.', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + userLimit.count++; + } + + /** + * Clean and normalize keywords + */ + private cleanKeywords(keywords: string[]): string[] { + return keywords + .map(keyword => keyword.trim().toLowerCase()) + .filter(keyword => keyword.length > 0) + .filter((keyword, index, arr) => arr.indexOf(keyword) === index); // Remove duplicates + } + + /** + * Generate enhanced keywords using AI (mock implementation) + */ + private async generateEnhancedKeywords(keywords: string[]): Promise { + // Simulate AI processing time + await new Promise(resolve => setTimeout(resolve, 500)); + + // Mock enhanced keywords - replace with actual AI generation + const enhancementPrefixes = ['modern', 'contemporary', 'sleek', 'stylish', 'elegant', 'trendy']; + const enhancementSuffixes = ['design', 'style', 'decor', 'interior', 'renovation', 'makeover']; + + const enhanced: string[] = []; + + for (const keyword of keywords) { + // Create variations with prefixes and suffixes + enhancementPrefixes.forEach(prefix => { + if (!keyword.startsWith(prefix)) { + enhanced.push(`${prefix}-${keyword}`); + } + }); + + enhancementSuffixes.forEach(suffix => { + if (!keyword.endsWith(suffix)) { + enhanced.push(`${keyword}-${suffix}`); + } + }); + + // Create compound keywords + if (keywords.length > 1) { + keywords.forEach(otherKeyword => { + if (keyword !== otherKeyword) { + enhanced.push(`${keyword}-${otherKeyword}`); + } + }); + } + } + + // Remove duplicates and limit results + return [...new Set(enhanced)].slice(0, 8); + } + + /** + * Generate related keywords (mock implementation) + */ + private async generateRelatedKeywords(keywords: string[]): Promise { + // Simulate AI processing time + await new Promise(resolve => setTimeout(resolve, 300)); + + // Mock related keywords - replace with actual AI generation + const relatedMap: Record = { + kitchen: ['culinary-space', 'cooking-area', 'kitchen-cabinets', 'kitchen-appliances', 'kitchen-island'], + modern: ['contemporary', 'minimalist', 'sleek', 'current', 'updated'], + renovation: ['remodel', 'makeover', 'upgrade', 'transformation', 'improvement'], + design: ['decor', 'style', 'interior', 'aesthetic', 'layout'], + }; + + const related: string[] = []; + keywords.forEach(keyword => { + if (relatedMap[keyword]) { + related.push(...relatedMap[keyword]); + } + }); + + // Add generic related terms + const genericRelated = [ + 'home-improvement', + 'interior-design', + 'space-optimization', + 'aesthetic-enhancement', + ]; + + return [...new Set([...related, ...genericRelated])].slice(0, 6); + } + + /** + * Generate long-tail keywords (mock implementation) + */ + private async generateLongTailKeywords(keywords: string[]): Promise { + // Simulate AI processing time + await new Promise(resolve => setTimeout(resolve, 400)); + + const currentYear = new Date().getFullYear(); + const longTailTemplates = [ + `{keyword}-ideas-${currentYear}`, + `{keyword}-trends-${currentYear}`, + `{keyword}-inspiration-gallery`, + `best-{keyword}-designs`, + `{keyword}-before-and-after`, + `affordable-{keyword}-solutions`, + ]; + + const longTail: string[] = []; + keywords.forEach(keyword => { + longTailTemplates.forEach(template => { + longTail.push(template.replace('{keyword}', keyword)); + }); + }); + + // Create compound long-tail keywords + if (keywords.length >= 2) { + const compound = keywords.slice(0, 2).join('-'); + longTail.push(`${compound}-design-ideas-${currentYear}`); + longTail.push(`${compound}-renovation-guide`); + longTail.push(`${compound}-style-trends`); + } + + return [...new Set(longTail)].slice(0, 4); + } + + /** + * Validate a single keyword + */ + private validateSingleKeyword(keyword: string): { isValid: boolean; reason: string } { + // Check length + if (keyword.length < 2) { + return { isValid: false, reason: 'Too short for SEO value' }; + } + + if (keyword.length > 60) { + return { isValid: false, reason: 'Too long for practical use' }; + } + + // Check for spaces (should use hyphens) + if (keyword.includes(' ')) { + return { isValid: false, reason: 'Use hyphens instead of spaces' }; + } + + // Check for invalid characters + if (!/^[a-zA-Z0-9-_]+$/.test(keyword)) { + return { isValid: false, reason: 'Contains invalid characters' }; + } + + // Check for double hyphens or underscores + if (keyword.includes('--') || keyword.includes('__')) { + return { isValid: false, reason: 'Avoid double hyphens or underscores' }; + } + + // Check if starts or ends with hyphen/underscore + if (keyword.startsWith('-') || keyword.endsWith('-') || + keyword.startsWith('_') || keyword.endsWith('_')) { + return { isValid: false, reason: 'Should not start or end with hyphen or underscore' }; + } + + return { isValid: true, reason: '' }; + } +} \ No newline at end of file diff --git a/packages/api/src/queue/processors/batch-processing.processor.ts b/packages/api/src/queue/processors/batch-processing.processor.ts new file mode 100644 index 0000000..3393389 --- /dev/null +++ b/packages/api/src/queue/processors/batch-processing.processor.ts @@ -0,0 +1,249 @@ +import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { BatchProcessingJobData, JobProgress } from '../queue.service'; + +@Processor('batch-processing') +export class BatchProcessingProcessor extends WorkerHost { + private readonly logger = new Logger(BatchProcessingProcessor.name); + + async process(job: Job): Promise { + const { batchId, userId, imageIds, keywords } = job.data; + + this.logger.log(`Processing batch: ${batchId} with ${imageIds.length} images`); + + try { + // Update progress - Starting + await this.updateProgress(job, { + percentage: 0, + processedCount: 0, + totalCount: imageIds.length, + status: 'starting', + }); + + let processedCount = 0; + const results = []; + + // Process each image in the batch + for (const imageId of imageIds) { + try { + this.logger.log(`Processing image ${processedCount + 1}/${imageIds.length}: ${imageId}`); + + // Update progress + const percentage = Math.round((processedCount / imageIds.length) * 90); // Reserve 10% for finalization + await this.updateProgress(job, { + percentage, + currentImage: imageId, + processedCount, + totalCount: imageIds.length, + status: 'processing-images', + }); + + // Simulate individual image processing + await this.processIndividualImage(imageId, batchId, keywords); + processedCount++; + + results.push({ + imageId, + success: true, + processedAt: new Date(), + }); + + } catch (error) { + this.logger.error(`Failed to process image in batch: ${imageId}`, error.stack); + results.push({ + imageId, + success: false, + error: error.message, + processedAt: new Date(), + }); + } + } + + // Finalize batch processing (90-100%) + await this.updateProgress(job, { + percentage: 95, + processedCount, + totalCount: imageIds.length, + status: 'finalizing', + }); + + // Update batch status in database + await this.finalizeBatchProcessing(batchId, results); + + // Complete processing + await this.updateProgress(job, { + percentage: 100, + processedCount, + totalCount: imageIds.length, + status: 'completed', + }); + + this.logger.log(`Completed batch processing: ${batchId}`); + + return { + batchId, + totalImages: imageIds.length, + successfulImages: results.filter(r => r.success).length, + failedImages: results.filter(r => !r.success).length, + processingTime: Date.now() - job.timestamp, + results, + }; + + } catch (error) { + this.logger.error(`Failed to process batch: ${batchId}`, error.stack); + + // Update progress - Failed + await this.updateProgress(job, { + percentage: 0, + processedCount: 0, + totalCount: imageIds.length, + status: 'failed', + }); + + // Mark batch as failed in database + await this.markBatchAsFailed(batchId, error.message); + + throw error; + } + } + + @OnWorkerEvent('completed') + onCompleted(job: Job) { + this.logger.log(`Batch processing completed: ${job.id}`); + } + + @OnWorkerEvent('failed') + onFailed(job: Job, err: Error) { + this.logger.error(`Batch processing failed: ${job.id}`, err.stack); + } + + @OnWorkerEvent('progress') + onProgress(job: Job, progress: JobProgress) { + this.logger.debug(`Batch processing progress: ${job.id} - ${progress.percentage}%`); + } + + /** + * Update job progress + */ + private async updateProgress(job: Job, progress: JobProgress): Promise { + await job.updateProgress(progress); + } + + /** + * Process an individual image within the batch + * @param imageId Image ID to process + * @param batchId Batch ID + * @param keywords Keywords for processing + */ + private async processIndividualImage( + imageId: string, + batchId: string, + keywords?: string[] + ): Promise { + // Simulate individual image processing time + await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000)); + + // TODO: Implement actual image processing logic + // This would typically: + // 1. Fetch image from storage + // 2. Perform AI vision analysis + // 3. Generate SEO filename + // 4. Update image record in database + + this.logger.debug(`Processed individual image: ${imageId}`); + } + + /** + * Finalize batch processing and update database + * @param batchId Batch ID + * @param results Processing results for all images + */ + private async finalizeBatchProcessing(batchId: string, results: any[]): Promise { + try { + const successCount = results.filter(r => r.success).length; + const failCount = results.filter(r => !r.success).length; + + // TODO: Update batch record in database + // This would typically: + // 1. Update batch status to DONE or ERROR + // 2. Set processedImages and failedImages counts + // 3. Set completedAt timestamp + // 4. Update any batch metadata + + this.logger.log(`Finalized batch ${batchId}: ${successCount} successful, ${failCount} failed`); + + // Simulate database update + await new Promise(resolve => setTimeout(resolve, 500)); + + } catch (error) { + this.logger.error(`Failed to finalize batch: ${batchId}`, error.stack); + throw error; + } + } + + /** + * Mark batch as failed in database + * @param batchId Batch ID + * @param errorMessage Error message + */ + private async markBatchAsFailed(batchId: string, errorMessage: string): Promise { + try { + // TODO: Update batch record in database + // This would typically: + // 1. Update batch status to ERROR + // 2. Set error message in metadata + // 3. Set completedAt timestamp + + this.logger.log(`Marked batch as failed: ${batchId}`); + + // Simulate database update + await new Promise(resolve => setTimeout(resolve, 200)); + + } catch (error) { + this.logger.error(`Failed to mark batch as failed: ${batchId}`, error.stack); + } + } + + /** + * Calculate batch processing statistics + * @param results Processing results + * @returns Statistics object + */ + private calculateBatchStats(results: any[]) { + const total = results.length; + const successful = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + const successRate = total > 0 ? (successful / total) * 100 : 0; + + return { + total, + successful, + failed, + successRate: Math.round(successRate * 100) / 100, + }; + } + + /** + * Send batch completion notification + * @param batchId Batch ID + * @param userId User ID + * @param stats Batch statistics + */ + private async sendBatchCompletionNotification( + batchId: string, + userId: string, + stats: any + ): Promise { + try { + // TODO: Implement notification system + // This could send email, push notification, or WebSocket event + + this.logger.log(`Sent batch completion notification: ${batchId} to user: ${userId}`); + + } catch (error) { + this.logger.error(`Failed to send batch completion notification: ${batchId}`, error.stack); + // Don't throw error - notification failure shouldn't fail the job + } + } +} \ No newline at end of file diff --git a/packages/api/src/queue/processors/image-processing.processor.ts b/packages/api/src/queue/processors/image-processing.processor.ts new file mode 100644 index 0000000..9500841 --- /dev/null +++ b/packages/api/src/queue/processors/image-processing.processor.ts @@ -0,0 +1,200 @@ +import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { ImageProcessingJobData, JobProgress } from '../queue.service'; + +@Processor('image-processing') +export class ImageProcessingProcessor extends WorkerHost { + private readonly logger = new Logger(ImageProcessingProcessor.name); + + async process(job: Job): Promise { + const { imageId, batchId, s3Key, originalName, userId, keywords } = job.data; + + this.logger.log(`Processing image: ${imageId} from batch: ${batchId}`); + + try { + // Update progress - Starting + await this.updateProgress(job, { + percentage: 0, + currentImage: originalName, + processedCount: 0, + totalCount: 1, + status: 'starting', + }); + + // Step 1: Download image from storage (10%) + await this.updateProgress(job, { + percentage: 10, + currentImage: originalName, + processedCount: 0, + totalCount: 1, + status: 'downloading', + }); + // TODO: Implement actual image download from storage + + // Step 2: AI Vision Analysis (50%) + await this.updateProgress(job, { + percentage: 30, + currentImage: originalName, + processedCount: 0, + totalCount: 1, + status: 'analyzing', + }); + + const visionTags = await this.performVisionAnalysis(s3Key, keywords); + + // Step 3: Generate SEO filename (70%) + await this.updateProgress(job, { + percentage: 70, + currentImage: originalName, + processedCount: 0, + totalCount: 1, + status: 'generating-filename', + }); + + const proposedName = await this.generateSeoFilename(visionTags, originalName, keywords); + + // Step 4: Update database (90%) + await this.updateProgress(job, { + percentage: 90, + currentImage: originalName, + processedCount: 0, + totalCount: 1, + status: 'updating-database', + }); + + // TODO: Update image record in database with vision tags and proposed name + + // Step 5: Complete (100%) + await this.updateProgress(job, { + percentage: 100, + currentImage: originalName, + processedCount: 1, + totalCount: 1, + status: 'completed', + }); + + this.logger.log(`Completed processing image: ${imageId}`); + + return { + imageId, + success: true, + proposedName, + visionTags, + processingTime: Date.now() - job.timestamp, + }; + + } catch (error) { + this.logger.error(`Failed to process image: ${imageId}`, error.stack); + + // Update progress - Failed + await this.updateProgress(job, { + percentage: 0, + currentImage: originalName, + processedCount: 0, + totalCount: 1, + status: 'failed', + }); + + throw error; + } + } + + @OnWorkerEvent('completed') + onCompleted(job: Job) { + this.logger.log(`Image processing completed: ${job.id}`); + } + + @OnWorkerEvent('failed') + onFailed(job: Job, err: Error) { + this.logger.error(`Image processing failed: ${job.id}`, err.stack); + } + + @OnWorkerEvent('progress') + onProgress(job: Job, progress: JobProgress) { + this.logger.debug(`Image processing progress: ${job.id} - ${progress.percentage}%`); + } + + /** + * Update job progress + */ + private async updateProgress(job: Job, progress: JobProgress): Promise { + await job.updateProgress(progress); + } + + /** + * Perform AI vision analysis on the image + * @param s3Key Storage key for the image + * @param keywords Additional keywords for context + * @returns Vision analysis results + */ + private async performVisionAnalysis(s3Key: string, keywords?: string[]): Promise { + // Simulate AI processing time + await new Promise(resolve => setTimeout(resolve, 2000)); + + // TODO: Implement actual AI vision analysis + // This would integrate with OpenAI GPT-4 Vision or similar service + + // Mock response for now + return { + objects: ['modern', 'kitchen', 'appliances', 'interior'], + colors: ['white', 'stainless-steel', 'gray'], + scene: 'modern kitchen interior', + description: 'A modern kitchen with stainless steel appliances and white cabinets', + confidence: 0.92, + aiModel: 'gpt-4-vision', + processingTime: 2.1, + keywords: keywords || [], + }; + } + + /** + * Generate SEO-friendly filename from vision analysis + * @param visionTags AI vision analysis results + * @param originalName Original filename + * @param keywords Additional keywords + * @returns SEO-optimized filename + */ + private async generateSeoFilename( + visionTags: any, + originalName: string, + keywords?: string[] + ): Promise { + try { + // Combine AI-detected objects with user keywords + const allKeywords = [ + ...(visionTags.objects || []), + ...(keywords || []), + ...(visionTags.colors || []).slice(0, 2), // Limit colors + ]; + + // Remove duplicates and filter out common words + const filteredKeywords = [...new Set(allKeywords)] + .filter(keyword => keyword.length > 2) + .filter(keyword => !['the', 'and', 'with', 'for', 'are', 'was'].includes(keyword.toLowerCase())) + .slice(0, 5); // Limit to 5 keywords for filename + + // Create SEO-friendly filename + let filename = filteredKeywords + .join('-') + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') // Remove special characters + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Replace multiple hyphens with single + .substring(0, 80); // Limit length + + // Get file extension from original name + const extension = originalName.split('.').pop()?.toLowerCase() || 'jpg'; + + // Ensure filename is not empty + if (!filename) { + filename = 'image'; + } + + return `${filename}.${extension}`; + } catch (error) { + this.logger.error('Failed to generate SEO filename', error.stack); + return originalName; // Fallback to original name + } + } +} \ No newline at end of file diff --git a/packages/api/src/queue/queue.module.ts b/packages/api/src/queue/queue.module.ts new file mode 100644 index 0000000..e897f27 --- /dev/null +++ b/packages/api/src/queue/queue.module.ts @@ -0,0 +1,61 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { QueueService } from './queue.service'; +import { ImageProcessingProcessor } from './processors/image-processing.processor'; +import { BatchProcessingProcessor } from './processors/batch-processing.processor'; + +@Module({ + imports: [ + BullModule.forRootAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + connection: { + host: configService.get('REDIS_HOST', 'localhost'), + port: configService.get('REDIS_PORT', 6379), + password: configService.get('REDIS_PASSWORD'), + db: configService.get('REDIS_DB', 0), + }, + defaultJobOptions: { + removeOnComplete: 100, + removeOnFail: 50, + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + }, + }), + inject: [ConfigService], + }), + BullModule.registerQueue( + { + name: 'image-processing', + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + }, + }, + { + name: 'batch-processing', + defaultJobOptions: { + attempts: 2, + backoff: { + type: 'fixed', + delay: 5000, + }, + }, + } + ), + ], + providers: [ + QueueService, + ImageProcessingProcessor, + BatchProcessingProcessor, + ], + exports: [QueueService], +}) +export class QueueModule {} \ No newline at end of file diff --git a/packages/api/src/queue/queue.service.ts b/packages/api/src/queue/queue.service.ts new file mode 100644 index 0000000..a8cf973 --- /dev/null +++ b/packages/api/src/queue/queue.service.ts @@ -0,0 +1,263 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue, Job } from 'bullmq'; + +export interface ImageProcessingJobData { + imageId: string; + batchId: string; + s3Key: string; + originalName: string; + userId: string; + keywords?: string[]; +} + +export interface BatchProcessingJobData { + batchId: string; + userId: string; + imageIds: string[]; + keywords?: string[]; +} + +export interface JobProgress { + percentage: number; + currentImage?: string; + processedCount: number; + totalCount: number; + status: string; +} + +@Injectable() +export class QueueService { + private readonly logger = new Logger(QueueService.name); + + constructor( + @InjectQueue('image-processing') private imageQueue: Queue, + @InjectQueue('batch-processing') private batchQueue: Queue, + ) {} + + /** + * Add image processing job to queue + * @param data Image processing job data + * @returns Job instance + */ + async addImageProcessingJob(data: ImageProcessingJobData): Promise { + try { + const job = await this.imageQueue.add('process-image', data, { + jobId: `image-${data.imageId}`, + priority: 1, + delay: 0, + }); + + this.logger.log(`Added image processing job: ${job.id} for image: ${data.imageId}`); + return job; + } catch (error) { + this.logger.error(`Failed to add image processing job: ${data.imageId}`, error.stack); + throw error; + } + } + + /** + * Add batch processing job to queue + * @param data Batch processing job data + * @returns Job instance + */ + async addBatchProcessingJob(data: BatchProcessingJobData): Promise { + try { + const job = await this.batchQueue.add('process-batch', data, { + jobId: `batch-${data.batchId}`, + priority: 2, + delay: 1000, // Small delay to ensure all images are uploaded first + }); + + this.logger.log(`Added batch processing job: ${job.id} for batch: ${data.batchId}`); + return job; + } catch (error) { + this.logger.error(`Failed to add batch processing job: ${data.batchId}`, error.stack); + throw error; + } + } + + /** + * Get job status and progress + * @param jobId Job ID + * @param queueName Queue name + * @returns Job status and progress + */ + async getJobStatus(jobId: string, queueName: 'image-processing' | 'batch-processing'): Promise<{ + status: string; + progress: JobProgress | null; + error?: string; + }> { + try { + const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; + const job = await queue.getJob(jobId); + + if (!job) { + return { status: 'not-found', progress: null }; + } + + const state = await job.getState(); + const progress = job.progress as JobProgress | null; + + return { + status: state, + progress, + error: job.failedReason, + }; + } catch (error) { + this.logger.error(`Failed to get job status: ${jobId}`, error.stack); + throw error; + } + } + + /** + * Cancel a job + * @param jobId Job ID + * @param queueName Queue name + */ + async cancelJob(jobId: string, queueName: 'image-processing' | 'batch-processing'): Promise { + try { + const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; + const job = await queue.getJob(jobId); + + if (job) { + await job.remove(); + this.logger.log(`Cancelled job: ${jobId}`); + } + } catch (error) { + this.logger.error(`Failed to cancel job: ${jobId}`, error.stack); + throw error; + } + } + + /** + * Get queue statistics + * @param queueName Queue name + * @returns Queue statistics + */ + async getQueueStats(queueName: 'image-processing' | 'batch-processing') { + try { + const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; + + const [waiting, active, completed, failed, delayed] = await Promise.all([ + queue.getWaiting(), + queue.getActive(), + queue.getCompleted(), + queue.getFailed(), + queue.getDelayed(), + ]); + + return { + waiting: waiting.length, + active: active.length, + completed: completed.length, + failed: failed.length, + delayed: delayed.length, + total: waiting.length + active.length + completed.length + failed.length + delayed.length, + }; + } catch (error) { + this.logger.error(`Failed to get queue stats: ${queueName}`, error.stack); + throw error; + } + } + + /** + * Clean completed jobs from queue + * @param queueName Queue name + * @param maxAge Maximum age in milliseconds + */ + async cleanQueue(queueName: 'image-processing' | 'batch-processing', maxAge: number = 24 * 60 * 60 * 1000): Promise { + try { + const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; + + await queue.clean(maxAge, 100, 'completed'); + await queue.clean(maxAge, 50, 'failed'); + + this.logger.log(`Cleaned queue: ${queueName}`); + } catch (error) { + this.logger.error(`Failed to clean queue: ${queueName}`, error.stack); + throw error; + } + } + + /** + * Pause queue processing + * @param queueName Queue name + */ + async pauseQueue(queueName: 'image-processing' | 'batch-processing'): Promise { + try { + const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; + await queue.pause(); + this.logger.log(`Paused queue: ${queueName}`); + } catch (error) { + this.logger.error(`Failed to pause queue: ${queueName}`, error.stack); + throw error; + } + } + + /** + * Resume queue processing + * @param queueName Queue name + */ + async resumeQueue(queueName: 'image-processing' | 'batch-processing'): Promise { + try { + const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; + await queue.resume(); + this.logger.log(`Resumed queue: ${queueName}`); + } catch (error) { + this.logger.error(`Failed to resume queue: ${queueName}`, error.stack); + throw error; + } + } + + /** + * Add multiple image processing jobs + * @param jobsData Array of image processing job data + * @returns Array of job instances + */ + async addMultipleImageJobs(jobsData: ImageProcessingJobData[]): Promise { + try { + const jobs = await this.imageQueue.addBulk( + jobsData.map((data, index) => ({ + name: 'process-image', + data, + opts: { + jobId: `image-${data.imageId}`, + priority: 1, + delay: index * 100, // Stagger jobs slightly + }, + })) + ); + + this.logger.log(`Added ${jobs.length} image processing jobs`); + return jobs; + } catch (error) { + this.logger.error('Failed to add multiple image jobs', error.stack); + throw error; + } + } + + /** + * Get active jobs for monitoring + * @param queueName Queue name + * @returns Array of active jobs + */ + async getActiveJobs(queueName: 'image-processing' | 'batch-processing') { + try { + const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; + const activeJobs = await queue.getActive(); + + return activeJobs.map(job => ({ + id: job.id, + name: job.name, + data: job.data, + progress: job.progress, + processedOn: job.processedOn, + opts: job.opts, + })); + } catch (error) { + this.logger.error(`Failed to get active jobs: ${queueName}`, error.stack); + throw error; + } + } +} \ No newline at end of file diff --git a/packages/api/src/storage/storage.module.ts b/packages/api/src/storage/storage.module.ts new file mode 100644 index 0000000..2b1b101 --- /dev/null +++ b/packages/api/src/storage/storage.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { StorageService } from './storage.service'; + +@Module({ + imports: [ConfigModule], + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} \ No newline at end of file diff --git a/packages/api/src/storage/storage.service.ts b/packages/api/src/storage/storage.service.ts new file mode 100644 index 0000000..9dd6262 --- /dev/null +++ b/packages/api/src/storage/storage.service.ts @@ -0,0 +1,263 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as Minio from 'minio'; +import { v4 as uuidv4 } from 'uuid'; +import * as crypto from 'crypto'; + +export interface StorageFile { + buffer: Buffer; + originalName: string; + mimeType: string; + size: number; +} + +export interface UploadResult { + key: string; + etag: string; + size: number; + checksum: string; +} + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private readonly minioClient: Minio.Client; + private readonly bucketName: string; + + constructor(private configService: ConfigService) { + // Initialize MinIO client + this.minioClient = new Minio.Client({ + endPoint: this.configService.get('MINIO_ENDPOINT', 'localhost'), + port: this.configService.get('MINIO_PORT', 9000), + useSSL: this.configService.get('MINIO_USE_SSL', false), + accessKey: this.configService.get('MINIO_ACCESS_KEY', 'minioadmin'), + secretKey: this.configService.get('MINIO_SECRET_KEY', 'minioadmin'), + }); + + this.bucketName = this.configService.get('MINIO_BUCKET_NAME', 'seo-image-renamer'); + this.initializeBucket(); + } + + /** + * Initialize the bucket if it doesn't exist + */ + private async initializeBucket(): Promise { + try { + const bucketExists = await this.minioClient.bucketExists(this.bucketName); + if (!bucketExists) { + await this.minioClient.makeBucket(this.bucketName, 'us-east-1'); + this.logger.log(`Created bucket: ${this.bucketName}`); + } + } catch (error) { + this.logger.error(`Failed to initialize bucket: ${error.message}`, error.stack); + } + } + + /** + * Upload a file to MinIO storage + * @param file File data to upload + * @param batchId Batch UUID for organizing files + * @returns Upload result with key and metadata + */ + async uploadFile(file: StorageFile, batchId: string): Promise { + try { + // Generate file checksum + const checksum = crypto.createHash('sha256').update(file.buffer).digest('hex'); + + // Generate unique filename with batch organization + const fileExtension = this.getFileExtension(file.originalName); + const fileName = `${uuidv4()}${fileExtension}`; + const objectKey = `batches/${batchId}/${fileName}`; + + // Upload metadata + const metaData = { + 'Content-Type': file.mimeType, + 'Original-Name': file.originalName, + 'Upload-Date': new Date().toISOString(), + 'Checksum-SHA256': checksum, + }; + + // Upload file to MinIO + const uploadInfo = await this.minioClient.putObject( + this.bucketName, + objectKey, + file.buffer, + file.size, + metaData + ); + + this.logger.log(`File uploaded successfully: ${objectKey}`); + + return { + key: objectKey, + etag: uploadInfo.etag, + size: file.size, + checksum, + }; + } catch (error) { + this.logger.error(`Failed to upload file: ${error.message}`, error.stack); + throw new Error(`File upload failed: ${error.message}`); + } + } + + /** + * Get a file from MinIO storage + * @param objectKey Object key to retrieve + * @returns File stream + */ + async getFile(objectKey: string): Promise { + try { + return await this.minioClient.getObject(this.bucketName, objectKey); + } catch (error) { + this.logger.error(`Failed to retrieve file: ${objectKey}`, error.stack); + throw new Error(`File retrieval failed: ${error.message}`); + } + } + + /** + * Get file metadata + * @param objectKey Object key to get metadata for + * @returns File metadata + */ + async getFileMetadata(objectKey: string): Promise { + try { + return await this.minioClient.statObject(this.bucketName, objectKey); + } catch (error) { + this.logger.error(`Failed to get file metadata: ${objectKey}`, error.stack); + throw new Error(`File metadata retrieval failed: ${error.message}`); + } + } + + /** + * Delete a file from MinIO storage + * @param objectKey Object key to delete + */ + async deleteFile(objectKey: string): Promise { + try { + await this.minioClient.removeObject(this.bucketName, objectKey); + this.logger.log(`File deleted successfully: ${objectKey}`); + } catch (error) { + this.logger.error(`Failed to delete file: ${objectKey}`, error.stack); + throw new Error(`File deletion failed: ${error.message}`); + } + } + + /** + * List files in a batch folder + * @param batchId Batch UUID + * @returns Array of object keys + */ + async listBatchFiles(batchId: string): Promise { + try { + const objects: string[] = []; + const objectStream = this.minioClient.listObjects( + this.bucketName, + `batches/${batchId}/`, + true + ); + + return new Promise((resolve, reject) => { + objectStream.on('data', (obj) => { + objects.push(obj.name); + }); + + objectStream.on('error', (err) => { + this.logger.error(`Failed to list batch files: ${batchId}`, err); + reject(new Error(`Failed to list batch files: ${err.message}`)); + }); + + objectStream.on('end', () => { + resolve(objects); + }); + }); + } catch (error) { + this.logger.error(`Failed to list batch files: ${batchId}`, error.stack); + throw new Error(`Batch file listing failed: ${error.message}`); + } + } + + /** + * Delete all files in a batch folder + * @param batchId Batch UUID + */ + async deleteBatchFiles(batchId: string): Promise { + try { + const objectKeys = await this.listBatchFiles(batchId); + + if (objectKeys.length > 0) { + await this.minioClient.removeObjects(this.bucketName, objectKeys); + this.logger.log(`Deleted ${objectKeys.length} files for batch: ${batchId}`); + } + } catch (error) { + this.logger.error(`Failed to delete batch files: ${batchId}`, error.stack); + throw new Error(`Batch file deletion failed: ${error.message}`); + } + } + + /** + * Generate a presigned URL for file download + * @param objectKey Object key + * @param expiry Expiry time in seconds (default: 1 hour) + * @returns Presigned URL + */ + async getPresignedUrl(objectKey: string, expiry: number = 3600): Promise { + try { + return await this.minioClient.presignedGetObject(this.bucketName, objectKey, expiry); + } catch (error) { + this.logger.error(`Failed to generate presigned URL: ${objectKey}`, error.stack); + throw new Error(`Presigned URL generation failed: ${error.message}`); + } + } + + /** + * Check if file exists in storage + * @param objectKey Object key to check + * @returns Whether file exists + */ + async fileExists(objectKey: string): Promise { + try { + await this.minioClient.statObject(this.bucketName, objectKey); + return true; + } catch (error) { + if (error.code === 'NotFound') { + return false; + } + throw error; + } + } + + /** + * Calculate SHA-256 checksum for duplicate detection + * @param buffer File buffer + * @returns SHA-256 checksum + */ + calculateChecksum(buffer: Buffer): string { + return crypto.createHash('sha256').update(buffer).digest('hex'); + } + + /** + * Get file extension from filename + * @param filename Original filename + * @returns File extension with dot + */ + private getFileExtension(filename: string): string { + const lastDotIndex = filename.lastIndexOf('.'); + return lastDotIndex !== -1 ? filename.substring(lastDotIndex) : ''; + } + + /** + * Validate file MIME type for image uploads + * @param mimeType MIME type to validate + * @returns Whether MIME type is valid + */ + isValidImageMimeType(mimeType: string): boolean { + const validMimeTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + ]; + return validMimeTypes.includes(mimeType.toLowerCase()); + } +} \ No newline at end of file diff --git a/packages/api/src/upload/upload.module.ts b/packages/api/src/upload/upload.module.ts new file mode 100644 index 0000000..d96dc6c --- /dev/null +++ b/packages/api/src/upload/upload.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { StorageModule } from '../storage/storage.module'; +import { UploadService } from './upload.service'; + +@Module({ + imports: [StorageModule], + providers: [UploadService], + exports: [UploadService], +}) +export class UploadModule {} \ No newline at end of file diff --git a/packages/api/src/upload/upload.service.ts b/packages/api/src/upload/upload.service.ts new file mode 100644 index 0000000..b83abf3 --- /dev/null +++ b/packages/api/src/upload/upload.service.ts @@ -0,0 +1,319 @@ +import { Injectable, Logger, BadRequestException, PayloadTooLargeException } from '@nestjs/common'; +import * as sharp from 'sharp'; +import { StorageService, StorageFile, UploadResult } from '../storage/storage.service'; + +export interface ImageMetadata { + width: number; + height: number; + format: string; + size: number; + hasAlpha: boolean; + density?: number; +} + +export interface ProcessedUpload { + uploadResult: UploadResult; + metadata: ImageMetadata; + originalName: string; + mimeType: string; +} + +export interface UploadQuotaCheck { + allowed: boolean; + remainingQuota: number; + requestedCount: number; + maxFileSize: number; +} + +@Injectable() +export class UploadService { + private readonly logger = new Logger(UploadService.name); + + // File size limits (in bytes) + private readonly MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB + private readonly MAX_TOTAL_SIZE = 500 * 1024 * 1024; // 500MB per batch + + // Quota limits by plan + private readonly QUOTA_LIMITS = { + BASIC: 50, + PRO: 500, + MAX: 1000, + }; + + constructor(private readonly storageService: StorageService) {} + + /** + * Process and upload multiple files + * @param files Array of uploaded files + * @param batchId Batch UUID for organization + * @param keywords Optional keywords for processing + * @returns Array of processed uploads + */ + async processMultipleFiles( + files: Express.Multer.File[], + batchId: string, + keywords?: string[] + ): Promise { + this.logger.log(`Processing ${files.length} files for batch: ${batchId}`); + + // Validate files + this.validateFiles(files); + + const results: ProcessedUpload[] = []; + const duplicateHashes = new Set(); + + for (const file of files) { + try { + // Check for duplicates by checksum + const checksum = this.storageService.calculateChecksum(file.buffer); + if (duplicateHashes.has(checksum)) { + this.logger.warn(`Duplicate file detected: ${file.originalname}`); + continue; + } + duplicateHashes.add(checksum); + + // Process individual file + const processed = await this.processSingleFile(file, batchId, keywords); + results.push(processed); + + } catch (error) { + this.logger.error(`Failed to process file: ${file.originalname}`, error.stack); + // Continue processing other files + } + } + + this.logger.log(`Successfully processed ${results.length}/${files.length} files`); + return results; + } + + /** + * Process a single file upload + * @param file Uploaded file + * @param batchId Batch UUID + * @param keywords Optional keywords + * @returns Processed upload result + */ + async processSingleFile( + file: Express.Multer.File, + batchId: string, + keywords?: string[] + ): Promise { + try { + // Validate file type + if (!this.storageService.isValidImageMimeType(file.mimetype)) { + throw new BadRequestException(`Unsupported file type: ${file.mimetype}`); + } + + // Extract image metadata + const metadata = await this.extractImageMetadata(file.buffer); + + // Create storage file object + const storageFile: StorageFile = { + buffer: file.buffer, + originalName: file.originalname, + mimeType: file.mimetype, + size: file.size, + }; + + // Upload to storage + const uploadResult = await this.storageService.uploadFile(storageFile, batchId); + + this.logger.log(`File processed successfully: ${file.originalname}`); + + return { + uploadResult, + metadata, + originalName: file.originalname, + mimeType: file.mimetype, + }; + + } catch (error) { + this.logger.error(`Failed to process file: ${file.originalname}`, error.stack); + throw error; + } + } + + /** + * Extract image metadata using Sharp + * @param buffer Image buffer + * @returns Image metadata + */ + async extractImageMetadata(buffer: Buffer): Promise { + try { + const image = sharp(buffer); + const metadata = await image.metadata(); + + return { + width: metadata.width || 0, + height: metadata.height || 0, + format: metadata.format || 'unknown', + size: buffer.length, + hasAlpha: metadata.hasAlpha || false, + density: metadata.density, + }; + } catch (error) { + this.logger.error('Failed to extract image metadata', error.stack); + throw new BadRequestException('Invalid image file'); + } + } + + /** + * Validate uploaded files + * @param files Array of files to validate + */ + private validateFiles(files: Express.Multer.File[]): void { + if (!files || files.length === 0) { + throw new BadRequestException('No files provided'); + } + + let totalSize = 0; + + for (const file of files) { + // Check individual file size + if (file.size > this.MAX_FILE_SIZE) { + throw new PayloadTooLargeException( + `File ${file.originalname} exceeds maximum size of ${this.MAX_FILE_SIZE / (1024 * 1024)}MB` + ); + } + + // Check file type + if (!this.storageService.isValidImageMimeType(file.mimetype)) { + throw new BadRequestException( + `Unsupported file type: ${file.mimetype} for file ${file.originalname}` + ); + } + + totalSize += file.size; + } + + // Check total batch size + if (totalSize > this.MAX_TOTAL_SIZE) { + throw new PayloadTooLargeException( + `Total batch size exceeds maximum of ${this.MAX_TOTAL_SIZE / (1024 * 1024)}MB` + ); + } + } + + /** + * Check if user has sufficient quota for upload + * @param fileCount Number of files to upload + * @param userPlan User's subscription plan + * @param remainingQuota User's remaining quota + * @returns Quota check result + */ + checkUploadQuota( + fileCount: number, + userPlan: 'BASIC' | 'PRO' | 'MAX', + remainingQuota: number + ): UploadQuotaCheck { + const maxQuota = this.QUOTA_LIMITS[userPlan]; + const allowed = remainingQuota >= fileCount; + + return { + allowed, + remainingQuota, + requestedCount: fileCount, + maxFileSize: this.MAX_FILE_SIZE, + }; + } + + /** + * Generate thumbnail for image + * @param buffer Original image buffer + * @param width Thumbnail width (default: 200) + * @param height Thumbnail height (default: 200) + * @returns Thumbnail buffer + */ + async generateThumbnail( + buffer: Buffer, + width: number = 200, + height: number = 200 + ): Promise { + try { + return await sharp(buffer) + .resize(width, height, { + fit: 'cover', + position: 'center', + }) + .jpeg({ + quality: 80, + progressive: true, + }) + .toBuffer(); + } catch (error) { + this.logger.error('Failed to generate thumbnail', error.stack); + throw new Error('Thumbnail generation failed'); + } + } + + /** + * Optimize image for web display + * @param buffer Original image buffer + * @param quality JPEG quality (1-100) + * @returns Optimized image buffer + */ + async optimizeImage(buffer: Buffer, quality: number = 85): Promise { + try { + const metadata = await sharp(buffer).metadata(); + + // Skip optimization for very small images + if ((metadata.width || 0) * (metadata.height || 0) < 50000) { + return buffer; + } + + return await sharp(buffer) + .jpeg({ + quality, + progressive: true, + mozjpeg: true, + }) + .toBuffer(); + } catch (error) { + this.logger.error('Failed to optimize image', error.stack); + return buffer; // Return original on error + } + } + + /** + * Validate file against virus/malware (placeholder for future implementation) + * @param buffer File buffer + * @returns Whether file is safe + */ + async validateFileSafety(buffer: Buffer): Promise { + // TODO: Implement virus scanning if needed + // For now, just check if it's a valid image + try { + await sharp(buffer).metadata(); + return true; + } catch { + return false; + } + } + + /** + * Get supported file types + * @returns Array of supported MIME types + */ + getSupportedFileTypes(): string[] { + return [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + ]; + } + + /** + * Get file size limits + * @returns File size limits configuration + */ + getFileSizeLimits() { + return { + maxFileSize: this.MAX_FILE_SIZE, + maxTotalSize: this.MAX_TOTAL_SIZE, + maxFileSizeMB: this.MAX_FILE_SIZE / (1024 * 1024), + maxTotalSizeMB: this.MAX_TOTAL_SIZE / (1024 * 1024), + }; + } +} \ No newline at end of file diff --git a/packages/api/src/websocket/progress.gateway.ts b/packages/api/src/websocket/progress.gateway.ts new file mode 100644 index 0000000..d9f8cc5 --- /dev/null +++ b/packages/api/src/websocket/progress.gateway.ts @@ -0,0 +1,356 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayConnection, + OnGatewayDisconnect, + OnGatewayInit, +} from '@nestjs/websockets'; +import { Logger, UseGuards } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; +import { JwtAuthGuard } from '../auth/auth.guard'; +import { QueueService } from '../queue/queue.service'; + +interface ProgressEvent { + image_id: string; + status: 'processing' | 'completed' | 'failed'; + progress?: number; + message?: string; + timestamp: string; +} + +interface ClientConnection { + userId: string; + batchIds: Set; +} + +@WebSocketGateway({ + cors: { + origin: process.env.FRONTEND_URL || 'http://localhost:3000', + credentials: true, + }, + namespace: '/progress', +}) +export class ProgressGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(ProgressGateway.name); + private readonly clients = new Map(); + + constructor(private readonly queueService: QueueService) {} + + afterInit(server: Server) { + this.logger.log('WebSocket Gateway initialized'); + } + + async handleConnection(client: Socket) { + try { + this.logger.log(`Client connected: ${client.id}`); + + // TODO: Implement JWT authentication for WebSocket connections + // For now, we'll extract user info from handshake or query params + const userId = client.handshake.query.userId as string; + + if (!userId) { + this.logger.warn(`Client ${client.id} connected without userId`); + client.disconnect(); + return; + } + + // Store client connection + this.clients.set(client.id, { + userId, + batchIds: new Set(), + }); + + // Send connection confirmation + client.emit('connected', { + message: 'Connected to progress updates', + timestamp: new Date().toISOString(), + }); + + } catch (error) { + this.logger.error(`Error handling connection: ${client.id}`, error.stack); + client.disconnect(); + } + } + + handleDisconnect(client: Socket) { + this.logger.log(`Client disconnected: ${client.id}`); + this.clients.delete(client.id); + } + + /** + * Subscribe to batch progress updates + */ + @SubscribeMessage('subscribe_batch') + async handleSubscribeBatch( + @ConnectedSocket() client: Socket, + @MessageBody() data: { batch_id: string } + ) { + try { + const connection = this.clients.get(client.id); + if (!connection) { + client.emit('error', { message: 'Connection not found' }); + return; + } + + const { batch_id: batchId } = data; + if (!batchId) { + client.emit('error', { message: 'batch_id is required' }); + return; + } + + // Add batch to client's subscriptions + connection.batchIds.add(batchId); + + // Join the batch room + await client.join(`batch:${batchId}`); + + this.logger.log(`Client ${client.id} subscribed to batch: ${batchId}`); + + // Send confirmation + client.emit('subscribed', { + batch_id: batchId, + message: 'Subscribed to batch progress updates', + timestamp: new Date().toISOString(), + }); + + // Send initial batch status + await this.sendBatchStatus(batchId, client); + + } catch (error) { + this.logger.error(`Error subscribing to batch: ${client.id}`, error.stack); + client.emit('error', { message: 'Failed to subscribe to batch' }); + } + } + + /** + * Unsubscribe from batch progress updates + */ + @SubscribeMessage('unsubscribe_batch') + async handleUnsubscribeBatch( + @ConnectedSocket() client: Socket, + @MessageBody() data: { batch_id: string } + ) { + try { + const connection = this.clients.get(client.id); + if (!connection) { + return; + } + + const { batch_id: batchId } = data; + if (!batchId) { + client.emit('error', { message: 'batch_id is required' }); + return; + } + + // Remove batch from client's subscriptions + connection.batchIds.delete(batchId); + + // Leave the batch room + await client.leave(`batch:${batchId}`); + + this.logger.log(`Client ${client.id} unsubscribed from batch: ${batchId}`); + + client.emit('unsubscribed', { + batch_id: batchId, + message: 'Unsubscribed from batch progress updates', + timestamp: new Date().toISOString(), + }); + + } catch (error) { + this.logger.error(`Error unsubscribing from batch: ${client.id}`, error.stack); + client.emit('error', { message: 'Failed to unsubscribe from batch' }); + } + } + + /** + * Get current batch status + */ + @SubscribeMessage('get_batch_status') + async handleGetBatchStatus( + @ConnectedSocket() client: Socket, + @MessageBody() data: { batch_id: string } + ) { + try { + const { batch_id: batchId } = data; + if (!batchId) { + client.emit('error', { message: 'batch_id is required' }); + return; + } + + await this.sendBatchStatus(batchId, client); + + } catch (error) { + this.logger.error(`Error getting batch status: ${client.id}`, error.stack); + client.emit('error', { message: 'Failed to get batch status' }); + } + } + + /** + * Broadcast progress update to all clients subscribed to a batch + */ + broadcastBatchProgress(batchId: string, progress: { + state: 'PROCESSING' | 'DONE' | 'ERROR'; + progress: number; + processedImages?: number; + totalImages?: number; + currentImage?: string; + }) { + try { + const event = { + batch_id: batchId, + ...progress, + timestamp: new Date().toISOString(), + }; + + this.server.to(`batch:${batchId}`).emit('batch_progress', event); + + this.logger.debug(`Broadcasted batch progress: ${batchId} - ${progress.progress}%`); + + } catch (error) { + this.logger.error(`Error broadcasting batch progress: ${batchId}`, error.stack); + } + } + + /** + * Broadcast image-specific progress update + */ + broadcastImageProgress(batchId: string, imageId: string, status: 'processing' | 'completed' | 'failed', message?: string) { + try { + const event: ProgressEvent = { + image_id: imageId, + status, + message, + timestamp: new Date().toISOString(), + }; + + this.server.to(`batch:${batchId}`).emit('image_progress', event); + + this.logger.debug(`Broadcasted image progress: ${imageId} - ${status}`); + + } catch (error) { + this.logger.error(`Error broadcasting image progress: ${imageId}`, error.stack); + } + } + + /** + * Broadcast batch completion + */ + broadcastBatchCompleted(batchId: string, summary: { + totalImages: number; + processedImages: number; + failedImages: number; + processingTime: number; + }) { + try { + const event = { + batch_id: batchId, + state: 'DONE', + progress: 100, + ...summary, + timestamp: new Date().toISOString(), + }; + + this.server.to(`batch:${batchId}`).emit('batch_completed', event); + + this.logger.log(`Broadcasted batch completion: ${batchId}`); + + } catch (error) { + this.logger.error(`Error broadcasting batch completion: ${batchId}`, error.stack); + } + } + + /** + * Broadcast batch error + */ + broadcastBatchError(batchId: string, error: string) { + try { + const event = { + batch_id: batchId, + state: 'ERROR', + progress: 0, + error, + timestamp: new Date().toISOString(), + }; + + this.server.to(`batch:${batchId}`).emit('batch_error', event); + + this.logger.log(`Broadcasted batch error: ${batchId}`); + + } catch (error) { + this.logger.error(`Error broadcasting batch error: ${batchId}`, error.stack); + } + } + + /** + * Send current batch status to a specific client + */ + private async sendBatchStatus(batchId: string, client: Socket) { + try { + // TODO: Get actual batch status from database + // For now, we'll send a mock status + + const mockStatus = { + batch_id: batchId, + state: 'PROCESSING' as const, + progress: 45, + processedImages: 4, + totalImages: 10, + timestamp: new Date().toISOString(), + }; + + client.emit('batch_status', mockStatus); + + } catch (error) { + this.logger.error(`Error sending batch status: ${batchId}`, error.stack); + client.emit('error', { message: 'Failed to get batch status' }); + } + } + + /** + * Get connected clients count for monitoring + */ + getConnectedClientsCount(): number { + return this.clients.size; + } + + /** + * Get subscriptions count for a specific batch + */ + getBatchSubscriptionsCount(batchId: string): number { + let count = 0; + for (const connection of this.clients.values()) { + if (connection.batchIds.has(batchId)) { + count++; + } + } + return count; + } + + /** + * Cleanup inactive connections (can be called periodically) + */ + cleanupInactiveConnections() { + const inactiveClients: string[] = []; + + for (const [clientId, connection] of this.clients.entries()) { + const socket = this.server.sockets.sockets.get(clientId); + if (!socket || !socket.connected) { + inactiveClients.push(clientId); + } + } + + for (const clientId of inactiveClients) { + this.clients.delete(clientId); + } + + if (inactiveClients.length > 0) { + this.logger.log(`Cleaned up ${inactiveClients.length} inactive connections`); + } + } +} \ No newline at end of file diff --git a/packages/api/src/websocket/websocket.module.ts b/packages/api/src/websocket/websocket.module.ts new file mode 100644 index 0000000..0c040a3 --- /dev/null +++ b/packages/api/src/websocket/websocket.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProgressGateway } from './progress.gateway'; +import { QueueModule } from '../queue/queue.module'; + +@Module({ + imports: [QueueModule], + providers: [ProgressGateway], + exports: [ProgressGateway], +}) +export class WebSocketModule {} \ No newline at end of file