feature/api-development #1
|
|
@ -1,3 +1,4 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { ImageService, AliasService } from '@/services/core';
|
||||
|
|
@ -10,6 +11,9 @@ 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,
|
||||
|
|
@ -51,7 +55,7 @@ imagesRouter.post(
|
|||
handleUploadErrors,
|
||||
asyncHandler(async (req: any, res: Response<UploadImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { alias, flowId, meta } = req.body;
|
||||
const { alias, flowId, flowAlias, meta } = req.body;
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({
|
||||
|
|
@ -70,6 +74,19 @@ 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
|
||||
let finalFlowId: string | null;
|
||||
if (flowId === undefined) {
|
||||
finalFlowId = randomUUID();
|
||||
} else if (flowId === null) {
|
||||
finalFlowId = null;
|
||||
} else {
|
||||
finalFlowId = flowId;
|
||||
}
|
||||
|
||||
try {
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
|
|
@ -96,7 +113,7 @@ imagesRouter.post(
|
|||
|
||||
const imageRecord = await service.create({
|
||||
projectId,
|
||||
flowId: flowId || null,
|
||||
flowId: finalFlowId,
|
||||
generationId: null,
|
||||
apiKeyId,
|
||||
storageKey: uploadResult.path!,
|
||||
|
|
@ -109,6 +126,39 @@ imagesRouter.post(
|
|||
meta: meta ? JSON.parse(meta) : {},
|
||||
});
|
||||
|
||||
// Eager flow creation if flowAlias is provided (Section 5.1)
|
||||
if (flowAlias && finalFlowId) {
|
||||
// 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: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Assign flow alias to uploaded image
|
||||
const flow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, finalFlowId),
|
||||
});
|
||||
|
||||
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, finalFlowId));
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: toImageResponse(imageRecord),
|
||||
|
|
@ -292,7 +342,7 @@ imagesRouter.put(
|
|||
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { id } = req.params;
|
||||
const { alias, focalPoint, meta } = req.body;
|
||||
const { focalPoint, meta } = req.body; // Removed alias (Section 6.1)
|
||||
|
||||
const image = await service.getById(id);
|
||||
if (!image) {
|
||||
|
|
@ -318,12 +368,10 @@ imagesRouter.put(
|
|||
}
|
||||
|
||||
const updates: {
|
||||
alias?: string;
|
||||
focalPoint?: { x: number; y: number };
|
||||
meta?: Record<string, unknown>;
|
||||
} = {};
|
||||
|
||||
if (alias !== undefined) updates.alias = alias;
|
||||
if (focalPoint !== undefined) updates.focalPoint = focalPoint;
|
||||
if (meta !== undefined) updates.meta = meta;
|
||||
|
||||
|
|
@ -394,7 +442,7 @@ imagesRouter.put(
|
|||
|
||||
/**
|
||||
* DELETE /api/v1/images/:id
|
||||
* Soft delete an image
|
||||
* Hard delete an image with MinIO cleanup and cascades (Section 7.1)
|
||||
*/
|
||||
imagesRouter.delete(
|
||||
'/:id',
|
||||
|
|
@ -427,14 +475,11 @@ imagesRouter.delete(
|
|||
return;
|
||||
}
|
||||
|
||||
const deleted = await service.softDelete(id);
|
||||
await service.hardDelete(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: deleted.id,
|
||||
deletedAt: deleted.deletedAt?.toISOString() || null,
|
||||
},
|
||||
data: { id },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { flows, generations, images } from '@banatie/database';
|
|||
import type { Flow, NewFlow, FlowFilters, FlowWithCounts } from '@/types/models';
|
||||
import { buildWhereClause, buildEqCondition } from '@/utils/helpers';
|
||||
import { ERROR_MESSAGES } from '@/utils/constants';
|
||||
import { GenerationService } from './GenerationService';
|
||||
import { ImageService } from './ImageService';
|
||||
|
||||
export class FlowService {
|
||||
async create(data: NewFlow): Promise<FlowWithCounts> {
|
||||
|
|
@ -163,7 +165,46 @@ export class FlowService {
|
|||
return await this.getByIdWithCounts(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade delete for flow with alias protection (Section 7.3)
|
||||
* Operations:
|
||||
* 1. Delete all generations associated with this flowId (follows conditional delete logic)
|
||||
* 2. Delete all images associated with this flowId EXCEPT images with project alias
|
||||
* 3. For images with alias: keep image, set flowId=NULL
|
||||
* 4. Delete flow record from DB
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
// Get all generations in this flow
|
||||
const flowGenerations = await db.query.generations.findMany({
|
||||
where: eq(generations.flowId, id),
|
||||
});
|
||||
|
||||
// Delete each generation (follows conditional delete logic from Section 7.2)
|
||||
const generationService = new GenerationService();
|
||||
for (const gen of flowGenerations) {
|
||||
await generationService.delete(gen.id);
|
||||
}
|
||||
|
||||
// Get all images in this flow
|
||||
const flowImages = await db.query.images.findMany({
|
||||
where: eq(images.flowId, id),
|
||||
});
|
||||
|
||||
const imageService = new ImageService();
|
||||
for (const img of flowImages) {
|
||||
if (img.alias) {
|
||||
// Image has project alias → keep, unlink from flow
|
||||
await db
|
||||
.update(images)
|
||||
.set({ flowId: null, updatedAt: new Date() })
|
||||
.where(eq(images.id, img.id));
|
||||
} else {
|
||||
// Image without alias → delete
|
||||
await imageService.hardDelete(img.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete flow record
|
||||
await db.delete(flows).where(eq(flows.id, id));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import { eq, desc, count } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { generations, flows } from '@banatie/database';
|
||||
import { generations, flows, images } from '@banatie/database';
|
||||
import type {
|
||||
Generation,
|
||||
NewGeneration,
|
||||
|
|
@ -539,6 +539,11 @@ export class GenerationService {
|
|||
return await this.getByIdWithRelations(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditional delete for generation (Section 7.2)
|
||||
* - If output image WITHOUT project alias → delete image + generation
|
||||
* - If output image WITH project alias → keep image, delete generation only, set generationId=NULL
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
const generation = await this.getById(id);
|
||||
if (!generation) {
|
||||
|
|
@ -546,9 +551,25 @@ export class GenerationService {
|
|||
}
|
||||
|
||||
if (generation.outputImageId) {
|
||||
await this.imageService.softDelete(generation.outputImageId);
|
||||
// Get the output image to check if it has a project alias
|
||||
const outputImage = await this.imageService.getById(generation.outputImageId);
|
||||
|
||||
if (outputImage) {
|
||||
if (outputImage.alias) {
|
||||
// Case 2: Image has project alias → keep image, delete generation only
|
||||
// Set generationId = NULL in image record
|
||||
await db
|
||||
.update(images)
|
||||
.set({ generationId: null, updatedAt: new Date() })
|
||||
.where(eq(images.id, outputImage.id));
|
||||
} else {
|
||||
// Case 1: Image has no alias → delete both image and generation
|
||||
await this.imageService.hardDelete(generation.outputImageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete generation record (hard delete)
|
||||
await db.delete(generations).where(eq(generations.id, id));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { eq, and, isNull, desc, count, inArray } from 'drizzle-orm';
|
||||
import { eq, and, isNull, desc, count, inArray, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { images, flows } from '@banatie/database';
|
||||
import { images, flows, generations } 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';
|
||||
import { StorageFactory } from '../StorageFactory';
|
||||
|
||||
export class ImageService {
|
||||
private aliasService: AliasService;
|
||||
|
|
@ -136,8 +137,96 @@ export class ImageService {
|
|||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete image with MinIO cleanup and cascades (Section 7.1)
|
||||
* 1. Delete physical file from MinIO storage
|
||||
* 2. Delete record from images table (hard delete)
|
||||
* 3. Cascade: set outputImageId = NULL in related generations
|
||||
* 4. Cascade: remove alias entries from flow.aliases
|
||||
* 5. Cascade: remove imageId from generation.referencedImages arrays
|
||||
*/
|
||||
async hardDelete(id: string): Promise<void> {
|
||||
await db.delete(images).where(eq(images.id, id));
|
||||
// Get image to retrieve storage info
|
||||
const image = await this.getById(id, true); // Include deleted
|
||||
if (!image) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Delete physical file from MinIO storage
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
const storageParts = image.storageKey.split('/');
|
||||
|
||||
if (storageParts.length >= 4) {
|
||||
const orgId = storageParts[0]!;
|
||||
const projectId = storageParts[1]!;
|
||||
const category = storageParts[2]! as 'uploads' | 'generated' | 'references';
|
||||
const filename = storageParts.slice(3).join('/');
|
||||
|
||||
await storageService.deleteFile(orgId, projectId, category, filename);
|
||||
}
|
||||
|
||||
// 2. Cascade: Set outputImageId = NULL in related generations
|
||||
await db
|
||||
.update(generations)
|
||||
.set({ outputImageId: null })
|
||||
.where(eq(generations.outputImageId, id));
|
||||
|
||||
// 3. Cascade: Remove alias entries from flow.aliases where this imageId is referenced
|
||||
const allFlows = await db.query.flows.findMany();
|
||||
for (const flow of allFlows) {
|
||||
const aliases = (flow.aliases as Record<string, string>) || {};
|
||||
let modified = false;
|
||||
|
||||
// Remove all entries where value equals this imageId
|
||||
const newAliases: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(aliases)) {
|
||||
if (value !== id) {
|
||||
newAliases[key] = value;
|
||||
} else {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
await db
|
||||
.update(flows)
|
||||
.set({ aliases: newAliases, updatedAt: new Date() })
|
||||
.where(eq(flows.id, flow.id));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Cascade: Remove imageId from generation.referencedImages JSON arrays
|
||||
const affectedGenerations = await db.query.generations.findMany({
|
||||
where: sql`${generations.referencedImages}::jsonb @> ${JSON.stringify([{ imageId: id }])}`,
|
||||
});
|
||||
|
||||
for (const gen of affectedGenerations) {
|
||||
const refs = (gen.referencedImages as Array<{ imageId: string; alias: string }>) || [];
|
||||
const filtered = refs.filter(ref => ref.imageId !== id);
|
||||
|
||||
await db
|
||||
.update(generations)
|
||||
.set({ referencedImages: filtered })
|
||||
.where(eq(generations.id, gen.id));
|
||||
}
|
||||
|
||||
// 5. Delete record from images table
|
||||
await db.delete(images).where(eq(images.id, id));
|
||||
|
||||
} catch (error) {
|
||||
// If MinIO delete fails, still proceed with DB cleanup (MVP mindset)
|
||||
// Log error but don't throw
|
||||
console.error('MinIO delete failed, proceeding with DB cleanup:', error);
|
||||
|
||||
// Still perform DB cleanup
|
||||
await db
|
||||
.update(generations)
|
||||
.set({ outputImageId: null })
|
||||
.where(eq(generations.outputImageId, id));
|
||||
|
||||
await db.delete(images).where(eq(images.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
async assignProjectAlias(imageId: string, alias: string): Promise<Image> {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export interface ListImagesQuery {
|
|||
}
|
||||
|
||||
export interface UpdateImageRequest {
|
||||
alias?: string;
|
||||
// Removed alias (Section 6.1) - use PUT /images/:id/alias instead
|
||||
focalPoint?: {
|
||||
x: number; // 0.0 to 1.0
|
||||
y: number; // 0.0 to 1.0
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ export interface GenerationResponse {
|
|||
id: string;
|
||||
projectId: string;
|
||||
flowId: string | null;
|
||||
originalPrompt: string;
|
||||
enhancedPrompt: string | null;
|
||||
prompt: string; // Prompt actually used for generation
|
||||
originalPrompt: string | null; // User's original (nullable, only if enhanced)
|
||||
aspectRatio: string | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
|
|
@ -98,7 +98,7 @@ export type GetImageResponse = ApiResponse<ImageResponse>;
|
|||
export type ListImagesResponse = PaginatedResponse<ImageResponse>;
|
||||
export type ResolveAliasResponse = ApiResponse<AliasResolutionResponse>;
|
||||
export type UpdateImageResponse = ApiResponse<ImageResponse>;
|
||||
export type DeleteImageResponse = ApiResponse<{ id: string; deletedAt: string | null }>;
|
||||
export type DeleteImageResponse = ApiResponse<{ id: string }>; // Hard delete, no deletedAt
|
||||
|
||||
// ========================================
|
||||
// FLOW RESPONSES
|
||||
|
|
@ -217,8 +217,8 @@ export const toGenerationResponse = (gen: GenerationWithRelations): GenerationRe
|
|||
id: gen.id,
|
||||
projectId: gen.projectId,
|
||||
flowId: gen.flowId,
|
||||
originalPrompt: gen.originalPrompt,
|
||||
enhancedPrompt: gen.enhancedPrompt,
|
||||
prompt: gen.prompt, // Prompt actually used
|
||||
originalPrompt: gen.originalPrompt, // User's original (null if not enhanced)
|
||||
aspectRatio: gen.aspectRatio,
|
||||
status: gen.status,
|
||||
errorMessage: gen.errorMessage,
|
||||
|
|
|
|||
|
|
@ -25,16 +25,22 @@ export const ERROR_MESSAGES = {
|
|||
// Resource Limits
|
||||
MAX_REFERENCE_IMAGES_EXCEEDED: 'Maximum number of reference images exceeded',
|
||||
MAX_FILE_SIZE_EXCEEDED: 'File size exceeds maximum allowed size',
|
||||
MAX_RETRY_COUNT_EXCEEDED: 'Maximum retry count exceeded',
|
||||
RATE_LIMIT_EXCEEDED: 'Rate limit exceeded',
|
||||
MAX_ALIASES_EXCEEDED: 'Maximum number of aliases per flow exceeded',
|
||||
|
||||
// Generation Errors
|
||||
GENERATION_FAILED: 'Image generation failed',
|
||||
GENERATION_ALREADY_SUCCEEDED: 'Cannot retry a generation that already succeeded',
|
||||
GENERATION_PENDING: 'Generation is still pending',
|
||||
REFERENCE_IMAGE_RESOLUTION_FAILED: 'Failed to resolve reference image alias',
|
||||
|
||||
// Live Scope Errors
|
||||
SCOPE_INVALID_FORMAT: 'Live scope format is invalid',
|
||||
SCOPE_CREATION_DISABLED: 'Creation of new live scopes is disabled for this project',
|
||||
SCOPE_GENERATION_LIMIT_EXCEEDED: 'Live scope generation limit exceeded',
|
||||
|
||||
// Storage Errors
|
||||
STORAGE_DELETE_FAILED: 'Failed to delete file from storage',
|
||||
|
||||
// Flow Errors
|
||||
TECHNICAL_ALIAS_REQUIRES_FLOW: 'Technical aliases (@last, @first, @upload) require a flowId',
|
||||
FLOW_HAS_NO_GENERATIONS: 'Flow has no generations',
|
||||
|
|
@ -77,16 +83,22 @@ export const ERROR_CODES = {
|
|||
RESOURCE_LIMIT_EXCEEDED: 'RESOURCE_LIMIT_EXCEEDED',
|
||||
MAX_REFERENCE_IMAGES_EXCEEDED: 'MAX_REFERENCE_IMAGES_EXCEEDED',
|
||||
MAX_FILE_SIZE_EXCEEDED: 'MAX_FILE_SIZE_EXCEEDED',
|
||||
MAX_RETRY_COUNT_EXCEEDED: 'MAX_RETRY_COUNT_EXCEEDED',
|
||||
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
|
||||
MAX_ALIASES_EXCEEDED: 'MAX_ALIASES_EXCEEDED',
|
||||
|
||||
// Generation Errors
|
||||
GENERATION_FAILED: 'GENERATION_FAILED',
|
||||
GENERATION_ALREADY_SUCCEEDED: 'GENERATION_ALREADY_SUCCEEDED',
|
||||
GENERATION_PENDING: 'GENERATION_PENDING',
|
||||
REFERENCE_IMAGE_RESOLUTION_FAILED: 'REFERENCE_IMAGE_RESOLUTION_FAILED',
|
||||
|
||||
// Live Scope Errors
|
||||
SCOPE_INVALID_FORMAT: 'SCOPE_INVALID_FORMAT',
|
||||
SCOPE_CREATION_DISABLED: 'SCOPE_CREATION_DISABLED',
|
||||
SCOPE_GENERATION_LIMIT_EXCEEDED: 'SCOPE_GENERATION_LIMIT_EXCEEDED',
|
||||
|
||||
// Storage Errors
|
||||
STORAGE_DELETE_FAILED: 'STORAGE_DELETE_FAILED',
|
||||
|
||||
// Flow Errors
|
||||
TECHNICAL_ALIAS_REQUIRES_FLOW: 'TECHNICAL_ALIAS_REQUIRES_FLOW',
|
||||
FLOW_HAS_NO_GENERATIONS: 'FLOW_HAS_NO_GENERATIONS',
|
||||
|
|
|
|||
Loading…
Reference in New Issue