748 lines
22 KiB
TypeScript
748 lines
22 KiB
TypeScript
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';
|
|
import { StorageFactory } from '@/services/StorageFactory';
|
|
import { asyncHandler } from '@/middleware/errorHandler';
|
|
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
|
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
|
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
|
import { uploadSingleImage, handleUploadErrors } from '@/middleware/upload';
|
|
import { validateAndNormalizePagination } from '@/utils/validators';
|
|
import { buildPaginatedResponse } from '@/utils/helpers';
|
|
import { toImageResponse } from '@/types/responses';
|
|
import { db } from '@/db';
|
|
import { flows } from '@banatie/database';
|
|
import { eq } from 'drizzle-orm';
|
|
import type {
|
|
UploadImageResponse,
|
|
ListImagesResponse,
|
|
GetImageResponse,
|
|
UpdateImageResponse,
|
|
DeleteImageResponse,
|
|
ResolveAliasResponse,
|
|
} from '@/types/responses';
|
|
|
|
export const imagesRouter: RouterType = Router();
|
|
|
|
let imageService: ImageService;
|
|
let aliasService: AliasService;
|
|
|
|
const getImageService = (): ImageService => {
|
|
if (!imageService) {
|
|
imageService = new ImageService();
|
|
}
|
|
return imageService;
|
|
};
|
|
|
|
const getAliasService = (): AliasService => {
|
|
if (!aliasService) {
|
|
aliasService = new AliasService();
|
|
}
|
|
return aliasService;
|
|
};
|
|
|
|
/**
|
|
* Upload a single image file to project storage
|
|
*
|
|
* Uploads an image file to MinIO storage and creates a database record with support for:
|
|
* - 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 pendingFlowId, defers flow creation (lazy)
|
|
* - null (explicitly null) → no flow association
|
|
* - string (specific value) → uses provided flow ID, creates if needed
|
|
*
|
|
* @route POST /api/v1/images/upload
|
|
* @authentication Project Key required
|
|
* @rateLimit 100 requests per hour per API key
|
|
*
|
|
* @param {File} req.file - Image file (multipart/form-data, max 5MB)
|
|
* @param {string} [req.body.alias] - Project-scoped alias (@custom-name)
|
|
* @param {string|null} [req.body.flowId] - Flow association (undefined=auto, null=none, string=specific)
|
|
* @param {string} [req.body.flowAlias] - Flow-scoped alias (requires flowId, triggers eager creation)
|
|
* @param {string} [req.body.meta] - Custom metadata (JSON string)
|
|
*
|
|
* @returns {UploadImageResponse} 201 - Uploaded image with storage details
|
|
* @returns {object} 400 - Missing file or validation error
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
* @returns {object} 413 - File too large
|
|
* @returns {object} 415 - Unsupported file type
|
|
* @returns {object} 429 - Rate limit exceeded
|
|
* @returns {object} 500 - Upload or storage error
|
|
*
|
|
* @throws {Error} VALIDATION_ERROR - No file provided
|
|
* @throws {Error} UPLOAD_ERROR - File upload failed
|
|
* @throws {Error} ALIAS_CONFLICT - Alias already exists
|
|
*
|
|
* @example
|
|
* // Upload with automatic flow creation
|
|
* POST /api/v1/images/upload
|
|
* Content-Type: multipart/form-data
|
|
* { file: <image.jpg>, alias: "@hero-bg" }
|
|
*
|
|
* @example
|
|
* // Upload with eager flow creation and flow alias
|
|
* POST /api/v1/images/upload
|
|
* { file: <image.jpg>, flowAlias: "@step-1" }
|
|
*/
|
|
imagesRouter.post(
|
|
'/upload',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
rateLimitByApiKey,
|
|
uploadSingleImage,
|
|
handleUploadErrors,
|
|
asyncHandler(async (req: any, res: Response<UploadImageResponse>) => {
|
|
const service = getImageService();
|
|
const { alias, flowId, flowAlias, meta } = req.body;
|
|
|
|
if (!req.file) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: {
|
|
message: 'No file provided',
|
|
code: 'VALIDATION_ERROR',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const projectId = req.apiKey.projectId;
|
|
const apiKeyId = req.apiKey.id;
|
|
const orgId = req.apiKey.organizationSlug || 'default';
|
|
const projectSlug = req.apiKey.projectSlug;
|
|
const file = req.file;
|
|
|
|
// 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) {
|
|
// 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 {
|
|
const storageService = await StorageFactory.getInstance();
|
|
|
|
const uploadResult = await storageService.uploadFile(
|
|
orgId,
|
|
projectSlug,
|
|
'uploads',
|
|
file.originalname,
|
|
file.buffer,
|
|
file.mimetype,
|
|
);
|
|
|
|
if (!uploadResult.success) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: {
|
|
message: 'File upload failed',
|
|
code: 'UPLOAD_ERROR',
|
|
details: uploadResult.error,
|
|
},
|
|
});
|
|
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!,
|
|
storageUrl: uploadResult.url!,
|
|
mimeType: file.mimetype,
|
|
fileSize: file.size,
|
|
fileHash: null,
|
|
source: 'uploaded',
|
|
alias: alias || null,
|
|
meta: meta ? JSON.parse(meta) : {},
|
|
width,
|
|
height,
|
|
});
|
|
|
|
// 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, flowIdToUse),
|
|
});
|
|
|
|
if (!existingFlow) {
|
|
await db.insert(flows).values({
|
|
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, flowIdToUse),
|
|
});
|
|
|
|
if (flow) {
|
|
const currentAliases = (flow.aliases as Record<string, string>) || {};
|
|
const updatedAliases = { ...currentAliases };
|
|
updatedAliases[flowAlias] = imageRecord.id;
|
|
|
|
await db
|
|
.update(flows)
|
|
.set({ aliases: updatedAliases, updatedAt: new Date() })
|
|
.where(eq(flows.id, flowIdToUse));
|
|
}
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
data: toImageResponse(imageRecord),
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: {
|
|
message: error instanceof Error ? error.message : 'Upload failed',
|
|
code: 'UPLOAD_ERROR',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
})
|
|
);
|
|
|
|
/**
|
|
* List all images for the project with filtering and pagination
|
|
*
|
|
* Retrieves images (both generated and uploaded) with support for:
|
|
* - Flow-based filtering
|
|
* - Source filtering (generated vs uploaded)
|
|
* - Alias filtering (exact match)
|
|
* - Pagination with configurable limit and offset
|
|
* - Optional inclusion of soft-deleted images
|
|
*
|
|
* @route GET /api/v1/images
|
|
* @authentication Project Key required
|
|
*
|
|
* @param {string} [req.query.flowId] - Filter by flow ID
|
|
* @param {string} [req.query.source] - Filter by source (generated|uploaded)
|
|
* @param {string} [req.query.alias] - Filter by exact alias match
|
|
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
|
* @param {number} [req.query.offset=0] - Number of results to skip
|
|
* @param {boolean} [req.query.includeDeleted=false] - Include soft-deleted images
|
|
*
|
|
* @returns {ListImagesResponse} 200 - Paginated list of images
|
|
* @returns {object} 400 - Invalid pagination parameters
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
*
|
|
* @example
|
|
* // List uploaded images in a flow
|
|
* GET /api/v1/images?flowId=abc-123&source=uploaded&limit=50
|
|
*/
|
|
imagesRouter.get(
|
|
'/',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
asyncHandler(async (req: any, res: Response<ListImagesResponse>) => {
|
|
const service = getImageService();
|
|
const { flowId, source, alias, 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,
|
|
source: source as 'generated' | 'uploaded' | undefined,
|
|
alias: alias as string | undefined,
|
|
deleted: includeDeleted === 'true' ? true : undefined,
|
|
},
|
|
validatedLimit,
|
|
validatedOffset
|
|
);
|
|
|
|
const responseData = result.images.map((img) => toImageResponse(img));
|
|
|
|
res.json(
|
|
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
|
);
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Resolve an alias to an image using 3-tier precedence system
|
|
*
|
|
* Resolves aliases through a priority-based lookup system:
|
|
* 1. Technical aliases (@last, @first, @upload) - computed on-the-fly
|
|
* 2. Flow-scoped aliases - looked up in flow's JSONB aliases field (requires flowId)
|
|
* 3. Project-scoped aliases - looked up in images.alias column
|
|
*
|
|
* Returns the image ID, resolution scope, and complete image details.
|
|
*
|
|
* @route GET /api/v1/images/resolve/:alias
|
|
* @authentication Project Key required
|
|
*
|
|
* @param {string} req.params.alias - Alias to resolve (e.g., "@last", "@hero", "@step-1")
|
|
* @param {string} [req.query.flowId] - Flow context for flow-scoped resolution
|
|
*
|
|
* @returns {ResolveAliasResponse} 200 - Resolved image with scope and details
|
|
* @returns {object} 404 - Alias not found in any scope
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
*
|
|
* @throws {Error} ALIAS_NOT_FOUND - Alias does not exist
|
|
* @throws {Error} RESOLUTION_ERROR - Resolution failed
|
|
*
|
|
* @example
|
|
* // Resolve technical alias
|
|
* GET /api/v1/images/resolve/@last
|
|
*
|
|
* @example
|
|
* // Resolve flow-scoped alias
|
|
* GET /api/v1/images/resolve/@step-1?flowId=abc-123
|
|
*
|
|
* @example
|
|
* // Resolve project-scoped alias
|
|
* GET /api/v1/images/resolve/@hero-bg
|
|
*/
|
|
imagesRouter.get(
|
|
'/resolve/:alias',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
asyncHandler(async (req: any, res: Response<ResolveAliasResponse>) => {
|
|
const aliasServiceInstance = getAliasService();
|
|
const { alias } = req.params;
|
|
const { flowId } = req.query;
|
|
|
|
const projectId = req.apiKey.projectId;
|
|
|
|
try {
|
|
const resolution = await aliasServiceInstance.resolve(
|
|
alias,
|
|
projectId,
|
|
flowId as string | undefined
|
|
);
|
|
|
|
if (!resolution) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: {
|
|
message: `Alias '${alias}' not found`,
|
|
code: 'ALIAS_NOT_FOUND',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Verify project ownership
|
|
if (resolution.image && resolution.image.projectId !== projectId) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: {
|
|
message: 'Alias not found',
|
|
code: 'ALIAS_NOT_FOUND',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
alias,
|
|
imageId: resolution.imageId,
|
|
scope: resolution.scope,
|
|
flowId: resolution.flowId,
|
|
image: resolution.image ? toImageResponse(resolution.image) : ({} as any),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: {
|
|
message: error instanceof Error ? error.message : 'Failed to resolve alias',
|
|
code: 'RESOLUTION_ERROR',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Get a single image by ID with complete details
|
|
*
|
|
* Retrieves full image information including:
|
|
* - Storage URLs and keys
|
|
* - Project and flow associations
|
|
* - Alias assignments (project-scoped)
|
|
* - Source (generated vs uploaded)
|
|
* - File metadata (size, MIME type, hash)
|
|
* - Focal point and custom metadata
|
|
*
|
|
* @route GET /api/v1/images/:id
|
|
* @authentication Project Key required
|
|
*
|
|
* @param {string} req.params.id - Image ID (UUID)
|
|
*
|
|
* @returns {GetImageResponse} 200 - Complete image details
|
|
* @returns {object} 404 - Image not found or access denied
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
*
|
|
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
|
*
|
|
* @example
|
|
* GET /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
|
*/
|
|
imagesRouter.get(
|
|
'/:id',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
asyncHandler(async (req: any, res: Response<GetImageResponse>) => {
|
|
const service = getImageService();
|
|
const { id } = req.params;
|
|
|
|
const image = await service.getById(id);
|
|
if (!image) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: {
|
|
message: 'Image not found',
|
|
code: 'IMAGE_NOT_FOUND',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (image.projectId !== req.apiKey.projectId) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: {
|
|
message: 'Image not found',
|
|
code: 'IMAGE_NOT_FOUND',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: toImageResponse(image),
|
|
});
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Update image metadata (focal point and custom metadata)
|
|
*
|
|
* Updates non-generative image properties:
|
|
* - Focal point for image cropping (x, y coordinates 0.0-1.0)
|
|
* - Custom metadata (arbitrary JSON object)
|
|
*
|
|
* Note: Alias assignment moved to separate endpoint PUT /images/:id/alias (Section 6.1)
|
|
*
|
|
* @route PUT /api/v1/images/:id
|
|
* @authentication Project Key required
|
|
*
|
|
* @param {string} req.params.id - Image ID (UUID)
|
|
* @param {UpdateImageRequest} req.body - Update parameters
|
|
* @param {object} [req.body.focalPoint] - Focal point for cropping
|
|
* @param {number} req.body.focalPoint.x - X coordinate (0.0-1.0)
|
|
* @param {number} req.body.focalPoint.y - Y coordinate (0.0-1.0)
|
|
* @param {object} [req.body.meta] - Custom metadata
|
|
*
|
|
* @returns {UpdateImageResponse} 200 - Updated image details
|
|
* @returns {object} 404 - Image not found or access denied
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
*
|
|
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
|
*
|
|
* @example
|
|
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
|
* {
|
|
* "focalPoint": { "x": 0.5, "y": 0.3 },
|
|
* "meta": { "category": "hero", "priority": 1 }
|
|
* }
|
|
*/
|
|
imagesRouter.put(
|
|
'/:id',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
|
const service = getImageService();
|
|
const { id } = req.params;
|
|
const { focalPoint, meta } = req.body; // Removed alias (Section 6.1)
|
|
|
|
const image = await service.getById(id);
|
|
if (!image) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: {
|
|
message: 'Image not found',
|
|
code: 'IMAGE_NOT_FOUND',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (image.projectId !== req.apiKey.projectId) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: {
|
|
message: 'Image not found',
|
|
code: 'IMAGE_NOT_FOUND',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const updates: {
|
|
focalPoint?: { x: number; y: number };
|
|
meta?: Record<string, unknown>;
|
|
} = {};
|
|
|
|
if (focalPoint !== undefined) updates.focalPoint = focalPoint;
|
|
if (meta !== undefined) updates.meta = meta;
|
|
|
|
const updated = await service.update(id, updates);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: toImageResponse(updated),
|
|
});
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Assign a project-scoped alias to an image
|
|
*
|
|
* Sets or updates the project-scoped alias for an image:
|
|
* - Alias must start with @ symbol
|
|
* - Must be unique within the project
|
|
* - Replaces existing alias if image already has one
|
|
* - Used for alias resolution in generations and CDN access
|
|
*
|
|
* This is a dedicated endpoint introduced in Section 6.1 to separate
|
|
* alias assignment from general metadata updates.
|
|
*
|
|
* @route PUT /api/v1/images/:id/alias
|
|
* @authentication Project Key required
|
|
*
|
|
* @param {string} req.params.id - Image ID (UUID)
|
|
* @param {object} req.body - Request body
|
|
* @param {string} req.body.alias - Project-scoped alias (e.g., "@hero-bg")
|
|
*
|
|
* @returns {UpdateImageResponse} 200 - Updated image with new alias
|
|
* @returns {object} 404 - Image not found or access denied
|
|
* @returns {object} 400 - Missing or invalid alias
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
* @returns {object} 409 - Alias already exists
|
|
*
|
|
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
|
* @throws {Error} VALIDATION_ERROR - Alias is required
|
|
* @throws {Error} ALIAS_CONFLICT - Alias already assigned to another image
|
|
*
|
|
* @example
|
|
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias
|
|
* {
|
|
* "alias": "@hero-background"
|
|
* }
|
|
*/
|
|
imagesRouter.put(
|
|
'/:id/alias',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
|
const service = getImageService();
|
|
const { id } = req.params;
|
|
const { alias } = req.body;
|
|
|
|
if (!alias || typeof alias !== 'string') {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: {
|
|
message: 'Alias is required and must be a string',
|
|
code: 'VALIDATION_ERROR',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const image = await service.getById(id);
|
|
if (!image) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: {
|
|
message: 'Image not found',
|
|
code: 'IMAGE_NOT_FOUND',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (image.projectId !== req.apiKey.projectId) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: {
|
|
message: 'Image not found',
|
|
code: 'IMAGE_NOT_FOUND',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const updated = await service.assignProjectAlias(id, alias);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: toImageResponse(updated),
|
|
});
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Delete an image with storage cleanup and cascading deletions
|
|
*
|
|
* Performs hard delete of image record and MinIO file with cascading operations:
|
|
* - Deletes image record from database (hard delete, no soft delete)
|
|
* - Removes file from MinIO storage permanently
|
|
* - Cascades to delete generation-image relationships
|
|
* - Removes image from flow aliases (if present)
|
|
* - Cannot be undone
|
|
*
|
|
* Use with caution: This is a destructive operation that permanently removes
|
|
* the image file and all database references.
|
|
*
|
|
* @route DELETE /api/v1/images/:id
|
|
* @authentication Project Key required
|
|
*
|
|
* @param {string} req.params.id - Image ID (UUID)
|
|
*
|
|
* @returns {DeleteImageResponse} 200 - Deletion confirmation with image ID
|
|
* @returns {object} 404 - Image not found or access denied
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
*
|
|
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
|
*
|
|
* @example
|
|
* DELETE /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
|
*
|
|
* Response:
|
|
* {
|
|
* "success": true,
|
|
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
|
* }
|
|
*/
|
|
imagesRouter.delete(
|
|
'/:id',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
asyncHandler(async (req: any, res: Response<DeleteImageResponse>) => {
|
|
const service = getImageService();
|
|
const { id } = req.params;
|
|
|
|
const image = await service.getById(id);
|
|
if (!image) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: {
|
|
message: 'Image not found',
|
|
code: 'IMAGE_NOT_FOUND',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (image.projectId !== req.apiKey.projectId) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: {
|
|
message: 'Image not found',
|
|
code: 'IMAGE_NOT_FOUND',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
await service.hardDelete(id);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: { id },
|
|
});
|
|
})
|
|
);
|