630 lines
18 KiB
TypeScript
630 lines
18 KiB
TypeScript
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<CreateFlowResponse>) => {
|
|
// 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<ListFlowsResponse>) => {
|
|
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<GetFlowResponse>) => {
|
|
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<ListFlowGenerationsResponse>) => {
|
|
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<ListFlowImagesResponse>) => {
|
|
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<UpdateFlowAliasesResponse>) => {
|
|
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 },
|
|
});
|
|
})
|
|
);
|