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

554 lines
16 KiB
TypeScript

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<CreateGenerationResponse>) => {
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<ListGenerationsResponse>) => {
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<GetGenerationResponse>) => {
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<GetGenerationResponse>) => {
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<GetGenerationResponse>) => {
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<CreateGenerationResponse>) => {
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 },
});
})
);