diff --git a/apps/api-service/src/routes/v1/flows.ts b/apps/api-service/src/routes/v1/flows.ts index d383ff8..a9ca0d5 100644 --- a/apps/api-service/src/routes/v1/flows.ts +++ b/apps/api-service/src/routes/v1/flows.ts @@ -1,9 +1,10 @@ import { Response, Router } from 'express'; import type { Router as RouterType } from 'express'; -import { FlowService } from '@/services/core'; +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'; @@ -19,6 +20,7 @@ import type { export const flowsRouter: RouterType = Router(); let flowService: FlowService; +let generationService: GenerationService; const getFlowService = (): FlowService => { if (!flowService) { @@ -27,32 +29,44 @@ const getFlowService = (): FlowService => { return flowService; }; +const getGenerationService = (): GenerationService => { + if (!generationService) { + generationService = new GenerationService(); + } + return generationService; +}; + /** * POST /api/v1/flows - * Create a new flow for organizing generation chains + * 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), - }); - }) -); +// 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), +// }); +// }) +// ); /** * GET /api/v1/flows @@ -333,6 +347,71 @@ flowsRouter.delete( }) ); +/** + * POST /api/v1/flows/:id/regenerate + * Regenerate the most recent generation in a flow (Section 3.6) + * - Returns error if flow has no generations + * - Uses parameters from the last generation + */ +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 /api/v1/flows/:id * Delete a flow diff --git a/apps/api-service/src/routes/v1/generations.ts b/apps/api-service/src/routes/v1/generations.ts index e0dc205..8eff255 100644 --- a/apps/api-service/src/routes/v1/generations.ts +++ b/apps/api-service/src/routes/v1/generations.ts @@ -211,18 +211,20 @@ generationsRouter.put( ); /** - * POST /api/v1/generations/:id/retry - * Retry a failed generation + * POST /api/v1/generations/:id/regenerate + * Regenerate existing generation with exact same parameters (Section 3) + * - Allows regeneration for any status + * - Updates existing image (same ID, path, URL) + * - No parameter overrides */ generationsRouter.post( - '/:id/retry', + '/:id/regenerate', validateApiKey, requireProjectKey, rateLimitByApiKey, - asyncHandler(async (req: any, res: Response) => { + asyncHandler(async (req: any, res: Response) => { const service = getGenerationService(); const { id } = req.params; - const { prompt, aspectRatio } = req.body; const original = await service.getById(id); if (!original) { @@ -247,11 +249,57 @@ generationsRouter.post( return; } - const newGeneration = await service.retry(id, { prompt, aspectRatio }); + const regenerated = await service.regenerate(id); + + res.json({ + success: true, + data: toGenerationResponse(regenerated), + }); + }) +); + +/** + * POST /api/v1/generations/:id/retry + * Legacy endpoint - delegates to regenerate + * @deprecated Use /regenerate instead + */ +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(newGeneration), + data: toGenerationResponse(regenerated), }); }) ); diff --git a/apps/api-service/src/services/core/GenerationService.ts b/apps/api-service/src/services/core/GenerationService.ts index a8b049b..c7cd114 100644 --- a/apps/api-service/src/services/core/GenerationService.ts +++ b/apps/api-service/src/services/core/GenerationService.ts @@ -71,13 +71,19 @@ export class GenerationService { finalFlowId = params.flowId; } + // Prompt semantics (Section 2.1): + // - If autoEnhance = false OR no enhancedPrompt: prompt = user input, originalPrompt = null + // - If autoEnhance = true AND enhancedPrompt: prompt = enhanced, originalPrompt = user input + const usedPrompt = params.enhancedPrompt || params.prompt; + const preservedOriginal = params.enhancedPrompt ? params.prompt : null; + const generationRecord: NewGeneration = { projectId: params.projectId, flowId: finalFlowId, apiKeyId: params.apiKeyId, status: 'pending', - originalPrompt: params.prompt, - enhancedPrompt: params.enhancedPrompt || null, + prompt: usedPrompt, // Prompt actually used for generation + originalPrompt: preservedOriginal, // User's original (only if enhanced) aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO, referencedImages: null, requestId: params.requestId || null, @@ -115,7 +121,7 @@ export class GenerationService { } const genResult = await this.imageGenService.generateImage({ - prompt: params.enhancedPrompt || params.prompt, + prompt: usedPrompt, // Use the prompt that was stored (enhanced or original) filename: `gen_${generation.id}`, referenceImages: referenceImageBuffers, aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO, @@ -363,39 +369,73 @@ export class GenerationService { }; } + /** + * Regenerate an existing generation (Section 3) + * - Allows regeneration for any status (no status checks) + * - Uses exact same parameters as original + * - Updates existing image (same ID, path, URL) + * - No retry count logic + */ + async regenerate(id: string): Promise { + const generation = await this.getById(id); + if (!generation) { + throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND); + } + + if (!generation.outputImageId) { + throw new Error('Cannot regenerate generation without output image'); + } + + const startTime = Date.now(); + + try { + // Update status to processing + await this.updateStatus(id, 'processing'); + + // Use EXACT same parameters as original (no overrides) + const genResult = await this.imageGenService.generateImage({ + prompt: generation.prompt, + filename: `gen_${id}`, + referenceImages: [], // TODO: Re-resolve referenced images if needed + aspectRatio: generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO, + orgId: 'default', + projectId: generation.projectId, + meta: generation.meta as Record || {}, + }); + + if (!genResult.success) { + const processingTime = Date.now() - startTime; + await this.updateStatus(id, 'failed', { + errorMessage: genResult.error || 'Regeneration failed', + processingTimeMs: processingTime, + }); + throw new Error(genResult.error || 'Regeneration failed'); + } + + // Note: Physical file in MinIO is overwritten by ImageGenService + // Image record preserves: imageId, storageKey, storageUrl, alias, createdAt + // Image record updates: fileSize (if changed), updatedAt + + const processingTime = Date.now() - startTime; + await this.updateStatus(id, 'success', { + processingTimeMs: processingTime, + }); + + return await this.getByIdWithRelations(id); + } catch (error) { + const processingTime = Date.now() - startTime; + await this.updateStatus(id, 'failed', { + errorMessage: error instanceof Error ? error.message : 'Unknown error', + processingTimeMs: processingTime, + }); + throw error; + } + } + + // Keep retry() for backward compatibility, delegate to regenerate() async retry(id: string, overrides?: { prompt?: string; aspectRatio?: string }): Promise { - const original = await this.getByIdWithRelations(id); - - if (original.status === 'success') { - throw new Error(ERROR_MESSAGES.GENERATION_ALREADY_SUCCEEDED); - } - - if (original.retryCount >= GENERATION_LIMITS.MAX_RETRY_COUNT) { - throw new Error(ERROR_MESSAGES.MAX_RETRY_COUNT_EXCEEDED); - } - - if (!original.apiKeyId) { - throw new Error('Cannot retry generation without API key'); - } - - const newParams: CreateGenerationParams = { - projectId: original.projectId, - apiKeyId: original.apiKeyId, - prompt: overrides?.prompt || original.originalPrompt, - aspectRatio: overrides?.aspectRatio || original.aspectRatio || undefined, - flowId: original.flowId || undefined, - enhancedPrompt: original.enhancedPrompt || undefined, - meta: original.meta as Record, - }; - - const newGeneration = await this.create(newParams); - - await db - .update(generations) - .set({ retryCount: original.retryCount + 1 }) - .where(eq(generations.id, newGeneration.id)); - - return newGeneration; + // Ignore overrides, regenerate with original parameters + return await this.regenerate(id); } async update( @@ -414,7 +454,7 @@ export class GenerationService { // Check if generative parameters changed (prompt or aspectRatio) const shouldRegenerate = - (updates.prompt !== undefined && updates.prompt !== generation.originalPrompt) || + (updates.prompt !== undefined && updates.prompt !== generation.prompt) || (updates.aspectRatio !== undefined && updates.aspectRatio !== generation.aspectRatio); // Handle flowId change (Section 9.2) @@ -437,7 +477,7 @@ export class GenerationService { // Update database fields const updateData: Partial = {}; if (updates.prompt !== undefined) { - updateData.originalPrompt = updates.prompt; + updateData.prompt = updates.prompt; // Update the prompt used for generation } if (updates.aspectRatio !== undefined) { updateData.aspectRatio = updates.aspectRatio; @@ -463,7 +503,7 @@ export class GenerationService { try { // Use updated prompt/aspectRatio or fall back to existing - const promptToUse = updates.prompt || generation.originalPrompt; + const promptToUse = updates.prompt || generation.prompt; const aspectRatioToUse = updates.aspectRatio || generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO; // Regenerate image diff --git a/packages/database/src/schema/generations.ts b/packages/database/src/schema/generations.ts index a61ac9b..890f94f 100644 --- a/packages/database/src/schema/generations.ts +++ b/packages/database/src/schema/generations.ts @@ -45,9 +45,11 @@ export const generations = pgTable( // Status status: generationStatusEnum('status').notNull().default('pending'), - // Prompts - originalPrompt: text('original_prompt').notNull(), - enhancedPrompt: text('enhanced_prompt'), // AI-enhanced version (if enabled) + // Prompts (Section 2.1: Reversed semantics) + // prompt: The prompt that was ACTUALLY USED for generation (enhanced OR original) + // originalPrompt: User's ORIGINAL input, only stored if autoEnhance was used + prompt: text('prompt').notNull(), // Prompt used for generation + originalPrompt: text('original_prompt'), // User's original (nullable, only if enhanced) // Generation parameters aspectRatio: varchar('aspect_ratio', { length: 10 }), diff --git a/packages/database/src/schema/projects.ts b/packages/database/src/schema/projects.ts index 653f0fd..2f3cea1 100644 --- a/packages/database/src/schema/projects.ts +++ b/packages/database/src/schema/projects.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, unique, boolean, integer } from 'drizzle-orm/pg-core'; import { organizations } from './organizations'; export const projects = pgTable( @@ -13,6 +13,10 @@ export const projects = pgTable( .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), + // Live scope settings (Section 8.4) + allowNewLiveScopes: boolean('allow_new_live_scopes').notNull().default(true), + newLiveScopesGenerationLimit: integer('new_live_scopes_generation_limit').notNull().default(30), + // Timestamps createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at')