diff --git a/apps/api-service/src/routes/v1/images.ts b/apps/api-service/src/routes/v1/images.ts index b1ee8ba..6d2ca83 100644 --- a/apps/api-service/src/routes/v1/images.ts +++ b/apps/api-service/src/routes/v1/images.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'crypto'; +import sizeOf from 'image-size'; import { Response, Router } from 'express'; import type { Router as RouterType } from 'express'; import { ImageService, AliasService } from '@/services/core'; @@ -46,16 +47,16 @@ const getAliasService = (): AliasService => { * Upload a single image file to project storage * * Uploads an image file to MinIO storage and creates a database record with support for: - * - Automatic flow creation when flowId is undefined (lazy creation) + * - Lazy flow creation using pendingFlowId when flowId is undefined * - Eager flow creation when flowAlias is provided * - Project-scoped alias assignment * - Custom metadata storage * - Multiple file formats (JPEG, PNG, WebP, etc.) * * FlowId behavior: - * - undefined (not provided) → generates new UUID for automatic flow creation + * - undefined (not provided) → generates pendingFlowId, defers flow creation (lazy) * - null (explicitly null) → no flow association - * - string (specific value) → uses provided flow ID + * - string (specific value) → uses provided flow ID, creates if needed * * @route POST /api/v1/images/upload * @authentication Project Key required @@ -118,17 +119,42 @@ imagesRouter.post( const projectSlug = req.apiKey.projectSlug; const file = req.file; - // FlowId logic (Section 10.1 & 5.1): - // - If undefined (not provided) → generate new UUID - // - If null (explicitly null) → keep null - // - If string (specific value) → use that value + // FlowId logic (matching GenerationService lazy pattern): + // - If undefined → generate UUID for pendingFlowId, flowId = null (lazy) + // - If null → flowId = null, pendingFlowId = null (explicitly no flow) + // - If string → flowId = string, pendingFlowId = null (use provided, create if needed) let finalFlowId: string | null; + let pendingFlowId: string | null = null; + if (flowId === undefined) { - finalFlowId = randomUUID(); - } else if (flowId === null) { + // Lazy pattern: defer flow creation until needed + pendingFlowId = randomUUID(); finalFlowId = null; + } else if (flowId === null) { + // Explicitly no flow + finalFlowId = null; + pendingFlowId = null; } else { + // Specific flowId provided - ensure flow exists (eager creation) finalFlowId = flowId; + pendingFlowId = null; + + // Check if flow exists, create if not + const existingFlow = await db.query.flows.findFirst({ + where: eq(flows.id, finalFlowId), + }); + + if (!existingFlow) { + await db.insert(flows).values({ + id: finalFlowId, + projectId, + aliases: {}, + meta: {}, + }); + + // Link any pending images to this new flow + await service.linkPendingImagesToFlow(finalFlowId, projectId); + } } try { @@ -155,9 +181,23 @@ imagesRouter.post( return; } + // Extract image dimensions from uploaded file buffer + let width: number | null = null; + let height: number | null = null; + try { + const dimensions = sizeOf(file.buffer); + if (dimensions.width && dimensions.height) { + width = dimensions.width; + height = dimensions.height; + } + } catch (error) { + console.warn('Failed to extract image dimensions:', error); + } + const imageRecord = await service.create({ projectId, flowId: finalFlowId, + pendingFlowId: pendingFlowId, generationId: null, apiKeyId, storageKey: uploadResult.path!, @@ -168,27 +208,41 @@ imagesRouter.post( source: 'uploaded', alias: alias || null, meta: meta ? JSON.parse(meta) : {}, + width, + height, }); - // Eager flow creation if flowAlias is provided (Section 5.1) - if (flowAlias && finalFlowId) { + // Eager flow creation if flowAlias is provided + if (flowAlias) { + // Use pendingFlowId if available, otherwise finalFlowId + const flowIdToUse = pendingFlowId || finalFlowId; + + if (!flowIdToUse) { + throw new Error('Cannot create flow: no flowId available'); + } + // Check if flow exists, create if not const existingFlow = await db.query.flows.findFirst({ - where: eq(flows.id, finalFlowId), + where: eq(flows.id, flowIdToUse), }); if (!existingFlow) { await db.insert(flows).values({ - id: finalFlowId, + id: flowIdToUse, projectId, aliases: {}, meta: {}, }); + + // Link pending images if this was a lazy flow + if (pendingFlowId) { + await service.linkPendingImagesToFlow(flowIdToUse, projectId); + } } // Assign flow alias to uploaded image const flow = await db.query.flows.findFirst({ - where: eq(flows.id, finalFlowId), + where: eq(flows.id, flowIdToUse), }); if (flow) { @@ -199,7 +253,7 @@ imagesRouter.post( await db .update(flows) .set({ aliases: updatedAliases, updatedAt: new Date() }) - .where(eq(flows.id, finalFlowId)); + .where(eq(flows.id, flowIdToUse)); } } diff --git a/apps/api-service/src/services/core/ImageService.ts b/apps/api-service/src/services/core/ImageService.ts index d4e1e23..5a29e94 100644 --- a/apps/api-service/src/services/core/ImageService.ts +++ b/apps/api-service/src/services/core/ImageService.ts @@ -292,4 +292,40 @@ export class ImageService { ), }); } + + /** + * Link all pending images to a flow + * Called when flow is created to attach all images with matching pendingFlowId + */ + async linkPendingImagesToFlow( + flowId: string, + projectId: string + ): Promise { + // Find all images with pendingFlowId matching this flowId + const pendingImages = await db.query.images.findMany({ + where: and( + eq(images.pendingFlowId, flowId), + eq(images.projectId, projectId) + ), + }); + + if (pendingImages.length === 0) { + return; + } + + // Update images: set flowId and clear pendingFlowId + await db + .update(images) + .set({ + flowId: flowId, + pendingFlowId: null, + updatedAt: new Date(), + }) + .where( + and( + eq(images.pendingFlowId, flowId), + eq(images.projectId, projectId) + ) + ); + } } diff --git a/packages/database/src/schema/images.ts b/packages/database/src/schema/images.ts index dddafa0..c80077f 100644 --- a/packages/database/src/schema/images.ts +++ b/packages/database/src/schema/images.ts @@ -43,6 +43,7 @@ export const images = pgTable( { onDelete: 'set null' }, ), flowId: uuid('flow_id').references(() => flows.id, { onDelete: 'cascade' }), + pendingFlowId: text('pending_flow_id'), // Temporary UUID for lazy flow pattern apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }), // Storage (MinIO path format: orgSlug/projectSlug/category/YYYY-MM/filename.ext) @@ -119,6 +120,11 @@ export const images = pgTable( .on(table.flowId) .where(sql`${table.flowId} IS NOT NULL`), + // Index for pending flow lookups (lazy pattern) + pendingFlowIdx: index('idx_images_pending_flow') + .on(table.pendingFlowId, table.createdAt.desc()) + .where(sql`${table.pendingFlowId} IS NOT NULL`), + // Index for generation lookup generationIdx: index('idx_images_generation').on(table.generationId), diff --git a/tests/api/02-basic.rest b/tests/api/02-basic.rest index 4279529..1e6bbce 100644 --- a/tests/api/02-basic.rest +++ b/tests/api/02-basic.rest @@ -13,7 +13,7 @@ X-API-Key: {{apiKey}} Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW ------WebKitFormBoundary7MA4YWxkTrZu0gW -Content-Disposition: form-data; name="file"; filename="test-image.png" +Content-Disposition: form-data; name="file"; filename="test-image2.png" Content-Type: image/png < ./fixture/test-image.png diff --git a/tests/api/fixture/test-image2.png b/tests/api/fixture/test-image2.png new file mode 100644 index 0000000..1660cdb Binary files /dev/null and b/tests/api/fixture/test-image2.png differ