From ed5f745a51a0db6937378980ab35dab2b26a5988 Mon Sep 17 00:00:00 2001 From: DustyWalker Date: Tue, 5 Aug 2025 17:24:27 +0200 Subject: [PATCH] feat(api): add images module for image filename management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/src/images/dto/image-response.dto.ts | 166 +++++++ .../api/src/images/dto/update-filename.dto.ts | 43 ++ packages/api/src/images/images.controller.ts | 304 ++++++++++++ packages/api/src/images/images.module.ts | 13 + packages/api/src/images/images.service.ts | 442 ++++++++++++++++++ 5 files changed, 968 insertions(+) create mode 100644 packages/api/src/images/dto/image-response.dto.ts create mode 100644 packages/api/src/images/dto/update-filename.dto.ts create mode 100644 packages/api/src/images/images.controller.ts create mode 100644 packages/api/src/images/images.module.ts create mode 100644 packages/api/src/images/images.service.ts diff --git a/packages/api/src/images/dto/image-response.dto.ts b/packages/api/src/images/dto/image-response.dto.ts new file mode 100644 index 0000000..801a704 --- /dev/null +++ b/packages/api/src/images/dto/image-response.dto.ts @@ -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; + }; +} \ No newline at end of file diff --git a/packages/api/src/images/dto/update-filename.dto.ts b/packages/api/src/images/dto/update-filename.dto.ts new file mode 100644 index 0000000..a23ce17 --- /dev/null +++ b/packages/api/src/images/dto/update-filename.dto.ts @@ -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; +} \ No newline at end of file diff --git a/packages/api/src/images/images.controller.ts b/packages/api/src/images/images.controller.ts new file mode 100644 index 0000000..ee65fe4 --- /dev/null +++ b/packages/api/src/images/images.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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'); + } + } +} \ No newline at end of file diff --git a/packages/api/src/images/images.module.ts b/packages/api/src/images/images.module.ts new file mode 100644 index 0000000..3b98019 --- /dev/null +++ b/packages/api/src/images/images.module.ts @@ -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 {} \ No newline at end of file diff --git a/packages/api/src/images/images.service.ts b/packages/api/src/images/images.service.ts new file mode 100644 index 0000000..db2848b --- /dev/null +++ b/packages/api/src/images/images.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} \ No newline at end of file