fix: upload
This commit is contained in:
parent
fba243cfbd
commit
88cb1f2c61
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
Loading…
Reference in New Issue