From 7d872029347e2381fbd379f0f630ca1337e2e3aa Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 17 Nov 2025 11:38:51 +0700 Subject: [PATCH] feat: phase 2 part 2 - upload enhancements and deletion strategy overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api-service/src/routes/v1/images.ts | 67 ++++++++++--- .../src/services/core/FlowService.ts | 41 ++++++++ .../src/services/core/GenerationService.ts | 25 ++++- .../src/services/core/ImageService.ts | 95 ++++++++++++++++++- apps/api-service/src/types/requests.ts | 2 +- apps/api-service/src/types/responses.ts | 10 +- .../api-service/src/utils/constants/errors.ts | 20 +++- 7 files changed, 234 insertions(+), 26 deletions(-) diff --git a/apps/api-service/src/routes/v1/images.ts b/apps/api-service/src/routes/v1/images.ts index cf31e1e..306ad44 100644 --- a/apps/api-service/src/routes/v1/images.ts +++ b/apps/api-service/src/routes/v1/images.ts @@ -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) => { 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) || {}; + 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) => { 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; } = {}; - 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 }, }); }) ); diff --git a/apps/api-service/src/services/core/FlowService.ts b/apps/api-service/src/services/core/FlowService.ts index 1e8064f..2a57350 100644 --- a/apps/api-service/src/services/core/FlowService.ts +++ b/apps/api-service/src/services/core/FlowService.ts @@ -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 { @@ -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 { + // 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)); } diff --git a/apps/api-service/src/services/core/GenerationService.ts b/apps/api-service/src/services/core/GenerationService.ts index c7cd114..80da8ae 100644 --- a/apps/api-service/src/services/core/GenerationService.ts +++ b/apps/api-service/src/services/core/GenerationService.ts @@ -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 { 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)); } } diff --git a/apps/api-service/src/services/core/ImageService.ts b/apps/api-service/src/services/core/ImageService.ts index 5c62301..d4e1e23 100644 --- a/apps/api-service/src/services/core/ImageService.ts +++ b/apps/api-service/src/services/core/ImageService.ts @@ -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 { - 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) || {}; + let modified = false; + + // Remove all entries where value equals this imageId + const newAliases: Record = {}; + 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 { diff --git a/apps/api-service/src/types/requests.ts b/apps/api-service/src/types/requests.ts index 06b94bc..5db8abe 100644 --- a/apps/api-service/src/types/requests.ts +++ b/apps/api-service/src/types/requests.ts @@ -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 diff --git a/apps/api-service/src/types/responses.ts b/apps/api-service/src/types/responses.ts index dd14bf1..552db97 100644 --- a/apps/api-service/src/types/responses.ts +++ b/apps/api-service/src/types/responses.ts @@ -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; export type ListImagesResponse = PaginatedResponse; export type ResolveAliasResponse = ApiResponse; export type UpdateImageResponse = ApiResponse; -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, diff --git a/apps/api-service/src/utils/constants/errors.ts b/apps/api-service/src/utils/constants/errors.ts index 17e4444..132714e 100644 --- a/apps/api-service/src/utils/constants/errors.ts +++ b/apps/api-service/src/utils/constants/errors.ts @@ -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',