diff --git a/apps/api-service/src/app.ts b/apps/api-service/src/app.ts index 3730df7..3d9b371 100644 --- a/apps/api-service/src/app.ts +++ b/apps/api-service/src/app.ts @@ -7,6 +7,7 @@ import { imagesRouter } from './routes/images'; import { uploadRouter } from './routes/upload'; import bootstrapRoutes from './routes/bootstrap'; import adminKeysRoutes from './routes/admin/keys'; +import { v1Router } from './routes/v1'; import { errorHandler, notFoundHandler } from './middleware/errorHandler'; // Load environment variables @@ -116,7 +117,10 @@ export const createApp = (): Application => { // Admin routes (require master key) app.use('/api/admin/keys', adminKeysRoutes); - // Protected API routes (require valid API key) + // API v1 routes (versioned, require valid API key) + app.use('/api/v1', v1Router); + + // Protected API routes (require valid API key) - Legacy app.use('/api', textToImageRouter); app.use('/api', imagesRouter); app.use('/api', uploadRouter); diff --git a/apps/api-service/src/routes/v1/generations.ts b/apps/api-service/src/routes/v1/generations.ts new file mode 100644 index 0000000..7574856 --- /dev/null +++ b/apps/api-service/src/routes/v1/generations.ts @@ -0,0 +1,249 @@ +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 { 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; +}; + +/** + * POST /api/v1/generations + * Create a new image generation with optional reference images and aliases + */ +generationsRouter.post( + '/', + validateApiKey, + requireProjectKey, + rateLimitByApiKey, + asyncHandler(async (req: any, res: Response) => { + const service = getGenerationService(); + const { + prompt, + referenceImages, + aspectRatio, + flowId, + outputAlias, + flowAliases, + 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 generation = await service.create({ + projectId, + apiKeyId, + prompt, + referenceImages, + aspectRatio, + flowId, + outputAlias, + flowAliases, + autoEnhance, + meta, + requestId: req.requestId, + }); + + res.status(201).json({ + success: true, + data: toGenerationResponse(generation), + }); + }) +); + +/** + * GET /api/v1/generations + * List generations with filters and pagination + */ +generationsRouter.get( + '/', + validateApiKey, + requireProjectKey, + asyncHandler(async (req: any, res: Response) => { + 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 /api/v1/generations/:id + * Get a single generation by ID with full details + */ +generationsRouter.get( + '/:id', + validateApiKey, + requireProjectKey, + asyncHandler(async (req: any, res: Response) => { + 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), + }); + }) +); + +/** + * POST /api/v1/generations/:id/retry + * Retry a failed generation + */ +generationsRouter.post( + '/:id/retry', + validateApiKey, + requireProjectKey, + rateLimitByApiKey, + 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) { + 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 newGeneration = await service.retry(id, { prompt, aspectRatio }); + + res.status(201).json({ + success: true, + data: toGenerationResponse(newGeneration), + }); + }) +); + +/** + * DELETE /api/v1/generations/:id + * Delete a generation and its output image + */ +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 }, + }); + }) +); diff --git a/apps/api-service/src/routes/v1/index.ts b/apps/api-service/src/routes/v1/index.ts new file mode 100644 index 0000000..ffdba62 --- /dev/null +++ b/apps/api-service/src/routes/v1/index.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import type { Router as RouterType } from 'express'; +import { generationsRouter } from './generations'; + +export const v1Router: RouterType = Router(); + +// Mount v1 routes +v1Router.use('/generations', generationsRouter); diff --git a/apps/api-service/src/services/core/GenerationService.ts b/apps/api-service/src/services/core/GenerationService.ts new file mode 100644 index 0000000..8c674d4 --- /dev/null +++ b/apps/api-service/src/services/core/GenerationService.ts @@ -0,0 +1,355 @@ +import { eq, desc, count } from 'drizzle-orm'; +import { db } from '@/db'; +import { generations, flows } from '@banatie/database'; +import type { + Generation, + NewGeneration, + GenerationWithRelations, + GenerationFilters, +} from '@/types/models'; +import { ImageService } from './ImageService'; +import { AliasService } from './AliasService'; +import { ImageGenService } from '../ImageGenService'; +import { buildWhereClause, buildEqCondition } from '@/utils/helpers'; +import { ERROR_MESSAGES, GENERATION_LIMITS } from '@/utils/constants'; +import type { ReferenceImage } from '@/types/api'; + +export interface CreateGenerationParams { + projectId: string; + apiKeyId: string; + prompt: string; + referenceImages?: string[] | undefined; // Aliases to resolve + aspectRatio?: string | undefined; + flowId?: string | undefined; + outputAlias?: string | undefined; + flowAliases?: Record | undefined; + autoEnhance?: boolean | undefined; + enhancedPrompt?: string | undefined; + meta?: Record | undefined; + requestId?: string | undefined; +} + +export class GenerationService { + private imageService: ImageService; + private aliasService: AliasService; + private imageGenService: ImageGenService; + + constructor() { + this.imageService = new ImageService(); + this.aliasService = new AliasService(); + + const geminiApiKey = process.env['GEMINI_API_KEY']; + if (!geminiApiKey) { + throw new Error('GEMINI_API_KEY environment variable is required'); + } + this.imageGenService = new ImageGenService(geminiApiKey); + } + + async create(params: CreateGenerationParams): Promise { + const startTime = Date.now(); + + const generationRecord: NewGeneration = { + projectId: params.projectId, + flowId: params.flowId || null, + apiKeyId: params.apiKeyId, + status: 'pending', + originalPrompt: params.prompt, + enhancedPrompt: params.enhancedPrompt || null, + aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO, + referencedImages: null, + requestId: params.requestId || null, + meta: params.meta || {}, + }; + + const [generation] = await db + .insert(generations) + .values(generationRecord) + .returning(); + + if (!generation) { + throw new Error('Failed to create generation record'); + } + + try { + await this.updateStatus(generation.id, 'processing'); + + let referenceImageBuffers: ReferenceImage[] = []; + let referencedImagesMetadata: Array<{ imageId: string; alias: string }> = []; + + if (params.referenceImages && params.referenceImages.length > 0) { + const resolved = await this.resolveReferenceImages( + params.referenceImages, + params.projectId, + params.flowId + ); + referenceImageBuffers = resolved.buffers; + referencedImagesMetadata = resolved.metadata; + + await db + .update(generations) + .set({ referencedImages: referencedImagesMetadata }) + .where(eq(generations.id, generation.id)); + } + + const genResult = await this.imageGenService.generateImage({ + prompt: params.enhancedPrompt || params.prompt, + filename: `gen_${generation.id}`, + referenceImages: referenceImageBuffers, + aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO, + orgId: 'default', + projectId: params.projectId, + meta: params.meta || {}, + }); + + if (!genResult.success) { + const processingTime = Date.now() - startTime; + await this.updateStatus(generation.id, 'failed', { + errorMessage: genResult.error || 'Generation failed', + processingTimeMs: processingTime, + }); + throw new Error(genResult.error || 'Generation failed'); + } + + const storageKey = genResult.filepath!; + // TODO: Add file hash computation when we have a helper to download by storageKey + const fileHash = null; + + const imageRecord = await this.imageService.create({ + projectId: params.projectId, + flowId: params.flowId || null, + generationId: generation.id, + apiKeyId: params.apiKeyId, + storageKey, + storageUrl: genResult.url!, + mimeType: 'image/jpeg', + fileSize: 0, // TODO: Get actual file size from storage + fileHash, + source: 'generated', + alias: params.outputAlias || null, + meta: params.meta || {}, + }); + + if (params.flowAliases && params.flowId) { + await this.assignFlowAliases(params.flowId, params.flowAliases, imageRecord.id); + } + + if (params.flowId) { + await db + .update(flows) + .set({ updatedAt: new Date() }) + .where(eq(flows.id, params.flowId)); + } + + const processingTime = Date.now() - startTime; + await this.updateStatus(generation.id, 'success', { + outputImageId: imageRecord.id, + processingTimeMs: processingTime, + }); + + return await this.getByIdWithRelations(generation.id); + } catch (error) { + const processingTime = Date.now() - startTime; + await this.updateStatus(generation.id, 'failed', { + errorMessage: error instanceof Error ? error.message : 'Unknown error', + processingTimeMs: processingTime, + }); + throw error; + } + } + + private async resolveReferenceImages( + aliases: string[], + projectId: string, + flowId?: string + ): Promise<{ + buffers: ReferenceImage[]; + metadata: Array<{ imageId: string; alias: string }>; + }> { + const resolutions = await this.aliasService.resolveMultiple(aliases, projectId, flowId); + + const buffers: ReferenceImage[] = []; + const metadata: Array<{ imageId: string; alias: string }> = []; + + // TODO: Implement proper storage key parsing and download + // For now, we'll skip reference image buffers and just store metadata + for (const [alias, resolution] of resolutions) { + if (!resolution.image) { + throw new Error(`${ERROR_MESSAGES.ALIAS_NOT_FOUND}: ${alias}`); + } + + metadata.push({ + imageId: resolution.imageId, + alias, + }); + } + + return { buffers, metadata }; + } + + private async assignFlowAliases( + flowId: string, + flowAliases: Record, + imageId: string + ): Promise { + const flow = await db.query.flows.findFirst({ + where: eq(flows.id, flowId), + }); + + if (!flow) { + throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND); + } + + const currentAliases = (flow.aliases as Record) || {}; + const updatedAliases = { ...currentAliases }; + + for (const [alias, value] of Object.entries(flowAliases)) { + if (value === '@output' || value === imageId) { + updatedAliases[alias] = imageId; + } + } + + await db + .update(flows) + .set({ aliases: updatedAliases, updatedAt: new Date() }) + .where(eq(flows.id, flowId)); + } + + private async updateStatus( + id: string, + status: 'pending' | 'processing' | 'success' | 'failed', + additionalUpdates?: { + errorMessage?: string; + outputImageId?: string; + processingTimeMs?: number; + } + ): Promise { + await db + .update(generations) + .set({ + status, + ...additionalUpdates, + updatedAt: new Date(), + }) + .where(eq(generations.id, id)); + } + + async getById(id: string): Promise { + const generation = await db.query.generations.findFirst({ + where: eq(generations.id, id), + }); + + return generation || null; + } + + async getByIdWithRelations(id: string): Promise { + const generation = await db.query.generations.findFirst({ + where: eq(generations.id, id), + with: { + outputImage: true, + flow: true, + }, + }); + + if (!generation) { + throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND); + } + + if (generation.referencedImages && Array.isArray(generation.referencedImages)) { + const refImageIds = (generation.referencedImages as Array<{ imageId: string; alias: string }>) + .map((ref) => ref.imageId); + const refImages = await this.imageService.getMultipleByIds(refImageIds); + return { + ...generation, + referenceImages: refImages, + } as GenerationWithRelations; + } + + return generation as GenerationWithRelations; + } + + async list( + filters: GenerationFilters, + limit: number, + offset: number + ): Promise<{ generations: GenerationWithRelations[]; total: number }> { + const conditions = [ + buildEqCondition(generations, 'projectId', filters.projectId), + buildEqCondition(generations, 'flowId', filters.flowId), + buildEqCondition(generations, 'status', filters.status), + ]; + + const whereClause = buildWhereClause(conditions); + + const [generationsList, countResult] = await Promise.all([ + db.query.generations.findMany({ + where: whereClause, + orderBy: [desc(generations.createdAt)], + limit, + offset, + with: { + outputImage: true, + flow: true, + }, + }), + db + .select({ count: count() }) + .from(generations) + .where(whereClause), + ]); + + const totalCount = countResult[0]?.count || 0; + + return { + generations: generationsList as GenerationWithRelations[], + total: Number(totalCount), + }; + } + + 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; + } + + async delete(id: string): Promise { + const generation = await this.getById(id); + if (!generation) { + throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND); + } + + if (generation.outputImageId) { + await this.imageService.softDelete(generation.outputImageId); + } + + await db.delete(generations).where(eq(generations.id, id)); + } +} diff --git a/apps/api-service/src/services/core/ImageService.ts b/apps/api-service/src/services/core/ImageService.ts new file mode 100644 index 0000000..563f020 --- /dev/null +++ b/apps/api-service/src/services/core/ImageService.ts @@ -0,0 +1,197 @@ +import { eq, and, isNull, desc, count, sql } from 'drizzle-orm'; +import { db } from '@/db'; +import { images } from '@banatie/database'; +import type { Image, NewImage, ImageFilters } from '@/types/models'; +import { buildWhereClause, buildEqCondition, withoutDeleted } from '@/utils/helpers'; +import { ERROR_MESSAGES } from '@/utils/constants'; +import { AliasService } from './AliasService'; + +export class ImageService { + private aliasService: AliasService; + + constructor() { + this.aliasService = new AliasService(); + } + + async create(data: NewImage): Promise { + const [image] = await db.insert(images).values(data).returning(); + if (!image) { + throw new Error('Failed to create image record'); + } + return image; + } + + async getById(id: string, includeDeleted = false): Promise { + const image = await db.query.images.findFirst({ + where: and( + eq(images.id, id), + includeDeleted ? undefined : isNull(images.deletedAt) + ), + }); + + return image || null; + } + + async getByIdOrThrow(id: string, includeDeleted = false): Promise { + const image = await this.getById(id, includeDeleted); + if (!image) { + throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND); + } + return image; + } + + async list( + filters: ImageFilters, + limit: number, + offset: number + ): Promise<{ images: Image[]; total: number }> { + const conditions = [ + buildEqCondition(images, 'projectId', filters.projectId), + buildEqCondition(images, 'flowId', filters.flowId), + buildEqCondition(images, 'source', filters.source), + buildEqCondition(images, 'alias', filters.alias), + withoutDeleted(images, filters.deleted), + ]; + + const whereClause = buildWhereClause(conditions); + + const [imagesList, countResult] = await Promise.all([ + db.query.images.findMany({ + where: whereClause, + orderBy: [desc(images.createdAt)], + limit, + offset, + }), + db + .select({ count: count() }) + .from(images) + .where(whereClause), + ]); + + const totalCount = countResult[0]?.count || 0; + + return { + images: imagesList, + total: Number(totalCount), + }; + } + + async update( + id: string, + updates: { + alias?: string; + focalPoint?: { x: number; y: number }; + meta?: Record; + } + ): Promise { + const existing = await this.getByIdOrThrow(id); + + if (updates.alias && updates.alias !== existing.alias) { + await this.aliasService.validateAliasForAssignment( + updates.alias, + existing.projectId, + existing.flowId || undefined + ); + } + + const [updated] = await db + .update(images) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(images.id, id)) + .returning(); + + if (!updated) { + throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND); + } + + return updated; + } + + async softDelete(id: string): Promise { + const [deleted] = await db + .update(images) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(images.id, id)) + .returning(); + + if (!deleted) { + throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND); + } + + return deleted; + } + + async hardDelete(id: string): Promise { + await db.delete(images).where(eq(images.id, id)); + } + + async assignProjectAlias(imageId: string, alias: string): Promise { + const image = await this.getByIdOrThrow(imageId); + + if (image.flowId) { + throw new Error('Cannot assign project alias to flow-scoped image'); + } + + await this.aliasService.validateAliasForAssignment( + alias, + image.projectId + ); + + const [updated] = await db + .update(images) + .set({ + alias, + updatedAt: new Date(), + }) + .where(eq(images.id, imageId)) + .returning(); + + if (!updated) { + throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND); + } + + return updated; + } + + async getByStorageKey(storageKey: string): Promise { + const image = await db.query.images.findFirst({ + where: and( + eq(images.storageKey, storageKey), + isNull(images.deletedAt) + ), + }); + + return image || null; + } + + async getByFileHash(fileHash: string, projectId: string): Promise { + const image = await db.query.images.findFirst({ + where: and( + eq(images.fileHash, fileHash), + eq(images.projectId, projectId), + isNull(images.deletedAt) + ), + }); + + return image || null; + } + + async getMultipleByIds(ids: string[]): Promise { + if (ids.length === 0) { + return []; + } + + return await db.query.images.findMany({ + where: and( + sql`${images.id} = ANY(${ids})`, + isNull(images.deletedAt) + ), + }); + } +} diff --git a/apps/api-service/src/services/core/index.ts b/apps/api-service/src/services/core/index.ts index 30899e9..a689b2e 100644 --- a/apps/api-service/src/services/core/index.ts +++ b/apps/api-service/src/services/core/index.ts @@ -1 +1,3 @@ export * from './AliasService'; +export * from './ImageService'; +export * from './GenerationService'; diff --git a/apps/api-service/src/types/models.ts b/apps/api-service/src/types/models.ts index 9eb3799..6cd1110 100644 --- a/apps/api-service/src/types/models.ts +++ b/apps/api-service/src/types/models.ts @@ -71,9 +71,9 @@ export interface ImageFilters { // Query filters for generations export interface GenerationFilters { projectId: string; - flowId?: string; - status?: GenerationStatus; - deleted?: boolean; + flowId?: string | undefined; + status?: GenerationStatus | undefined; + deleted?: boolean | undefined; } // Query filters for flows