banatie-service/apps/api-service/src/routes/v1/flows.ts

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 },
});
})
);