import { Response, Router } from 'express'; import type { Router as RouterType } from 'express'; import { GenerationService } from '@/services/core'; import { asyncHandler } from '@/middleware/errorHandler'; import { validateApiKey } from '@/middleware/auth/validateApiKey'; import { requireProjectKey } from '@/middleware/auth/requireProjectKey'; import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter'; import { autoEnhancePrompt } from '@/middleware/promptEnhancement'; import { validateAndNormalizePagination } from '@/utils/validators'; import { buildPaginatedResponse } from '@/utils/helpers'; import { toGenerationResponse } from '@/types/responses'; import type { CreateGenerationResponse, ListGenerationsResponse, GetGenerationResponse, } from '@/types/responses'; export const generationsRouter: RouterType = Router(); let generationService: GenerationService; const getGenerationService = (): GenerationService => { if (!generationService) { generationService = new GenerationService(); } return generationService; }; /** * Create a new image generation from a text prompt * * Generates AI-powered images using Gemini Flash Image model with support for: * - Text prompts with optional auto-enhancement * - Reference images for style/context * - Flow association and flow-scoped aliases * - Project-scoped aliases for direct access * - Custom metadata storage * * @route POST /api/v1/generations * @authentication Project Key required * @rateLimit 100 requests per hour per API key * * @param {CreateGenerationRequest} req.body - Generation parameters * @param {string} req.body.prompt - Text description of desired image (required) * @param {string[]} [req.body.referenceImages] - Array of aliases to use as references * @param {string} [req.body.aspectRatio='1:1'] - Aspect ratio (1:1, 16:9, 3:2, 9:16) * @param {string} [req.body.flowId] - Associate with existing flow * @param {string} [req.body.alias] - Project-scoped alias (@custom-name) * @param {string} [req.body.flowAlias] - Flow-scoped alias (requires flowId) * @param {boolean} [req.body.autoEnhance=true] - Enable prompt enhancement * @param {object} [req.body.meta] - Custom metadata * * @returns {CreateGenerationResponse} 201 - Generation created with status * @returns {object} 400 - Invalid request parameters * @returns {object} 401 - Missing or invalid API key * @returns {object} 429 - Rate limit exceeded * * @throws {Error} VALIDATION_ERROR - Missing or invalid prompt * @throws {Error} ALIAS_CONFLICT - Alias already exists * @throws {Error} FLOW_NOT_FOUND - Flow ID does not exist * @throws {Error} IMAGE_NOT_FOUND - Reference image alias not found * * @example * // Basic generation * POST /api/v1/generations * { * "prompt": "A serene mountain landscape at sunset", * "aspectRatio": "16:9" * } * * @example * // With reference images and alias * POST /api/v1/generations * { * "prompt": "Product photo in this style", * "referenceImages": ["@brand-style", "@product-template"], * "alias": "@hero-image", * "autoEnhance": true * } */ generationsRouter.post( '/', validateApiKey, requireProjectKey, rateLimitByApiKey, autoEnhancePrompt, asyncHandler(async (req: any, res: Response) => { const service = getGenerationService(); // Extract original prompt from middleware property if enhancement was attempted // Otherwise fall back to request body const prompt = req.originalPrompt || req.body.prompt; const { referenceImages, aspectRatio, flowId, alias, flowAlias, autoEnhance, meta, } = req.body; if (!prompt || typeof prompt !== 'string') { res.status(400).json({ success: false, error: { message: 'Prompt is required and must be a string', code: 'VALIDATION_ERROR', }, }); return; } const projectId = req.apiKey.projectId; const apiKeyId = req.apiKey.id; const organizationSlug = req.apiKey.organizationSlug || process.env['DEFAULT_ORG_SLUG'] || 'default'; const projectSlug = req.apiKey.projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main'; const generation = await service.create({ projectId, apiKeyId, organizationSlug, projectSlug, prompt, referenceImages, aspectRatio, flowId, alias, flowAlias, autoEnhance, enhancedPrompt: req.enhancedPrompt, meta, requestId: req.requestId, }); res.status(201).json({ success: true, data: toGenerationResponse(generation), }); }) ); /** * List all generations for the project with filtering and pagination * * Retrieves generations with support for: * - Flow-based filtering * - Status filtering (pending, processing, success, failed) * - Pagination with configurable limit and offset * - Optional inclusion of soft-deleted generations * * @route GET /api/v1/generations * @authentication Project Key required * * @param {string} [req.query.flowId] - Filter by flow ID * @param {string} [req.query.status] - Filter by status (pending|processing|success|failed) * @param {number} [req.query.limit=20] - Results per page (max 100) * @param {number} [req.query.offset=0] - Number of results to skip * @param {boolean} [req.query.includeDeleted=false] - Include soft-deleted generations * * @returns {ListGenerationsResponse} 200 - Paginated list of generations * @returns {object} 400 - Invalid pagination parameters * @returns {object} 401 - Missing or invalid API key * * @example * // List recent generations * GET /api/v1/generations?limit=10&offset=0 * * @example * // Filter by flow and status * GET /api/v1/generations?flowId=abc-123&status=success&limit=50 */ generationsRouter.get( '/', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getGenerationService(); const { flowId, status, 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, status: status as 'pending' | 'processing' | 'success' | 'failed' | undefined, deleted: includeDeleted === 'true' ? true : undefined, }, validatedLimit, validatedOffset ); const responseData = result.generations.map((gen) => toGenerationResponse(gen)); res.json( buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset) ); }) ); /** * Get a single generation by ID with full details * * Retrieves complete generation information including: * - Generation status and metadata * - Output image details (URL, dimensions, etc.) * - Reference images used * - Flow association * - Timestamps and audit trail * * @route GET /api/v1/generations/:id * @authentication Project Key required * * @param {string} req.params.id - Generation ID (UUID) * * @returns {GetGenerationResponse} 200 - Complete generation details * @returns {object} 404 - Generation not found or access denied * @returns {object} 401 - Missing or invalid API key * * @throws {Error} GENERATION_NOT_FOUND - Generation does not exist * * @example * GET /api/v1/generations/550e8400-e29b-41d4-a716-446655440000 */ generationsRouter.get( '/:id', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getGenerationService(); const { id } = req.params; const generation = await service.getByIdWithRelations(id); if (generation.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Generation not found', code: 'GENERATION_NOT_FOUND', }, }); return; } res.json({ success: true, data: toGenerationResponse(generation), }); }) ); /** * Update generation parameters with automatic regeneration * * Updates generation settings with intelligent regeneration behavior: * - Changing prompt or aspectRatio triggers automatic regeneration * - Changing flowId or meta updates metadata only (no regeneration) * - Regeneration replaces existing output image (same ID and URLs) * - All changes preserve generation history and IDs * * @route PUT /api/v1/generations/:id * @authentication Project Key required * @rateLimit 100 requests per hour per API key * * @param {string} req.params.id - Generation ID (UUID) * @param {UpdateGenerationRequest} req.body - Update parameters * @param {string} [req.body.prompt] - New prompt (triggers regeneration) * @param {string} [req.body.aspectRatio] - New aspect ratio (triggers regeneration) * @param {string|null} [req.body.flowId] - Change flow association (null to detach) * @param {object} [req.body.meta] - Update custom metadata * * @returns {GetGenerationResponse} 200 - Updated generation with new output * @returns {object} 404 - Generation not found or access denied * @returns {object} 401 - Missing or invalid API key * @returns {object} 429 - Rate limit exceeded * * @throws {Error} GENERATION_NOT_FOUND - Generation does not exist * @throws {Error} FLOW_NOT_FOUND - New flow ID does not exist * * @example * // Update prompt (triggers regeneration) * PUT /api/v1/generations/550e8400-e29b-41d4-a716-446655440000 * { * "prompt": "Updated: A mountain landscape with vibrant colors" * } * * @example * // Change flow association (no regeneration) * PUT /api/v1/generations/550e8400-e29b-41d4-a716-446655440000 * { * "flowId": "new-flow-id-123" * } */ generationsRouter.put( '/:id', validateApiKey, requireProjectKey, rateLimitByApiKey, asyncHandler(async (req: any, res: Response) => { const service = getGenerationService(); const { id } = req.params; const { prompt, aspectRatio, flowId, meta } = req.body; const original = await service.getById(id); if (!original) { res.status(404).json({ success: false, error: { message: 'Generation not found', code: 'GENERATION_NOT_FOUND', }, }); return; } if (original.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Generation not found', code: 'GENERATION_NOT_FOUND', }, }); return; } const updated = await service.update(id, { prompt, aspectRatio, flowId, meta, }); res.json({ success: true, data: toGenerationResponse(updated), }); }) ); /** * Regenerate existing generation with exact same parameters * * Creates a new image using the original generation parameters: * - Uses exact same prompt, aspect ratio, and reference images * - Works regardless of current status (success, failed, pending) * - Replaces existing output image (preserves ID and URLs) * - No parameter modifications allowed (use PUT for changes) * - Useful for refreshing stale images or recovering from failures * * @route POST /api/v1/generations/:id/regenerate * @authentication Project Key required * @rateLimit 100 requests per hour per API key * * @param {string} req.params.id - Generation ID (UUID) * * @returns {GetGenerationResponse} 200 - Regenerated generation with new output * @returns {object} 404 - Generation not found or access denied * @returns {object} 401 - Missing or invalid API key * @returns {object} 429 - Rate limit exceeded * * @throws {Error} GENERATION_NOT_FOUND - Generation does not exist * * @example * POST /api/v1/generations/550e8400-e29b-41d4-a716-446655440000/regenerate */ generationsRouter.post( '/:id/regenerate', validateApiKey, requireProjectKey, rateLimitByApiKey, asyncHandler(async (req: any, res: Response) => { const service = getGenerationService(); const { id } = req.params; const original = await service.getById(id); if (!original) { res.status(404).json({ success: false, error: { message: 'Generation not found', code: 'GENERATION_NOT_FOUND', }, }); return; } if (original.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Generation not found', code: 'GENERATION_NOT_FOUND', }, }); return; } const regenerated = await service.regenerate(id); res.json({ success: true, data: toGenerationResponse(regenerated), }); }) ); /** * Retry a failed generation (legacy endpoint) * * @deprecated Use POST /api/v1/generations/:id/regenerate instead * * This endpoint is maintained for backward compatibility and delegates * to the regenerate endpoint. New integrations should use /regenerate. * * @route POST /api/v1/generations/:id/retry * @authentication Project Key required * @rateLimit 100 requests per hour per API key * * @param {string} req.params.id - Generation ID (UUID) * * @returns {CreateGenerationResponse} 201 - Regenerated generation * @returns {object} 404 - Generation not found or access denied * @returns {object} 401 - Missing or invalid API key * @returns {object} 429 - Rate limit exceeded * * @see POST /api/v1/generations/:id/regenerate - Preferred endpoint */ generationsRouter.post( '/:id/retry', validateApiKey, requireProjectKey, rateLimitByApiKey, asyncHandler(async (req: any, res: Response) => { const service = getGenerationService(); const { id } = req.params; const original = await service.getById(id); if (!original) { res.status(404).json({ success: false, error: { message: 'Generation not found', code: 'GENERATION_NOT_FOUND', }, }); return; } if (original.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Generation not found', code: 'GENERATION_NOT_FOUND', }, }); return; } const regenerated = await service.regenerate(id); res.status(201).json({ success: true, data: toGenerationResponse(regenerated), }); }) ); /** * Delete a generation and conditionally its output image (Section 7.2) * * Performs deletion with alias protection: * - Hard delete generation record (permanently removed from database) * - If output image has NO project alias: hard delete image with MinIO cleanup * - If output image HAS project alias: keep image, set generationId=NULL * * Rationale: Images with aliases are used as standalone assets and should be preserved. * Images without aliases were created only for this generation and can be deleted together. * * @route DELETE /api/v1/generations/:id * @authentication Project Key required * * @param {string} req.params.id - Generation ID (UUID) * * @returns {object} 200 - Deletion confirmation with generation ID * @returns {object} 404 - Generation not found or access denied * @returns {object} 401 - Missing or invalid API key * * @throws {Error} GENERATION_NOT_FOUND - Generation does not exist * * @example * DELETE /api/v1/generations/550e8400-e29b-41d4-a716-446655440000 * * Response: * { * "success": true, * "data": { "id": "550e8400-e29b-41d4-a716-446655440000" } * } */ generationsRouter.delete( '/:id', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getGenerationService(); const { id } = req.params; const generation = await service.getById(id); if (!generation) { res.status(404).json({ success: false, error: { message: 'Generation not found', code: 'GENERATION_NOT_FOUND', }, }); return; } if (generation.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Generation not found', code: 'GENERATION_NOT_FOUND', }, }); return; } await service.delete(id); res.json({ success: true, data: { id }, }); }) );