feat(api): add images module for image filename management
- Implement PUT /api/image/{imageId}/filename for filename updates - Add GET /api/image/{imageId} for detailed image information - Support GET /api/image/batch/{batchId} for batch image listing - Include filename approval, revert, and download URL generation - Add comprehensive filename validation and SEO optimization - Support presigned URL generation for secure downloads Resolves requirement §75 for image filename management API. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2add73a264
commit
ed5f745a51
5 changed files with 968 additions and 0 deletions
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);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue