feature/api-development #1

Merged
usulpro merged 47 commits from feature/api-development into main 2025-11-29 23:03:01 +07:00
5 changed files with 246 additions and 73 deletions
Showing only changes of commit 9b9c47e2bf - Show all commits

View File

@ -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

View File

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

View File

@ -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

View File

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

View File

@ -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')