import { randomUUID } from 'crypto'; import { Response, Router } from 'express'; import type { Router as RouterType } from 'express'; import { ImageService, AliasService } 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 { db } from '@/db'; import { flows } from '@banatie/database'; import { eq } from 'drizzle-orm'; import type { UploadImageResponse, ListImagesResponse, GetImageResponse, UpdateImageResponse, DeleteImageResponse, ResolveAliasResponse, } from '@/types/responses'; export const imagesRouter: RouterType = Router(); let imageService: ImageService; let aliasService: AliasService; const getImageService = (): ImageService => { if (!imageService) { imageService = new ImageService(); } return imageService; }; const getAliasService = (): AliasService => { if (!aliasService) { aliasService = new AliasService(); } return aliasService; }; /** * 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, flowAlias, 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; // FlowId logic (Section 10.1 & 5.1): // - If undefined (not provided) → generate new UUID // - If null (explicitly null) → keep null // - If string (specific value) → use that value let finalFlowId: string | null; if (flowId === undefined) { finalFlowId = randomUUID(); } else if (flowId === null) { finalFlowId = null; } else { finalFlowId = flowId; } 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: finalFlowId, 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) : {}, }); // Eager flow creation if flowAlias is provided (Section 5.1) if (flowAlias && finalFlowId) { // Check if flow exists, create if not const existingFlow = await db.query.flows.findFirst({ where: eq(flows.id, finalFlowId), }); if (!existingFlow) { await db.insert(flows).values({ id: finalFlowId, projectId, aliases: {}, meta: {}, }); } // Assign flow alias to uploaded image const flow = await db.query.flows.findFirst({ where: eq(flows.id, finalFlowId), }); if (flow) { const currentAliases = (flow.aliases as Record) || {}; const updatedAliases = { ...currentAliases }; updatedAliases[flowAlias] = imageRecord.id; await db .update(flows) .set({ aliases: updatedAliases, updatedAt: new Date() }) .where(eq(flows.id, finalFlowId)); } } 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/resolve/:alias * Resolve an alias to an image using 3-tier precedence (technical -> flow -> project) */ imagesRouter.get( '/resolve/:alias', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const aliasServiceInstance = getAliasService(); const { alias } = req.params; const { flowId } = req.query; const projectId = req.apiKey.projectId; try { const resolution = await aliasServiceInstance.resolve( alias, projectId, flowId as string | undefined ); if (!resolution) { res.status(404).json({ success: false, error: { message: `Alias '${alias}' not found`, code: 'ALIAS_NOT_FOUND', }, }); return; } // Verify project ownership if (resolution.image && resolution.image.projectId !== projectId) { res.status(404).json({ success: false, error: { message: 'Alias not found', code: 'ALIAS_NOT_FOUND', }, }); return; } res.json({ success: true, data: { alias, imageId: resolution.imageId, scope: resolution.scope, flowId: resolution.flowId, image: resolution.image ? toImageResponse(resolution.image) : ({} as any), }, }); } catch (error) { res.status(500).json({ success: false, error: { message: error instanceof Error ? error.message : 'Failed to resolve alias', code: 'RESOLUTION_ERROR', }, }); return; } }) ); /** * 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 { focalPoint, meta } = req.body; // Removed alias (Section 6.1) 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: { focalPoint?: { x: number; y: number }; meta?: Record; } = {}; 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 * Hard delete an image with MinIO cleanup and cascades (Section 7.1) */ 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; } await service.hardDelete(id); res.json({ success: true, data: { id }, }); }) );