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 { Response, Router } from 'express';
import type { Router as RouterType } 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 { asyncHandler } from '@/middleware/errorHandler';
import { validateApiKey } from '@/middleware/auth/validateApiKey'; import { validateApiKey } from '@/middleware/auth/validateApiKey';
import { requireProjectKey } from '@/middleware/auth/requireProjectKey'; import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
import { validateAndNormalizePagination } from '@/utils/validators'; import { validateAndNormalizePagination } from '@/utils/validators';
import { buildPaginatedResponse } from '@/utils/helpers'; import { buildPaginatedResponse } from '@/utils/helpers';
import { toFlowResponse, toGenerationResponse, toImageResponse } from '@/types/responses'; import { toFlowResponse, toGenerationResponse, toImageResponse } from '@/types/responses';
@ -19,6 +20,7 @@ import type {
export const flowsRouter: RouterType = Router(); export const flowsRouter: RouterType = Router();
let flowService: FlowService; let flowService: FlowService;
let generationService: GenerationService;
const getFlowService = (): FlowService => { const getFlowService = (): FlowService => {
if (!flowService) { if (!flowService) {
@ -27,32 +29,44 @@ const getFlowService = (): FlowService => {
return flowService; return flowService;
}; };
const getGenerationService = (): GenerationService => {
if (!generationService) {
generationService = new GenerationService();
}
return generationService;
};
/** /**
* POST /api/v1/flows * 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( // flowsRouter.post(
'/', // '/',
validateApiKey, // validateApiKey,
requireProjectKey, // requireProjectKey,
asyncHandler(async (req: any, res: Response<CreateFlowResponse>) => { // asyncHandler(async (req: any, res: Response<CreateFlowResponse>) => {
const service = getFlowService(); // const service = getFlowService();
const { meta } = req.body; // const { meta } = req.body;
//
const projectId = req.apiKey.projectId; // const projectId = req.apiKey.projectId;
//
const flow = await service.create({ // const flow = await service.create({
projectId, // projectId,
aliases: {}, // aliases: {},
meta: meta || {}, // meta: meta || {},
}); // });
//
res.status(201).json({ // res.status(201).json({
success: true, // success: true,
data: toFlowResponse(flow), // data: toFlowResponse(flow),
}); // });
}) // })
); // );
/** /**
* GET /api/v1/flows * 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 /api/v1/flows/:id
* Delete a flow * Delete a flow

View File

@ -211,18 +211,20 @@ generationsRouter.put(
); );
/** /**
* POST /api/v1/generations/:id/retry * POST /api/v1/generations/:id/regenerate
* Retry a failed generation * 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( generationsRouter.post(
'/:id/retry', '/:id/regenerate',
validateApiKey, validateApiKey,
requireProjectKey, requireProjectKey,
rateLimitByApiKey, rateLimitByApiKey,
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => { asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
const service = getGenerationService(); const service = getGenerationService();
const { id } = req.params; const { id } = req.params;
const { prompt, aspectRatio } = req.body;
const original = await service.getById(id); const original = await service.getById(id);
if (!original) { if (!original) {
@ -247,11 +249,57 @@ generationsRouter.post(
return; 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({ res.status(201).json({
success: true, success: true,
data: toGenerationResponse(newGeneration), data: toGenerationResponse(regenerated),
}); });
}) })
); );

View File

@ -71,13 +71,19 @@ export class GenerationService {
finalFlowId = params.flowId; 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 = { const generationRecord: NewGeneration = {
projectId: params.projectId, projectId: params.projectId,
flowId: finalFlowId, flowId: finalFlowId,
apiKeyId: params.apiKeyId, apiKeyId: params.apiKeyId,
status: 'pending', status: 'pending',
originalPrompt: params.prompt, prompt: usedPrompt, // Prompt actually used for generation
enhancedPrompt: params.enhancedPrompt || null, originalPrompt: preservedOriginal, // User's original (only if enhanced)
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO, aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
referencedImages: null, referencedImages: null,
requestId: params.requestId || null, requestId: params.requestId || null,
@ -115,7 +121,7 @@ export class GenerationService {
} }
const genResult = await this.imageGenService.generateImage({ 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}`, filename: `gen_${generation.id}`,
referenceImages: referenceImageBuffers, referenceImages: referenceImageBuffers,
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO, 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> { async retry(id: string, overrides?: { prompt?: string; aspectRatio?: string }): Promise<GenerationWithRelations> {
const original = await this.getByIdWithRelations(id); // Ignore overrides, regenerate with original parameters
return await this.regenerate(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;
} }
async update( async update(
@ -414,7 +454,7 @@ export class GenerationService {
// Check if generative parameters changed (prompt or aspectRatio) // Check if generative parameters changed (prompt or aspectRatio)
const shouldRegenerate = const shouldRegenerate =
(updates.prompt !== undefined && updates.prompt !== generation.originalPrompt) || (updates.prompt !== undefined && updates.prompt !== generation.prompt) ||
(updates.aspectRatio !== undefined && updates.aspectRatio !== generation.aspectRatio); (updates.aspectRatio !== undefined && updates.aspectRatio !== generation.aspectRatio);
// Handle flowId change (Section 9.2) // Handle flowId change (Section 9.2)
@ -437,7 +477,7 @@ export class GenerationService {
// Update database fields // Update database fields
const updateData: Partial<NewGeneration> = {}; const updateData: Partial<NewGeneration> = {};
if (updates.prompt !== undefined) { if (updates.prompt !== undefined) {
updateData.originalPrompt = updates.prompt; updateData.prompt = updates.prompt; // Update the prompt used for generation
} }
if (updates.aspectRatio !== undefined) { if (updates.aspectRatio !== undefined) {
updateData.aspectRatio = updates.aspectRatio; updateData.aspectRatio = updates.aspectRatio;
@ -463,7 +503,7 @@ export class GenerationService {
try { try {
// Use updated prompt/aspectRatio or fall back to existing // 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; const aspectRatioToUse = updates.aspectRatio || generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO;
// Regenerate image // Regenerate image

View File

@ -45,9 +45,11 @@ export const generations = pgTable(
// Status // Status
status: generationStatusEnum('status').notNull().default('pending'), status: generationStatusEnum('status').notNull().default('pending'),
// Prompts // Prompts (Section 2.1: Reversed semantics)
originalPrompt: text('original_prompt').notNull(), // prompt: The prompt that was ACTUALLY USED for generation (enhanced OR original)
enhancedPrompt: text('enhanced_prompt'), // AI-enhanced version (if enabled) // 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 // Generation parameters
aspectRatio: varchar('aspect_ratio', { length: 10 }), 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'; import { organizations } from './organizations';
export const projects = pgTable( export const projects = pgTable(
@ -13,6 +13,10 @@ export const projects = pgTable(
.notNull() .notNull()
.references(() => organizations.id, { onDelete: 'cascade' }), .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 // Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(), createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at') updatedAt: timestamp('updated_at')