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