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:
DustyWalker 2025-08-05 17:24:27 +02:00
parent 2add73a264
commit ed5f745a51
5 changed files with 968 additions and 0 deletions

View 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;
};
}

View 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;
}

View 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');
}
}
}

View 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 {}

View 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);
}
}