From 8065c522e0a529e640bec6cb85a25c9de2905af1 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 4 Apr 2026 07:56:39 -0700 Subject: [PATCH] =?UTF-8?q?feat(photos):=20=E2=9C=A8=20Introduce=20PhotosC?= =?UTF-8?q?ontroller,=20PhotosService,=20and=20DTOs=20for=20photo=20upload?= =?UTF-8?q?,=20retrieval,=20and=20processing=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../backend-api/src/modules/photos/index.ts | 4 - .../src/modules/photos/photos.controller.ts | 113 ------ .../src/modules/photos/photos.dto.ts | 194 ----------- .../src/modules/photos/photos.module.ts | 21 -- .../src/modules/photos/photos.service.ts | 324 ------------------ 5 files changed, 656 deletions(-) delete mode 100644 features/video-studio/packages/media-gallery/backend-api/src/modules/photos/index.ts delete mode 100644 features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.controller.ts delete mode 100644 features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.dto.ts delete mode 100644 features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.module.ts delete mode 100644 features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.service.ts diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/index.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/index.ts deleted file mode 100644 index da6fd4981..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { PhotosModule } from './photos.module'; -export { PhotosService } from './photos.service'; -export { PhotosController } from './photos.controller'; -export * from './photos.dto'; diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.controller.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.controller.ts deleted file mode 100644 index bb8a298d2..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.controller.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - Controller, - Get, - Post, - Delete, - Param, - Query, - ParseUUIDPipe, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; - -import { PhotoQueryDto, PhotoResponseDto, PhotoListResponseDto } from './photos.dto'; -import { PhotosService } from './photos.service'; - -@ApiTags('photos') -@Controller('api/photos') -export class PhotosController { - constructor(private readonly photosService: PhotosService) {} - - @Get() - @ApiOperation({ summary: 'List photos with filtering and pagination' }) - @ApiResponse({ - status: 200, - description: 'Photos retrieved successfully', - type: PhotoListResponseDto, - }) - async list(@Query() query: PhotoQueryDto) { - const result = await this.photosService.findAll(query); - return { - success: true, - data: result, - }; - } - - @Get('categories') - @ApiOperation({ summary: 'Get photo counts by classification category' }) - async categories() { - const counts = await this.photosService.getCategoryCounts(); - return { success: true, data: counts }; - } - - @Get('stats') - @ApiOperation({ summary: 'Get photo counts by media type, favorites, and screenshots' }) - async stats() { - const result = await this.photosService.getPhotoStats(); - return { success: true, data: result }; - } - - @Get(':id') - @ApiOperation({ summary: 'Get photo details by ID' }) - @ApiParam({ name: 'id', description: 'Photo UUID', format: 'uuid' }) - @ApiResponse({ - status: 200, - description: 'Photo retrieved successfully', - type: PhotoResponseDto, - }) - @ApiResponse({ status: 404, description: 'Photo not found' }) - async get(@Param('id', ParseUUIDPipe) id: string) { - const photo = await this.photosService.findOne(id); - return { - success: true, - data: photo, - }; - } - - @Post(':id/favorite') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Toggle favorite status for a photo' }) - @ApiParam({ name: 'id', description: 'Photo UUID', format: 'uuid' }) - @ApiResponse({ - status: 200, - description: 'Favorite status toggled', - type: PhotoResponseDto, - }) - @ApiResponse({ status: 404, description: 'Photo not found' }) - async toggleFavorite(@Param('id', ParseUUIDPipe) id: string) { - const photo = await this.photosService.toggleFavorite(id); - return { - success: true, - data: photo, - }; - } - - @Post(':id/hide') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Toggle hidden status for a photo' }) - @ApiParam({ name: 'id', description: 'Photo UUID', format: 'uuid' }) - @ApiResponse({ - status: 200, - description: 'Hidden status toggled', - type: PhotoResponseDto, - }) - @ApiResponse({ status: 404, description: 'Photo not found' }) - async toggleHidden(@Param('id', ParseUUIDPipe) id: string) { - const photo = await this.photosService.toggleHidden(id); - return { - success: true, - data: photo, - }; - } - - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete a photo and its files' }) - @ApiParam({ name: 'id', description: 'Photo UUID', format: 'uuid' }) - @ApiResponse({ status: 204, description: 'Photo deleted successfully' }) - @ApiResponse({ status: 404, description: 'Photo not found' }) - async delete(@Param('id', ParseUUIDPipe) id: string) { - await this.photosService.delete(id); - } -} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.dto.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.dto.ts deleted file mode 100644 index 412b43704..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.dto.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsOptional, IsString, IsNumber, IsBoolean, IsIn, IsUUID, IsDateString, Min, Max } from 'class-validator'; - -export class PhotoQueryDto { - @ApiPropertyOptional({ description: 'Device ID to filter by', format: 'uuid' }) - @IsOptional() - @IsUUID() - deviceId?: string; - - @ApiPropertyOptional({ - description: 'Media type filter', - enum: ['image', 'video', 'live_photo'], - }) - @IsOptional() - @IsString() - @IsIn(['image', 'video', 'live_photo']) - mediaType?: 'image' | 'video' | 'live_photo'; - - @ApiPropertyOptional({ description: 'Filter favorites only' }) - @IsOptional() - @IsBoolean() - @Type(() => Boolean) - isFavorite?: boolean; - - @ApiPropertyOptional({ description: 'Include hidden photos' }) - @IsOptional() - @IsBoolean() - @Type(() => Boolean) - includeHidden?: boolean; - - @ApiPropertyOptional({ description: 'Filter screenshots only' }) - @IsOptional() - @IsBoolean() - @Type(() => Boolean) - isScreenshot?: boolean; - - @ApiPropertyOptional({ description: 'Filter selfies only' }) - @IsOptional() - @IsBoolean() - @Type(() => Boolean) - isSelfie?: boolean; - - @ApiPropertyOptional({ description: 'Album ID to filter by', format: 'uuid' }) - @IsOptional() - @IsUUID() - albumId?: string; - - @ApiPropertyOptional({ description: 'Start date for date range filter', example: '2026-01-01' }) - @IsOptional() - @IsDateString() - startDate?: string; - - @ApiPropertyOptional({ description: 'End date for date range filter', example: '2026-01-31' }) - @IsOptional() - @IsDateString() - endDate?: string; - - @ApiPropertyOptional({ description: 'Search in location names', example: 'Reykjavik' }) - @IsOptional() - @IsString() - location?: string; - - @ApiPropertyOptional({ description: 'Sort field', enum: ['capturedAt', 'createdAt', 'fileSize'], default: 'capturedAt' }) - @IsOptional() - @IsString() - @IsIn(['capturedAt', 'createdAt', 'fileSize']) - sortBy?: 'capturedAt' | 'createdAt' | 'fileSize'; - - @ApiPropertyOptional({ description: 'Sort direction', enum: ['ASC', 'DESC'], default: 'DESC' }) - @IsOptional() - @IsString() - @IsIn(['ASC', 'DESC']) - sortDir?: 'ASC' | 'DESC'; - - @ApiPropertyOptional({ description: 'Cursor for pagination (photo ID)', format: 'uuid' }) - @IsOptional() - @IsUUID() - cursor?: string; - - @ApiPropertyOptional({ description: 'Number of items per page', default: 50, minimum: 1, maximum: 200 }) - @IsOptional() - @IsNumber() - @Type(() => Number) - @Min(1) - @Max(200) - limit?: number; - - @ApiPropertyOptional({ - description: 'View mode: "uploaded" shows only photos with binaries (default), "all" shows all metadata', - enum: ['uploaded', 'all'], - default: 'uploaded', - }) - @IsOptional() - @IsString() - @IsIn(['uploaded', 'all']) - view?: 'uploaded' | 'all'; - - @ApiPropertyOptional({ description: 'Filter by classification category' }) - @IsOptional() - @IsString() - category?: string; - - @ApiPropertyOptional({ description: 'Filter by identity ID', format: 'uuid' }) - @IsOptional() - @IsUUID() - identityId?: string; -} - -export class PhotoResponseDto { - @ApiProperty({ format: 'uuid' }) - id!: string; - - @ApiProperty({ enum: ['image', 'video', 'live_photo'] }) - mediaType!: 'image' | 'video' | 'live_photo'; - - @ApiProperty() - width!: number; - - @ApiProperty() - height!: number; - - @ApiPropertyOptional() - fileSize?: number; - - @ApiPropertyOptional() - durationSeconds?: number; - - @ApiProperty() - capturedAt!: Date; - - @ApiPropertyOptional() - originalFilename?: string; - - @ApiPropertyOptional() - latitude?: number; - - @ApiPropertyOptional() - longitude?: number; - - @ApiPropertyOptional() - locationName?: string; - - @ApiProperty() - isFavorite!: boolean; - - @ApiProperty() - isHidden!: boolean; - - @ApiProperty() - isScreenshot!: boolean; - - @ApiProperty() - isSelfie!: boolean; - - @ApiPropertyOptional({ description: 'URL to the thumbnail (300x300)' }) - thumbnailUrl?: string; - - @ApiPropertyOptional({ description: 'URL to the preview (1200px)' }) - previewUrl?: string; - - @ApiPropertyOptional({ description: 'URL to the original file' }) - originalUrl?: string; - - @ApiPropertyOptional({ description: 'Processing status' }) - processingStatus!: string; - - @ApiPropertyOptional({ description: 'Classification category' }) - category?: string; - - @ApiPropertyOptional({ description: 'Classification status' }) - classificationStatus?: string; - - @ApiPropertyOptional({ - description: 'Semantic attribute scores from imajin-semantic (SigLIP zero-shot detection)', - type: 'object', - additionalProperties: { type: 'number' }, - }) - semanticTags?: Record; -} - -export class PhotoListResponseDto { - @ApiProperty({ type: [PhotoResponseDto] }) - photos!: PhotoResponseDto[]; - - @ApiPropertyOptional({ description: 'Cursor for next page', format: 'uuid' }) - nextCursor?: string; - - @ApiProperty({ description: 'Whether there are more results' }) - hasMore!: boolean; - - @ApiProperty({ description: 'Total count (may be estimated for large datasets)' }) - total!: number; -} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.module.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.module.ts deleted file mode 100644 index 4ddfcb3a1..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { PhotosController } from './photos.controller'; -import { PhotosService } from './photos.service'; - -import { MinioModule } from '@/common/minio'; -import { PhotoEntity } from '@/entities'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([PhotoEntity]), - MinioModule.forEnv({ - defaultBucket: 'media-gallery', - }), - ], - controllers: [PhotosController], - providers: [PhotosService], - exports: [PhotosService], -}) -export class PhotosModule {} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.service.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.service.ts deleted file mode 100644 index 8d5f17553..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/photos/photos.service.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, SelectQueryBuilder } from 'typeorm'; - -import { PhotoQueryDto, PhotoResponseDto, PhotoListResponseDto } from './photos.dto'; - -import { createLogger } from '@/common'; -import { MinioService } from '@/common/minio'; -import { PhotoEntity } from '@/entities'; - -const PRESIGNED_URL_EXPIRY = 3600; // 1 hour - -@Injectable() -export class PhotosService { - private readonly logger = createLogger(PhotosService.name); - - constructor( - @InjectRepository(PhotoEntity) - private readonly photoRepository: Repository, - private readonly minioService: MinioService, - ) {} - - async findAll(query: PhotoQueryDto): Promise { - const limit = query.limit || 50; - const sortBy = query.sortBy || 'capturedAt'; - const sortDir = query.sortDir || 'DESC'; - - const qb = this.photoRepository.createQueryBuilder('photo'); - - // Apply filters - this.applyFilters(qb, query); - - // Apply cursor-based pagination - if (query.cursor) { - const cursorPhoto = await this.photoRepository.findOne({ - where: { id: query.cursor }, - select: [sortBy as keyof PhotoEntity, 'id'], - }); - - if (cursorPhoto) { - const cursorValue = cursorPhoto[sortBy as keyof PhotoEntity]; - if (sortDir === 'DESC') { - qb.andWhere(`(photo.${sortBy} < :cursorValue OR (photo.${sortBy} = :cursorValue AND photo.id < :cursorId))`, { - cursorValue, - cursorId: query.cursor, - }); - } else { - qb.andWhere(`(photo.${sortBy} > :cursorValue OR (photo.${sortBy} = :cursorValue AND photo.id > :cursorId))`, { - cursorValue, - cursorId: query.cursor, - }); - } - } - } - - // Apply sorting - qb.orderBy(`photo.${sortBy}`, sortDir); - qb.addOrderBy('photo.id', sortDir); - - // Fetch one extra to determine if there are more results - qb.take(limit + 1); - - const photos = await qb.getMany(); - const hasMore = photos.length > limit; - - if (hasMore) { - photos.pop(); // Remove the extra item - } - - // Get total count (for small datasets; for large datasets consider caching) - const totalQb = this.photoRepository.createQueryBuilder('photo'); - this.applyFilters(totalQb, query); - const total = await totalQb.getCount(); - - // Map to response DTOs with presigned URLs - const photoResponses = await Promise.all(photos.map((photo) => this.mapToResponse(photo))); - - return { - photos: photoResponses, - nextCursor: hasMore ? photos[photos.length - 1].id : undefined, - hasMore, - total, - }; - } - - async findOne(id: string): Promise { - const photo = await this.photoRepository.findOne({ where: { id } }); - - if (!photo) { - throw new NotFoundException('Photo not found'); - } - - return this.mapToResponse(photo); - } - - async findByDateRange(startDate: Date, endDate: Date, deviceId?: string): Promise { - const qb = this.photoRepository - .createQueryBuilder('photo') - .where('photo.capturedAt >= :startDate', { startDate }) - .andWhere('photo.capturedAt <= :endDate', { endDate }); - - if (deviceId) { - qb.andWhere('photo.deviceId = :deviceId', { deviceId }); - } - - qb.orderBy('photo.capturedAt', 'DESC'); - - const photos = await qb.getMany(); - return Promise.all(photos.map((photo) => this.mapToResponse(photo))); - } - - async toggleFavorite(id: string): Promise { - const photo = await this.photoRepository.findOne({ where: { id } }); - - if (!photo) { - throw new NotFoundException('Photo not found'); - } - - photo.isFavorite = !photo.isFavorite; - await this.photoRepository.save(photo); - - return this.mapToResponse(photo); - } - - async toggleHidden(id: string): Promise { - const photo = await this.photoRepository.findOne({ where: { id } }); - - if (!photo) { - throw new NotFoundException('Photo not found'); - } - - photo.isHidden = !photo.isHidden; - await this.photoRepository.save(photo); - - return this.mapToResponse(photo); - } - - async getCategoryCounts(): Promise> { - return this.photoRepository - .createQueryBuilder('photo') - .select('photo.category', 'category') - .addSelect('COUNT(*)', 'count') - .where('photo.classificationStatus = :status', { status: 'completed' }) - .groupBy('photo.category') - .orderBy('count', 'DESC') - .getRawMany<{ category: string | null; count: string }>(); - } - - async delete(id: string): Promise { - const photo = await this.photoRepository.findOne({ where: { id } }); - - if (!photo) { - throw new NotFoundException('Photo not found'); - } - - // Delete from MinIO - const keysToDelete = [photo.storageKey, photo.thumbnailKey, photo.previewKey].filter(Boolean) as string[]; - - for (const key of keysToDelete) { - try { - await this.minioService.delete(key); - } catch (error) { - this.logger.logWithData('warn', 'Failed to delete object from MinIO', { - key, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - // Delete from database - await this.photoRepository.remove(photo); - } - - private applyFilters(qb: SelectQueryBuilder, query: PhotoQueryDto): void { - // Default to showing only uploaded photos (view=uploaded or undefined) - // Use view=all to show all photos including metadata-only - if (query.view !== 'all') { - qb.andWhere('photo.storageKey IS NOT NULL'); - } - - if (query.deviceId) { - qb.andWhere('photo.deviceId = :deviceId', { deviceId: query.deviceId }); - } - - if (query.mediaType) { - qb.andWhere('photo.mediaType = :mediaType', { mediaType: query.mediaType }); - } - - if (query.isFavorite !== undefined) { - qb.andWhere('photo.isFavorite = :isFavorite', { isFavorite: query.isFavorite }); - } - - if (!query.includeHidden) { - qb.andWhere('photo.isHidden = false'); - } - - if (query.isScreenshot !== undefined) { - qb.andWhere('photo.isScreenshot = :isScreenshot', { isScreenshot: query.isScreenshot }); - } - - if (query.isSelfie !== undefined) { - qb.andWhere('photo.isSelfie = :isSelfie', { isSelfie: query.isSelfie }); - } - - if (query.albumId) { - qb.innerJoin('photo.albums', 'album', 'album.id = :albumId', { albumId: query.albumId }); - } - - if (query.startDate) { - qb.andWhere('photo.capturedAt >= :startDate', { startDate: new Date(query.startDate) }); - } - - if (query.endDate) { - qb.andWhere('photo.capturedAt <= :endDate', { endDate: new Date(query.endDate) }); - } - - if (query.location) { - qb.andWhere('photo.locationName ILIKE :location', { location: `%${query.location}%` }); - } - - if (query.category !== undefined) { - qb.andWhere('photo.category = :category', { category: query.category }); - } - - if (query.identityId) { - qb.innerJoin('photo.identities', 'identity', 'identity.id = :identityId', { identityId: query.identityId }); - } - } - - private async mapToResponse(photo: PhotoEntity): Promise { - const response: PhotoResponseDto = { - id: photo.id, - mediaType: photo.mediaType, - width: photo.width, - height: photo.height, - fileSize: photo.fileSize ?? undefined, - durationSeconds: photo.durationSeconds ?? undefined, - capturedAt: photo.capturedAt, - originalFilename: photo.originalFilename ?? undefined, - latitude: photo.latitude ?? undefined, - longitude: photo.longitude ?? undefined, - locationName: photo.locationName ?? undefined, - isFavorite: photo.isFavorite, - isHidden: photo.isHidden, - isScreenshot: photo.isScreenshot, - isSelfie: photo.isSelfie, - processingStatus: photo.processingStatus, - category: photo.category ?? undefined, - classificationStatus: photo.classificationStatus, - semanticTags: photo.semanticTags ?? undefined, - }; - - // Generate presigned URLs if storage keys exist - if (photo.thumbnailKey) { - try { - response.thumbnailUrl = await this.minioService.getDownloadUrl( - photo.thumbnailKey, - PRESIGNED_URL_EXPIRY, - ); - } catch { - // URL generation failed, leave undefined - } - } - - if (photo.previewKey) { - try { - response.previewUrl = await this.minioService.getDownloadUrl( - photo.previewKey, - PRESIGNED_URL_EXPIRY, - ); - } catch { - // URL generation failed, leave undefined - } - } - - if (photo.storageKey) { - try { - response.originalUrl = await this.minioService.getDownloadUrl( - photo.storageKey, - PRESIGNED_URL_EXPIRY, - ); - } catch { - // URL generation failed, leave undefined - } - } - - return response; - } - - async getPhotoStats(): Promise<{ - byMediaType: Array<{ mediaType: string; count: string }>; - favoriteCount: string; - screenshotCount: string; - }> { - const [byMediaType, [favoriteRow], [screenshotRow]] = await Promise.all([ - this.photoRepository - .createQueryBuilder('photo') - .select('photo.mediaType', 'mediaType') - .addSelect('COUNT(*)', 'count') - .where('photo.storageKey IS NOT NULL') - .groupBy('photo.mediaType') - .orderBy('count', 'DESC') - .getRawMany<{ mediaType: string; count: string }>(), - - this.photoRepository - .createQueryBuilder('photo') - .select('COUNT(*)', 'count') - .where('photo.isFavorite = true AND photo.storageKey IS NOT NULL') - .getRawMany<{ count: string }>(), - - this.photoRepository - .createQueryBuilder('photo') - .select('COUNT(*)', 'count') - .where('photo.isScreenshot = true AND photo.storageKey IS NOT NULL') - .getRawMany<{ count: string }>(), - ]); - - return { - byMediaType, - favoriteCount: favoriteRow?.count ?? '0', - screenshotCount: screenshotRow?.count ?? '0', - }; - } -}