import { Response, Router } from 'express'; import type { Router as RouterType } from 'express'; import { FlowService, 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 { validateAndNormalizePagination } from '@/utils/validators'; import { buildPaginatedResponse } from '@/utils/helpers'; import { toFlowResponse, toGenerationResponse, toImageResponse } from '@/types/responses'; import type { ListFlowsResponse, GetFlowResponse, UpdateFlowAliasesResponse, ListFlowGenerationsResponse, ListFlowImagesResponse, } from '@/types/responses'; export const flowsRouter: RouterType = Router(); let flowService: FlowService; let generationService: GenerationService; const getFlowService = (): FlowService => { if (!flowService) { flowService = new FlowService(); } return flowService; }; const getGenerationService = (): GenerationService => { if (!generationService) { generationService = new GenerationService(); } return generationService; }; /** * POST /api/v1/flows * REMOVED (Section 4.3): Lazy flow creation pattern * Flows are now created automatically when: * - A generation/upload specifies a flowId * - A generation/upload provides a flowAlias (eager creation) * * @deprecated Flows are created automatically, no explicit endpoint needed */ // flowsRouter.post( // '/', // validateApiKey, // requireProjectKey, // asyncHandler(async (req: any, res: Response) => { // const service = getFlowService(); // const { meta } = req.body; // // const projectId = req.apiKey.projectId; // // const flow = await service.create({ // projectId, // aliases: {}, // meta: meta || {}, // }); // // res.status(201).json({ // success: true, // data: toFlowResponse(flow), // }); // }) // ); /** * List all flows for a project with pagination and computed counts * * Retrieves flows created automatically when generations/uploads specify: * - A flowId in their request * - A flowAlias (creates flow eagerly if doesn't exist) * * Each flow includes: * - Computed generationCount and imageCount * - Flow-scoped aliases (JSONB key-value pairs) * - Custom metadata * * @route GET /api/v1/flows * @authentication Project Key required * * @param {number} [req.query.limit=20] - Results per page (max 100) * @param {number} [req.query.offset=0] - Number of results to skip * * @returns {ListFlowsResponse} 200 - Paginated list of flows with counts * @returns {object} 400 - Invalid pagination parameters * @returns {object} 401 - Missing or invalid API key * * @example * GET /api/v1/flows?limit=50&offset=0 */ flowsRouter.get( '/', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getFlowService(); const { limit, offset } = 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 }, validatedLimit, validatedOffset ); const responseData = result.flows.map((flow) => toFlowResponse(flow)); res.json( buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset) ); }) ); /** * Get a single flow by ID with computed statistics * * Retrieves detailed flow information including: * - All flow-scoped aliases * - Computed generationCount (active generations only) * - Computed imageCount (active images only) * - Custom metadata * - Creation and update timestamps * * @route GET /api/v1/flows/:id * @authentication Project Key required * * @param {string} req.params.id - Flow ID (UUID) * * @returns {GetFlowResponse} 200 - Complete flow details with counts * @returns {object} 404 - Flow not found or access denied * @returns {object} 401 - Missing or invalid API key * * @throws {Error} FLOW_NOT_FOUND - Flow does not exist * * @example * GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000 */ flowsRouter.get( '/:id', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getFlowService(); const { id } = req.params; const flow = await service.getByIdWithCounts(id); if (flow.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Flow not found', code: 'FLOW_NOT_FOUND', }, }); return; } res.json({ success: true, data: toFlowResponse(flow), }); }) ); /** * List all generations in a specific flow with pagination * * Retrieves all generations associated with this flow, ordered by creation date (newest first). * Includes only active (non-deleted) generations. * * @route GET /api/v1/flows/:id/generations * @authentication Project Key required * * @param {string} req.params.id - Flow ID (UUID) * @param {number} [req.query.limit=20] - Results per page (max 100) * @param {number} [req.query.offset=0] - Number of results to skip * * @returns {ListFlowGenerationsResponse} 200 - Paginated list of generations * @returns {object} 404 - Flow not found or access denied * @returns {object} 400 - Invalid pagination parameters * @returns {object} 401 - Missing or invalid API key * * @example * GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/generations?limit=10 */ flowsRouter.get( '/:id/generations', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getFlowService(); const { id } = req.params; const { limit, offset } = req.query; const flow = await service.getById(id); if (!flow) { res.status(404).json({ success: false, data: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false }, }); return; } if (flow.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, data: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false }, }); return; } 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 result = await service.getFlowGenerations(id, validatedLimit, validatedOffset); const responseData = result.generations.map((gen) => toGenerationResponse(gen)); res.json( buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset) ); }) ); /** * List all images in a specific flow with pagination * * Retrieves all images (generated and uploaded) associated with this flow, * ordered by creation date (newest first). Includes only active (non-deleted) images. * * @route GET /api/v1/flows/:id/images * @authentication Project Key required * * @param {string} req.params.id - Flow ID (UUID) * @param {number} [req.query.limit=20] - Results per page (max 100) * @param {number} [req.query.offset=0] - Number of results to skip * * @returns {ListFlowImagesResponse} 200 - Paginated list of images * @returns {object} 404 - Flow not found or access denied * @returns {object} 400 - Invalid pagination parameters * @returns {object} 401 - Missing or invalid API key * * @example * GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/images?limit=20 */ flowsRouter.get( '/:id/images', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getFlowService(); const { id } = req.params; const { limit, offset } = req.query; const flow = await service.getById(id); if (!flow) { res.status(404).json({ success: false, data: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false }, }); return; } if (flow.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, data: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false }, }); return; } 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 result = await service.getFlowImages(id, validatedLimit, validatedOffset); const responseData = result.images.map((img) => toImageResponse(img)); res.json( buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset) ); }) ); /** * Update flow-scoped aliases (add or modify existing) * * Updates the JSONB aliases field with new or modified key-value pairs. * Aliases are merged with existing aliases (does not replace all). * * Flow-scoped aliases: * - Must start with @ symbol * - Unique within the flow only (not project-wide) * - Used for alias resolution in generations * - Stored as JSONB for efficient lookups * * @route PUT /api/v1/flows/:id/aliases * @authentication Project Key required * * @param {string} req.params.id - Flow ID (UUID) * @param {UpdateFlowAliasesRequest} req.body - Alias updates * @param {object} req.body.aliases - Key-value pairs of aliases to add/update * * @returns {UpdateFlowAliasesResponse} 200 - Updated flow with merged aliases * @returns {object} 404 - Flow not found or access denied * @returns {object} 400 - Invalid aliases format * @returns {object} 401 - Missing or invalid API key * * @throws {Error} FLOW_NOT_FOUND - Flow does not exist * @throws {Error} VALIDATION_ERROR - Aliases must be an object * * @example * PUT /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/aliases * { * "aliases": { * "@hero": "image-id-123", * "@background": "image-id-456" * } * } */ flowsRouter.put( '/:id/aliases', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getFlowService(); const { id } = req.params; const { aliases } = req.body; if (!aliases || typeof aliases !== 'object' || Array.isArray(aliases)) { res.status(400).json({ success: false, error: { message: 'Aliases must be an object with key-value pairs', code: 'VALIDATION_ERROR', }, }); return; } const flow = await service.getById(id); if (!flow) { res.status(404).json({ success: false, error: { message: 'Flow not found', code: 'FLOW_NOT_FOUND', }, }); return; } if (flow.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Flow not found', code: 'FLOW_NOT_FOUND', }, }); return; } const updatedFlow = await service.updateAliases(id, aliases); res.json({ success: true, data: toFlowResponse(updatedFlow), }); }) ); /** * Remove a specific alias from a flow * * Deletes a single alias key-value pair from the flow's JSONB aliases field. * Other aliases remain unchanged. * * @route DELETE /api/v1/flows/:id/aliases/:alias * @authentication Project Key required * * @param {string} req.params.id - Flow ID (UUID) * @param {string} req.params.alias - Alias to remove (e.g., "@hero") * * @returns {object} 200 - Updated flow with alias removed * @returns {object} 404 - Flow not found or access denied * @returns {object} 401 - Missing or invalid API key * * @throws {Error} FLOW_NOT_FOUND - Flow does not exist * * @example * DELETE /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/aliases/@hero */ flowsRouter.delete( '/:id/aliases/:alias', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getFlowService(); const { id, alias } = req.params; const flow = await service.getById(id); if (!flow) { res.status(404).json({ success: false, error: { message: 'Flow not found', code: 'FLOW_NOT_FOUND', }, }); return; } if (flow.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Flow not found', code: 'FLOW_NOT_FOUND', }, }); return; } const updatedFlow = await service.removeAlias(id, alias); res.json({ success: true, data: toFlowResponse(updatedFlow), }); }) ); /** * Regenerate the most recent generation in a flow (Section 3.6) * * Logic: * 1. Find the flow by ID * 2. Query for the most recent generation (ordered by createdAt desc) * 3. Trigger regeneration with exact same parameters * 4. Replace existing output image (preserves ID and URLs) * * @route POST /api/v1/flows/:id/regenerate * @authentication Project Key required * @rateLimit 100 requests per hour per API key * * @param {string} req.params.id - Flow ID (affects: determines which flow's latest generation to regenerate) * * @returns {object} 200 - Regenerated generation with updated output image * @returns {object} 404 - Flow not found or access denied * @returns {object} 400 - Flow has no generations * @returns {object} 401 - Missing or invalid API key * @returns {object} 429 - Rate limit exceeded * * @throws {Error} FLOW_NOT_FOUND - Flow does not exist * @throws {Error} FLOW_HAS_NO_GENERATIONS - Flow contains no generations to regenerate * * @example * POST /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/regenerate */ flowsRouter.post( '/:id/regenerate', validateApiKey, requireProjectKey, rateLimitByApiKey, asyncHandler(async (req: any, res: Response) => { const flowSvc = getFlowService(); const genSvc = getGenerationService(); const { id } = req.params; const flow = await flowSvc.getById(id); if (!flow) { res.status(404).json({ success: false, error: { message: 'Flow not found', code: 'FLOW_NOT_FOUND', }, }); return; } if (flow.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Flow not found', code: 'FLOW_NOT_FOUND', }, }); return; } // Get the most recent generation in the flow const result = await flowSvc.getFlowGenerations(id, 1, 0); // limit=1, offset=0 if (result.total === 0 || result.generations.length === 0) { res.status(400).json({ success: false, error: { message: 'Flow has no generations to regenerate', code: 'FLOW_HAS_NO_GENERATIONS', }, }); return; } const latestGeneration = result.generations[0]!; // Regenerate the latest generation const regenerated = await genSvc.regenerate(latestGeneration.id); res.json({ success: true, data: toGenerationResponse(regenerated), }); }) ); /** * Delete a flow with cascade deletion (Section 7.3) * * Permanently removes the flow with cascade behavior: * - Flow record is hard deleted * - All generations in flow are hard deleted * - Images WITHOUT project alias: hard deleted with MinIO cleanup * - Images WITH project alias: kept, but flowId set to NULL (unlinked) * * Rationale: Images with project aliases are used globally and should be preserved. * Flow deletion removes the organizational structure but protects important assets. * * @route DELETE /api/v1/flows/:id * @authentication Project Key required * * @param {string} req.params.id - Flow ID (UUID) * * @returns {object} 200 - Deletion confirmation with flow ID * @returns {object} 404 - Flow not found or access denied * @returns {object} 401 - Missing or invalid API key * * @throws {Error} FLOW_NOT_FOUND - Flow does not exist * * @example * DELETE /api/v1/flows/550e8400-e29b-41d4-a716-446655440000 * * Response: * { * "success": true, * "data": { "id": "550e8400-e29b-41d4-a716-446655440000" } * } */ flowsRouter.delete( '/:id', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getFlowService(); const { id } = req.params; const flow = await service.getById(id); if (!flow) { res.status(404).json({ success: false, error: { message: 'Flow not found', code: 'FLOW_NOT_FOUND', }, }); return; } if (flow.projectId !== req.apiKey.projectId) { res.status(404).json({ success: false, error: { message: 'Flow not found', code: 'FLOW_NOT_FOUND', }, }); return; } await service.delete(id); res.json({ success: true, data: { id }, }); }) );