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
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