diff --git a/apps/api-service/src/routes/v1/images.ts b/apps/api-service/src/routes/v1/images.ts new file mode 100644 index 0000000..1a962ea --- /dev/null +++ b/apps/api-service/src/routes/v1/images.ts @@ -0,0 +1,363 @@ +import { Response, Router } from 'express'; +import type { Router as RouterType } from 'express'; +import { ImageService } from '@/services/core'; +import { StorageFactory } from '@/services/StorageFactory'; +import { asyncHandler } from '@/middleware/errorHandler'; +import { validateApiKey } from '@/middleware/auth/validateApiKey'; +import { requireProjectKey } from '@/middleware/auth/requireProjectKey'; +import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter'; +import { uploadSingleImage, handleUploadErrors } from '@/middleware/upload'; +import { validateAndNormalizePagination } from '@/utils/validators'; +import { buildPaginatedResponse } from '@/utils/helpers'; +import { toImageResponse } from '@/types/responses'; +import type { + UploadImageResponse, + ListImagesResponse, + GetImageResponse, + UpdateImageResponse, + DeleteImageResponse, +} from '@/types/responses'; + +export const imagesRouter: RouterType = Router(); + +let imageService: ImageService; + +const getImageService = (): ImageService => { + if (!imageService) { + imageService = new ImageService(); + } + return imageService; +}; + +/** + * POST /api/v1/images/upload + * Upload a single image file and create database record + */ +imagesRouter.post( + '/upload', + validateApiKey, + requireProjectKey, + rateLimitByApiKey, + uploadSingleImage, + handleUploadErrors, + asyncHandler(async (req: any, res: Response) => { + const service = getImageService(); + const { alias, flowId, meta } = req.body; + + if (!req.file) { + res.status(400).json({ + success: false, + error: { + message: 'No file provided', + code: 'VALIDATION_ERROR', + }, + }); + return; + } + + const projectId = req.apiKey.projectId; + const apiKeyId = req.apiKey.id; + const orgId = req.apiKey.organizationSlug || 'default'; + const projectSlug = req.apiKey.projectSlug; + const file = req.file; + + try { + const storageService = await StorageFactory.getInstance(); + + const uploadResult = await storageService.uploadFile( + orgId, + projectSlug, + 'uploads', + file.originalname, + file.buffer, + file.mimetype, + ); + + if (!uploadResult.success) { + res.status(500).json({ + success: false, + error: { + message: 'File upload failed', + code: 'UPLOAD_ERROR', + details: uploadResult.error, + }, + }); + return; + } + + const imageRecord = await service.create({ + projectId, + flowId: flowId || null, + generationId: null, + apiKeyId, + storageKey: uploadResult.path!, + storageUrl: uploadResult.url!, + mimeType: file.mimetype, + fileSize: file.size, + fileHash: null, + source: 'uploaded', + alias: alias || null, + meta: meta ? JSON.parse(meta) : {}, + }); + + res.status(201).json({ + success: true, + data: toImageResponse(imageRecord), + }); + } catch (error) { + res.status(500).json({ + success: false, + error: { + message: error instanceof Error ? error.message : 'Upload failed', + code: 'UPLOAD_ERROR', + }, + }); + return; + } + }) +); + +/** + * GET /api/v1/images + * List images with filters and pagination + */ +imagesRouter.get( + '/', + validateApiKey, + requireProjectKey, + asyncHandler(async (req: any, res: Response) => { + const service = getImageService(); + const { flowId, source, alias, limit, offset, includeDeleted } = req.query; + + const paginationResult = validateAndNormalizePagination(limit, offset); + if (!paginationResult.valid) { + res.status(400).json({ + success: false, + data: [], + pagination: { total: 0, limit: 20, offset: 0, hasMore: false }, + }); + return; + } + + const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!; + const projectId = req.apiKey.projectId; + + const result = await service.list( + { + projectId, + flowId: flowId as string | undefined, + source: source as 'generated' | 'uploaded' | undefined, + alias: alias as string | undefined, + deleted: includeDeleted === 'true' ? true : undefined, + }, + validatedLimit, + validatedOffset + ); + + const responseData = result.images.map((img) => toImageResponse(img)); + + res.json( + buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset) + ); + }) +); + +/** + * GET /api/v1/images/:id + * Get a single image by ID + */ +imagesRouter.get( + '/:id', + validateApiKey, + requireProjectKey, + asyncHandler(async (req: any, res: Response) => { + const service = getImageService(); + const { id } = req.params; + + const image = await service.getById(id); + if (!image) { + res.status(404).json({ + success: false, + error: { + message: 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + if (image.projectId !== req.apiKey.projectId) { + res.status(404).json({ + success: false, + error: { + message: 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + res.json({ + success: true, + data: toImageResponse(image), + }); + }) +); + +/** + * PUT /api/v1/images/:id + * Update image metadata (alias, focal point, meta) + */ +imagesRouter.put( + '/:id', + validateApiKey, + requireProjectKey, + asyncHandler(async (req: any, res: Response) => { + const service = getImageService(); + const { id } = req.params; + const { alias, focalPoint, meta } = req.body; + + const image = await service.getById(id); + if (!image) { + res.status(404).json({ + success: false, + error: { + message: 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + if (image.projectId !== req.apiKey.projectId) { + res.status(404).json({ + success: false, + error: { + message: 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + const updates: { + alias?: string; + focalPoint?: { x: number; y: number }; + meta?: Record; + } = {}; + + if (alias !== undefined) updates.alias = alias; + if (focalPoint !== undefined) updates.focalPoint = focalPoint; + if (meta !== undefined) updates.meta = meta; + + const updated = await service.update(id, updates); + + res.json({ + success: true, + data: toImageResponse(updated), + }); + }) +); + +/** + * PUT /api/v1/images/:id/alias + * Assign a project-scoped alias to an image + */ +imagesRouter.put( + '/:id/alias', + validateApiKey, + requireProjectKey, + asyncHandler(async (req: any, res: Response) => { + const service = getImageService(); + const { id } = req.params; + const { alias } = req.body; + + if (!alias || typeof alias !== 'string') { + res.status(400).json({ + success: false, + error: { + message: 'Alias is required and must be a string', + code: 'VALIDATION_ERROR', + }, + }); + return; + } + + const image = await service.getById(id); + if (!image) { + res.status(404).json({ + success: false, + error: { + message: 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + if (image.projectId !== req.apiKey.projectId) { + res.status(404).json({ + success: false, + error: { + message: 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + const updated = await service.assignProjectAlias(id, alias); + + res.json({ + success: true, + data: toImageResponse(updated), + }); + }) +); + +/** + * DELETE /api/v1/images/:id + * Soft delete an image + */ +imagesRouter.delete( + '/:id', + validateApiKey, + requireProjectKey, + asyncHandler(async (req: any, res: Response) => { + const service = getImageService(); + const { id } = req.params; + + const image = await service.getById(id); + if (!image) { + res.status(404).json({ + success: false, + error: { + message: 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + if (image.projectId !== req.apiKey.projectId) { + res.status(404).json({ + success: false, + error: { + message: 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + const deleted = await service.softDelete(id); + + res.json({ + success: true, + data: { + id: deleted.id, + deletedAt: deleted.deletedAt?.toISOString() || null, + }, + }); + }) +); diff --git a/apps/api-service/src/routes/v1/index.ts b/apps/api-service/src/routes/v1/index.ts index 54a05ad..1329f98 100644 --- a/apps/api-service/src/routes/v1/index.ts +++ b/apps/api-service/src/routes/v1/index.ts @@ -2,9 +2,11 @@ import { Router } from 'express'; import type { Router as RouterType } from 'express'; import { generationsRouter } from './generations'; import { flowsRouter } from './flows'; +import { imagesRouter } from './images'; export const v1Router: RouterType = Router(); // Mount v1 routes v1Router.use('/generations', generationsRouter); v1Router.use('/flows', flowsRouter); +v1Router.use('/images', imagesRouter); diff --git a/apps/api-service/src/types/models.ts b/apps/api-service/src/types/models.ts index 6cd1110..2972700 100644 --- a/apps/api-service/src/types/models.ts +++ b/apps/api-service/src/types/models.ts @@ -62,10 +62,10 @@ export interface PaginationMeta { // Query filters for images export interface ImageFilters { projectId: string; - flowId?: string; - source?: ImageSource; - alias?: string; - deleted?: boolean; + flowId?: string | undefined; + source?: ImageSource | undefined; + alias?: string | undefined; + deleted?: boolean | undefined; } // Query filters for generations