feature/api-development #1
|
|
@ -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<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),
|
||||
});
|
||||
})
|
||||
);
|
||||
// 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),
|
||||
// });
|
||||
// })
|
||||
// );
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -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<CreateGenerationResponse>) => {
|
||||
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
|
||||
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<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(newGeneration),
|
||||
data: toGenerationResponse(regenerated),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<GenerationWithRelations> {
|
||||
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<string, unknown> || {},
|
||||
});
|
||||
|
||||
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<GenerationWithRelations> {
|
||||
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<string, unknown>,
|
||||
};
|
||||
|
||||
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<NewGeneration> = {};
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue