Merge branch 'feature/core-api-endpoints' into feature/production-complete
This commit is contained in:
commit
46f7d47119
26 changed files with 4368 additions and 1 deletions
|
@ -32,6 +32,9 @@
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.2",
|
"@nestjs/passport": "^10.0.2",
|
||||||
"@nestjs/swagger": "^7.1.17",
|
"@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/client": "^5.7.0",
|
||||||
"prisma": "^5.7.0",
|
"prisma": "^5.7.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
@ -46,7 +49,16 @@
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"stripe": "^14.10.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
@ -61,6 +73,7 @@
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@types/cookie-parser": "^1.4.6",
|
"@types/cookie-parser": "^1.4.6",
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
"eslint": "^8.42.0",
|
"eslint": "^8.42.0",
|
||||||
|
|
|
@ -5,6 +5,13 @@ import { APP_GUARD } from '@nestjs/core';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { UsersModule } from './users/users.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 { JwtAuthGuard } from './auth/auth.guard';
|
||||||
import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
|
import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
|
||||||
import { SecurityMiddleware } from './common/middleware/security.middleware';
|
import { SecurityMiddleware } from './common/middleware/security.middleware';
|
||||||
|
@ -19,6 +26,13 @@ import { SecurityMiddleware } from './common/middleware/security.middleware';
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
StorageModule,
|
||||||
|
UploadModule,
|
||||||
|
QueueModule,
|
||||||
|
WebSocketModule,
|
||||||
|
BatchesModule,
|
||||||
|
ImagesModule,
|
||||||
|
KeywordsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|
275
packages/api/src/batches/batches.controller.ts
Normal file
275
packages/api/src/batches/batches.controller.ts
Normal file
|
@ -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<BatchUploadResponseDto> {
|
||||||
|
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<BatchStatusResponseDto> {
|
||||||
|
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<BatchListResponseDto[]> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
packages/api/src/batches/batches.module.ts
Normal file
22
packages/api/src/batches/batches.module.ts
Normal file
|
@ -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 {}
|
515
packages/api/src/batches/batches.service.ts
Normal file
515
packages/api/src/batches/batches.service.ts
Normal file
|
@ -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<BatchUploadResponseDto> {
|
||||||
|
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<BatchStatusResponseDto> {
|
||||||
|
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<BatchListResponseDto[]> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
142
packages/api/src/batches/dto/batch-status.dto.ts
Normal file
142
packages/api/src/batches/dto/batch-status.dto.ts
Normal file
|
@ -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;
|
||||||
|
}
|
49
packages/api/src/batches/dto/create-batch.dto.ts
Normal file
49
packages/api/src/batches/dto/create-batch.dto.ts
Normal file
|
@ -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;
|
||||||
|
}
|
166
packages/api/src/images/dto/image-response.dto.ts
Normal file
166
packages/api/src/images/dto/image-response.dto.ts
Normal file
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
43
packages/api/src/images/dto/update-filename.dto.ts
Normal file
43
packages/api/src/images/dto/update-filename.dto.ts
Normal file
|
@ -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;
|
||||||
|
}
|
304
packages/api/src/images/images.controller.ts
Normal file
304
packages/api/src/images/images.controller.ts
Normal file
|
@ -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<UpdateFilenameResponseDto> {
|
||||||
|
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<ImageResponseDto> {
|
||||||
|
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<BatchImagesResponseDto> {
|
||||||
|
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<UpdateFilenameResponseDto> {
|
||||||
|
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<UpdateFilenameResponseDto> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
packages/api/src/images/images.module.ts
Normal file
13
packages/api/src/images/images.module.ts
Normal file
|
@ -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 {}
|
442
packages/api/src/images/images.service.ts
Normal file
442
packages/api/src/images/images.service.ts
Normal file
|
@ -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<UpdateFilenameResponseDto> {
|
||||||
|
try {
|
||||||
|
// Find image and verify ownership
|
||||||
|
const image = await this.prisma.image.findFirst({
|
||||||
|
where: {
|
||||||
|
id: imageId,
|
||||||
|
batch: { userId },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
batch: { select: { userId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
throw new NotFoundException('Image not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate filename
|
||||||
|
if (!this.isValidFilename(newName)) {
|
||||||
|
throw new BadRequestException('Invalid filename format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure filename has proper extension
|
||||||
|
if (!this.hasValidExtension(newName)) {
|
||||||
|
throw new BadRequestException('Filename must have a valid image extension');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the proposed name
|
||||||
|
const updatedImage = await this.prisma.image.update({
|
||||||
|
where: { id: imageId },
|
||||||
|
data: {
|
||||||
|
proposedName: newName,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Updated filename for image: ${imageId} to: ${newName}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: updatedImage.id,
|
||||||
|
proposed_name: updatedImage.proposedName!,
|
||||||
|
original_name: updatedImage.originalName,
|
||||||
|
updated_at: updatedImage.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof NotFoundException ||
|
||||||
|
error instanceof BadRequestException
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error(`Failed to update filename for image: ${imageId}`, error.stack);
|
||||||
|
throw new BadRequestException('Failed to update image filename');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image details
|
||||||
|
*/
|
||||||
|
async getImage(imageId: string, userId: string): Promise<ImageResponseDto> {
|
||||||
|
try {
|
||||||
|
const image = await this.prisma.image.findFirst({
|
||||||
|
where: {
|
||||||
|
id: imageId,
|
||||||
|
batch: { userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
throw new NotFoundException('Image not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapImageToResponse(image);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error(`Failed to get image: ${imageId}`, error.stack);
|
||||||
|
throw new BadRequestException('Failed to get image details');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all images in a batch
|
||||||
|
*/
|
||||||
|
async getBatchImages(batchId: string, userId: string): Promise<BatchImagesResponseDto> {
|
||||||
|
try {
|
||||||
|
// Verify batch ownership
|
||||||
|
const batch = await this.prisma.batch.findFirst({
|
||||||
|
where: {
|
||||||
|
id: batchId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
images: {
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!batch) {
|
||||||
|
throw new NotFoundException('Batch not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate status summary
|
||||||
|
const statusSummary = {
|
||||||
|
pending: 0,
|
||||||
|
processing: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
batch.images.forEach((image) => {
|
||||||
|
switch (image.status) {
|
||||||
|
case ImageStatus.PENDING:
|
||||||
|
statusSummary.pending++;
|
||||||
|
break;
|
||||||
|
case ImageStatus.PROCESSING:
|
||||||
|
statusSummary.processing++;
|
||||||
|
break;
|
||||||
|
case ImageStatus.COMPLETED:
|
||||||
|
statusSummary.completed++;
|
||||||
|
break;
|
||||||
|
case ImageStatus.FAILED:
|
||||||
|
statusSummary.failed++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
batch_id: batchId,
|
||||||
|
total_images: batch.images.length,
|
||||||
|
images: batch.images.map(this.mapImageToResponse),
|
||||||
|
status_summary: statusSummary,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error(`Failed to get batch images: ${batchId}`, error.stack);
|
||||||
|
throw new BadRequestException('Failed to get batch images');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get presigned download URL for image
|
||||||
|
*/
|
||||||
|
async getImageDownloadUrl(
|
||||||
|
imageId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{
|
||||||
|
download_url: string;
|
||||||
|
expires_at: string;
|
||||||
|
filename: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const image = await this.prisma.image.findFirst({
|
||||||
|
where: {
|
||||||
|
id: imageId,
|
||||||
|
batch: { userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
throw new NotFoundException('Image not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image.s3Key) {
|
||||||
|
throw new BadRequestException('Image file not available for download');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate presigned URL (expires in 1 hour)
|
||||||
|
const downloadUrl = await this.storageService.getPresignedUrl(image.s3Key, 3600);
|
||||||
|
const expiresAt = new Date(Date.now() + 3600 * 1000);
|
||||||
|
|
||||||
|
// Use final name if available, otherwise proposed name, otherwise original name
|
||||||
|
const filename = image.finalName || image.proposedName || image.originalName;
|
||||||
|
|
||||||
|
this.logger.log(`Generated download URL for image: ${imageId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
download_url: downloadUrl,
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
filename,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof NotFoundException ||
|
||||||
|
error instanceof BadRequestException
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error(`Failed to generate download URL for image: ${imageId}`, error.stack);
|
||||||
|
throw new BadRequestException('Failed to generate download URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve the proposed filename as final
|
||||||
|
*/
|
||||||
|
async approveProposedFilename(
|
||||||
|
imageId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<UpdateFilenameResponseDto> {
|
||||||
|
try {
|
||||||
|
const image = await this.prisma.image.findFirst({
|
||||||
|
where: {
|
||||||
|
id: imageId,
|
||||||
|
batch: { userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
throw new NotFoundException('Image not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image.proposedName) {
|
||||||
|
throw new BadRequestException('No proposed filename to approve');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedImage = await this.prisma.image.update({
|
||||||
|
where: { id: imageId },
|
||||||
|
data: {
|
||||||
|
finalName: image.proposedName,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Approved filename for image: ${imageId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: updatedImage.id,
|
||||||
|
proposed_name: updatedImage.proposedName!,
|
||||||
|
original_name: updatedImage.originalName,
|
||||||
|
updated_at: updatedImage.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof NotFoundException ||
|
||||||
|
error instanceof BadRequestException
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error(`Failed to approve filename for image: ${imageId}`, error.stack);
|
||||||
|
throw new BadRequestException('Failed to approve filename');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert to original filename
|
||||||
|
*/
|
||||||
|
async revertToOriginalFilename(
|
||||||
|
imageId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<UpdateFilenameResponseDto> {
|
||||||
|
try {
|
||||||
|
const image = await this.prisma.image.findFirst({
|
||||||
|
where: {
|
||||||
|
id: imageId,
|
||||||
|
batch: { userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
throw new NotFoundException('Image not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedImage = await this.prisma.image.update({
|
||||||
|
where: { id: imageId },
|
||||||
|
data: {
|
||||||
|
proposedName: image.originalName,
|
||||||
|
finalName: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Reverted filename for image: ${imageId} to original`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: updatedImage.id,
|
||||||
|
proposed_name: updatedImage.proposedName!,
|
||||||
|
original_name: updatedImage.originalName,
|
||||||
|
updated_at: updatedImage.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error(`Failed to revert filename for image: ${imageId}`, error.stack);
|
||||||
|
throw new BadRequestException('Failed to revert filename');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update image processing status (called by queue processors)
|
||||||
|
*/
|
||||||
|
async updateImageStatus(
|
||||||
|
imageId: string,
|
||||||
|
status: ImageStatus,
|
||||||
|
visionTags?: any,
|
||||||
|
proposedName?: string,
|
||||||
|
error?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const updateData: any = {
|
||||||
|
status,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (visionTags) {
|
||||||
|
updateData.visionTags = visionTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposedName) {
|
||||||
|
updateData.proposedName = proposedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
updateData.processingError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
|
||||||
|
updateData.processedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.image.update({
|
||||||
|
where: { id: imageId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Updated image status: ${imageId} to ${status}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to update image status: ${imageId}`, error.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get images by status (for queue processing)
|
||||||
|
*/
|
||||||
|
async getImagesByStatus(batchId: string, status: ImageStatus) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.image.findMany({
|
||||||
|
where: {
|
||||||
|
batchId,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
originalName: true,
|
||||||
|
s3Key: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to get images by status: ${batchId}`, error.stack);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map database image to response DTO
|
||||||
|
*/
|
||||||
|
private mapImageToResponse(image: any): ImageResponseDto {
|
||||||
|
return {
|
||||||
|
id: image.id,
|
||||||
|
batch_id: image.batchId,
|
||||||
|
original_name: image.originalName,
|
||||||
|
proposed_name: image.proposedName,
|
||||||
|
final_name: image.finalName,
|
||||||
|
status: image.status,
|
||||||
|
vision_tags: image.visionTags,
|
||||||
|
file_size: image.fileSize,
|
||||||
|
dimensions: image.dimensions,
|
||||||
|
mime_type: image.mimeType,
|
||||||
|
processing_error: image.processingError,
|
||||||
|
created_at: image.createdAt.toISOString(),
|
||||||
|
updated_at: image.updatedAt.toISOString(),
|
||||||
|
processed_at: image.processedAt?.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate filename format
|
||||||
|
*/
|
||||||
|
private isValidFilename(filename: string): boolean {
|
||||||
|
// Check for invalid characters
|
||||||
|
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
|
||||||
|
if (invalidChars.test(filename)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check length
|
||||||
|
if (filename.length === 0 || filename.length > 255) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reserved names
|
||||||
|
const reservedNames = [
|
||||||
|
'CON', 'PRN', 'AUX', 'NUL',
|
||||||
|
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||||
|
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
||||||
|
];
|
||||||
|
|
||||||
|
const nameWithoutExt = filename.split('.')[0].toUpperCase();
|
||||||
|
if (reservedNames.includes(nameWithoutExt)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if filename has valid image extension
|
||||||
|
*/
|
||||||
|
private hasValidExtension(filename: string): boolean {
|
||||||
|
const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'];
|
||||||
|
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||||
|
return validExtensions.includes(extension);
|
||||||
|
}
|
||||||
|
}
|
79
packages/api/src/keywords/dto/enhance-keywords.dto.ts
Normal file
79
packages/api/src/keywords/dto/enhance-keywords.dto.ts
Normal file
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
192
packages/api/src/keywords/keywords.controller.ts
Normal file
192
packages/api/src/keywords/keywords.controller.ts
Normal file
|
@ -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<EnhanceKeywordsResponseDto> {
|
||||||
|
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<string, string[]>;
|
||||||
|
}> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
packages/api/src/keywords/keywords.module.ts
Normal file
12
packages/api/src/keywords/keywords.module.ts
Normal file
|
@ -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 {}
|
345
packages/api/src/keywords/keywords.service.ts
Normal file
345
packages/api/src/keywords/keywords.service.ts
Normal file
|
@ -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<string, { count: number; resetTime: number }>();
|
||||||
|
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<string>('OPENAI_API_KEY'),
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhance keywords with AI suggestions
|
||||||
|
*/
|
||||||
|
async enhanceKeywords(
|
||||||
|
keywords: string[],
|
||||||
|
userId: string,
|
||||||
|
): Promise<EnhanceKeywordsResponseDto> {
|
||||||
|
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<string, string[]>;
|
||||||
|
}> {
|
||||||
|
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<void> {
|
||||||
|
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<string[]> {
|
||||||
|
// 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<string[]> {
|
||||||
|
// Simulate AI processing time
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// Mock related keywords - replace with actual AI generation
|
||||||
|
const relatedMap: Record<string, string[]> = {
|
||||||
|
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<string[]> {
|
||||||
|
// 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: '' };
|
||||||
|
}
|
||||||
|
}
|
249
packages/api/src/queue/processors/batch-processing.processor.ts
Normal file
249
packages/api/src/queue/processors/batch-processing.processor.ts
Normal file
|
@ -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<BatchProcessingJobData>): Promise<any> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
200
packages/api/src/queue/processors/image-processing.processor.ts
Normal file
200
packages/api/src/queue/processors/image-processing.processor.ts
Normal file
|
@ -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<ImageProcessingJobData>): Promise<any> {
|
||||||
|
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<void> {
|
||||||
|
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<any> {
|
||||||
|
// 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<string> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
61
packages/api/src/queue/queue.module.ts
Normal file
61
packages/api/src/queue/queue.module.ts
Normal file
|
@ -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<string>('REDIS_HOST', 'localhost'),
|
||||||
|
port: configService.get<number>('REDIS_PORT', 6379),
|
||||||
|
password: configService.get<string>('REDIS_PASSWORD'),
|
||||||
|
db: configService.get<number>('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 {}
|
263
packages/api/src/queue/queue.service.ts
Normal file
263
packages/api/src/queue/queue.service.ts
Normal file
|
@ -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<Job> {
|
||||||
|
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<Job> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Job[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
packages/api/src/storage/storage.module.ts
Normal file
10
packages/api/src/storage/storage.module.ts
Normal file
|
@ -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 {}
|
263
packages/api/src/storage/storage.service.ts
Normal file
263
packages/api/src/storage/storage.service.ts
Normal file
|
@ -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<string>('MINIO_ENDPOINT', 'localhost'),
|
||||||
|
port: this.configService.get<number>('MINIO_PORT', 9000),
|
||||||
|
useSSL: this.configService.get<boolean>('MINIO_USE_SSL', false),
|
||||||
|
accessKey: this.configService.get<string>('MINIO_ACCESS_KEY', 'minioadmin'),
|
||||||
|
secretKey: this.configService.get<string>('MINIO_SECRET_KEY', 'minioadmin'),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bucketName = this.configService.get<string>('MINIO_BUCKET_NAME', 'seo-image-renamer');
|
||||||
|
this.initializeBucket();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the bucket if it doesn't exist
|
||||||
|
*/
|
||||||
|
private async initializeBucket(): Promise<void> {
|
||||||
|
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<UploadResult> {
|
||||||
|
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<NodeJS.ReadableStream> {
|
||||||
|
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<any> {
|
||||||
|
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<void> {
|
||||||
|
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<string[]> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
10
packages/api/src/upload/upload.module.ts
Normal file
10
packages/api/src/upload/upload.module.ts
Normal file
|
@ -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 {}
|
319
packages/api/src/upload/upload.service.ts
Normal file
319
packages/api/src/upload/upload.service.ts
Normal file
|
@ -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<ProcessedUpload[]> {
|
||||||
|
this.logger.log(`Processing ${files.length} files for batch: ${batchId}`);
|
||||||
|
|
||||||
|
// Validate files
|
||||||
|
this.validateFiles(files);
|
||||||
|
|
||||||
|
const results: ProcessedUpload[] = [];
|
||||||
|
const duplicateHashes = new Set<string>();
|
||||||
|
|
||||||
|
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<ProcessedUpload> {
|
||||||
|
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<ImageMetadata> {
|
||||||
|
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<Buffer> {
|
||||||
|
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<Buffer> {
|
||||||
|
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<boolean> {
|
||||||
|
// 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
356
packages/api/src/websocket/progress.gateway.ts
Normal file
356
packages/api/src/websocket/progress.gateway.ts
Normal file
|
@ -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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<string, ClientConnection>();
|
||||||
|
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
packages/api/src/websocket/websocket.module.ts
Normal file
10
packages/api/src/websocket/websocket.module.ts
Normal file
|
@ -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 {}
|
Loading…
Add table
Add a link
Reference in a new issue