fix: api & docs

This commit is contained in:
Oleg Proskurin 2025-11-28 00:07:06 +07:00
parent df3737ed44
commit 504b1f8395
6 changed files with 47 additions and 40 deletions

View File

@ -84,27 +84,34 @@ Project is in active development with no existing clients. All changes can be ma
1. **Rename field:** `enhancedPrompt``originalPrompt` 1. **Rename field:** `enhancedPrompt``originalPrompt`
2. **Change field semantics:** 2. **Change field semantics:**
- `prompt` - ALWAYS contains the prompt that was used for generation (enhanced or original) - `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:** **Field population logic:**
``` ```
Case 1: autoEnhance = false Case 1: autoEnhance = false
prompt = user input prompt = user input
originalPrompt = NULL originalPrompt = user input (same value, preserved for consistency)
Case 2: autoEnhance = true Case 2: autoEnhance = true
prompt = enhanced prompt (used for generation) prompt = enhanced prompt (used for generation)
originalPrompt = user input (preserved) 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 ### 2.2 API Response Format
**Response structure:** **Response structure:**
```json ```json
{ {
"prompt": "detailed enhanced prompt...", // Always the prompt used for generation "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
} }
``` ```

View File

@ -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: * Logic:
* - Uses exact same parameters (prompt, aspect ratio, references) * 1. Find the flow by ID
* - Replaces existing output image (preserves ID and URLs) * 2. Query for the most recent generation (ordered by createdAt desc)
* - Returns error if flow has no generations * 3. Trigger regeneration with exact same parameters
* - Ordered by creation date (newest first) * 4. Replace existing output image (preserves ID and URLs)
* *
* @route POST /api/v1/flows/:id/regenerate * @route POST /api/v1/flows/:id/regenerate
* @authentication Project Key required * @authentication Project Key required
* @rateLimit 100 requests per hour per API key * @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} 404 - Flow not found or access denied
* @returns {object} 400 - Flow has no generations * @returns {object} 400 - Flow has no generations
* @returns {object} 401 - Missing or invalid API key * @returns {object} 401 - Missing or invalid API key
* @returns {object} 429 - Rate limit exceeded * @returns {object} 429 - Rate limit exceeded
* *
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist * @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 * @example
* POST /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/regenerate * 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: * Permanently removes the flow with cascade behavior:
* - Flow record is hard deleted (no soft delete) * - Flow record is hard deleted
* - Generations remain intact (not cascaded) * - All generations in flow are hard deleted
* - Images remain intact (not cascaded) * - Images WITHOUT project alias: hard deleted with MinIO cleanup
* - Flow-scoped aliases are removed with flow * - 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 * @route DELETE /api/v1/flows/:id
* @authentication Project Key required * @authentication Project Key required

View File

@ -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: * Performs deletion with alias protection:
* - Soft delete generation record (sets deletedAt timestamp) * - Hard delete generation record (permanently removed from database)
* - Hard delete output image if no project/flow aliases exist * - If output image has NO project alias: hard delete image with MinIO cleanup
* - Soft delete output image if aliases exist (preserves for CDN access) * - If output image HAS project alias: keep image, set generationId=NULL
* - Cascades to remove generation-image relationships *
* 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 * @route DELETE /api/v1/generations/:id
* @authentication Project Key required * @authentication Project Key required

View File

@ -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 { db } from '@/db';
import { images, flows } from '@banatie/database'; import { images, flows } from '@banatie/database';
import type { AliasResolution, Image } from '@/types/models'; import type { AliasResolution, Image } from '@/types/models';
@ -142,9 +142,11 @@ export class AliasService {
flowId: string, flowId: string,
projectId: string projectId: string
): Promise<Image | undefined> { ): Promise<Image | undefined> {
// 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({ return await db.query.images.findFirst({
where: and( where: and(
eq(images.flowId, flowId), or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
eq(images.projectId, projectId), eq(images.projectId, projectId),
eq(images.source, 'generated'), eq(images.source, 'generated'),
isNull(images.deletedAt) isNull(images.deletedAt)
@ -157,9 +159,10 @@ export class AliasService {
flowId: string, flowId: string,
projectId: string projectId: string
): Promise<Image | undefined> { ): Promise<Image | undefined> {
// Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
const allImages = await db.query.images.findMany({ const allImages = await db.query.images.findMany({
where: and( where: and(
eq(images.flowId, flowId), or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
eq(images.projectId, projectId), eq(images.projectId, projectId),
eq(images.source, 'generated'), eq(images.source, 'generated'),
isNull(images.deletedAt) isNull(images.deletedAt)
@ -175,9 +178,10 @@ export class AliasService {
flowId: string, flowId: string,
projectId: string projectId: string
): Promise<Image | undefined> { ): Promise<Image | undefined> {
// Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
return await db.query.images.findFirst({ return await db.query.images.findFirst({
where: and( where: and(
eq(images.flowId, flowId), or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
eq(images.projectId, projectId), eq(images.projectId, projectId),
eq(images.source, 'uploaded'), eq(images.source, 'uploaded'),
isNull(images.deletedAt) isNull(images.deletedAt)

View File

@ -215,17 +215,10 @@ export class ImageService {
await db.delete(images).where(eq(images.id, id)); await db.delete(images).where(eq(images.id, id));
} catch (error) { } catch (error) {
// If MinIO delete fails, still proceed with DB cleanup (MVP mindset) // Per Section 7.4: If MinIO delete fails, do NOT proceed with DB cleanup
// Log error but don't throw // This prevents orphaned files in MinIO
console.error('MinIO delete failed, proceeding with DB cleanup:', error); console.error('MinIO delete failed, aborting image deletion:', error);
throw new Error(ERROR_MESSAGES.STORAGE_DELETE_FAILED || 'Failed to delete file from storage');
// 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));
} }
} }

View File

@ -268,7 +268,7 @@ export const toGenerationResponse = (gen: GenerationWithRelations): GenerationRe
export const toImageResponse = (img: Image): ImageResponse => ({ export const toImageResponse = (img: Image): ImageResponse => ({
id: img.id, id: img.id,
projectId: img.projectId, projectId: img.projectId,
flowId: img.flowId, flowId: img.flowId ?? img.pendingFlowId ?? null, // Return actual flowId or pendingFlowId for client
storageKey: img.storageKey, storageKey: img.storageKey,
storageUrl: img.storageUrl, storageUrl: img.storageUrl,
mimeType: img.mimeType, mimeType: img.mimeType,