fix: upload

This commit is contained in:
Oleg Proskurin 2025-11-24 00:14:46 +07:00
parent fba243cfbd
commit 88cb1f2c61
5 changed files with 112 additions and 16 deletions

View File

@ -1,4 +1,5 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import sizeOf from 'image-size';
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 { ImageService, AliasService } from '@/services/core'; import { ImageService, AliasService } from '@/services/core';
@ -46,16 +47,16 @@ const getAliasService = (): AliasService => {
* Upload a single image file to project storage * Upload a single image file to project storage
* *
* Uploads an image file to MinIO storage and creates a database record with support for: * 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 * - Eager flow creation when flowAlias is provided
* - Project-scoped alias assignment * - Project-scoped alias assignment
* - Custom metadata storage * - Custom metadata storage
* - Multiple file formats (JPEG, PNG, WebP, etc.) * - Multiple file formats (JPEG, PNG, WebP, etc.)
* *
* FlowId behavior: * 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 * - 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 * @route POST /api/v1/images/upload
* @authentication Project Key required * @authentication Project Key required
@ -118,17 +119,42 @@ imagesRouter.post(
const projectSlug = req.apiKey.projectSlug; const projectSlug = req.apiKey.projectSlug;
const file = req.file; const file = req.file;
// FlowId logic (Section 10.1 & 5.1): // FlowId logic (matching GenerationService lazy pattern):
// - If undefined (not provided) → generate new UUID // - If undefined → generate UUID for pendingFlowId, flowId = null (lazy)
// - If null (explicitly null) → keep null // - If null → flowId = null, pendingFlowId = null (explicitly no flow)
// - If string (specific value) → use that value // - If string → flowId = string, pendingFlowId = null (use provided, create if needed)
let finalFlowId: string | null; let finalFlowId: string | null;
let pendingFlowId: string | null = null;
if (flowId === undefined) { if (flowId === undefined) {
finalFlowId = randomUUID(); // Lazy pattern: defer flow creation until needed
} else if (flowId === null) { pendingFlowId = randomUUID();
finalFlowId = null; finalFlowId = null;
} else if (flowId === null) {
// Explicitly no flow
finalFlowId = null;
pendingFlowId = null;
} else { } else {
// Specific flowId provided - ensure flow exists (eager creation)
finalFlowId = flowId; 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 { try {
@ -155,9 +181,23 @@ imagesRouter.post(
return; 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({ const imageRecord = await service.create({
projectId, projectId,
flowId: finalFlowId, flowId: finalFlowId,
pendingFlowId: pendingFlowId,
generationId: null, generationId: null,
apiKeyId, apiKeyId,
storageKey: uploadResult.path!, storageKey: uploadResult.path!,
@ -168,27 +208,41 @@ imagesRouter.post(
source: 'uploaded', source: 'uploaded',
alias: alias || null, alias: alias || null,
meta: meta ? JSON.parse(meta) : {}, meta: meta ? JSON.parse(meta) : {},
width,
height,
}); });
// Eager flow creation if flowAlias is provided (Section 5.1) // Eager flow creation if flowAlias is provided
if (flowAlias && finalFlowId) { 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 // Check if flow exists, create if not
const existingFlow = await db.query.flows.findFirst({ const existingFlow = await db.query.flows.findFirst({
where: eq(flows.id, finalFlowId), where: eq(flows.id, flowIdToUse),
}); });
if (!existingFlow) { if (!existingFlow) {
await db.insert(flows).values({ await db.insert(flows).values({
id: finalFlowId, id: flowIdToUse,
projectId, projectId,
aliases: {}, aliases: {},
meta: {}, meta: {},
}); });
// Link pending images if this was a lazy flow
if (pendingFlowId) {
await service.linkPendingImagesToFlow(flowIdToUse, projectId);
}
} }
// Assign flow alias to uploaded image // Assign flow alias to uploaded image
const flow = await db.query.flows.findFirst({ const flow = await db.query.flows.findFirst({
where: eq(flows.id, finalFlowId), where: eq(flows.id, flowIdToUse),
}); });
if (flow) { if (flow) {
@ -199,7 +253,7 @@ imagesRouter.post(
await db await db
.update(flows) .update(flows)
.set({ aliases: updatedAliases, updatedAt: new Date() }) .set({ aliases: updatedAliases, updatedAt: new Date() })
.where(eq(flows.id, finalFlowId)); .where(eq(flows.id, flowIdToUse));
} }
} }

View File

@ -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<void> {
// 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)
)
);
}
} }

View File

@ -43,6 +43,7 @@ export const images = pgTable(
{ onDelete: 'set null' }, { onDelete: 'set null' },
), ),
flowId: uuid('flow_id').references(() => flows.id, { onDelete: 'cascade' }), 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' }), apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
// Storage (MinIO path format: orgSlug/projectSlug/category/YYYY-MM/filename.ext) // Storage (MinIO path format: orgSlug/projectSlug/category/YYYY-MM/filename.ext)
@ -119,6 +120,11 @@ export const images = pgTable(
.on(table.flowId) .on(table.flowId)
.where(sql`${table.flowId} IS NOT NULL`), .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 // Index for generation lookup
generationIdx: index('idx_images_generation').on(table.generationId), generationIdx: index('idx_images_generation').on(table.generationId),

View File

@ -13,7 +13,7 @@ X-API-Key: {{apiKey}}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------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 Content-Type: image/png
< ./fixture/test-image.png < ./fixture/test-image.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB