diff --git a/api-refactoring-final.md b/api-refactoring-final.md index e3f945d..c399e8d 100644 --- a/api-refactoring-final.md +++ b/api-refactoring-final.md @@ -84,27 +84,34 @@ Project is in active development with no existing clients. All changes can be ma 1. **Rename field:** `enhancedPrompt` → `originalPrompt` 2. **Change field semantics:** - `prompt` - ALWAYS contains the prompt that was used for generation (enhanced or original) - - `originalPrompt` - contains user's original input ONLY if autoEnhance was used (nullable) + - `originalPrompt` - ALWAYS contains user's original input (for transparency and audit trail) **Field population logic:** ``` Case 1: autoEnhance = false prompt = user input - originalPrompt = NULL + originalPrompt = user input (same value, preserved for consistency) Case 2: autoEnhance = true prompt = enhanced prompt (used for generation) originalPrompt = user input (preserved) ``` +**Rationale:** Always storing `originalPrompt` provides: +- Audit trail of user's actual input +- Ability to compare original vs enhanced prompts +- Consistent API response structure +- Simplified client logic (no null checks needed) + ### 2.2 API Response Format **Response structure:** ```json { "prompt": "detailed enhanced prompt...", // Always the prompt used for generation - "originalPrompt": "sunset" // Only present if enhancement was used + "originalPrompt": "sunset", // Always the user's original input + "autoEnhance": true // True if prompt differs from originalPrompt } ``` diff --git a/apps/api-service/src/routes/v1/flows.ts b/apps/api-service/src/routes/v1/flows.ts index 4f37d59..4772d0d 100644 --- a/apps/api-service/src/routes/v1/flows.ts +++ b/apps/api-service/src/routes/v1/flows.ts @@ -472,28 +472,28 @@ flowsRouter.delete( ); /** - * Regenerate the most recent generation in a flow + * Regenerate the most recent generation in a flow (Section 3.6) * - * Identifies the latest generation in the flow and regenerates it: - * - Uses exact same parameters (prompt, aspect ratio, references) - * - Replaces existing output image (preserves ID and URLs) - * - Returns error if flow has no generations - * - Ordered by creation date (newest first) + * Logic: + * 1. Find the flow by ID + * 2. Query for the most recent generation (ordered by createdAt desc) + * 3. Trigger regeneration with exact same parameters + * 4. Replace existing output image (preserves ID and URLs) * * @route POST /api/v1/flows/:id/regenerate * @authentication Project Key required * @rateLimit 100 requests per hour per API key * - * @param {string} req.params.id - Flow ID (UUID) + * @param {string} req.params.id - Flow ID (affects: determines which flow's latest generation to regenerate) * - * @returns {object} 200 - Regenerated generation response + * @returns {object} 200 - Regenerated generation with updated output image * @returns {object} 404 - Flow not found or access denied * @returns {object} 400 - Flow has no generations * @returns {object} 401 - Missing or invalid API key * @returns {object} 429 - Rate limit exceeded * * @throws {Error} FLOW_NOT_FOUND - Flow does not exist - * @throws {Error} FLOW_HAS_NO_GENERATIONS - Flow contains no generations + * @throws {Error} FLOW_HAS_NO_GENERATIONS - Flow contains no generations to regenerate * * @example * POST /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/regenerate @@ -558,15 +558,16 @@ flowsRouter.post( ); /** - * Delete a flow (hard delete) + * Delete a flow with cascade deletion (Section 7.3) * - * Permanently removes the flow record: - * - Flow record is hard deleted (no soft delete) - * - Generations remain intact (not cascaded) - * - Images remain intact (not cascaded) - * - Flow-scoped aliases are removed with flow + * Permanently removes the flow with cascade behavior: + * - Flow record is hard deleted + * - All generations in flow are hard deleted + * - Images WITHOUT project alias: hard deleted with MinIO cleanup + * - Images WITH project alias: kept, but flowId set to NULL (unlinked) * - * Note: Generations and images lose their flow association but remain accessible. + * Rationale: Images with project aliases are used globally and should be preserved. + * Flow deletion removes the organizational structure but protects important assets. * * @route DELETE /api/v1/flows/:id * @authentication Project Key required diff --git a/apps/api-service/src/routes/v1/generations.ts b/apps/api-service/src/routes/v1/generations.ts index 9b4e4cb..f7cd5d6 100644 --- a/apps/api-service/src/routes/v1/generations.ts +++ b/apps/api-service/src/routes/v1/generations.ts @@ -478,13 +478,15 @@ generationsRouter.post( ); /** - * Delete a generation and its output image + * Delete a generation and conditionally its output image (Section 7.2) * * Performs deletion with alias protection: - * - Soft delete generation record (sets deletedAt timestamp) - * - Hard delete output image if no project/flow aliases exist - * - Soft delete output image if aliases exist (preserves for CDN access) - * - Cascades to remove generation-image relationships + * - Hard delete generation record (permanently removed from database) + * - If output image has NO project alias: hard delete image with MinIO cleanup + * - If output image HAS project alias: keep image, set generationId=NULL + * + * Rationale: Images with aliases are used as standalone assets and should be preserved. + * Images without aliases were created only for this generation and can be deleted together. * * @route DELETE /api/v1/generations/:id * @authentication Project Key required diff --git a/apps/api-service/src/services/core/AliasService.ts b/apps/api-service/src/services/core/AliasService.ts index ff842ef..4141b0e 100644 --- a/apps/api-service/src/services/core/AliasService.ts +++ b/apps/api-service/src/services/core/AliasService.ts @@ -1,4 +1,4 @@ -import { eq, and, isNull, desc } from 'drizzle-orm'; +import { eq, and, isNull, desc, or } from 'drizzle-orm'; import { db } from '@/db'; import { images, flows } from '@banatie/database'; import type { AliasResolution, Image } from '@/types/models'; @@ -142,9 +142,11 @@ export class AliasService { flowId: string, projectId: string ): Promise { + // Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1) + // Images may have pendingFlowId before the flow record is created return await db.query.images.findFirst({ where: and( - eq(images.flowId, flowId), + or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)), eq(images.projectId, projectId), eq(images.source, 'generated'), isNull(images.deletedAt) @@ -157,9 +159,10 @@ export class AliasService { flowId: string, projectId: string ): Promise { + // Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1) const allImages = await db.query.images.findMany({ where: and( - eq(images.flowId, flowId), + or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)), eq(images.projectId, projectId), eq(images.source, 'generated'), isNull(images.deletedAt) @@ -175,9 +178,10 @@ export class AliasService { flowId: string, projectId: string ): Promise { + // Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1) return await db.query.images.findFirst({ where: and( - eq(images.flowId, flowId), + or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)), eq(images.projectId, projectId), eq(images.source, 'uploaded'), isNull(images.deletedAt) diff --git a/apps/api-service/src/services/core/ImageService.ts b/apps/api-service/src/services/core/ImageService.ts index dfbe2c3..668fbfb 100644 --- a/apps/api-service/src/services/core/ImageService.ts +++ b/apps/api-service/src/services/core/ImageService.ts @@ -215,17 +215,10 @@ export class ImageService { 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)); + // Per Section 7.4: If MinIO delete fails, do NOT proceed with DB cleanup + // This prevents orphaned files in MinIO + console.error('MinIO delete failed, aborting image deletion:', error); + throw new Error(ERROR_MESSAGES.STORAGE_DELETE_FAILED || 'Failed to delete file from storage'); } } diff --git a/apps/api-service/src/types/responses.ts b/apps/api-service/src/types/responses.ts index ed1034d..6082c45 100644 --- a/apps/api-service/src/types/responses.ts +++ b/apps/api-service/src/types/responses.ts @@ -268,7 +268,7 @@ export const toGenerationResponse = (gen: GenerationWithRelations): GenerationRe export const toImageResponse = (img: Image): ImageResponse => ({ id: img.id, projectId: img.projectId, - flowId: img.flowId, + flowId: img.flowId ?? img.pendingFlowId ?? null, // Return actual flowId or pendingFlowId for client storageKey: img.storageKey, storageUrl: img.storageUrl, mimeType: img.mimeType,