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