feat: phase 2 part 2 - upload enhancements and deletion strategy overhaul

Implement comprehensive deletion cascade logic, upload enhancements, and alias
management updates for Phase 2 Part 2 of the API refactoring.

**Upload Enhancements (Section 5):**
- POST /api/v1/images/upload now supports flowAlias parameter
- Eager flow creation: creates flow record immediately when flowAlias provided
- FlowId logic: undefined → generate UUID, null → keep null, UUID → use provided
- Automatically assigns flowAlias in flow.aliases JSONB upon upload

**Alias Management (Section 6):**
- Removed alias from PUT /api/v1/images/:id request body
- Only focalPoint and meta can be updated via PUT endpoint
- Use dedicated PUT /api/v1/images/:id/alias endpoint for alias assignment

**Deletion Strategy Overhaul (Section 7):**
- **ImageService.hardDelete()** with MinIO cleanup and cascades:
  - Deletes physical file from MinIO storage
  - Cascades: sets outputImageId=NULL in related generations
  - Cascades: removes alias entries from flow.aliases
  - Cascades: removes imageId from generation.referencedImages arrays
  - MVP approach: proceeds with DB cleanup even if MinIO fails

- **GenerationService.delete()** with conditional logic:
  - If output image WITHOUT alias → hard delete both image and generation
  - If output image WITH alias → keep image, delete generation only, set generationId=NULL

- **FlowService.delete()** with cascade and alias protection:
  - Deletes all generations (uses conditional delete logic)
  - Deletes all images WITHOUT alias
  - Keeps images WITH alias (sets flowId=NULL)
  - Deletes flow record from database

**Type Updates:**
- UploadImageRequest: Added flowAlias parameter (string)
- UpdateImageRequest: Removed alias field (Section 6.1)
- GenerationResponse: Updated prompt fields to match reversed semantics
  - prompt: string (what was actually used for generation)
  - originalPrompt: string | null (user's original, only if enhanced)
- DeleteImageResponse: Changed to { id: string } (hard delete, no deletedAt)

**Error Constants (Section 11):**
- Removed: GENERATION_ALREADY_SUCCEEDED, MAX_RETRY_COUNT_EXCEEDED
- Added: SCOPE_INVALID_FORMAT, SCOPE_CREATION_DISABLED,
  SCOPE_GENERATION_LIMIT_EXCEEDED, STORAGE_DELETE_FAILED

**Technical Notes:**
- Hard delete replaces soft delete throughout the system
- Cascade operations maintain referential integrity
- Alias protection ensures valuable images are preserved
- All Phase 2 Part 2 code compiles with zero new TypeScript errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Oleg Proskurin 2025-11-17 11:38:51 +07:00
parent 9b9c47e2bf
commit 7d87202934
7 changed files with 234 additions and 26 deletions

View File

@ -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 },
});
})
);

View File

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

View File

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

View File

@ -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> {

View File

@ -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

View File

@ -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,

View File

@ -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',