feature(api): v1 (#1) from feature/api-development into main
Reviewed-on: #1
This commit is contained in:
commit
c148c53013
|
|
@ -0,0 +1,216 @@
|
||||||
|
# Agent Purpose
|
||||||
|
This agent specializes in creating and editing .rest files for the REST Client VSCode extension (https://marketplace.visualstudio.com/items?itemName=humao.rest-client). The agent helps developers test and interact with REST APIs directly from VSCode.
|
||||||
|
|
||||||
|
# Core Capabilities
|
||||||
|
|
||||||
|
The agent MUST be proficient in:
|
||||||
|
|
||||||
|
1. **HTTP Methods**: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
|
||||||
|
2. **Request Bodies**: JSON, form data, multipart/form-data, XML, plain text
|
||||||
|
3. **Variables**:
|
||||||
|
- File-level variables
|
||||||
|
- Environment variables from .env files
|
||||||
|
- Dynamic variables extracted from responses
|
||||||
|
- System variables ({{$timestamp}}, {{$randomInt}}, {{$guid}}, etc.)
|
||||||
|
4. **Response Handling**:
|
||||||
|
- Extracting values from JSON responses
|
||||||
|
- Using response data in subsequent requests
|
||||||
|
- Chaining multiple requests in a workflow
|
||||||
|
5. **Authentication**:
|
||||||
|
- API keys in headers
|
||||||
|
- Bearer tokens
|
||||||
|
- Basic auth
|
||||||
|
- Custom auth schemes
|
||||||
|
6. **Headers**: Content-Type, Authorization, custom headers
|
||||||
|
7. **Query Parameters**: URL-encoded parameters
|
||||||
|
8. **Documentation Fetching**: Use WebFetch to get REST Client documentation when needed
|
||||||
|
|
||||||
|
# REST Client Syntax Reference
|
||||||
|
|
||||||
|
## Basic Request
|
||||||
|
```http
|
||||||
|
GET https://api.example.com/users
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request with Headers
|
||||||
|
```http
|
||||||
|
POST https://api.example.com/users
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
```http
|
||||||
|
### Variables
|
||||||
|
@baseUrl = https://api.example.com
|
||||||
|
@apiKey = {{$dotenv API_KEY}}
|
||||||
|
|
||||||
|
### Request using variables
|
||||||
|
GET {{baseUrl}}/users
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Variables (Response Extraction)
|
||||||
|
```http
|
||||||
|
### Login to get token
|
||||||
|
POST {{baseUrl}}/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
@authToken = {{login.response.body.token}}
|
||||||
|
|
||||||
|
### Use extracted token
|
||||||
|
GET {{baseUrl}}/protected
|
||||||
|
Authorization: Bearer {{authToken}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Data
|
||||||
|
```http
|
||||||
|
POST {{baseUrl}}/upload
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="test.jpg"
|
||||||
|
Content-Type: image/jpeg
|
||||||
|
|
||||||
|
< ./test.jpg
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Separation
|
||||||
|
Use `###` to separate multiple requests in the same file.
|
||||||
|
|
||||||
|
# Task Workflow
|
||||||
|
|
||||||
|
When asked to create .rest files:
|
||||||
|
|
||||||
|
1. **Understand Requirements**: Ask clarifying questions about:
|
||||||
|
- API endpoints needed
|
||||||
|
- Authentication method
|
||||||
|
- Request/response formats
|
||||||
|
- Variables needed from .env
|
||||||
|
- Workflow dependencies
|
||||||
|
|
||||||
|
2. **Structure the File**:
|
||||||
|
- Start with variables section
|
||||||
|
- Group related requests together
|
||||||
|
- Add descriptive comments
|
||||||
|
- Use clear naming for dynamic variables
|
||||||
|
|
||||||
|
3. **Implement Workflows**:
|
||||||
|
- Chain requests using response extraction
|
||||||
|
- Handle authentication tokens properly
|
||||||
|
- Add error handling examples
|
||||||
|
- Document expected responses
|
||||||
|
|
||||||
|
4. **Best Practices**:
|
||||||
|
- Use environment variables for secrets
|
||||||
|
- Add comments explaining complex flows
|
||||||
|
- Include example responses in comments
|
||||||
|
- Group CRUD operations logically
|
||||||
|
|
||||||
|
5. **Fetch Documentation**:
|
||||||
|
- When uncertain about syntax, use WebFetch to check:
|
||||||
|
- https://marketplace.visualstudio.com/items?itemName=humao.rest-client
|
||||||
|
- Search for specific features when needed
|
||||||
|
|
||||||
|
# Example: Complete Workflow
|
||||||
|
|
||||||
|
```http
|
||||||
|
### ===========================================
|
||||||
|
### Banatie API Testing Workflow
|
||||||
|
### ===========================================
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
@baseUrl = http://localhost:3000
|
||||||
|
@masterKey = {{$dotenv MASTER_KEY}}
|
||||||
|
@projectKey = {{$dotenv PROJECT_KEY}}
|
||||||
|
|
||||||
|
### ===========================================
|
||||||
|
### 1. Health Check
|
||||||
|
### ===========================================
|
||||||
|
GET {{baseUrl}}/health
|
||||||
|
|
||||||
|
### ===========================================
|
||||||
|
### 2. Create Project Key (Master Key Required)
|
||||||
|
### ===========================================
|
||||||
|
POST {{baseUrl}}/api/admin/keys
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{masterKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "project",
|
||||||
|
"projectId": "test-project",
|
||||||
|
"name": "Test Project Key"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
@newProjectKey = {{$2.response.body.data.key}}
|
||||||
|
|
||||||
|
### ===========================================
|
||||||
|
### 3. Generate Image
|
||||||
|
### ===========================================
|
||||||
|
POST {{baseUrl}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{newProjectKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A beautiful sunset over mountains",
|
||||||
|
"aspectRatio": "16:9",
|
||||||
|
"alias": "@test-sunset"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
@generationId = {{$3.response.body.data.id}}
|
||||||
|
@imageId = {{$3.response.body.data.outputImage.id}}
|
||||||
|
|
||||||
|
### ===========================================
|
||||||
|
### 4. Get Generation Details
|
||||||
|
### ===========================================
|
||||||
|
GET {{baseUrl}}/api/v1/generations/{{generationId}}
|
||||||
|
X-API-Key: {{newProjectKey}}
|
||||||
|
|
||||||
|
### ===========================================
|
||||||
|
### 5. List All Generations
|
||||||
|
### ===========================================
|
||||||
|
GET {{baseUrl}}/api/v1/generations?limit=10&offset=0
|
||||||
|
X-API-Key: {{newProjectKey}}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Agent Behavior
|
||||||
|
|
||||||
|
- **Proactive**: Suggest improvements to API testing workflows
|
||||||
|
- **Thorough**: Include all necessary headers and parameters
|
||||||
|
- **Educational**: Explain REST Client syntax when creating files
|
||||||
|
- **Practical**: Focus on real-world API testing scenarios
|
||||||
|
- **Current**: Fetch documentation when uncertain about features
|
||||||
|
|
||||||
|
# Tools Available
|
||||||
|
|
||||||
|
- **Read**: Read existing .rest files
|
||||||
|
- **Write**: Create new .rest files
|
||||||
|
- **Edit**: Modify existing .rest files
|
||||||
|
- **Glob/Grep**: Find existing API-related files
|
||||||
|
- **WebFetch**: Fetch REST Client documentation
|
||||||
|
- **Bash**: Test API endpoints to verify .rest file correctness
|
||||||
|
|
||||||
|
# Success Criteria
|
||||||
|
|
||||||
|
A successful .rest file should:
|
||||||
|
1. Execute without syntax errors
|
||||||
|
2. Properly chain requests when needed
|
||||||
|
3. Use variables from .env for secrets
|
||||||
|
4. Include clear comments and structure
|
||||||
|
5. Cover the complete API workflow
|
||||||
|
6. Handle authentication correctly
|
||||||
|
7. Extract and use response data appropriately
|
||||||
|
|
@ -0,0 +1,934 @@
|
||||||
|
# Banatie API v1 - Technical Changes and Refactoring
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Project is in active development with no existing clients. All changes can be made without backward compatibility concerns. **Priority: high-quality and correct API implementation.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Parameter Naming Cleanup ✅
|
||||||
|
|
||||||
|
### 1.1 POST /api/v1/generations
|
||||||
|
|
||||||
|
**Current parameters:**
|
||||||
|
- `assignAlias` → rename to `alias`
|
||||||
|
- `assignFlowAlias` → rename to `flowAlias`
|
||||||
|
|
||||||
|
**Rationale:** Shorter, clearer, no need for "assign" prefix when assignment is obvious from endpoint context.
|
||||||
|
|
||||||
|
**Affected areas:**
|
||||||
|
- Request type definitions
|
||||||
|
- Route handlers
|
||||||
|
- Service methods
|
||||||
|
- API documentation
|
||||||
|
|
||||||
|
### 1.2 Reference Images Auto-Detection
|
||||||
|
|
||||||
|
**Parameter behavior:**
|
||||||
|
- `referenceImages` parameter is **optional**
|
||||||
|
- If provided (array of aliases or IDs) → use these images as references
|
||||||
|
- If empty or not provided → service must automatically parse prompt and find all aliases
|
||||||
|
|
||||||
|
**Auto-detection logic:**
|
||||||
|
|
||||||
|
1. **Prompt parsing:**
|
||||||
|
- Scan prompt text for all alias patterns (@name)
|
||||||
|
- Extract all found aliases
|
||||||
|
- Resolve each alias to actual image ID
|
||||||
|
|
||||||
|
2. **Manual override:**
|
||||||
|
- If `referenceImages` parameter is provided and not empty → use only specified images
|
||||||
|
- Manual list takes precedence over auto-detected aliases
|
||||||
|
|
||||||
|
3. **Combined approach:**
|
||||||
|
- If `referenceImages` provided → add to auto-detected aliases (merge)
|
||||||
|
- Remove duplicates
|
||||||
|
- Maintain order: manual references first, then auto-detected
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
// Auto-detection (no referenceImages parameter)
|
||||||
|
{
|
||||||
|
"prompt": "A landscape based on @sunset with elements from @mountain"
|
||||||
|
// System automatically detects @sunset and @mountain
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual specification
|
||||||
|
{
|
||||||
|
"prompt": "A landscape",
|
||||||
|
"referenceImages": ["@sunset", "image-uuid-123"]
|
||||||
|
// System uses only specified images
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined
|
||||||
|
{
|
||||||
|
"prompt": "A landscape based on @sunset",
|
||||||
|
"referenceImages": ["@mountain"]
|
||||||
|
// System uses both @mountain (manual) and @sunset (auto-detected)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation notes:**
|
||||||
|
- Alias detection must use the same validation rules as alias creation
|
||||||
|
- Invalid aliases in prompt should be logged but not cause generation failure
|
||||||
|
- Maximum reference images limit still applies after combining manual + auto-detected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Enhanced Prompt Support - Logic Redesign
|
||||||
|
|
||||||
|
### 2.1 Database Schema Changes
|
||||||
|
|
||||||
|
**Required schema modifications:**
|
||||||
|
|
||||||
|
1. **Rename field:** `enhancedPrompt` → `originalPrompt`
|
||||||
|
2. **Change field semantics:**
|
||||||
|
- `prompt` - ALWAYS contains the prompt that was used for generation (enhanced or original)
|
||||||
|
- `originalPrompt` - ALWAYS contains user's original input (for transparency and audit trail)
|
||||||
|
|
||||||
|
**Field population logic:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Case 1: autoEnhance = false
|
||||||
|
prompt = user input
|
||||||
|
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", // Always the user's original input
|
||||||
|
"autoEnhance": true // True if prompt differs from originalPrompt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Affected endpoints:**
|
||||||
|
- `POST /api/v1/generations` response
|
||||||
|
- `GET /api/v1/generations/:id` response
|
||||||
|
- `GET /api/v1/generations` list response
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Regeneration Endpoint Refactoring ✅
|
||||||
|
|
||||||
|
### 3.1 Endpoint Rename
|
||||||
|
|
||||||
|
**Change:**
|
||||||
|
- ❌ OLD: `POST /api/v1/generations/:id/retry`
|
||||||
|
- ✅ NEW: `POST /api/v1/generations/:id/regenerate`
|
||||||
|
|
||||||
|
### 3.2 Remove Status Checks
|
||||||
|
|
||||||
|
- Remove `if (original.status === 'success') throw error` check
|
||||||
|
- Remove `GENERATION_ALREADY_SUCCEEDED` error constant
|
||||||
|
- Allow regeneration for any status (pending, processing, success, failed)
|
||||||
|
|
||||||
|
### 3.3 Remove Retry Logic
|
||||||
|
|
||||||
|
- Remove `retryCount >= MAX_RETRY_COUNT` check
|
||||||
|
- Remove retryCount increment
|
||||||
|
- Remove `MAX_RETRY_COUNT` constant
|
||||||
|
|
||||||
|
### 3.4 Remove Override Parameters
|
||||||
|
|
||||||
|
- Remove `prompt` and `aspectRatio` parameters from request body
|
||||||
|
- Always regenerate with exact same parameters as original
|
||||||
|
|
||||||
|
### 3.5 Image Update Behavior
|
||||||
|
|
||||||
|
**Update existing image instead of creating new:**
|
||||||
|
|
||||||
|
**Preserve:**
|
||||||
|
- `imageId` (UUID remains the same)
|
||||||
|
- `storageKey` (MinIO path)
|
||||||
|
- `storageUrl`
|
||||||
|
- `alias` (if assigned)
|
||||||
|
- `createdAt` (original creation timestamp)
|
||||||
|
|
||||||
|
**Update:**
|
||||||
|
- Physical file in MinIO (overwrite)
|
||||||
|
- `fileSize` (if changed)
|
||||||
|
- `updatedAt` timestamp
|
||||||
|
|
||||||
|
**Generation record:**
|
||||||
|
- Update `status` → processing → success/failed
|
||||||
|
- Update `processingTimeMs`
|
||||||
|
- Keep `outputImageId` (same value)
|
||||||
|
- Keep `flowId` (if present)
|
||||||
|
|
||||||
|
### 3.6 Additional Endpoint
|
||||||
|
|
||||||
|
**Add for Flow:**
|
||||||
|
- `POST /api/v1/flows/:id/regenerate`
|
||||||
|
- Regenerates the most recent generation in flow
|
||||||
|
- Returns `FLOW_HAS_NO_GENERATIONS` error if flow is empty
|
||||||
|
- Uses parameters from the last generation in flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Flow Auto-Creation (Lazy Flow Pattern)
|
||||||
|
|
||||||
|
### 4.1 Lazy Flow Creation Strategy
|
||||||
|
|
||||||
|
**Concept:**
|
||||||
|
1. **First request without flowId** → return generated `flowId` in response, but **DO NOT create in DB**
|
||||||
|
2. **Any request with valid flowId** → create flow in DB if doesn't exist, add this request to flow
|
||||||
|
3. **If flowAlias specified in request** → create flow immediately (eager creation)
|
||||||
|
|
||||||
|
### 4.2 Implementation Details
|
||||||
|
|
||||||
|
**Flow ID Generation:**
|
||||||
|
- When generation/upload has no flowId, generate UUID for potential flow
|
||||||
|
- Return this flowId in response
|
||||||
|
- Save `flowId` in generation/image record, but DO NOT create flow record
|
||||||
|
|
||||||
|
**Flow Creation in DB:**
|
||||||
|
|
||||||
|
**Trigger:** ANY request with valid flowId value
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Check if flow record exists in DB
|
||||||
|
2. Check if there are existing generations/images with this flowId
|
||||||
|
3. If flow doesn't exist:
|
||||||
|
- Create flow record with provided flowId
|
||||||
|
- Include all existing records with this flowId
|
||||||
|
- Maintain chronological order based on createdAt timestamps
|
||||||
|
4. If flow exists:
|
||||||
|
- Add new record to existing flow
|
||||||
|
|
||||||
|
**Eager creation:**
|
||||||
|
- If request includes `flowAlias` → create flow immediately
|
||||||
|
- Set alias in `flow.aliases` object
|
||||||
|
|
||||||
|
**Database Schema:**
|
||||||
|
- `generations` table already has `flowId` field (foreign key to flows.id)
|
||||||
|
- `images` table already has `flowId` field (foreign key to flows.id)
|
||||||
|
- No schema changes needed
|
||||||
|
|
||||||
|
**Orphan flowId handling:**
|
||||||
|
- If `flowId` exists in generation/image record but not in `flows` table - this is normal
|
||||||
|
- Such records are called "orphans" and simply not shown in `GET /api/v1/flows` list
|
||||||
|
- No cleanup job needed
|
||||||
|
- Do NOT delete such records automatically
|
||||||
|
- System works correctly with orphan flowIds until flow record is created
|
||||||
|
|
||||||
|
### 4.3 Endpoint Changes
|
||||||
|
|
||||||
|
**Remove:**
|
||||||
|
- ❌ `POST /api/v1/flows` endpoint (no longer needed)
|
||||||
|
|
||||||
|
**Modify responses:**
|
||||||
|
- `POST /api/v1/generations` → always return `flowId` in response (see section 10.1)
|
||||||
|
- `POST /api/v1/images/upload` → always return `flowId` in response (see section 10.1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Upload Image Enhancements
|
||||||
|
|
||||||
|
### 5.1 Add Parameters
|
||||||
|
|
||||||
|
**POST /api/v1/images/upload:**
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `alias` (optional, string) - project-scoped alias
|
||||||
|
- `flowAlias` (optional, string) - flow-scoped alias for uploaded image
|
||||||
|
- `flowId` (optional, string) - flow association
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- If `flowAlias` and `flowId` specified:
|
||||||
|
- Ensure flow exists (or create via lazy pattern)
|
||||||
|
- Add alias to `flow.aliases` object
|
||||||
|
- If `flowAlias` WITHOUT `flowId`:
|
||||||
|
- Apply lazy flow creation with eager pattern
|
||||||
|
- Create flow immediately, set flowAlias
|
||||||
|
- If only `alias` specified:
|
||||||
|
- Set project-scoped alias on image
|
||||||
|
|
||||||
|
### 5.2 Alias Conflict Resolution
|
||||||
|
|
||||||
|
**Validation rules:**
|
||||||
|
|
||||||
|
1. **Technical aliases are forbidden:**
|
||||||
|
- Cannot use: `@last`, `@first`, `@upload` or any reserved technical alias
|
||||||
|
- Return validation error if attempted
|
||||||
|
|
||||||
|
2. **Alias override behavior:**
|
||||||
|
- If alias already exists → new request has higher priority
|
||||||
|
- Alias points to new image
|
||||||
|
- Previous image loses its alias but is NOT deleted
|
||||||
|
- Same logic applies to both project aliases and flow aliases
|
||||||
|
|
||||||
|
3. **Applies to both:**
|
||||||
|
- Image upload with alias
|
||||||
|
- Generation with alias/flowAlias
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
State: Image A has alias "@hero"
|
||||||
|
Request: Upload Image B with alias "@hero"
|
||||||
|
Result:
|
||||||
|
- Image B now has alias "@hero"
|
||||||
|
- Image A loses alias (alias = NULL)
|
||||||
|
- Image A is NOT deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Image Alias Management Refactoring
|
||||||
|
|
||||||
|
### 6.1 Endpoint Consolidation
|
||||||
|
|
||||||
|
**Remove alias handling from:**
|
||||||
|
- ❌ `PUT /api/v1/images/:id` (body: { alias, focalPoint, meta })
|
||||||
|
- Remove `alias` parameter
|
||||||
|
- Keep only `focalPoint` and `meta`
|
||||||
|
|
||||||
|
**Single method for project-scoped alias management:**
|
||||||
|
- ✅ `PUT /api/v1/images/:id/alias` (body: { alias })
|
||||||
|
- Set new alias
|
||||||
|
- Change existing alias
|
||||||
|
- Remove alias (pass `alias: null`)
|
||||||
|
|
||||||
|
**Rationale:** Explicit intent, dedicated endpoint for alias operations, simpler validation.
|
||||||
|
|
||||||
|
### 6.2 Alias as Image Identifier
|
||||||
|
|
||||||
|
**Support alias in path parameters:**
|
||||||
|
|
||||||
|
**Syntax:**
|
||||||
|
- UUID: `GET /api/v1/images/550e8400-e29b-41d4-a716-446655440000`
|
||||||
|
- Alias: `GET /api/v1/images/@hero`
|
||||||
|
- `@` symbol distinguishes alias from UUID (UUIDs never contain `@`)
|
||||||
|
|
||||||
|
**UUID validation:** UUIDs can NEVER contain `@` symbol - this guarantees no conflicts
|
||||||
|
|
||||||
|
**Flow-scoped resolution:**
|
||||||
|
- `GET /api/v1/images/@hero?flowId=uuid-123`
|
||||||
|
- Searches for alias `@hero` in context of flow `uuid-123`
|
||||||
|
- Uses 3-tier precedence (technical → flow → project)
|
||||||
|
|
||||||
|
**Endpoints with alias support:**
|
||||||
|
- `GET /api/v1/images/:id_or_alias`
|
||||||
|
- `PUT /api/v1/images/:id_or_alias` (for focalPoint, meta)
|
||||||
|
- `PUT /api/v1/images/:id_or_alias/alias`
|
||||||
|
- `DELETE /api/v1/images/:id_or_alias`
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- Check first character of path parameter
|
||||||
|
- If starts with `@` → resolve via AliasService
|
||||||
|
- If doesn't start with `@` → treat as UUID
|
||||||
|
- After resolution, work with imageId as usual
|
||||||
|
|
||||||
|
### 6.3 CDN-style Image URLs with Alias Support
|
||||||
|
|
||||||
|
**Current URL format must be changed.**
|
||||||
|
|
||||||
|
**New standardized URL patterns:**
|
||||||
|
|
||||||
|
**For all generated and uploaded images:**
|
||||||
|
```
|
||||||
|
GET /cdn/:orgSlug/:projectSlug/img/:filenameOrAlias
|
||||||
|
```
|
||||||
|
|
||||||
|
**For live URLs:**
|
||||||
|
```
|
||||||
|
GET /cdn/:orgSlug/:projectSlug/live/:scope?prompt=...&aspectRatio=...
|
||||||
|
```
|
||||||
|
|
||||||
|
**All image URLs returned by API must follow this pattern.**
|
||||||
|
|
||||||
|
**Resolution Logic:**
|
||||||
|
1. Check if `:filenameOrAlias` starts with `@`
|
||||||
|
2. If yes → resolve alias via AliasService
|
||||||
|
3. If no → search by filename/storageKey
|
||||||
|
4. Return image bytes with proper content-type headers
|
||||||
|
|
||||||
|
**Response Headers:**
|
||||||
|
- Content-Type: image/jpeg (or appropriate MIME type)
|
||||||
|
- Cache-Control: public, max-age=31536000
|
||||||
|
- ETag: based on imageId or fileHash
|
||||||
|
|
||||||
|
**URL Encoding for prompts:**
|
||||||
|
- Spaces can be replaced with underscores `_` for convenience
|
||||||
|
- Both `prompt=beautiful%20sunset` and `prompt=beautiful_sunset` are valid
|
||||||
|
- System should handle both formats
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
GET /cdn/acme/website/img/@hero → resolve @hero alias
|
||||||
|
GET /cdn/acme/website/img/logo.png → find by filename
|
||||||
|
GET /cdn/acme/website/img/@product-1 → resolve @product-1 alias
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Handling:**
|
||||||
|
- Alias not found → 404
|
||||||
|
- Filename not found → 404
|
||||||
|
- Multiple matches → alias takes priority over filename
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deletion Strategy Overhaul
|
||||||
|
|
||||||
|
### 7.1 Image Deletion (Hard Delete)
|
||||||
|
|
||||||
|
**DELETE /api/v1/images/:id**
|
||||||
|
|
||||||
|
**Operations:**
|
||||||
|
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: **completely remove alias entries** from all `flow.aliases` where imageId is referenced
|
||||||
|
- Remove entire key-value pairs, not just values
|
||||||
|
5. Cascade: remove imageId from `generation.referencedImages` JSON arrays
|
||||||
|
|
||||||
|
**Example cascade for flow.aliases:**
|
||||||
|
```
|
||||||
|
Before: flow.aliases = { "@hero": "img-123", "@product": "img-456" }
|
||||||
|
Delete img-123
|
||||||
|
After: flow.aliases = { "@product": "img-456" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** User wants to delete - remove completely, free storage. Alias entries are also completely removed.
|
||||||
|
|
||||||
|
### 7.2 Generation Deletion (Conditional)
|
||||||
|
|
||||||
|
**DELETE /api/v1/generations/:id**
|
||||||
|
|
||||||
|
**Behavior depends on output image alias:**
|
||||||
|
|
||||||
|
**Case 1: Output image WITHOUT project alias**
|
||||||
|
1. Delete output image completely (hard delete with MinIO cleanup)
|
||||||
|
2. Delete generation record (hard delete)
|
||||||
|
|
||||||
|
**Case 2: Output image WITH project alias**
|
||||||
|
1. Keep output image (do not delete)
|
||||||
|
2. Delete only generation record (hard delete)
|
||||||
|
3. Set `generationId = NULL` in image record
|
||||||
|
|
||||||
|
**Decision Logic:**
|
||||||
|
- If `outputImage.alias !== null` → keep image, delete only generation
|
||||||
|
- If `outputImage.alias === null` → delete both image and generation
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Image with project alias is used as standalone asset, preserve it
|
||||||
|
- Image without alias was created only for this generation, delete together
|
||||||
|
|
||||||
|
**No regeneration of deleted generations** - deleted generations cannot be regenerated
|
||||||
|
|
||||||
|
### 7.3 Flow Deletion (Cascade with Alias Protection)
|
||||||
|
|
||||||
|
**DELETE /api/v1/flows/:id**
|
||||||
|
|
||||||
|
**Operations:**
|
||||||
|
1. Delete flow record from DB
|
||||||
|
2. Cascade: delete all generations associated with this flowId
|
||||||
|
3. Cascade: delete all images associated with this flowId **EXCEPT** images with project alias
|
||||||
|
|
||||||
|
**Detailed Cascade Logic:**
|
||||||
|
|
||||||
|
**For Generations:**
|
||||||
|
- Delete each generation (follows conditional delete from 7.2)
|
||||||
|
- If output image has no alias → delete image
|
||||||
|
- If output image has alias → keep image, set generationId = NULL, set flowId = NULL
|
||||||
|
|
||||||
|
**For Images (uploaded):**
|
||||||
|
- If image has no alias → delete (with MinIO cleanup)
|
||||||
|
- If image has alias → keep, set flowId = NULL
|
||||||
|
|
||||||
|
**Summary:**
|
||||||
|
- Flow record → DELETE
|
||||||
|
- All generations → DELETE
|
||||||
|
- Images without alias → DELETE (with MinIO cleanup)
|
||||||
|
- Images with project alias → KEEP (unlink: flowId = NULL)
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
Flow deletion removes all content except images with project aliases (used globally in project).
|
||||||
|
|
||||||
|
### 7.4 Transactional Delete Pattern
|
||||||
|
|
||||||
|
**All delete operations must be transactional:**
|
||||||
|
|
||||||
|
1. Delete from MinIO storage first
|
||||||
|
2. Delete from database (with cascades)
|
||||||
|
3. If MinIO delete fails → rollback DB transaction
|
||||||
|
4. If DB delete fails → cleanup MinIO file (or rollback if possible)
|
||||||
|
5. Log all delete operations for audit trail
|
||||||
|
|
||||||
|
**Principle:** System must be designed so orphaned files in MinIO NEVER occur.
|
||||||
|
|
||||||
|
**Database Constraints:**
|
||||||
|
- ON DELETE CASCADE for appropriate foreign keys
|
||||||
|
- ON DELETE SET NULL where related records must be preserved
|
||||||
|
- Proper referential integrity
|
||||||
|
|
||||||
|
**No background cleanup jobs needed** - system is self-sufficient and always consistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Live URL System
|
||||||
|
|
||||||
|
### 8.1 Core Concept
|
||||||
|
|
||||||
|
**Purpose:** Permanent URLs that can be immediately inserted into HTML and work forever.
|
||||||
|
|
||||||
|
**Use Case:**
|
||||||
|
```html
|
||||||
|
<img src="https://banatie.app/cdn/acme/website/live/hero-section?prompt=beautiful_sunset&aspectRatio=16:9"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Points:**
|
||||||
|
- URL is constructed immediately and used permanently
|
||||||
|
- No preliminary generation through API needed
|
||||||
|
- No signed URLs or tokens in query params
|
||||||
|
- First request → generation, subsequent → cache
|
||||||
|
|
||||||
|
### 8.2 URL Format & Structure
|
||||||
|
|
||||||
|
**URL Pattern:**
|
||||||
|
```
|
||||||
|
/cdn/:orgSlug/:projectSlug/live/:scope?prompt=...&aspectRatio=...
|
||||||
|
```
|
||||||
|
|
||||||
|
**URL Components:**
|
||||||
|
```
|
||||||
|
/cdn/acme/website/live/hero-section?prompt=beautiful_sunset&aspectRatio=16:9
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ │ └─ Generation params (query string)
|
||||||
|
│ │ │ └─ Scope identifier
|
||||||
|
│ │ └─ "live" prefix
|
||||||
|
│ └─ Project slug
|
||||||
|
└─ Organization slug
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scope Parameter:**
|
||||||
|
- Name: `scope` (confirmed)
|
||||||
|
- Purpose: logical separation of live URLs within project
|
||||||
|
- Format: alphanumeric + hyphens + underscores
|
||||||
|
- Any user can specify any scope (no validation/signature required)
|
||||||
|
|
||||||
|
### 8.3 First Request Flow
|
||||||
|
|
||||||
|
**Cache MISS (first request):**
|
||||||
|
1. Parse orgSlug, projectSlug, scope from URL
|
||||||
|
2. Compute cache key: hash(projectId + scope + prompt + params)
|
||||||
|
3. Check if image exists in cache
|
||||||
|
4. If NOT found:
|
||||||
|
- Check scope settings (allowNewGenerations, limit)
|
||||||
|
- Trigger image generation
|
||||||
|
- Create database records (generation, image, cache entry)
|
||||||
|
- Wait for generation to complete
|
||||||
|
- Return image bytes
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
- Content-Type: image/jpeg
|
||||||
|
- Cache-Control: public, max-age=31536000
|
||||||
|
- X-Cache-Status: MISS
|
||||||
|
- X-Scope: hero-section
|
||||||
|
- X-Image-Id: uuid
|
||||||
|
|
||||||
|
**Cache HIT (subsequent requests):**
|
||||||
|
1. Same cache key lookup
|
||||||
|
2. Found existing image
|
||||||
|
3. Return cached image bytes immediately
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
- Content-Type: image/jpeg
|
||||||
|
- Cache-Control: public, max-age=31536000
|
||||||
|
- X-Cache-Status: HIT
|
||||||
|
- X-Image-Id: uuid
|
||||||
|
|
||||||
|
**Generation in Progress:**
|
||||||
|
- If image is not in cache but generation is already running:
|
||||||
|
- System must have internal status to track this
|
||||||
|
- Wait for generation to complete
|
||||||
|
- Return image bytes immediately when ready
|
||||||
|
- This ensures consistent behavior for concurrent requests
|
||||||
|
|
||||||
|
### 8.4 Scope Management
|
||||||
|
|
||||||
|
**Database Table: `live_scopes`**
|
||||||
|
|
||||||
|
Create dedicated table with fields:
|
||||||
|
- `id` (UUID, primary key)
|
||||||
|
- `project_id` (UUID, foreign key to projects)
|
||||||
|
- `slug` (TEXT, unique within project) - used in URL
|
||||||
|
- `allowNewGenerations` (BOOLEAN, default: true) - controls if new generations can be triggered
|
||||||
|
- `newGenerationsLimit` (INTEGER, default: 30) - max number of generations in this scope
|
||||||
|
- `created_at` (TIMESTAMP)
|
||||||
|
- `updated_at` (TIMESTAMP)
|
||||||
|
|
||||||
|
**Scope Behavior:**
|
||||||
|
|
||||||
|
**allowNewGenerations:**
|
||||||
|
- Controls whether new generations can be triggered in this scope
|
||||||
|
- Already generated images are ALWAYS served publicly regardless of this setting
|
||||||
|
- Default: true
|
||||||
|
|
||||||
|
**newGenerationsLimit:**
|
||||||
|
- Limit on number of generations in this scope
|
||||||
|
- Only affects NEW generations, does not affect regeneration
|
||||||
|
- Default: 30
|
||||||
|
|
||||||
|
**Scope Creation:**
|
||||||
|
- Manual: via dedicated endpoint (see below)
|
||||||
|
- Automatic: when new scope is used in live URL (if project allows)
|
||||||
|
|
||||||
|
**Project-level Settings:**
|
||||||
|
|
||||||
|
Add to projects table or settings:
|
||||||
|
- `allowNewLiveScopes` (BOOLEAN, default: true) - allows creating new scopes via live URLs
|
||||||
|
- If false: new scopes cannot be created via live URL
|
||||||
|
- If false: scopes can still be created via API endpoint
|
||||||
|
- `newLiveScopesGenerationLimit` (INTEGER, default: 30) - generation limit for auto-created scopes
|
||||||
|
- This value is set as `newGenerationsLimit` for newly created scopes
|
||||||
|
|
||||||
|
### 8.5 Scope Management API
|
||||||
|
|
||||||
|
**Create scope (manual):**
|
||||||
|
```
|
||||||
|
POST /api/v1/live/scopes
|
||||||
|
Headers: X-API-Key: bnt_project_key
|
||||||
|
Body: {
|
||||||
|
"slug": "hero-section",
|
||||||
|
"allowNewGenerations": true,
|
||||||
|
"newGenerationsLimit": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**List scopes:**
|
||||||
|
```
|
||||||
|
GET /api/v1/live/scopes
|
||||||
|
Headers: X-API-Key: bnt_project_key
|
||||||
|
Response: {
|
||||||
|
"scopes": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"slug": "hero-section",
|
||||||
|
"allowNewGenerations": true,
|
||||||
|
"newGenerationsLimit": 50,
|
||||||
|
"currentGenerations": 23,
|
||||||
|
"lastGeneratedAt": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get scope details:**
|
||||||
|
```
|
||||||
|
GET /api/v1/live/scopes/:slug
|
||||||
|
Headers: X-API-Key: bnt_project_key
|
||||||
|
Response: {
|
||||||
|
"id": "uuid",
|
||||||
|
"slug": "hero-section",
|
||||||
|
"allowNewGenerations": true,
|
||||||
|
"newGenerationsLimit": 50,
|
||||||
|
"currentGenerations": 23,
|
||||||
|
"images": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update scope:**
|
||||||
|
```
|
||||||
|
PUT /api/v1/live/scopes/:slug
|
||||||
|
Headers: X-API-Key: bnt_project_key
|
||||||
|
Body: {
|
||||||
|
"allowNewGenerations": false,
|
||||||
|
"newGenerationsLimit": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regenerate scope images:**
|
||||||
|
```
|
||||||
|
POST /api/v1/live/scopes/:slug/regenerate
|
||||||
|
Headers: X-API-Key: bnt_project_key
|
||||||
|
Body: { "imageId": "uuid" } // Optional: regenerate specific image
|
||||||
|
Response: {
|
||||||
|
"regenerated": 1,
|
||||||
|
"images": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delete scope:**
|
||||||
|
```
|
||||||
|
DELETE /api/v1/live/scopes/:slug
|
||||||
|
Headers: X-API-Key: bnt_project_key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deletion behavior:** Deletes all images in this scope (follows standard image deletion with alias protection).
|
||||||
|
|
||||||
|
### 8.6 Security & Rate Limiting
|
||||||
|
|
||||||
|
**Rate Limiting by IP:**
|
||||||
|
- Aggressive limits for live URLs (e.g., 10 new generations per hour per IP)
|
||||||
|
- Separate from API key limits
|
||||||
|
- Cache hits do NOT count toward limit
|
||||||
|
- Only new generations count
|
||||||
|
|
||||||
|
**Scope Quotas:**
|
||||||
|
- Maximum N unique prompts per scope (newGenerationsLimit)
|
||||||
|
- After limit reached → return existing images, do not generate new
|
||||||
|
- Regeneration does not count toward limit
|
||||||
|
|
||||||
|
### 8.7 Caching Strategy
|
||||||
|
|
||||||
|
**Cache Key:**
|
||||||
|
```
|
||||||
|
cacheKey = hash(projectId + scope + prompt + aspectRatio + otherParams)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cache Invalidation:**
|
||||||
|
- Manual: via API endpoint regenerate
|
||||||
|
- Automatic: never (images cached forever unless explicitly regenerated)
|
||||||
|
|
||||||
|
**Scope Naming:** `scope` (confirmed)
|
||||||
|
|
||||||
|
**URL Encoding:**
|
||||||
|
- Prompt in query string: URL-encoded or underscores for spaces
|
||||||
|
- Both formats supported: `prompt=beautiful%20sunset` and `prompt=beautiful_sunset`
|
||||||
|
- Scope in path: alphanumeric + hyphens + underscores
|
||||||
|
|
||||||
|
### 8.8 Error Handling
|
||||||
|
|
||||||
|
**Detailed errors for live URLs:**
|
||||||
|
|
||||||
|
- Invalid scope format → 400 "Invalid scope format. Use alphanumeric characters, hyphens, and underscores"
|
||||||
|
- New scope creation disabled → 403 "Creating new live scopes is disabled for this project"
|
||||||
|
- Generation limit exceeded → 429 "Scope generation limit exceeded. Maximum N generations per scope"
|
||||||
|
- Generation fails → 500 with retry logic
|
||||||
|
- Rate limit by IP exceeded → 429 "Rate limit exceeded. Try again in X seconds" with Retry-After header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Generation Modification
|
||||||
|
|
||||||
|
### 9.1 Update Generation Endpoint
|
||||||
|
|
||||||
|
**New endpoint:**
|
||||||
|
```
|
||||||
|
PUT /api/v1/generations/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modifiable Fields:**
|
||||||
|
- `prompt` - change prompt
|
||||||
|
- `aspectRatio` - change aspect ratio
|
||||||
|
- `flowId` - change/remove/add flow association
|
||||||
|
- `meta` - update metadata
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
**Case 1: Non-generative parameters (flowId, meta)**
|
||||||
|
- Simply update fields in DB
|
||||||
|
- Do NOT regenerate image
|
||||||
|
|
||||||
|
**Case 2: Generative parameters (prompt, aspectRatio)**
|
||||||
|
- Update fields in DB
|
||||||
|
- Automatically trigger regeneration
|
||||||
|
- Update existing image (same imageId, path, URL)
|
||||||
|
|
||||||
|
### 9.2 FlowId Management
|
||||||
|
|
||||||
|
**FlowId handling:**
|
||||||
|
- `flowId: null` → detach from flow
|
||||||
|
- `flowId: "new-uuid"` → attach to different flow
|
||||||
|
- If flow doesn't exist → create new flow eagerly (with this flowId)
|
||||||
|
- If flow exists → add generation to existing flow
|
||||||
|
- `flowId: undefined` → do not change current value
|
||||||
|
|
||||||
|
**Use Case - "Detach from Flow":**
|
||||||
|
- Set `flowId: null` to detach generation from flow
|
||||||
|
- Output image is preserved (if has alias)
|
||||||
|
- Useful before deleting flow to protect important generations
|
||||||
|
|
||||||
|
### 9.3 Validation Rules
|
||||||
|
|
||||||
|
**Use existing validation logic from generation creation:**
|
||||||
|
- Prompt validation (existing rules)
|
||||||
|
- AspectRatio validation (existing rules)
|
||||||
|
- FlowId validation:
|
||||||
|
- If provided (not null): must be valid UUID format
|
||||||
|
- Flow does NOT need to exist (will be created eagerly if missing)
|
||||||
|
- Allow null explicitly (for detachment)
|
||||||
|
|
||||||
|
### 9.4 Response Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "gen-uuid",
|
||||||
|
"prompt": "updated prompt",
|
||||||
|
"aspectRatio": "16:9",
|
||||||
|
"flowId": null,
|
||||||
|
"status": "processing", // If regeneration triggered
|
||||||
|
"regenerated": true, // Flag indicating regeneration started
|
||||||
|
"outputImage": { ... } // Current image (updates when regeneration completes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Response Format Consistency
|
||||||
|
|
||||||
|
### 10.1 FlowId in Responses
|
||||||
|
|
||||||
|
**Rule for flowId in generation and upload responses:**
|
||||||
|
|
||||||
|
**If request has `flowId: undefined` (not provided):**
|
||||||
|
- Generate new flowId
|
||||||
|
- Return in response: `"flowId": "new-uuid"`
|
||||||
|
|
||||||
|
**If request has `flowId: null` (explicitly null):**
|
||||||
|
- Do NOT generate flowId
|
||||||
|
- Flow is definitely not needed
|
||||||
|
- Return in response: `"flowId": null`
|
||||||
|
|
||||||
|
**If request has `flowId: "uuid"` (specific value):**
|
||||||
|
- Use provided flowId
|
||||||
|
- Return in response: `"flowId": "uuid"`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```json
|
||||||
|
// Request without flowId
|
||||||
|
POST /api/v1/generations
|
||||||
|
Body: { "prompt": "sunset" }
|
||||||
|
Response: { "flowId": "generated-uuid", ... }
|
||||||
|
|
||||||
|
// Request with explicit null
|
||||||
|
POST /api/v1/generations
|
||||||
|
Body: { "prompt": "sunset", "flowId": null }
|
||||||
|
Response: { "flowId": null, ... }
|
||||||
|
|
||||||
|
// Request with specific flowId
|
||||||
|
POST /api/v1/generations
|
||||||
|
Body: { "prompt": "sunset", "flowId": "my-flow-uuid" }
|
||||||
|
Response: { "flowId": "my-flow-uuid", ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Error Messages Updates
|
||||||
|
|
||||||
|
**Remove constants:**
|
||||||
|
- `GENERATION_ALREADY_SUCCEEDED` (no longer needed)
|
||||||
|
- `MAX_RETRY_COUNT_EXCEEDED` (no longer needed)
|
||||||
|
|
||||||
|
**Add constants:**
|
||||||
|
- `SCOPE_INVALID_FORMAT` - "Invalid scope format. Use alphanumeric characters, hyphens, and underscores"
|
||||||
|
- `SCOPE_CREATION_DISABLED` - "Creating new live scopes is disabled for this project"
|
||||||
|
- `SCOPE_GENERATION_LIMIT_EXCEEDED` - "Scope generation limit exceeded. Maximum {limit} generations per scope"
|
||||||
|
- `STORAGE_DELETE_FAILED` - "Failed to delete file from storage"
|
||||||
|
|
||||||
|
**Update constants:**
|
||||||
|
- `GENERATION_FAILED` - include details about network/storage errors
|
||||||
|
- `IMAGE_NOT_FOUND` - distinguish between deleted and never existed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Code Documentation Standards
|
||||||
|
|
||||||
|
### 12.1 Endpoint JSDoc Comments
|
||||||
|
|
||||||
|
**Requirement:** Every API endpoint must have comprehensive JSDoc comment.
|
||||||
|
|
||||||
|
**Required sections:**
|
||||||
|
|
||||||
|
1. **Purpose:** What this endpoint does (one sentence)
|
||||||
|
2. **Logic:** Brief description of how it works (2-3 key steps)
|
||||||
|
3. **Parameters:** Description of each parameter and what it affects
|
||||||
|
4. **Authentication:** Required authentication level
|
||||||
|
5. **Response:** What is returned
|
||||||
|
|
||||||
|
**Example format:**
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Generate new image from text prompt with optional reference images.
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* 1. Parse prompt to auto-detect reference image aliases
|
||||||
|
* 2. Resolve all aliases (auto-detected + manual) to image IDs
|
||||||
|
* 3. Trigger AI generation with prompt and reference images
|
||||||
|
* 4. Store result with metadata and return generation record
|
||||||
|
*
|
||||||
|
* @param {string} prompt - Text description for image generation (affects: output style and content)
|
||||||
|
* @param {string[]} referenceImages - Optional aliases/IDs for reference images (affects: visual style transfer)
|
||||||
|
* @param {string} aspectRatio - Image dimensions ratio (affects: output dimensions, default: 1:1)
|
||||||
|
* @param {string} flowId - Optional flow association (affects: organization and flow-scoped aliases)
|
||||||
|
* @param {string} alias - Optional project-scoped alias (affects: image referencing across project)
|
||||||
|
* @param {string} flowAlias - Optional flow-scoped alias (affects: image referencing within flow)
|
||||||
|
* @param {boolean} autoEnhance - Enable AI prompt enhancement (affects: prompt quality and detail)
|
||||||
|
* @param {object} meta - Custom metadata (affects: searchability and organization)
|
||||||
|
*
|
||||||
|
* @authentication Project Key required
|
||||||
|
* @returns {GenerationResponse} Generation record with status and output image details
|
||||||
|
*/
|
||||||
|
router.post('/generations', ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apply to:**
|
||||||
|
- All route handlers in `/routes/**/*.ts`
|
||||||
|
- All public service methods that implement core business logic
|
||||||
|
- Complex utility functions with non-obvious behavior
|
||||||
|
|
||||||
|
**Parameter descriptions must include "affects:"**
|
||||||
|
- Explain what each parameter influences in the system
|
||||||
|
- Help developers understand parameter impact
|
||||||
|
- Make API more discoverable and self-documenting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
1. Rename `enhancedPrompt` → `originalPrompt` in generations table
|
||||||
|
2. Create `live_scopes` table with fields: id, project_id, slug, allowNewGenerations, newGenerationsLimit
|
||||||
|
3. Add project settings: allowNewLiveScopes, newLiveScopesGenerationLimit
|
||||||
|
4. Add `scope` and `isLiveUrl` fields to images table (optional, can use meta)
|
||||||
|
|
||||||
|
### API Changes
|
||||||
|
1. Rename parameters: assignAlias → alias, assignFlowAlias → flowAlias
|
||||||
|
2. Make referenceImages parameter optional with auto-detection from prompt
|
||||||
|
3. Rename endpoint: POST /generations/:id/retry → /generations/:id/regenerate
|
||||||
|
4. Remove endpoint: POST /api/v1/flows (no longer needed)
|
||||||
|
5. Add endpoint: POST /api/v1/flows/:id/regenerate
|
||||||
|
6. Add endpoint: PUT /api/v1/generations/:id (modification)
|
||||||
|
7. Add CDN endpoints:
|
||||||
|
- GET /cdn/:org/:project/img/:filenameOrAlias (all images)
|
||||||
|
- GET /cdn/:org/:project/live/:scope (live URLs)
|
||||||
|
8. Add scope management endpoints (CRUD for live_scopes)
|
||||||
|
9. Update all image URLs in API responses to use CDN format
|
||||||
|
|
||||||
|
### Behavior Changes
|
||||||
|
1. Lazy flow creation (create on second request or when flowAlias present)
|
||||||
|
2. Alias conflict resolution (new overwrites old)
|
||||||
|
3. Regenerate updates existing image (same ID, path, URL)
|
||||||
|
4. Hard delete for images (with MinIO cleanup)
|
||||||
|
5. Conditional delete for generations (based on alias)
|
||||||
|
6. Cascade delete for flows (with alias protection)
|
||||||
|
7. Live URL caching and scope management
|
||||||
|
8. FlowId in responses (generate if undefined, keep if null)
|
||||||
|
9. Auto-detect reference images from prompt aliases
|
||||||
|
|
||||||
|
### Validation Changes
|
||||||
|
1. @ symbol distinguishes aliases from UUIDs
|
||||||
|
2. Technical aliases forbidden in user input
|
||||||
|
3. Flow creation on-the-fly for non-existent flowIds
|
||||||
|
4. Scope format validation for live URLs
|
||||||
|
|
||||||
|
### Documentation Changes
|
||||||
|
1. Add comprehensive JSDoc comments to all endpoints
|
||||||
|
2. Include purpose, logic, parameters with "affects" descriptions
|
||||||
|
3. Document authentication requirements in comments
|
||||||
|
|
@ -43,10 +43,12 @@
|
||||||
"@google/genai": "^1.22.0",
|
"@google/genai": "^1.22.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.2",
|
||||||
|
"drizzle-orm": "^0.36.4",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^7.4.1",
|
"express-rate-limit": "^7.4.1",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
|
"image-size": "^2.0.2",
|
||||||
"mime": "3.0.0",
|
"mime": "3.0.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import express, { Application } from 'express';
|
import express, { Application } from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import { Config } from './types/api';
|
import { Config } from './types/api';
|
||||||
import { textToImageRouter } from './routes/textToImage';
|
import { textToImageRouter } from './routes/textToImage';
|
||||||
import { imagesRouter } from './routes/images';
|
import { imagesRouter } from './routes/images';
|
||||||
import { uploadRouter } from './routes/upload';
|
import { uploadRouter } from './routes/upload';
|
||||||
import bootstrapRoutes from './routes/bootstrap';
|
import bootstrapRoutes from './routes/bootstrap';
|
||||||
import adminKeysRoutes from './routes/admin/keys';
|
import adminKeysRoutes from './routes/admin/keys';
|
||||||
|
import { v1Router } from './routes/v1';
|
||||||
|
import { cdnRouter } from './routes/cdn';
|
||||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
|
|
@ -42,7 +45,7 @@ export const createApp = (): Application => {
|
||||||
|
|
||||||
// Request ID middleware for logging
|
// Request ID middleware for logging
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
req.requestId = Math.random().toString(36).substr(2, 9);
|
req.requestId = randomUUID();
|
||||||
res.setHeader('X-Request-ID', req.requestId);
|
res.setHeader('X-Request-ID', req.requestId);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
@ -110,13 +113,19 @@ export const createApp = (): Application => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public routes (no authentication)
|
// Public routes (no authentication)
|
||||||
|
// CDN routes for serving images and live URLs (public, no auth)
|
||||||
|
app.use('/cdn', cdnRouter);
|
||||||
|
|
||||||
// Bootstrap route (no auth, but works only once)
|
// Bootstrap route (no auth, but works only once)
|
||||||
app.use('/api/bootstrap', bootstrapRoutes);
|
app.use('/api/bootstrap', bootstrapRoutes);
|
||||||
|
|
||||||
// Admin routes (require master key)
|
// Admin routes (require master key)
|
||||||
app.use('/api/admin/keys', adminKeysRoutes);
|
app.use('/api/admin/keys', adminKeysRoutes);
|
||||||
|
|
||||||
// Protected API routes (require valid API key)
|
// API v1 routes (versioned, require valid API key)
|
||||||
|
app.use('/api/v1', v1Router);
|
||||||
|
|
||||||
|
// Protected API routes (require valid API key) - Legacy
|
||||||
app.use('/api', textToImageRouter);
|
app.use('/api', textToImageRouter);
|
||||||
app.use('/api', imagesRouter);
|
app.use('/api', imagesRouter);
|
||||||
app.use('/api', uploadRouter);
|
app.use('/api', uploadRouter);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP-based rate limiter for live URL generation (Section 8.6)
|
||||||
|
*
|
||||||
|
* Limits: 10 new generations per hour per IP address
|
||||||
|
* - Separate from API key rate limits
|
||||||
|
* - Cache hits do NOT count toward limit
|
||||||
|
* - Only new generations (cache MISS) count
|
||||||
|
*
|
||||||
|
* Implementation uses in-memory store with automatic cleanup
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
resetAt: number; // Timestamp when count resets
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory store for IP rate limits
|
||||||
|
// Key: IP address, Value: { count, resetAt }
|
||||||
|
const ipRateLimits = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
const MAX_REQUESTS_PER_WINDOW = 10; // 10 new generations per hour
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address from request
|
||||||
|
* Supports X-Forwarded-For header for proxy/load balancer setups
|
||||||
|
*/
|
||||||
|
const getClientIp = (req: Request): string => {
|
||||||
|
// Check X-Forwarded-For header (used by proxies/load balancers)
|
||||||
|
const forwardedFor = req.headers['x-forwarded-for'];
|
||||||
|
if (forwardedFor) {
|
||||||
|
// X-Forwarded-For can contain multiple IPs, take the first one
|
||||||
|
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
||||||
|
return ips?.split(',')[0]?.trim() || req.ip || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to req.ip
|
||||||
|
return req.ip || 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired entries from the rate limit store
|
||||||
|
* Called periodically to prevent memory leaks
|
||||||
|
*/
|
||||||
|
const cleanupExpiredEntries = (): void => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [ip, entry] of ipRateLimits.entries()) {
|
||||||
|
if (now > entry.resetAt) {
|
||||||
|
ipRateLimits.delete(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run cleanup every 5 minutes
|
||||||
|
setInterval(cleanupExpiredEntries, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IP has exceeded rate limit
|
||||||
|
* Returns true if limit exceeded, false otherwise
|
||||||
|
*/
|
||||||
|
export const checkIpRateLimit = (ip: string): boolean => {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = ipRateLimits.get(ip);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
// First request from this IP
|
||||||
|
ipRateLimits.set(ip, {
|
||||||
|
count: 1,
|
||||||
|
resetAt: now + RATE_LIMIT_WINDOW_MS,
|
||||||
|
});
|
||||||
|
return false; // Not limited
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if (now > entry.resetAt) {
|
||||||
|
// Reset the counter
|
||||||
|
entry.count = 1;
|
||||||
|
entry.resetAt = now + RATE_LIMIT_WINDOW_MS;
|
||||||
|
return false; // Not limited
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment counter
|
||||||
|
entry.count += 1;
|
||||||
|
|
||||||
|
// Check if limit exceeded
|
||||||
|
return entry.count > MAX_REQUESTS_PER_WINDOW;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining requests for IP
|
||||||
|
*/
|
||||||
|
export const getRemainingRequests = (ip: string): number => {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = ipRateLimits.get(ip);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return MAX_REQUESTS_PER_WINDOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if (now > entry.resetAt) {
|
||||||
|
return MAX_REQUESTS_PER_WINDOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, MAX_REQUESTS_PER_WINDOW - entry.count);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get time until rate limit resets (in seconds)
|
||||||
|
*/
|
||||||
|
export const getResetTime = (ip: string): number => {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = ipRateLimits.get(ip);
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: IP-based rate limiter for live URLs
|
||||||
|
* Only increments counter on cache MISS (new generation)
|
||||||
|
* Use this middleware BEFORE cache check, but only increment after cache MISS
|
||||||
|
*/
|
||||||
|
export const ipRateLimiterMiddleware = (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const ip = getClientIp(req);
|
||||||
|
|
||||||
|
// Attach IP to request for later use
|
||||||
|
(req as any).clientIp = ip;
|
||||||
|
|
||||||
|
// Attach rate limit check function to request
|
||||||
|
(req as any).checkIpRateLimit = () => {
|
||||||
|
const limited = checkIpRateLimit(ip);
|
||||||
|
if (limited) {
|
||||||
|
const resetTime = getResetTime(ip);
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: `Rate limit exceeded. Try again in ${resetTime} seconds`,
|
||||||
|
code: 'IP_RATE_LIMIT_EXCEEDED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.setHeader('Retry-After', resetTime.toString());
|
||||||
|
res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString());
|
||||||
|
res.setHeader('X-RateLimit-Remaining', '0');
|
||||||
|
res.setHeader('X-RateLimit-Reset', getResetTime(ip).toString());
|
||||||
|
return true; // Limited
|
||||||
|
}
|
||||||
|
return false; // Not limited
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set rate limit headers
|
||||||
|
const remaining = getRemainingRequests(ip);
|
||||||
|
const resetTime = getResetTime(ip);
|
||||||
|
res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString());
|
||||||
|
res.setHeader('X-RateLimit-Remaining', remaining.toString());
|
||||||
|
if (resetTime > 0) {
|
||||||
|
res.setHeader('X-RateLimit-Reset', resetTime.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to manually increment IP rate limit counter
|
||||||
|
* Use this after confirming cache MISS (new generation)
|
||||||
|
*/
|
||||||
|
export const incrementIpRateLimit = (_ip: string): void => {
|
||||||
|
// Counter already incremented in checkIpRateLimit
|
||||||
|
// This is a no-op, kept for API consistency
|
||||||
|
};
|
||||||
|
|
@ -81,8 +81,6 @@ export const autoEnhancePrompt = async (
|
||||||
}),
|
}),
|
||||||
enhancements: result.metadata?.enhancements || [],
|
enhancements: result.metadata?.enhancements || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
req.body.prompt = result.enhancedPrompt;
|
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`);
|
console.warn(`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`);
|
||||||
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);
|
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,481 @@
|
||||||
|
import { Response, Router } from 'express';
|
||||||
|
import type { Router as RouterType } from 'express';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { organizations, projects, images } from '@banatie/database';
|
||||||
|
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||||
|
import { ImageService, GenerationService, LiveScopeService } from '@/services/core';
|
||||||
|
import { StorageFactory } from '@/services/StorageFactory';
|
||||||
|
import { asyncHandler } from '@/middleware/errorHandler';
|
||||||
|
import { ipRateLimiterMiddleware } from '@/middleware/ipRateLimiter';
|
||||||
|
import { computeLiveUrlCacheKey } from '@/utils/helpers';
|
||||||
|
import { GENERATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants';
|
||||||
|
import type { LiveGenerationQuery } from '@/types/requests';
|
||||||
|
|
||||||
|
export const cdnRouter: RouterType = Router();
|
||||||
|
|
||||||
|
let imageService: ImageService;
|
||||||
|
let generationService: GenerationService;
|
||||||
|
let liveScopeService: LiveScopeService;
|
||||||
|
|
||||||
|
const getImageService = (): ImageService => {
|
||||||
|
if (!imageService) {
|
||||||
|
imageService = new ImageService();
|
||||||
|
}
|
||||||
|
return imageService;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGenerationService = (): GenerationService => {
|
||||||
|
if (!generationService) {
|
||||||
|
generationService = new GenerationService();
|
||||||
|
}
|
||||||
|
return generationService;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLiveScopeService = (): LiveScopeService => {
|
||||||
|
if (!liveScopeService) {
|
||||||
|
liveScopeService = new LiveScopeService();
|
||||||
|
}
|
||||||
|
return liveScopeService;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve images by filename or project-scoped alias via public CDN
|
||||||
|
*
|
||||||
|
* Public CDN endpoint for serving images without authentication:
|
||||||
|
* - Supports filename-based access (exact match in storageKey)
|
||||||
|
* - Supports project-scoped alias access (@alias-name)
|
||||||
|
* - Returns raw image bytes with optimal caching headers
|
||||||
|
* - Long-term browser caching (1 year max-age)
|
||||||
|
* - No rate limiting (public access)
|
||||||
|
*
|
||||||
|
* URL structure matches MinIO storage organization for efficient lookups.
|
||||||
|
*
|
||||||
|
* @route GET /cdn/:orgSlug/:projectSlug/img/:filenameOrAlias
|
||||||
|
* @authentication None - Public endpoint
|
||||||
|
*
|
||||||
|
* @param {string} req.params.orgSlug - Organization slug
|
||||||
|
* @param {string} req.params.projectSlug - Project slug
|
||||||
|
* @param {string} req.params.filenameOrAlias - Filename or @alias
|
||||||
|
*
|
||||||
|
* @returns {Buffer} 200 - Image file bytes with Content-Type header
|
||||||
|
* @returns {object} 404 - Organization, project, or image not found
|
||||||
|
* @returns {object} 500 - CDN or storage error
|
||||||
|
*
|
||||||
|
* @throws {Error} ORG_NOT_FOUND - Organization does not exist
|
||||||
|
* @throws {Error} PROJECT_NOT_FOUND - Project does not exist
|
||||||
|
* @throws {Error} IMAGE_NOT_FOUND - Image not found
|
||||||
|
* @throws {Error} CDN_ERROR - General CDN error
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Access by filename
|
||||||
|
* GET /cdn/acme/website/img/hero-background.jpg
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Access by alias
|
||||||
|
* GET /cdn/acme/website/img/@hero
|
||||||
|
*
|
||||||
|
* Response Headers:
|
||||||
|
* Content-Type: image/jpeg
|
||||||
|
* Content-Length: 245810
|
||||||
|
* Cache-Control: public, max-age=31536000
|
||||||
|
* X-Image-Id: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
*/
|
||||||
|
cdnRouter.get(
|
||||||
|
'/:orgSlug/:projectSlug/img/:filenameOrAlias',
|
||||||
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
|
const { orgSlug, projectSlug, filenameOrAlias } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve organization and project
|
||||||
|
const org = await db.query.organizations.findFirst({
|
||||||
|
where: eq(organizations.slug, orgSlug),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Organization not found', code: 'ORG_NOT_FOUND' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await db.query.projects.findFirst({
|
||||||
|
where: and(eq(projects.slug, projectSlug), eq(projects.organizationId, org.id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Project not found', code: 'PROJECT_NOT_FOUND' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let image;
|
||||||
|
|
||||||
|
// Check if filenameOrAlias is an alias (starts with @)
|
||||||
|
if (filenameOrAlias.startsWith('@')) {
|
||||||
|
// Lookup by project-scoped alias
|
||||||
|
const allImages = await db.query.images.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(images.projectId, project.id),
|
||||||
|
eq(images.alias, filenameOrAlias),
|
||||||
|
isNull(images.deletedAt),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
image = allImages[0] || null;
|
||||||
|
} else {
|
||||||
|
// Lookup by filename in storageKey
|
||||||
|
const allImages = await db.query.images.findMany({
|
||||||
|
where: and(eq(images.projectId, project.id), isNull(images.deletedAt)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find image where storageKey ends with filename
|
||||||
|
image = allImages.find((img) => img.storageKey.includes(filenameOrAlias)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: ERROR_MESSAGES.IMAGE_NOT_FOUND, code: 'IMAGE_NOT_FOUND' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download image from storage
|
||||||
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
const keyParts = image.storageKey.split('/');
|
||||||
|
|
||||||
|
if (keyParts.length < 4) {
|
||||||
|
throw new Error('Invalid storage key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = keyParts[0]!;
|
||||||
|
const projectId = keyParts[1]!;
|
||||||
|
const category = keyParts[2]! as 'uploads' | 'generated' | 'references';
|
||||||
|
const filename = keyParts.slice(3).join('/');
|
||||||
|
|
||||||
|
const buffer = await storageService.downloadFile(orgId, projectId, category, filename);
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
res.setHeader('Content-Type', image.mimeType);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
|
||||||
|
res.setHeader('X-Image-Id', image.id);
|
||||||
|
|
||||||
|
// Stream image bytes
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CDN image serve error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to serve image',
|
||||||
|
code: 'CDN_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live URL generation with automatic caching and scope management
|
||||||
|
*
|
||||||
|
* Public endpoint for on-demand image generation via URL parameters:
|
||||||
|
* - No authentication required (public access)
|
||||||
|
* - Automatic prompt-based caching (cache key computed from params)
|
||||||
|
* - IP-based rate limiting (10 new generations per hour)
|
||||||
|
* - Scope-based generation limits
|
||||||
|
* - Lazy scope creation (automatic on first use)
|
||||||
|
* - Returns raw image bytes with caching headers
|
||||||
|
*
|
||||||
|
* Cache behavior:
|
||||||
|
* - Cache HIT: Returns existing image instantly, no rate limit check
|
||||||
|
* - Cache MISS: Generates new image, counts toward IP rate limit
|
||||||
|
* - Cache key computed from: prompt + aspectRatio + autoEnhance + template
|
||||||
|
* - Cached images stored with meta.isLiveUrl = true
|
||||||
|
*
|
||||||
|
* Scope management:
|
||||||
|
* - Scopes separate generation budgets (e.g., "hero", "gallery")
|
||||||
|
* - Auto-created on first use if allowNewLiveScopes = true
|
||||||
|
* - Generation limits per scope (default: 30)
|
||||||
|
* - Scope stats tracked (currentGenerations, lastGeneratedAt)
|
||||||
|
*
|
||||||
|
* @route GET /cdn/:orgSlug/:projectSlug/live/:scope
|
||||||
|
* @authentication None - Public endpoint
|
||||||
|
* @rateLimit 10 new generations per hour per IP (cache hits excluded)
|
||||||
|
*
|
||||||
|
* @param {string} req.params.orgSlug - Organization slug
|
||||||
|
* @param {string} req.params.projectSlug - Project slug
|
||||||
|
* @param {string} req.params.scope - Scope identifier (alphanumeric + hyphens + underscores)
|
||||||
|
* @param {string} req.query.prompt - Image description (required)
|
||||||
|
* @param {string} [req.query.aspectRatio='1:1'] - Aspect ratio (1:1, 16:9, 3:2, 9:16)
|
||||||
|
* @param {boolean} [req.query.autoEnhance=false] - Enable prompt enhancement
|
||||||
|
* @param {string} [req.query.template] - Enhancement template (photorealistic, illustration, etc.)
|
||||||
|
*
|
||||||
|
* @returns {Buffer} 200 - Image file bytes with headers
|
||||||
|
* @returns {object} 400 - Missing/invalid prompt or scope format
|
||||||
|
* @returns {object} 403 - Scope creation disabled
|
||||||
|
* @returns {object} 404 - Organization or project not found
|
||||||
|
* @returns {object} 429 - Rate limit or scope generation limit exceeded
|
||||||
|
* @returns {object} 500 - Generation or storage error
|
||||||
|
*
|
||||||
|
* @throws {Error} VALIDATION_ERROR - Prompt is required
|
||||||
|
* @throws {Error} SCOPE_INVALID_FORMAT - Invalid scope format
|
||||||
|
* @throws {Error} ORG_NOT_FOUND - Organization does not exist
|
||||||
|
* @throws {Error} PROJECT_NOT_FOUND - Project does not exist
|
||||||
|
* @throws {Error} SCOPE_CREATION_DISABLED - New scope creation not allowed
|
||||||
|
* @throws {Error} SCOPE_GENERATION_LIMIT_EXCEEDED - Scope limit reached
|
||||||
|
* @throws {Error} IP_RATE_LIMIT_EXCEEDED - IP rate limit exceeded
|
||||||
|
* @throws {Error} LIVE_URL_ERROR - General generation error
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Basic generation with caching
|
||||||
|
* GET /cdn/acme/website/live/hero-section?prompt=mountain+landscape&aspectRatio=16:9
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // With auto-enhancement
|
||||||
|
* GET /cdn/acme/website/live/gallery?prompt=product+photo&autoEnhance=true&template=product
|
||||||
|
*
|
||||||
|
* Response Headers (Cache HIT):
|
||||||
|
* Content-Type: image/jpeg
|
||||||
|
* Content-Length: 245810
|
||||||
|
* Cache-Control: public, max-age=31536000
|
||||||
|
* X-Cache-Status: HIT
|
||||||
|
* X-Scope: hero-section
|
||||||
|
* X-Image-Id: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
*
|
||||||
|
* Response Headers (Cache MISS):
|
||||||
|
* Content-Type: image/jpeg
|
||||||
|
* Content-Length: 198234
|
||||||
|
* Cache-Control: public, max-age=31536000
|
||||||
|
* X-Cache-Status: MISS
|
||||||
|
* X-Scope: hero-section
|
||||||
|
* X-Generation-Id: 660e8400-e29b-41d4-a716-446655440001
|
||||||
|
* X-Image-Id: 770e8400-e29b-41d4-a716-446655440002
|
||||||
|
* X-RateLimit-Limit: 10
|
||||||
|
* X-RateLimit-Remaining: 9
|
||||||
|
* X-RateLimit-Reset: 3456
|
||||||
|
*/
|
||||||
|
cdnRouter.get(
|
||||||
|
'/:orgSlug/:projectSlug/live/:scope',
|
||||||
|
ipRateLimiterMiddleware,
|
||||||
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
|
const { orgSlug, projectSlug, scope } = req.params;
|
||||||
|
const { prompt, aspectRatio, autoEnhance, template } = req.query as LiveGenerationQuery;
|
||||||
|
|
||||||
|
const genService = getGenerationService();
|
||||||
|
const imgService = getImageService();
|
||||||
|
const scopeService = getLiveScopeService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate prompt
|
||||||
|
if (!prompt || typeof prompt !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Prompt is required', code: 'VALIDATION_ERROR' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate scope format (alphanumeric + hyphens + underscores)
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(scope)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: ERROR_MESSAGES.SCOPE_INVALID_FORMAT, code: 'SCOPE_INVALID_FORMAT' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve organization
|
||||||
|
const org = await db.query.organizations.findFirst({
|
||||||
|
where: eq(organizations.slug, orgSlug),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Organization not found', code: 'ORG_NOT_FOUND' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve project
|
||||||
|
const project = await db.query.projects.findFirst({
|
||||||
|
where: and(eq(projects.slug, projectSlug), eq(projects.organizationId, org.id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Project not found', code: 'PROJECT_NOT_FOUND' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute cache key
|
||||||
|
const normalizedAutoEnhance =
|
||||||
|
typeof autoEnhance === 'string' ? autoEnhance === 'true' : Boolean(autoEnhance);
|
||||||
|
|
||||||
|
const cacheParams: {
|
||||||
|
aspectRatio?: string;
|
||||||
|
autoEnhance?: boolean;
|
||||||
|
template?: string;
|
||||||
|
} = {};
|
||||||
|
if (aspectRatio) cacheParams.aspectRatio = aspectRatio as string;
|
||||||
|
if (autoEnhance !== undefined) cacheParams.autoEnhance = normalizedAutoEnhance;
|
||||||
|
if (template) cacheParams.template = template as string;
|
||||||
|
|
||||||
|
const cacheKey = computeLiveUrlCacheKey(project.id, scope, prompt, cacheParams);
|
||||||
|
|
||||||
|
// Check cache: find image with meta.liveUrlCacheKey = cacheKey
|
||||||
|
const cachedImages = await db.query.images.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(images.projectId, project.id),
|
||||||
|
isNull(images.deletedAt),
|
||||||
|
sql`${images.meta}->>'scope' = ${scope}`,
|
||||||
|
sql`${images.meta}->>'isLiveUrl' = 'true'`,
|
||||||
|
sql`${images.meta}->>'cacheKey' = ${cacheKey}`,
|
||||||
|
),
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cachedImage = cachedImages[0];
|
||||||
|
|
||||||
|
if (cachedImage) {
|
||||||
|
// Cache HIT - serve existing image
|
||||||
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
const keyParts = cachedImage.storageKey.split('/');
|
||||||
|
|
||||||
|
if (keyParts.length < 4) {
|
||||||
|
throw new Error('Invalid storage key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = keyParts[0]!;
|
||||||
|
const projectId = keyParts[1]!;
|
||||||
|
const category = keyParts[2]! as 'uploads' | 'generated' | 'references';
|
||||||
|
const filename = keyParts.slice(3).join('/');
|
||||||
|
|
||||||
|
const buffer = await storageService.downloadFile(orgId, projectId, category, filename);
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
res.setHeader('Content-Type', cachedImage.mimeType);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
|
||||||
|
res.setHeader('X-Cache-Status', 'HIT');
|
||||||
|
res.setHeader('X-Scope', scope);
|
||||||
|
res.setHeader('X-Image-Id', cachedImage.id);
|
||||||
|
|
||||||
|
res.send(buffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache MISS - check IP rate limit before generating
|
||||||
|
// Only count new generations (cache MISS) toward IP rate limit
|
||||||
|
const isLimited = (req as any).checkIpRateLimit();
|
||||||
|
if (isLimited) {
|
||||||
|
return; // Rate limit response already sent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache MISS - check scope and generate
|
||||||
|
// Get or create scope
|
||||||
|
let liveScope;
|
||||||
|
try {
|
||||||
|
liveScope = await scopeService.createOrGet(project.id, scope, {
|
||||||
|
allowNewLiveScopes: project.allowNewLiveScopes,
|
||||||
|
newLiveScopesGenerationLimit: project.newLiveScopesGenerationLimit,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === ERROR_MESSAGES.SCOPE_CREATION_DISABLED) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.SCOPE_CREATION_DISABLED,
|
||||||
|
code: 'SCOPE_CREATION_DISABLED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if scope allows new generations
|
||||||
|
const scopeStats = await scopeService.getByIdWithStats(liveScope.id);
|
||||||
|
const canGenerate = await scopeService.canGenerateNew(
|
||||||
|
liveScope,
|
||||||
|
scopeStats.currentGenerations,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canGenerate) {
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.SCOPE_GENERATION_LIMIT_EXCEEDED,
|
||||||
|
code: 'SCOPE_GENERATION_LIMIT_EXCEEDED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new image (no API key, use system generation)
|
||||||
|
const generation = await genService.create({
|
||||||
|
projectId: project.id,
|
||||||
|
apiKeyId: null as unknown as string, // System generation for live URLs
|
||||||
|
prompt,
|
||||||
|
aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||||
|
autoEnhance: normalizedAutoEnhance,
|
||||||
|
requestId: `live-${scope}-${Date.now()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!generation.outputImage) {
|
||||||
|
throw new Error('Generation succeeded but no output image was created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update image meta to mark as live URL with cache key and scope
|
||||||
|
await imgService.update(generation.outputImage.id, {
|
||||||
|
meta: {
|
||||||
|
...generation.outputImage.meta,
|
||||||
|
scope,
|
||||||
|
isLiveUrl: true,
|
||||||
|
cacheKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download newly generated image
|
||||||
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
const keyParts = generation.outputImage.storageKey.split('/');
|
||||||
|
|
||||||
|
if (keyParts.length < 4) {
|
||||||
|
throw new Error('Invalid storage key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = keyParts[0]!;
|
||||||
|
const projectId = keyParts[1]!;
|
||||||
|
const category = keyParts[2]! as 'uploads' | 'generated' | 'references';
|
||||||
|
const filename = keyParts.slice(3).join('/');
|
||||||
|
|
||||||
|
const buffer = await storageService.downloadFile(orgId, projectId, category, filename);
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
res.setHeader('Content-Type', generation.outputImage.mimeType);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
|
||||||
|
res.setHeader('X-Cache-Status', 'MISS');
|
||||||
|
res.setHeader('X-Scope', scope);
|
||||||
|
res.setHeader('X-Generation-Id', generation.id);
|
||||||
|
res.setHeader('X-Image-Id', generation.outputImage.id);
|
||||||
|
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Live URL generation error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : 'Generation failed',
|
||||||
|
code: 'LIVE_URL_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,630 @@
|
||||||
|
import { Response, Router } from 'express';
|
||||||
|
import type { Router as RouterType } from 'express';
|
||||||
|
import { FlowService, GenerationService } from '@/services/core';
|
||||||
|
import { asyncHandler } from '@/middleware/errorHandler';
|
||||||
|
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
||||||
|
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
||||||
|
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
||||||
|
import { validateAndNormalizePagination } from '@/utils/validators';
|
||||||
|
import { buildPaginatedResponse } from '@/utils/helpers';
|
||||||
|
import { toFlowResponse, toGenerationResponse, toImageResponse } from '@/types/responses';
|
||||||
|
import type {
|
||||||
|
CreateFlowResponse,
|
||||||
|
ListFlowsResponse,
|
||||||
|
GetFlowResponse,
|
||||||
|
UpdateFlowAliasesResponse,
|
||||||
|
ListFlowGenerationsResponse,
|
||||||
|
ListFlowImagesResponse,
|
||||||
|
} from '@/types/responses';
|
||||||
|
|
||||||
|
export const flowsRouter: RouterType = Router();
|
||||||
|
|
||||||
|
let flowService: FlowService;
|
||||||
|
let generationService: GenerationService;
|
||||||
|
|
||||||
|
const getFlowService = (): FlowService => {
|
||||||
|
if (!flowService) {
|
||||||
|
flowService = new FlowService();
|
||||||
|
}
|
||||||
|
return flowService;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGenerationService = (): GenerationService => {
|
||||||
|
if (!generationService) {
|
||||||
|
generationService = new GenerationService();
|
||||||
|
}
|
||||||
|
return generationService;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/flows
|
||||||
|
* REMOVED (Section 4.3): Lazy flow creation pattern
|
||||||
|
* Flows are now created automatically when:
|
||||||
|
* - A generation/upload specifies a flowId
|
||||||
|
* - A generation/upload provides a flowAlias (eager creation)
|
||||||
|
*
|
||||||
|
* @deprecated Flows are created automatically, no explicit endpoint needed
|
||||||
|
*/
|
||||||
|
// flowsRouter.post(
|
||||||
|
// '/',
|
||||||
|
// validateApiKey,
|
||||||
|
// requireProjectKey,
|
||||||
|
// asyncHandler(async (req: any, res: Response<CreateFlowResponse>) => {
|
||||||
|
// const service = getFlowService();
|
||||||
|
// const { meta } = req.body;
|
||||||
|
//
|
||||||
|
// const projectId = req.apiKey.projectId;
|
||||||
|
//
|
||||||
|
// const flow = await service.create({
|
||||||
|
// projectId,
|
||||||
|
// aliases: {},
|
||||||
|
// meta: meta || {},
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// res.status(201).json({
|
||||||
|
// success: true,
|
||||||
|
// data: toFlowResponse(flow),
|
||||||
|
// });
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all flows for a project with pagination and computed counts
|
||||||
|
*
|
||||||
|
* Retrieves flows created automatically when generations/uploads specify:
|
||||||
|
* - A flowId in their request
|
||||||
|
* - A flowAlias (creates flow eagerly if doesn't exist)
|
||||||
|
*
|
||||||
|
* Each flow includes:
|
||||||
|
* - Computed generationCount and imageCount
|
||||||
|
* - Flow-scoped aliases (JSONB key-value pairs)
|
||||||
|
* - Custom metadata
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/flows
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||||
|
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||||
|
*
|
||||||
|
* @returns {ListFlowsResponse} 200 - Paginated list of flows with counts
|
||||||
|
* @returns {object} 400 - Invalid pagination parameters
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/v1/flows?limit=50&offset=0
|
||||||
|
*/
|
||||||
|
flowsRouter.get(
|
||||||
|
'/',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<ListFlowsResponse>) => {
|
||||||
|
const service = getFlowService();
|
||||||
|
const { limit, offset } = req.query;
|
||||||
|
|
||||||
|
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||||
|
if (!paginationResult.valid) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
const result = await service.list(
|
||||||
|
{ projectId },
|
||||||
|
validatedLimit,
|
||||||
|
validatedOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = result.flows.map((flow) => toFlowResponse(flow));
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single flow by ID with computed statistics
|
||||||
|
*
|
||||||
|
* Retrieves detailed flow information including:
|
||||||
|
* - All flow-scoped aliases
|
||||||
|
* - Computed generationCount (active generations only)
|
||||||
|
* - Computed imageCount (active images only)
|
||||||
|
* - Custom metadata
|
||||||
|
* - Creation and update timestamps
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/flows/:id
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Flow ID (UUID)
|
||||||
|
*
|
||||||
|
* @returns {GetFlowResponse} 200 - Complete flow details with counts
|
||||||
|
* @returns {object} 404 - Flow not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
*/
|
||||||
|
flowsRouter.get(
|
||||||
|
'/:id',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<GetFlowResponse>) => {
|
||||||
|
const service = getFlowService();
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const flow = await service.getByIdWithCounts(id);
|
||||||
|
|
||||||
|
if (flow.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Flow not found',
|
||||||
|
code: 'FLOW_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toFlowResponse(flow),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all generations in a specific flow with pagination
|
||||||
|
*
|
||||||
|
* Retrieves all generations associated with this flow, ordered by creation date (newest first).
|
||||||
|
* Includes only active (non-deleted) generations.
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/flows/:id/generations
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Flow ID (UUID)
|
||||||
|
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||||
|
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||||
|
*
|
||||||
|
* @returns {ListFlowGenerationsResponse} 200 - Paginated list of generations
|
||||||
|
* @returns {object} 404 - Flow not found or access denied
|
||||||
|
* @returns {object} 400 - Invalid pagination parameters
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/generations?limit=10
|
||||||
|
*/
|
||||||
|
flowsRouter.get(
|
||||||
|
'/:id/generations',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<ListFlowGenerationsResponse>) => {
|
||||||
|
const service = getFlowService();
|
||||||
|
const { id } = req.params;
|
||||||
|
const { limit, offset } = req.query;
|
||||||
|
|
||||||
|
const flow = await service.getById(id);
|
||||||
|
if (!flow) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flow.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||||
|
if (!paginationResult.valid) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||||
|
|
||||||
|
const result = await service.getFlowGenerations(id, validatedLimit, validatedOffset);
|
||||||
|
|
||||||
|
const responseData = result.generations.map((gen) => toGenerationResponse(gen));
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all images in a specific flow with pagination
|
||||||
|
*
|
||||||
|
* Retrieves all images (generated and uploaded) associated with this flow,
|
||||||
|
* ordered by creation date (newest first). Includes only active (non-deleted) images.
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/flows/:id/images
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Flow ID (UUID)
|
||||||
|
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||||
|
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||||
|
*
|
||||||
|
* @returns {ListFlowImagesResponse} 200 - Paginated list of images
|
||||||
|
* @returns {object} 404 - Flow not found or access denied
|
||||||
|
* @returns {object} 400 - Invalid pagination parameters
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/images?limit=20
|
||||||
|
*/
|
||||||
|
flowsRouter.get(
|
||||||
|
'/:id/images',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<ListFlowImagesResponse>) => {
|
||||||
|
const service = getFlowService();
|
||||||
|
const { id } = req.params;
|
||||||
|
const { limit, offset } = req.query;
|
||||||
|
|
||||||
|
const flow = await service.getById(id);
|
||||||
|
if (!flow) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flow.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||||
|
if (!paginationResult.valid) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||||
|
|
||||||
|
const result = await service.getFlowImages(id, validatedLimit, validatedOffset);
|
||||||
|
|
||||||
|
const responseData = result.images.map((img) => toImageResponse(img));
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update flow-scoped aliases (add or modify existing)
|
||||||
|
*
|
||||||
|
* Updates the JSONB aliases field with new or modified key-value pairs.
|
||||||
|
* Aliases are merged with existing aliases (does not replace all).
|
||||||
|
*
|
||||||
|
* Flow-scoped aliases:
|
||||||
|
* - Must start with @ symbol
|
||||||
|
* - Unique within the flow only (not project-wide)
|
||||||
|
* - Used for alias resolution in generations
|
||||||
|
* - Stored as JSONB for efficient lookups
|
||||||
|
*
|
||||||
|
* @route PUT /api/v1/flows/:id/aliases
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Flow ID (UUID)
|
||||||
|
* @param {UpdateFlowAliasesRequest} req.body - Alias updates
|
||||||
|
* @param {object} req.body.aliases - Key-value pairs of aliases to add/update
|
||||||
|
*
|
||||||
|
* @returns {UpdateFlowAliasesResponse} 200 - Updated flow with merged aliases
|
||||||
|
* @returns {object} 404 - Flow not found or access denied
|
||||||
|
* @returns {object} 400 - Invalid aliases format
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist
|
||||||
|
* @throws {Error} VALIDATION_ERROR - Aliases must be an object
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* PUT /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/aliases
|
||||||
|
* {
|
||||||
|
* "aliases": {
|
||||||
|
* "@hero": "image-id-123",
|
||||||
|
* "@background": "image-id-456"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
flowsRouter.put(
|
||||||
|
'/:id/aliases',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<UpdateFlowAliasesResponse>) => {
|
||||||
|
const service = getFlowService();
|
||||||
|
const { id } = req.params;
|
||||||
|
const { aliases } = req.body;
|
||||||
|
|
||||||
|
if (!aliases || typeof aliases !== 'object' || Array.isArray(aliases)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Aliases must be an object with key-value pairs',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = await service.getById(id);
|
||||||
|
if (!flow) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Flow not found',
|
||||||
|
code: 'FLOW_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flow.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Flow not found',
|
||||||
|
code: 'FLOW_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedFlow = await service.updateAliases(id, aliases);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toFlowResponse(updatedFlow),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific alias from a flow
|
||||||
|
*
|
||||||
|
* Deletes a single alias key-value pair from the flow's JSONB aliases field.
|
||||||
|
* Other aliases remain unchanged.
|
||||||
|
*
|
||||||
|
* @route DELETE /api/v1/flows/:id/aliases/:alias
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Flow ID (UUID)
|
||||||
|
* @param {string} req.params.alias - Alias to remove (e.g., "@hero")
|
||||||
|
*
|
||||||
|
* @returns {object} 200 - Updated flow with alias removed
|
||||||
|
* @returns {object} 404 - Flow not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* DELETE /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/aliases/@hero
|
||||||
|
*/
|
||||||
|
flowsRouter.delete(
|
||||||
|
'/:id/aliases/:alias',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
|
const service = getFlowService();
|
||||||
|
const { id, alias } = req.params;
|
||||||
|
|
||||||
|
const flow = await service.getById(id);
|
||||||
|
if (!flow) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Flow not found',
|
||||||
|
code: 'FLOW_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flow.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Flow not found',
|
||||||
|
code: 'FLOW_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedFlow = await service.removeAlias(id, alias);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toFlowResponse(updatedFlow),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate the most recent generation in a flow (Section 3.6)
|
||||||
|
*
|
||||||
|
* 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 (affects: determines which flow's latest generation to regenerate)
|
||||||
|
*
|
||||||
|
* @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 to regenerate
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* POST /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/regenerate
|
||||||
|
*/
|
||||||
|
flowsRouter.post(
|
||||||
|
'/:id/regenerate',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
|
const flowSvc = getFlowService();
|
||||||
|
const genSvc = getGenerationService();
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const flow = await flowSvc.getById(id);
|
||||||
|
if (!flow) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Flow not found',
|
||||||
|
code: 'FLOW_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flow.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Flow not found',
|
||||||
|
code: 'FLOW_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the most recent generation in the flow
|
||||||
|
const result = await flowSvc.getFlowGenerations(id, 1, 0); // limit=1, offset=0
|
||||||
|
|
||||||
|
if (result.total === 0 || result.generations.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Flow has no generations to regenerate',
|
||||||
|
code: 'FLOW_HAS_NO_GENERATIONS',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestGeneration = result.generations[0]!;
|
||||||
|
|
||||||
|
// Regenerate the latest generation
|
||||||
|
const regenerated = await genSvc.regenerate(latestGeneration.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toGenerationResponse(regenerated),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a flow with cascade deletion (Section 7.3)
|
||||||
|
*
|
||||||
|
* 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)
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Flow ID (UUID)
|
||||||
|
*
|
||||||
|
* @returns {object} 200 - Deletion confirmation with flow ID
|
||||||
|
* @returns {object} 404 - Flow not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* DELETE /api/v1/flows/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* {
|
||||||
|
* "success": true,
|
||||||
|
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
flowsRouter.delete(
|
||||||
|
'/:id',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
|
const service = getFlowService();
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const flow = await service.getById(id);
|
||||||
|
if (!flow) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Flow not found',
|
||||||
|
code: 'FLOW_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flow.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Flow not found',
|
||||||
|
code: 'FLOW_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.delete(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { id },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,549 @@
|
||||||
|
import { Response, Router } from 'express';
|
||||||
|
import type { Router as RouterType } from 'express';
|
||||||
|
import { GenerationService } from '@/services/core';
|
||||||
|
import { asyncHandler } from '@/middleware/errorHandler';
|
||||||
|
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
||||||
|
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
||||||
|
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
||||||
|
import { autoEnhancePrompt } from '@/middleware/promptEnhancement';
|
||||||
|
import { validateAndNormalizePagination } from '@/utils/validators';
|
||||||
|
import { buildPaginatedResponse } from '@/utils/helpers';
|
||||||
|
import { toGenerationResponse } from '@/types/responses';
|
||||||
|
import type {
|
||||||
|
CreateGenerationResponse,
|
||||||
|
ListGenerationsResponse,
|
||||||
|
GetGenerationResponse,
|
||||||
|
} from '@/types/responses';
|
||||||
|
|
||||||
|
export const generationsRouter: RouterType = Router();
|
||||||
|
|
||||||
|
let generationService: GenerationService;
|
||||||
|
|
||||||
|
const getGenerationService = (): GenerationService => {
|
||||||
|
if (!generationService) {
|
||||||
|
generationService = new GenerationService();
|
||||||
|
}
|
||||||
|
return generationService;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new image generation from a text prompt
|
||||||
|
*
|
||||||
|
* Generates AI-powered images using Gemini Flash Image model with support for:
|
||||||
|
* - Text prompts with optional auto-enhancement
|
||||||
|
* - Reference images for style/context
|
||||||
|
* - Flow association and flow-scoped aliases
|
||||||
|
* - Project-scoped aliases for direct access
|
||||||
|
* - Custom metadata storage
|
||||||
|
*
|
||||||
|
* @route POST /api/v1/generations
|
||||||
|
* @authentication Project Key required
|
||||||
|
* @rateLimit 100 requests per hour per API key
|
||||||
|
*
|
||||||
|
* @param {CreateGenerationRequest} req.body - Generation parameters
|
||||||
|
* @param {string} req.body.prompt - Text description of desired image (required)
|
||||||
|
* @param {string[]} [req.body.referenceImages] - Array of aliases to use as references
|
||||||
|
* @param {string} [req.body.aspectRatio='1:1'] - Aspect ratio (1:1, 16:9, 3:2, 9:16)
|
||||||
|
* @param {string} [req.body.flowId] - Associate with existing flow
|
||||||
|
* @param {string} [req.body.alias] - Project-scoped alias (@custom-name)
|
||||||
|
* @param {string} [req.body.flowAlias] - Flow-scoped alias (requires flowId)
|
||||||
|
* @param {boolean} [req.body.autoEnhance=true] - Enable prompt enhancement
|
||||||
|
* @param {object} [req.body.meta] - Custom metadata
|
||||||
|
*
|
||||||
|
* @returns {CreateGenerationResponse} 201 - Generation created with status
|
||||||
|
* @returns {object} 400 - Invalid request parameters
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
* @returns {object} 429 - Rate limit exceeded
|
||||||
|
*
|
||||||
|
* @throws {Error} VALIDATION_ERROR - Missing or invalid prompt
|
||||||
|
* @throws {Error} ALIAS_CONFLICT - Alias already exists
|
||||||
|
* @throws {Error} FLOW_NOT_FOUND - Flow ID does not exist
|
||||||
|
* @throws {Error} IMAGE_NOT_FOUND - Reference image alias not found
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Basic generation
|
||||||
|
* POST /api/v1/generations
|
||||||
|
* {
|
||||||
|
* "prompt": "A serene mountain landscape at sunset",
|
||||||
|
* "aspectRatio": "16:9"
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // With reference images and alias
|
||||||
|
* POST /api/v1/generations
|
||||||
|
* {
|
||||||
|
* "prompt": "Product photo in this style",
|
||||||
|
* "referenceImages": ["@brand-style", "@product-template"],
|
||||||
|
* "alias": "@hero-image",
|
||||||
|
* "autoEnhance": true
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
generationsRouter.post(
|
||||||
|
'/',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
autoEnhancePrompt,
|
||||||
|
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => {
|
||||||
|
const service = getGenerationService();
|
||||||
|
|
||||||
|
// Extract original prompt from middleware property if enhancement was attempted
|
||||||
|
// Otherwise fall back to request body
|
||||||
|
const prompt = req.originalPrompt || req.body.prompt;
|
||||||
|
|
||||||
|
const {
|
||||||
|
referenceImages,
|
||||||
|
aspectRatio,
|
||||||
|
flowId,
|
||||||
|
alias,
|
||||||
|
flowAlias,
|
||||||
|
autoEnhance,
|
||||||
|
meta,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!prompt || typeof prompt !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Prompt is required and must be a string',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
const apiKeyId = req.apiKey.id;
|
||||||
|
|
||||||
|
const generation = await service.create({
|
||||||
|
projectId,
|
||||||
|
apiKeyId,
|
||||||
|
prompt,
|
||||||
|
referenceImages,
|
||||||
|
aspectRatio,
|
||||||
|
flowId,
|
||||||
|
alias,
|
||||||
|
flowAlias,
|
||||||
|
autoEnhance,
|
||||||
|
enhancedPrompt: req.enhancedPrompt,
|
||||||
|
meta,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: toGenerationResponse(generation),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all generations for the project with filtering and pagination
|
||||||
|
*
|
||||||
|
* Retrieves generations with support for:
|
||||||
|
* - Flow-based filtering
|
||||||
|
* - Status filtering (pending, processing, success, failed)
|
||||||
|
* - Pagination with configurable limit and offset
|
||||||
|
* - Optional inclusion of soft-deleted generations
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/generations
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} [req.query.flowId] - Filter by flow ID
|
||||||
|
* @param {string} [req.query.status] - Filter by status (pending|processing|success|failed)
|
||||||
|
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||||
|
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||||
|
* @param {boolean} [req.query.includeDeleted=false] - Include soft-deleted generations
|
||||||
|
*
|
||||||
|
* @returns {ListGenerationsResponse} 200 - Paginated list of generations
|
||||||
|
* @returns {object} 400 - Invalid pagination parameters
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // List recent generations
|
||||||
|
* GET /api/v1/generations?limit=10&offset=0
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Filter by flow and status
|
||||||
|
* GET /api/v1/generations?flowId=abc-123&status=success&limit=50
|
||||||
|
*/
|
||||||
|
generationsRouter.get(
|
||||||
|
'/',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<ListGenerationsResponse>) => {
|
||||||
|
const service = getGenerationService();
|
||||||
|
const { flowId, status, limit, offset, includeDeleted } = req.query;
|
||||||
|
|
||||||
|
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||||
|
if (!paginationResult.valid) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
const result = await service.list(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
flowId: flowId as string | undefined,
|
||||||
|
status: status as 'pending' | 'processing' | 'success' | 'failed' | undefined,
|
||||||
|
deleted: includeDeleted === 'true' ? true : undefined,
|
||||||
|
},
|
||||||
|
validatedLimit,
|
||||||
|
validatedOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = result.generations.map((gen) => toGenerationResponse(gen));
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single generation by ID with full details
|
||||||
|
*
|
||||||
|
* Retrieves complete generation information including:
|
||||||
|
* - Generation status and metadata
|
||||||
|
* - Output image details (URL, dimensions, etc.)
|
||||||
|
* - Reference images used
|
||||||
|
* - Flow association
|
||||||
|
* - Timestamps and audit trail
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/generations/:id
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Generation ID (UUID)
|
||||||
|
*
|
||||||
|
* @returns {GetGenerationResponse} 200 - Complete generation details
|
||||||
|
* @returns {object} 404 - Generation not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
*/
|
||||||
|
generationsRouter.get(
|
||||||
|
'/:id',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
|
||||||
|
const service = getGenerationService();
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const generation = await service.getByIdWithRelations(id);
|
||||||
|
|
||||||
|
if (generation.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Generation not found',
|
||||||
|
code: 'GENERATION_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toGenerationResponse(generation),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update generation parameters with automatic regeneration
|
||||||
|
*
|
||||||
|
* Updates generation settings with intelligent regeneration behavior:
|
||||||
|
* - Changing prompt or aspectRatio triggers automatic regeneration
|
||||||
|
* - Changing flowId or meta updates metadata only (no regeneration)
|
||||||
|
* - Regeneration replaces existing output image (same ID and URLs)
|
||||||
|
* - All changes preserve generation history and IDs
|
||||||
|
*
|
||||||
|
* @route PUT /api/v1/generations/:id
|
||||||
|
* @authentication Project Key required
|
||||||
|
* @rateLimit 100 requests per hour per API key
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Generation ID (UUID)
|
||||||
|
* @param {UpdateGenerationRequest} req.body - Update parameters
|
||||||
|
* @param {string} [req.body.prompt] - New prompt (triggers regeneration)
|
||||||
|
* @param {string} [req.body.aspectRatio] - New aspect ratio (triggers regeneration)
|
||||||
|
* @param {string|null} [req.body.flowId] - Change flow association (null to detach)
|
||||||
|
* @param {object} [req.body.meta] - Update custom metadata
|
||||||
|
*
|
||||||
|
* @returns {GetGenerationResponse} 200 - Updated generation with new output
|
||||||
|
* @returns {object} 404 - Generation not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
* @returns {object} 429 - Rate limit exceeded
|
||||||
|
*
|
||||||
|
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
||||||
|
* @throws {Error} FLOW_NOT_FOUND - New flow ID does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Update prompt (triggers regeneration)
|
||||||
|
* PUT /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
* {
|
||||||
|
* "prompt": "Updated: A mountain landscape with vibrant colors"
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Change flow association (no regeneration)
|
||||||
|
* PUT /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
* {
|
||||||
|
* "flowId": "new-flow-id-123"
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
generationsRouter.put(
|
||||||
|
'/:id',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
|
||||||
|
const service = getGenerationService();
|
||||||
|
const { id } = req.params;
|
||||||
|
const { prompt, aspectRatio, flowId, meta } = req.body;
|
||||||
|
|
||||||
|
const original = await service.getById(id);
|
||||||
|
if (!original) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Generation not found',
|
||||||
|
code: 'GENERATION_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (original.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Generation not found',
|
||||||
|
code: 'GENERATION_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await service.update(id, {
|
||||||
|
prompt,
|
||||||
|
aspectRatio,
|
||||||
|
flowId,
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toGenerationResponse(updated),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate existing generation with exact same parameters
|
||||||
|
*
|
||||||
|
* Creates a new image using the original generation parameters:
|
||||||
|
* - Uses exact same prompt, aspect ratio, and reference images
|
||||||
|
* - Works regardless of current status (success, failed, pending)
|
||||||
|
* - Replaces existing output image (preserves ID and URLs)
|
||||||
|
* - No parameter modifications allowed (use PUT for changes)
|
||||||
|
* - Useful for refreshing stale images or recovering from failures
|
||||||
|
*
|
||||||
|
* @route POST /api/v1/generations/:id/regenerate
|
||||||
|
* @authentication Project Key required
|
||||||
|
* @rateLimit 100 requests per hour per API key
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Generation ID (UUID)
|
||||||
|
*
|
||||||
|
* @returns {GetGenerationResponse} 200 - Regenerated generation with new output
|
||||||
|
* @returns {object} 404 - Generation not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
* @returns {object} 429 - Rate limit exceeded
|
||||||
|
*
|
||||||
|
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* POST /api/v1/generations/550e8400-e29b-41d4-a716-446655440000/regenerate
|
||||||
|
*/
|
||||||
|
generationsRouter.post(
|
||||||
|
'/:id/regenerate',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
|
||||||
|
const service = getGenerationService();
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const original = await service.getById(id);
|
||||||
|
if (!original) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Generation not found',
|
||||||
|
code: 'GENERATION_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (original.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Generation not found',
|
||||||
|
code: 'GENERATION_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regenerated = await service.regenerate(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toGenerationResponse(regenerated),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a failed generation (legacy endpoint)
|
||||||
|
*
|
||||||
|
* @deprecated Use POST /api/v1/generations/:id/regenerate instead
|
||||||
|
*
|
||||||
|
* This endpoint is maintained for backward compatibility and delegates
|
||||||
|
* to the regenerate endpoint. New integrations should use /regenerate.
|
||||||
|
*
|
||||||
|
* @route POST /api/v1/generations/:id/retry
|
||||||
|
* @authentication Project Key required
|
||||||
|
* @rateLimit 100 requests per hour per API key
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Generation ID (UUID)
|
||||||
|
*
|
||||||
|
* @returns {CreateGenerationResponse} 201 - Regenerated generation
|
||||||
|
* @returns {object} 404 - Generation not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
* @returns {object} 429 - Rate limit exceeded
|
||||||
|
*
|
||||||
|
* @see POST /api/v1/generations/:id/regenerate - Preferred endpoint
|
||||||
|
*/
|
||||||
|
generationsRouter.post(
|
||||||
|
'/:id/retry',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => {
|
||||||
|
const service = getGenerationService();
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const original = await service.getById(id);
|
||||||
|
if (!original) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Generation not found',
|
||||||
|
code: 'GENERATION_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (original.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Generation not found',
|
||||||
|
code: 'GENERATION_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regenerated = await service.regenerate(id);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: toGenerationResponse(regenerated),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a generation and conditionally its output image (Section 7.2)
|
||||||
|
*
|
||||||
|
* Performs deletion with alias protection:
|
||||||
|
* - 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
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id - Generation ID (UUID)
|
||||||
|
*
|
||||||
|
* @returns {object} 200 - Deletion confirmation with generation ID
|
||||||
|
* @returns {object} 404 - Generation not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* DELETE /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* {
|
||||||
|
* "success": true,
|
||||||
|
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
generationsRouter.delete(
|
||||||
|
'/:id',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
|
const service = getGenerationService();
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const generation = await service.getById(id);
|
||||||
|
if (!generation) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Generation not found',
|
||||||
|
code: 'GENERATION_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Generation not found',
|
||||||
|
code: 'GENERATION_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.delete(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { id },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,946 @@
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import sizeOf from 'image-size';
|
||||||
|
import { Response, Router } from 'express';
|
||||||
|
import type { Router as RouterType } from 'express';
|
||||||
|
import { ImageService, AliasService } from '@/services/core';
|
||||||
|
import { StorageFactory } from '@/services/StorageFactory';
|
||||||
|
import { asyncHandler } from '@/middleware/errorHandler';
|
||||||
|
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
||||||
|
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
||||||
|
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
||||||
|
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, type Image } from '@banatie/database';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type {
|
||||||
|
UploadImageResponse,
|
||||||
|
ListImagesResponse,
|
||||||
|
GetImageResponse,
|
||||||
|
UpdateImageResponse,
|
||||||
|
DeleteImageResponse,
|
||||||
|
ResolveAliasResponse,
|
||||||
|
} from '@/types/responses';
|
||||||
|
|
||||||
|
export const imagesRouter: RouterType = Router();
|
||||||
|
|
||||||
|
let imageService: ImageService;
|
||||||
|
let aliasService: AliasService;
|
||||||
|
|
||||||
|
const getImageService = (): ImageService => {
|
||||||
|
if (!imageService) {
|
||||||
|
imageService = new ImageService();
|
||||||
|
}
|
||||||
|
return imageService;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAliasService = (): AliasService => {
|
||||||
|
if (!aliasService) {
|
||||||
|
aliasService = new AliasService();
|
||||||
|
}
|
||||||
|
return aliasService;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve id_or_alias parameter to imageId
|
||||||
|
* Supports both UUID and alias (@-prefixed) identifiers
|
||||||
|
* Per Section 6.2 of api-refactoring-final.md
|
||||||
|
*
|
||||||
|
* @param identifier - UUID or alias string
|
||||||
|
* @param projectId - Project ID for alias resolution
|
||||||
|
* @param flowId - Optional flow ID for flow-scoped alias resolution
|
||||||
|
* @returns imageId (UUID)
|
||||||
|
* @throws Error if alias not found
|
||||||
|
*/
|
||||||
|
async function resolveImageIdentifier(
|
||||||
|
identifier: string,
|
||||||
|
projectId: string,
|
||||||
|
flowId?: string
|
||||||
|
): Promise<string> {
|
||||||
|
// Check if parameter is alias (starts with @)
|
||||||
|
if (identifier.startsWith('@')) {
|
||||||
|
const aliasServiceInstance = getAliasService();
|
||||||
|
const resolution = await aliasServiceInstance.resolve(
|
||||||
|
identifier,
|
||||||
|
projectId,
|
||||||
|
flowId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resolution) {
|
||||||
|
throw new Error(`Alias '${identifier}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolution.imageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise treat as UUID
|
||||||
|
return identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a single image file to project storage
|
||||||
|
*
|
||||||
|
* Uploads an image file to MinIO storage and creates a database record with support for:
|
||||||
|
* - Lazy flow creation using pendingFlowId when flowId is undefined
|
||||||
|
* - Eager flow creation when flowAlias is provided
|
||||||
|
* - Project-scoped alias assignment
|
||||||
|
* - Custom metadata storage
|
||||||
|
* - Multiple file formats (JPEG, PNG, WebP, etc.)
|
||||||
|
*
|
||||||
|
* FlowId behavior:
|
||||||
|
* - undefined (not provided) → generates pendingFlowId, defers flow creation (lazy)
|
||||||
|
* - null (explicitly null) → no flow association
|
||||||
|
* - string (specific value) → uses provided flow ID, creates if needed
|
||||||
|
*
|
||||||
|
* @route POST /api/v1/images/upload
|
||||||
|
* @authentication Project Key required
|
||||||
|
* @rateLimit 100 requests per hour per API key
|
||||||
|
*
|
||||||
|
* @param {File} req.file - Image file (multipart/form-data, max 5MB)
|
||||||
|
* @param {string} [req.body.alias] - Project-scoped alias (@custom-name)
|
||||||
|
* @param {string|null} [req.body.flowId] - Flow association (undefined=auto, null=none, string=specific)
|
||||||
|
* @param {string} [req.body.flowAlias] - Flow-scoped alias (requires flowId, triggers eager creation)
|
||||||
|
* @param {string} [req.body.meta] - Custom metadata (JSON string)
|
||||||
|
*
|
||||||
|
* @returns {UploadImageResponse} 201 - Uploaded image with storage details
|
||||||
|
* @returns {object} 400 - Missing file or validation error
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
* @returns {object} 413 - File too large
|
||||||
|
* @returns {object} 415 - Unsupported file type
|
||||||
|
* @returns {object} 429 - Rate limit exceeded
|
||||||
|
* @returns {object} 500 - Upload or storage error
|
||||||
|
*
|
||||||
|
* @throws {Error} VALIDATION_ERROR - No file provided
|
||||||
|
* @throws {Error} UPLOAD_ERROR - File upload failed
|
||||||
|
* @throws {Error} ALIAS_CONFLICT - Alias already exists
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Upload with automatic flow creation
|
||||||
|
* POST /api/v1/images/upload
|
||||||
|
* Content-Type: multipart/form-data
|
||||||
|
* { file: <image.jpg>, alias: "@hero-bg" }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Upload with eager flow creation and flow alias
|
||||||
|
* POST /api/v1/images/upload
|
||||||
|
* { file: <image.jpg>, flowAlias: "@step-1" }
|
||||||
|
*/
|
||||||
|
imagesRouter.post(
|
||||||
|
'/upload',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
uploadSingleImage,
|
||||||
|
handleUploadErrors,
|
||||||
|
asyncHandler(async (req: any, res: Response<UploadImageResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { alias, flowId, flowAlias, meta } = req.body;
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'No file provided',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
const apiKeyId = req.apiKey.id;
|
||||||
|
const orgId = req.apiKey.organizationSlug || 'default';
|
||||||
|
const projectSlug = req.apiKey.projectSlug;
|
||||||
|
const file = req.file;
|
||||||
|
|
||||||
|
// FlowId logic (matching GenerationService lazy pattern):
|
||||||
|
// - If undefined → generate UUID for pendingFlowId, flowId = null (lazy)
|
||||||
|
// - If null → flowId = null, pendingFlowId = null (explicitly no flow)
|
||||||
|
// - If string → flowId = string, pendingFlowId = null (use provided, create if needed)
|
||||||
|
let finalFlowId: string | null;
|
||||||
|
let pendingFlowId: string | null = null;
|
||||||
|
|
||||||
|
if (flowId === undefined) {
|
||||||
|
// Lazy pattern: defer flow creation until needed
|
||||||
|
pendingFlowId = randomUUID();
|
||||||
|
finalFlowId = null;
|
||||||
|
} else if (flowId === null) {
|
||||||
|
// Explicitly no flow
|
||||||
|
finalFlowId = null;
|
||||||
|
pendingFlowId = null;
|
||||||
|
} else {
|
||||||
|
// Specific flowId provided - ensure flow exists (eager creation)
|
||||||
|
finalFlowId = flowId;
|
||||||
|
pendingFlowId = null;
|
||||||
|
|
||||||
|
// 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: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link any pending images to this new flow
|
||||||
|
await service.linkPendingImagesToFlow(finalFlowId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
|
||||||
|
const uploadResult = await storageService.uploadFile(
|
||||||
|
orgId,
|
||||||
|
projectSlug,
|
||||||
|
'uploads',
|
||||||
|
file.originalname,
|
||||||
|
file.buffer,
|
||||||
|
file.mimetype,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uploadResult.success) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'File upload failed',
|
||||||
|
code: 'UPLOAD_ERROR',
|
||||||
|
details: uploadResult.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract image dimensions from uploaded file buffer
|
||||||
|
let width: number | null = null;
|
||||||
|
let height: number | null = null;
|
||||||
|
try {
|
||||||
|
const dimensions = sizeOf(file.buffer);
|
||||||
|
if (dimensions.width && dimensions.height) {
|
||||||
|
width = dimensions.width;
|
||||||
|
height = dimensions.height;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to extract image dimensions:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageRecord = await service.create({
|
||||||
|
projectId,
|
||||||
|
flowId: finalFlowId,
|
||||||
|
pendingFlowId: pendingFlowId,
|
||||||
|
generationId: null,
|
||||||
|
apiKeyId,
|
||||||
|
storageKey: uploadResult.path!,
|
||||||
|
storageUrl: uploadResult.url!,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
fileSize: file.size,
|
||||||
|
fileHash: null,
|
||||||
|
source: 'uploaded',
|
||||||
|
alias: null,
|
||||||
|
meta: meta ? JSON.parse(meta) : {},
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reassign project alias if provided (override behavior per Section 5.2)
|
||||||
|
if (alias) {
|
||||||
|
await service.reassignProjectAlias(alias, imageRecord.id, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eager flow creation if flowAlias is provided
|
||||||
|
if (flowAlias) {
|
||||||
|
// Use pendingFlowId if available, otherwise finalFlowId
|
||||||
|
const flowIdToUse = pendingFlowId || finalFlowId;
|
||||||
|
|
||||||
|
if (!flowIdToUse) {
|
||||||
|
throw new Error('Cannot create flow: no flowId available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if flow exists, create if not
|
||||||
|
const existingFlow = await db.query.flows.findFirst({
|
||||||
|
where: eq(flows.id, flowIdToUse),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingFlow) {
|
||||||
|
await db.insert(flows).values({
|
||||||
|
id: flowIdToUse,
|
||||||
|
projectId,
|
||||||
|
aliases: {},
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link pending images if this was a lazy flow
|
||||||
|
if (pendingFlowId) {
|
||||||
|
await service.linkPendingImagesToFlow(flowIdToUse, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign flow alias to uploaded image
|
||||||
|
const flow = await db.query.flows.findFirst({
|
||||||
|
where: eq(flows.id, flowIdToUse),
|
||||||
|
});
|
||||||
|
|
||||||
|
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, flowIdToUse));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refetch image to include any updates (alias assignment, flow alias)
|
||||||
|
const finalImage = await service.getById(imageRecord.id);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: toImageResponse(finalImage!),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : 'Upload failed',
|
||||||
|
code: 'UPLOAD_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all images for the project with filtering and pagination
|
||||||
|
*
|
||||||
|
* Retrieves images (both generated and uploaded) with support for:
|
||||||
|
* - Flow-based filtering
|
||||||
|
* - Source filtering (generated vs uploaded)
|
||||||
|
* - Alias filtering (exact match)
|
||||||
|
* - Pagination with configurable limit and offset
|
||||||
|
* - Optional inclusion of soft-deleted images
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/images
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} [req.query.flowId] - Filter by flow ID
|
||||||
|
* @param {string} [req.query.source] - Filter by source (generated|uploaded)
|
||||||
|
* @param {string} [req.query.alias] - Filter by exact alias match
|
||||||
|
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||||
|
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||||
|
* @param {boolean} [req.query.includeDeleted=false] - Include soft-deleted images
|
||||||
|
*
|
||||||
|
* @returns {ListImagesResponse} 200 - Paginated list of images
|
||||||
|
* @returns {object} 400 - Invalid pagination parameters
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // List uploaded images in a flow
|
||||||
|
* GET /api/v1/images?flowId=abc-123&source=uploaded&limit=50
|
||||||
|
*/
|
||||||
|
imagesRouter.get(
|
||||||
|
'/',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<ListImagesResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { flowId, source, alias, limit, offset, includeDeleted } = req.query;
|
||||||
|
|
||||||
|
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||||
|
if (!paginationResult.valid) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
const result = await service.list(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
flowId: flowId as string | undefined,
|
||||||
|
source: source as 'generated' | 'uploaded' | undefined,
|
||||||
|
alias: alias as string | undefined,
|
||||||
|
deleted: includeDeleted === 'true' ? true : undefined,
|
||||||
|
},
|
||||||
|
validatedLimit,
|
||||||
|
validatedOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = result.images.map((img) => toImageResponse(img));
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use GET /api/v1/images/:alias directly instead (Section 6.2)
|
||||||
|
*
|
||||||
|
* Resolve an alias to an image using 3-tier precedence system
|
||||||
|
*
|
||||||
|
* **DEPRECATED**: This endpoint is deprecated as of Section 6.2. Use the main
|
||||||
|
* GET /api/v1/images/:id_or_alias endpoint instead, which supports both UUIDs
|
||||||
|
* and aliases (@-prefixed) directly in the path parameter.
|
||||||
|
*
|
||||||
|
* **Migration Guide**:
|
||||||
|
* - Old: GET /api/v1/images/resolve/@hero
|
||||||
|
* - New: GET /api/v1/images/@hero
|
||||||
|
*
|
||||||
|
* This endpoint remains functional for backwards compatibility but will be
|
||||||
|
* removed in a future version.
|
||||||
|
*
|
||||||
|
* Resolves aliases through a priority-based lookup system:
|
||||||
|
* 1. Technical aliases (@last, @first, @upload) - computed on-the-fly
|
||||||
|
* 2. Flow-scoped aliases - looked up in flow's JSONB aliases field (requires flowId)
|
||||||
|
* 3. Project-scoped aliases - looked up in images.alias column
|
||||||
|
*
|
||||||
|
* Returns the image ID, resolution scope, and complete image details.
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/images/resolve/:alias
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.alias - Alias to resolve (e.g., "@last", "@hero", "@step-1")
|
||||||
|
* @param {string} [req.query.flowId] - Flow context for flow-scoped resolution
|
||||||
|
*
|
||||||
|
* @returns {ResolveAliasResponse} 200 - Resolved image with scope and details (includes X-Deprecated header)
|
||||||
|
* @returns {object} 404 - Alias not found in any scope
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} ALIAS_NOT_FOUND - Alias does not exist
|
||||||
|
* @throws {Error} RESOLUTION_ERROR - Resolution failed
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Resolve technical alias
|
||||||
|
* GET /api/v1/images/resolve/@last
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Resolve flow-scoped alias
|
||||||
|
* GET /api/v1/images/resolve/@step-1?flowId=abc-123
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Resolve project-scoped alias
|
||||||
|
* GET /api/v1/images/resolve/@hero-bg
|
||||||
|
*/
|
||||||
|
imagesRouter.get(
|
||||||
|
'/resolve/:alias',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<ResolveAliasResponse>) => {
|
||||||
|
const aliasServiceInstance = getAliasService();
|
||||||
|
const { alias } = req.params;
|
||||||
|
const { flowId } = req.query;
|
||||||
|
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
// Add deprecation header
|
||||||
|
res.setHeader(
|
||||||
|
'X-Deprecated',
|
||||||
|
'This endpoint is deprecated. Use GET /api/v1/images/:alias instead (Section 6.2)'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolution = await aliasServiceInstance.resolve(
|
||||||
|
alias,
|
||||||
|
projectId,
|
||||||
|
flowId as string | undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resolution) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: `Alias '${alias}' not found`,
|
||||||
|
code: 'ALIAS_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify project ownership
|
||||||
|
if (resolution.image && resolution.image.projectId !== projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Alias not found',
|
||||||
|
code: 'ALIAS_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
alias,
|
||||||
|
imageId: resolution.imageId,
|
||||||
|
scope: resolution.scope,
|
||||||
|
flowId: resolution.flowId,
|
||||||
|
image: resolution.image ? toImageResponse(resolution.image) : ({} as any),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to resolve alias',
|
||||||
|
code: 'RESOLUTION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single image by ID with complete details
|
||||||
|
*
|
||||||
|
* Retrieves full image information including:
|
||||||
|
* - Storage URLs and keys
|
||||||
|
* - Project and flow associations
|
||||||
|
* - Alias assignments (project-scoped)
|
||||||
|
* - Source (generated vs uploaded)
|
||||||
|
* - File metadata (size, MIME type, hash)
|
||||||
|
* - Focal point and custom metadata
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/images/:id_or_alias
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@alias)
|
||||||
|
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||||
|
*
|
||||||
|
* @returns {GetImageResponse} 200 - Complete image details
|
||||||
|
* @returns {object} 404 - Image not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
* GET /api/v1/images/@hero
|
||||||
|
* GET /api/v1/images/@hero?flowId=abc-123
|
||||||
|
*/
|
||||||
|
imagesRouter.get(
|
||||||
|
'/:id_or_alias',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<GetImageResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { id_or_alias } = req.params;
|
||||||
|
const { flowId } = req.query;
|
||||||
|
|
||||||
|
// Resolve alias to imageId if needed (Section 6.2)
|
||||||
|
let imageId: string;
|
||||||
|
try {
|
||||||
|
imageId = await resolveImageIdentifier(
|
||||||
|
id_or_alias,
|
||||||
|
req.apiKey.projectId,
|
||||||
|
flowId as string | undefined
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await service.getById(imageId);
|
||||||
|
if (!image) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toImageResponse(image),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update image metadata (focal point and custom metadata)
|
||||||
|
*
|
||||||
|
* Updates non-generative image properties:
|
||||||
|
* - Focal point for image cropping (x, y coordinates 0.0-1.0)
|
||||||
|
* - Custom metadata (arbitrary JSON object)
|
||||||
|
*
|
||||||
|
* Note: Alias assignment moved to separate endpoint PUT /images/:id/alias (Section 6.1)
|
||||||
|
* Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
|
||||||
|
*
|
||||||
|
* @route PUT /api/v1/images/:id_or_alias
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
|
||||||
|
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||||
|
* @param {UpdateImageRequest} req.body - Update parameters
|
||||||
|
* @param {object} [req.body.focalPoint] - Focal point for cropping
|
||||||
|
* @param {number} req.body.focalPoint.x - X coordinate (0.0-1.0)
|
||||||
|
* @param {number} req.body.focalPoint.y - Y coordinate (0.0-1.0)
|
||||||
|
* @param {object} [req.body.meta] - Custom metadata
|
||||||
|
*
|
||||||
|
* @returns {UpdateImageResponse} 200 - Updated image details
|
||||||
|
* @returns {object} 404 - Image not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||||
|
*
|
||||||
|
* @example UUID identifier
|
||||||
|
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
* {
|
||||||
|
* "focalPoint": { "x": 0.5, "y": 0.3 },
|
||||||
|
* "meta": { "category": "hero", "priority": 1 }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example Project-scoped alias
|
||||||
|
* PUT /api/v1/images/@hero-banner
|
||||||
|
* {
|
||||||
|
* "focalPoint": { "x": 0.5, "y": 0.3 }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example Flow-scoped alias
|
||||||
|
* PUT /api/v1/images/@product-shot?flowId=123e4567-e89b-12d3-a456-426614174000
|
||||||
|
* {
|
||||||
|
* "meta": { "category": "product" }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
imagesRouter.put(
|
||||||
|
'/:id_or_alias',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { id_or_alias } = req.params;
|
||||||
|
const { flowId } = req.query;
|
||||||
|
const { focalPoint, meta } = req.body; // Removed alias (Section 6.1)
|
||||||
|
|
||||||
|
// Resolve alias to imageId if needed (Section 6.2)
|
||||||
|
let imageId: string;
|
||||||
|
try {
|
||||||
|
imageId = await resolveImageIdentifier(
|
||||||
|
id_or_alias,
|
||||||
|
req.apiKey.projectId,
|
||||||
|
flowId as string | undefined
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await service.getById(imageId);
|
||||||
|
if (!image) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: {
|
||||||
|
focalPoint?: { x: number; y: number };
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (focalPoint !== undefined) updates.focalPoint = focalPoint;
|
||||||
|
if (meta !== undefined) updates.meta = meta;
|
||||||
|
|
||||||
|
const updated = await service.update(imageId, updates);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toImageResponse(updated),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign or remove a project-scoped alias from an image
|
||||||
|
*
|
||||||
|
* Sets, updates, or removes the project-scoped alias for an image:
|
||||||
|
* - Alias must start with @ symbol (when assigning)
|
||||||
|
* - Must be unique within the project
|
||||||
|
* - Replaces existing alias if image already has one
|
||||||
|
* - Used for alias resolution in generations and CDN access
|
||||||
|
* - Set alias to null to remove existing alias
|
||||||
|
*
|
||||||
|
* This is a dedicated endpoint introduced in Section 6.1 to separate
|
||||||
|
* alias assignment from general metadata updates.
|
||||||
|
* Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
|
||||||
|
*
|
||||||
|
* @route PUT /api/v1/images/:id_or_alias/alias
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
|
||||||
|
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||||
|
* @param {object} req.body - Request body
|
||||||
|
* @param {string|null} req.body.alias - Project-scoped alias (e.g., "@hero-bg") or null to remove
|
||||||
|
*
|
||||||
|
* @returns {UpdateImageResponse} 200 - Updated image with new/removed alias
|
||||||
|
* @returns {object} 404 - Image not found or access denied
|
||||||
|
* @returns {object} 400 - Invalid alias format
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
* @returns {object} 409 - Alias already exists
|
||||||
|
*
|
||||||
|
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||||
|
* @throws {Error} VALIDATION_ERROR - Invalid alias format
|
||||||
|
* @throws {Error} ALIAS_CONFLICT - Alias already assigned to another image
|
||||||
|
*
|
||||||
|
* @example Assign alias
|
||||||
|
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias
|
||||||
|
* {
|
||||||
|
* "alias": "@hero-background"
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example Remove alias
|
||||||
|
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias
|
||||||
|
* {
|
||||||
|
* "alias": null
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example Project-scoped alias identifier
|
||||||
|
* PUT /api/v1/images/@old-hero/alias
|
||||||
|
* {
|
||||||
|
* "alias": "@new-hero"
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example Flow-scoped alias identifier
|
||||||
|
* PUT /api/v1/images/@temp-product/alias?flowId=123e4567-e89b-12d3-a456-426614174000
|
||||||
|
* {
|
||||||
|
* "alias": "@final-product"
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
imagesRouter.put(
|
||||||
|
'/:id_or_alias/alias',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { id_or_alias } = req.params;
|
||||||
|
const { flowId } = req.query;
|
||||||
|
const { alias } = req.body;
|
||||||
|
|
||||||
|
// Validate: alias must be null (to remove) or a non-empty string
|
||||||
|
if (alias !== null && (typeof alias !== 'string' || alias.trim() === '')) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Alias must be null (to remove) or a non-empty string',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve alias to imageId if needed (Section 6.2)
|
||||||
|
let imageId: string;
|
||||||
|
try {
|
||||||
|
imageId = await resolveImageIdentifier(
|
||||||
|
id_or_alias,
|
||||||
|
req.apiKey.projectId,
|
||||||
|
flowId as string | undefined
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await service.getById(imageId);
|
||||||
|
if (!image) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either remove alias (null) or assign new one (override behavior per Section 5.2)
|
||||||
|
let updated: Image;
|
||||||
|
if (alias === null) {
|
||||||
|
// Remove alias
|
||||||
|
updated = await service.update(imageId, { alias: null });
|
||||||
|
} else {
|
||||||
|
// Reassign alias (clears from any existing image, then assigns to this one)
|
||||||
|
await service.reassignProjectAlias(alias, imageId, image.projectId);
|
||||||
|
updated = (await service.getById(imageId))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toImageResponse(updated),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an image with storage cleanup and cascading deletions
|
||||||
|
*
|
||||||
|
* Performs hard delete of image record and MinIO file with cascading operations:
|
||||||
|
* - Deletes image record from database (hard delete, no soft delete)
|
||||||
|
* - Removes file from MinIO storage permanently
|
||||||
|
* - Cascades to delete generation-image relationships
|
||||||
|
* - Removes image from flow aliases (if present)
|
||||||
|
* - Cannot be undone
|
||||||
|
*
|
||||||
|
* Use with caution: This is a destructive operation that permanently removes
|
||||||
|
* the image file and all database references.
|
||||||
|
* Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
|
||||||
|
*
|
||||||
|
* @route DELETE /api/v1/images/:id_or_alias
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
|
||||||
|
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||||
|
*
|
||||||
|
* @returns {DeleteImageResponse} 200 - Deletion confirmation with image ID
|
||||||
|
* @returns {object} 404 - Image not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||||
|
*
|
||||||
|
* @example UUID identifier
|
||||||
|
* DELETE /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* {
|
||||||
|
* "success": true,
|
||||||
|
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example Project-scoped alias
|
||||||
|
* DELETE /api/v1/images/@old-banner
|
||||||
|
*
|
||||||
|
* @example Flow-scoped alias
|
||||||
|
* DELETE /api/v1/images/@temp-image?flowId=123e4567-e89b-12d3-a456-426614174000
|
||||||
|
*/
|
||||||
|
imagesRouter.delete(
|
||||||
|
'/:id_or_alias',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<DeleteImageResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { id_or_alias } = req.params;
|
||||||
|
const { flowId } = req.query;
|
||||||
|
|
||||||
|
// Resolve alias to imageId if needed (Section 6.2)
|
||||||
|
let imageId: string;
|
||||||
|
try {
|
||||||
|
imageId = await resolveImageIdentifier(
|
||||||
|
id_or_alias,
|
||||||
|
req.apiKey.projectId,
|
||||||
|
flowId as string | undefined
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await service.getById(imageId);
|
||||||
|
if (!image) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.hardDelete(imageId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { id: imageId },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import type { Router as RouterType } from 'express';
|
||||||
|
import { generationsRouter } from './generations';
|
||||||
|
import { flowsRouter } from './flows';
|
||||||
|
import { imagesRouter } from './images';
|
||||||
|
import { liveRouter } from './live';
|
||||||
|
import { scopesRouter } from './scopes';
|
||||||
|
|
||||||
|
export const v1Router: RouterType = Router();
|
||||||
|
|
||||||
|
// Mount v1 routes
|
||||||
|
v1Router.use('/generations', generationsRouter);
|
||||||
|
v1Router.use('/flows', flowsRouter);
|
||||||
|
v1Router.use('/images', imagesRouter);
|
||||||
|
v1Router.use('/live', liveRouter);
|
||||||
|
v1Router.use('/live/scopes', scopesRouter);
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { Response, Router } from 'express';
|
||||||
|
import type { Router as RouterType } from 'express';
|
||||||
|
import { PromptCacheService, GenerationService, ImageService } from '@/services/core';
|
||||||
|
import { StorageFactory } from '@/services/StorageFactory';
|
||||||
|
import { asyncHandler } from '@/middleware/errorHandler';
|
||||||
|
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
||||||
|
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
||||||
|
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
||||||
|
import { GENERATION_LIMITS } from '@/utils/constants';
|
||||||
|
|
||||||
|
export const liveRouter: RouterType = Router();
|
||||||
|
|
||||||
|
let promptCacheService: PromptCacheService;
|
||||||
|
let generationService: GenerationService;
|
||||||
|
let imageService: ImageService;
|
||||||
|
|
||||||
|
const getPromptCacheService = (): PromptCacheService => {
|
||||||
|
if (!promptCacheService) {
|
||||||
|
promptCacheService = new PromptCacheService();
|
||||||
|
}
|
||||||
|
return promptCacheService;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGenerationService = (): GenerationService => {
|
||||||
|
if (!generationService) {
|
||||||
|
generationService = new GenerationService();
|
||||||
|
}
|
||||||
|
return generationService;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageService = (): ImageService => {
|
||||||
|
if (!imageService) {
|
||||||
|
imageService = new ImageService();
|
||||||
|
}
|
||||||
|
return imageService;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/live
|
||||||
|
* Generate image with prompt caching
|
||||||
|
* Returns image bytes directly with cache headers
|
||||||
|
*/
|
||||||
|
liveRouter.get(
|
||||||
|
'/',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
|
const cacheService = getPromptCacheService();
|
||||||
|
const genService = getGenerationService();
|
||||||
|
const imgService = getImageService();
|
||||||
|
const { prompt, aspectRatio } = req.query;
|
||||||
|
|
||||||
|
// Validate prompt
|
||||||
|
if (!prompt || typeof prompt !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Prompt is required and must be a string',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
const apiKeyId = req.apiKey.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Compute prompt hash for cache lookup
|
||||||
|
const promptHash = cacheService.computePromptHash(prompt);
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
const cachedEntry = await cacheService.getCachedEntry(promptHash, projectId);
|
||||||
|
|
||||||
|
if (cachedEntry) {
|
||||||
|
// Cache HIT - fetch and stream existing image
|
||||||
|
await cacheService.recordCacheHit(cachedEntry.id);
|
||||||
|
|
||||||
|
// Get image from database
|
||||||
|
const image = await imgService.getById(cachedEntry.imageId);
|
||||||
|
if (!image) {
|
||||||
|
throw new Error('Cached image not found in database');
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
|
||||||
|
// Parse storage key to get components
|
||||||
|
// Format: orgId/projectId/category/filename.ext
|
||||||
|
const keyParts = image.storageKey.split('/');
|
||||||
|
if (keyParts.length < 4) {
|
||||||
|
throw new Error('Invalid storage key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = keyParts[0];
|
||||||
|
const projectIdSlug = keyParts[1];
|
||||||
|
const category = keyParts[2] as 'uploads' | 'generated' | 'references';
|
||||||
|
const filename = keyParts.slice(3).join('/');
|
||||||
|
|
||||||
|
// Download image from storage
|
||||||
|
const buffer = await storageService.downloadFile(
|
||||||
|
orgId!,
|
||||||
|
projectIdSlug!,
|
||||||
|
category,
|
||||||
|
filename!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set cache headers
|
||||||
|
res.setHeader('Content-Type', image.mimeType);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
|
||||||
|
res.setHeader('X-Cache-Status', 'HIT');
|
||||||
|
res.setHeader('X-Cache-Hit-Count', cachedEntry.hitCount.toString());
|
||||||
|
res.setHeader('X-Image-Id', image.id);
|
||||||
|
|
||||||
|
// Stream image bytes
|
||||||
|
res.send(buffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache MISS - generate new image
|
||||||
|
const generation = await genService.create({
|
||||||
|
projectId,
|
||||||
|
apiKeyId,
|
||||||
|
prompt,
|
||||||
|
aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the output image
|
||||||
|
if (!generation.outputImage) {
|
||||||
|
throw new Error('Generation succeeded but no output image was created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cache entry
|
||||||
|
const queryParamsHash = cacheService.computePromptHash(
|
||||||
|
JSON.stringify({ aspectRatio: aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO })
|
||||||
|
);
|
||||||
|
|
||||||
|
await cacheService.createCacheEntry({
|
||||||
|
projectId,
|
||||||
|
generationId: generation.id,
|
||||||
|
imageId: generation.outputImage.id,
|
||||||
|
promptHash,
|
||||||
|
queryParamsHash,
|
||||||
|
originalPrompt: prompt,
|
||||||
|
requestParams: {
|
||||||
|
aspectRatio: aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||||
|
},
|
||||||
|
hitCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download newly generated image
|
||||||
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
|
||||||
|
// Format: orgId/projectId/category/filename.ext
|
||||||
|
const keyParts = generation.outputImage.storageKey.split('/');
|
||||||
|
if (keyParts.length < 4) {
|
||||||
|
throw new Error('Invalid storage key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = keyParts[0];
|
||||||
|
const projectIdSlug = keyParts[1];
|
||||||
|
const category = keyParts[2] as 'uploads' | 'generated' | 'references';
|
||||||
|
const filename = keyParts.slice(3).join('/');
|
||||||
|
|
||||||
|
const buffer = await storageService.downloadFile(
|
||||||
|
orgId!,
|
||||||
|
projectIdSlug!,
|
||||||
|
category,
|
||||||
|
filename!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set cache headers
|
||||||
|
res.setHeader('Content-Type', generation.outputImage.mimeType);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
|
||||||
|
res.setHeader('X-Cache-Status', 'MISS');
|
||||||
|
res.setHeader('X-Generation-Id', generation.id);
|
||||||
|
res.setHeader('X-Image-Id', generation.outputImage.id);
|
||||||
|
|
||||||
|
// Stream image bytes
|
||||||
|
res.send(buffer);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Live generation error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : 'Generation failed',
|
||||||
|
code: 'GENERATION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,510 @@
|
||||||
|
import { Response, Router } from 'express';
|
||||||
|
import type { Router as RouterType } from 'express';
|
||||||
|
import { LiveScopeService, ImageService, GenerationService } from '@/services/core';
|
||||||
|
import { asyncHandler } from '@/middleware/errorHandler';
|
||||||
|
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
||||||
|
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
||||||
|
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
||||||
|
import { PAGINATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants';
|
||||||
|
import { buildPaginationMeta } from '@/utils/helpers';
|
||||||
|
import { toLiveScopeResponse, toImageResponse } from '@/types/responses';
|
||||||
|
import type {
|
||||||
|
CreateLiveScopeRequest,
|
||||||
|
ListLiveScopesQuery,
|
||||||
|
UpdateLiveScopeRequest,
|
||||||
|
RegenerateScopeRequest,
|
||||||
|
} from '@/types/requests';
|
||||||
|
import type {
|
||||||
|
CreateLiveScopeResponse,
|
||||||
|
GetLiveScopeResponse,
|
||||||
|
ListLiveScopesResponse,
|
||||||
|
UpdateLiveScopeResponse,
|
||||||
|
DeleteLiveScopeResponse,
|
||||||
|
RegenerateScopeResponse,
|
||||||
|
} from '@/types/responses';
|
||||||
|
|
||||||
|
export const scopesRouter: RouterType = Router();
|
||||||
|
|
||||||
|
let scopeService: LiveScopeService;
|
||||||
|
let imageService: ImageService;
|
||||||
|
let generationService: GenerationService;
|
||||||
|
|
||||||
|
const getScopeService = (): LiveScopeService => {
|
||||||
|
if (!scopeService) {
|
||||||
|
scopeService = new LiveScopeService();
|
||||||
|
}
|
||||||
|
return scopeService;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageService = (): ImageService => {
|
||||||
|
if (!imageService) {
|
||||||
|
imageService = new ImageService();
|
||||||
|
}
|
||||||
|
return imageService;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGenerationService = (): GenerationService => {
|
||||||
|
if (!generationService) {
|
||||||
|
generationService = new GenerationService();
|
||||||
|
}
|
||||||
|
return generationService;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new live scope manually with settings
|
||||||
|
*
|
||||||
|
* Creates a live scope for organizing live URL generations:
|
||||||
|
* - Slug must be unique within the project
|
||||||
|
* - Slug format: alphanumeric + hyphens + underscores only
|
||||||
|
* - Configure generation limits and permissions
|
||||||
|
* - Optional custom metadata storage
|
||||||
|
*
|
||||||
|
* Note: Scopes are typically auto-created via live URLs, but this endpoint
|
||||||
|
* allows pre-configuration with specific settings.
|
||||||
|
*
|
||||||
|
* @route POST /api/v1/live/scopes
|
||||||
|
* @authentication Project Key required
|
||||||
|
* @rateLimit 100 requests per hour per API key
|
||||||
|
*
|
||||||
|
* @param {CreateLiveScopeRequest} req.body - Scope configuration
|
||||||
|
* @param {string} req.body.slug - Unique scope identifier (alphanumeric + hyphens + underscores)
|
||||||
|
* @param {boolean} [req.body.allowNewGenerations=true] - Allow new generations in scope
|
||||||
|
* @param {number} [req.body.newGenerationsLimit=30] - Maximum generations allowed
|
||||||
|
* @param {object} [req.body.meta] - Custom metadata
|
||||||
|
*
|
||||||
|
* @returns {CreateLiveScopeResponse} 201 - Created scope with stats
|
||||||
|
* @returns {object} 400 - Invalid slug format
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
* @returns {object} 409 - Scope slug already exists
|
||||||
|
* @returns {object} 429 - Rate limit exceeded
|
||||||
|
*
|
||||||
|
* @throws {Error} SCOPE_INVALID_FORMAT - Invalid slug format
|
||||||
|
* @throws {Error} SCOPE_ALREADY_EXISTS - Slug already in use
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* POST /api/v1/live/scopes
|
||||||
|
* {
|
||||||
|
* "slug": "hero-section",
|
||||||
|
* "allowNewGenerations": true,
|
||||||
|
* "newGenerationsLimit": 50,
|
||||||
|
* "meta": { "description": "Hero section images" }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
scopesRouter.post(
|
||||||
|
'/',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<CreateLiveScopeResponse>) => {
|
||||||
|
const service = getScopeService();
|
||||||
|
const { slug, allowNewGenerations, newGenerationsLimit, meta } = req.body as CreateLiveScopeRequest;
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
// Validate slug format
|
||||||
|
if (!slug || !/^[a-zA-Z0-9_-]+$/.test(slug)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.SCOPE_INVALID_FORMAT,
|
||||||
|
code: 'SCOPE_INVALID_FORMAT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if scope already exists
|
||||||
|
const existing = await service.getBySlug(projectId, slug);
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Scope with this slug already exists',
|
||||||
|
code: 'SCOPE_ALREADY_EXISTS',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create scope
|
||||||
|
const scope = await service.create({
|
||||||
|
projectId,
|
||||||
|
slug,
|
||||||
|
allowNewGenerations: allowNewGenerations ?? true,
|
||||||
|
newGenerationsLimit: newGenerationsLimit ?? 30,
|
||||||
|
meta: meta || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get with stats
|
||||||
|
const scopeWithStats = await service.getByIdWithStats(scope.id);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: toLiveScopeResponse(scopeWithStats),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all live scopes for the project with pagination and statistics
|
||||||
|
*
|
||||||
|
* Retrieves all scopes (both auto-created and manually created) with:
|
||||||
|
* - Computed currentGenerations count (active only)
|
||||||
|
* - Last generation timestamp
|
||||||
|
* - Pagination support
|
||||||
|
* - Optional slug filtering
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/live/scopes
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} [req.query.slug] - Filter by exact slug match
|
||||||
|
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||||
|
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||||
|
*
|
||||||
|
* @returns {ListLiveScopesResponse} 200 - Paginated list of scopes with stats
|
||||||
|
* @returns {object} 400 - Invalid pagination parameters
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/v1/live/scopes?limit=50&offset=0
|
||||||
|
*/
|
||||||
|
scopesRouter.get(
|
||||||
|
'/',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<ListLiveScopesResponse>) => {
|
||||||
|
const service = getScopeService();
|
||||||
|
const { slug, limit, offset } = req.query as ListLiveScopesQuery;
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
const parsedLimit = Math.min(
|
||||||
|
(limit ? parseInt(limit.toString(), 10) : PAGINATION_LIMITS.DEFAULT_LIMIT) || PAGINATION_LIMITS.DEFAULT_LIMIT,
|
||||||
|
PAGINATION_LIMITS.MAX_LIMIT,
|
||||||
|
);
|
||||||
|
const parsedOffset = (offset ? parseInt(offset.toString(), 10) : 0) || 0;
|
||||||
|
|
||||||
|
const result = await service.list(
|
||||||
|
{ projectId, slug },
|
||||||
|
parsedLimit,
|
||||||
|
parsedOffset,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scopeResponses = result.scopes.map(toLiveScopeResponse);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: scopeResponses,
|
||||||
|
pagination: buildPaginationMeta(result.total, parsedLimit, parsedOffset),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single live scope by slug with complete statistics
|
||||||
|
*
|
||||||
|
* Retrieves detailed scope information including:
|
||||||
|
* - Current generation count (active generations only)
|
||||||
|
* - Last generation timestamp
|
||||||
|
* - Settings (allowNewGenerations, newGenerationsLimit)
|
||||||
|
* - Custom metadata
|
||||||
|
* - Creation and update timestamps
|
||||||
|
*
|
||||||
|
* @route GET /api/v1/live/scopes/:slug
|
||||||
|
* @authentication Project Key required
|
||||||
|
*
|
||||||
|
* @param {string} req.params.slug - Scope slug identifier
|
||||||
|
*
|
||||||
|
* @returns {GetLiveScopeResponse} 200 - Complete scope details with stats
|
||||||
|
* @returns {object} 404 - Scope not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
*
|
||||||
|
* @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/v1/live/scopes/hero-section
|
||||||
|
*/
|
||||||
|
scopesRouter.get(
|
||||||
|
'/:slug',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<GetLiveScopeResponse>) => {
|
||||||
|
const service = getScopeService();
|
||||||
|
const { slug } = req.params;
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
const scopeWithStats = await service.getBySlugWithStats(projectId, slug);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toLiveScopeResponse(scopeWithStats),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update live scope settings and metadata
|
||||||
|
*
|
||||||
|
* Modifies scope configuration:
|
||||||
|
* - Enable/disable new generations
|
||||||
|
* - Adjust generation limits
|
||||||
|
* - Update custom metadata
|
||||||
|
*
|
||||||
|
* Changes take effect immediately for new live URL requests.
|
||||||
|
*
|
||||||
|
* @route PUT /api/v1/live/scopes/:slug
|
||||||
|
* @authentication Project Key required
|
||||||
|
* @rateLimit 100 requests per hour per API key
|
||||||
|
*
|
||||||
|
* @param {string} req.params.slug - Scope slug identifier
|
||||||
|
* @param {UpdateLiveScopeRequest} req.body - Update parameters
|
||||||
|
* @param {boolean} [req.body.allowNewGenerations] - Allow/disallow new generations
|
||||||
|
* @param {number} [req.body.newGenerationsLimit] - Update generation limit
|
||||||
|
* @param {object} [req.body.meta] - Update custom metadata
|
||||||
|
*
|
||||||
|
* @returns {UpdateLiveScopeResponse} 200 - Updated scope with stats
|
||||||
|
* @returns {object} 404 - Scope not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
* @returns {object} 429 - Rate limit exceeded
|
||||||
|
*
|
||||||
|
* @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* PUT /api/v1/live/scopes/hero-section
|
||||||
|
* {
|
||||||
|
* "allowNewGenerations": false,
|
||||||
|
* "newGenerationsLimit": 100
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
scopesRouter.put(
|
||||||
|
'/:slug',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<UpdateLiveScopeResponse>) => {
|
||||||
|
const service = getScopeService();
|
||||||
|
const { slug } = req.params;
|
||||||
|
const { allowNewGenerations, newGenerationsLimit, meta } = req.body as UpdateLiveScopeRequest;
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
// Get scope
|
||||||
|
const scope = await service.getBySlugOrThrow(projectId, slug);
|
||||||
|
|
||||||
|
// Update scope
|
||||||
|
const updates: {
|
||||||
|
allowNewGenerations?: boolean;
|
||||||
|
newGenerationsLimit?: number;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
} = {};
|
||||||
|
if (allowNewGenerations !== undefined) updates.allowNewGenerations = allowNewGenerations;
|
||||||
|
if (newGenerationsLimit !== undefined) updates.newGenerationsLimit = newGenerationsLimit;
|
||||||
|
if (meta !== undefined) updates.meta = meta;
|
||||||
|
|
||||||
|
await service.update(scope.id, updates);
|
||||||
|
|
||||||
|
// Get updated scope with stats
|
||||||
|
const updated = await service.getByIdWithStats(scope.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toLiveScopeResponse(updated),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate images in a live scope
|
||||||
|
*
|
||||||
|
* Regenerates either a specific image or all images in the scope:
|
||||||
|
* - Specific image: Provide imageId in request body
|
||||||
|
* - All images: Omit imageId to regenerate entire scope
|
||||||
|
* - Uses exact same parameters (prompt, aspect ratio, etc.)
|
||||||
|
* - Updates existing images (preserves IDs and URLs)
|
||||||
|
* - Verifies image belongs to scope before regenerating
|
||||||
|
*
|
||||||
|
* Useful for refreshing stale cached images or recovering from failures.
|
||||||
|
*
|
||||||
|
* @route POST /api/v1/live/scopes/:slug/regenerate
|
||||||
|
* @authentication Project Key required
|
||||||
|
* @rateLimit 100 requests per hour per API key
|
||||||
|
*
|
||||||
|
* @param {string} req.params.slug - Scope slug identifier
|
||||||
|
* @param {RegenerateScopeRequest} [req.body] - Regeneration options
|
||||||
|
* @param {string} [req.body.imageId] - Specific image to regenerate (omit for all)
|
||||||
|
*
|
||||||
|
* @returns {RegenerateScopeResponse} 200 - Regeneration results
|
||||||
|
* @returns {object} 400 - Image not in scope
|
||||||
|
* @returns {object} 404 - Scope or image not found
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
* @returns {object} 429 - Rate limit exceeded
|
||||||
|
*
|
||||||
|
* @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
|
||||||
|
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||||
|
* @throws {Error} IMAGE_NOT_IN_SCOPE - Image doesn't belong to scope
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Regenerate specific image
|
||||||
|
* POST /api/v1/live/scopes/hero-section/regenerate
|
||||||
|
* {
|
||||||
|
* "imageId": "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Regenerate all images in scope
|
||||||
|
* POST /api/v1/live/scopes/hero-section/regenerate
|
||||||
|
* {}
|
||||||
|
*/
|
||||||
|
scopesRouter.post(
|
||||||
|
'/:slug/regenerate',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<RegenerateScopeResponse>) => {
|
||||||
|
const scopeService = getScopeService();
|
||||||
|
const imgService = getImageService();
|
||||||
|
const genService = getGenerationService();
|
||||||
|
const { slug } = req.params;
|
||||||
|
const { imageId } = req.body as RegenerateScopeRequest;
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
// Get scope
|
||||||
|
const scope = await scopeService.getBySlugWithStats(projectId, slug);
|
||||||
|
|
||||||
|
if (imageId) {
|
||||||
|
// Regenerate specific image
|
||||||
|
const image = await imgService.getById(imageId);
|
||||||
|
if (!image) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.IMAGE_NOT_FOUND,
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if image belongs to this scope
|
||||||
|
const imageMeta = image.meta as Record<string, unknown>;
|
||||||
|
if (imageMeta['scope'] !== slug) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image does not belong to this scope',
|
||||||
|
code: 'IMAGE_NOT_IN_SCOPE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate the image's generation
|
||||||
|
if (image.generationId) {
|
||||||
|
await genService.regenerate(image.generationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const regeneratedImage = await imgService.getById(imageId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
regenerated: 1,
|
||||||
|
images: regeneratedImage ? [toImageResponse(regeneratedImage)] : [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Regenerate all images in scope
|
||||||
|
if (!scope.images || scope.images.length === 0) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
regenerated: 0,
|
||||||
|
images: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regeneratedImages = [];
|
||||||
|
for (const image of scope.images) {
|
||||||
|
if (image.generationId) {
|
||||||
|
await genService.regenerate(image.generationId);
|
||||||
|
const regenerated = await imgService.getById(image.id);
|
||||||
|
if (regenerated) {
|
||||||
|
regeneratedImages.push(toImageResponse(regenerated));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
regenerated: regeneratedImages.length,
|
||||||
|
images: regeneratedImages,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a live scope with cascading image deletion
|
||||||
|
*
|
||||||
|
* Permanently removes the scope and all its associated images:
|
||||||
|
* - Hard deletes all images in scope (MinIO + database)
|
||||||
|
* - Follows alias protection rules for each image
|
||||||
|
* - Hard deletes scope record (no soft delete)
|
||||||
|
* - Cannot be undone
|
||||||
|
*
|
||||||
|
* Use with caution: This is a destructive operation that permanently
|
||||||
|
* removes the scope and all cached live URL images.
|
||||||
|
*
|
||||||
|
* @route DELETE /api/v1/live/scopes/:slug
|
||||||
|
* @authentication Project Key required
|
||||||
|
* @rateLimit 100 requests per hour per API key
|
||||||
|
*
|
||||||
|
* @param {string} req.params.slug - Scope slug identifier
|
||||||
|
*
|
||||||
|
* @returns {DeleteLiveScopeResponse} 200 - Deletion confirmation with scope ID
|
||||||
|
* @returns {object} 404 - Scope not found or access denied
|
||||||
|
* @returns {object} 401 - Missing or invalid API key
|
||||||
|
* @returns {object} 429 - Rate limit exceeded
|
||||||
|
*
|
||||||
|
* @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* DELETE /api/v1/live/scopes/hero-section
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* {
|
||||||
|
* "success": true,
|
||||||
|
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
scopesRouter.delete(
|
||||||
|
'/:slug',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<DeleteLiveScopeResponse>) => {
|
||||||
|
const scopeService = getScopeService();
|
||||||
|
const imgService = getImageService();
|
||||||
|
const { slug } = req.params;
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
// Get scope with images
|
||||||
|
const scope = await scopeService.getBySlugWithStats(projectId, slug);
|
||||||
|
|
||||||
|
// Delete all images in scope (follows alias protection rules)
|
||||||
|
if (scope.images) {
|
||||||
|
for (const image of scope.images) {
|
||||||
|
await imgService.hardDelete(image.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete scope record
|
||||||
|
await scopeService.delete(scope.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { id: scope.id },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { GoogleGenAI } from '@google/genai';
|
import { GoogleGenAI } from '@google/genai';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const mime = require('mime') as any;
|
const mime = require('mime') as any;
|
||||||
|
import sizeOf from 'image-size';
|
||||||
import {
|
import {
|
||||||
ImageGenerationOptions,
|
ImageGenerationOptions,
|
||||||
ImageGenerationResult,
|
ImageGenerationResult,
|
||||||
|
|
@ -78,8 +79,10 @@ export class ImageGenService {
|
||||||
filename: uploadResult.filename,
|
filename: uploadResult.filename,
|
||||||
filepath: uploadResult.path,
|
filepath: uploadResult.path,
|
||||||
url: uploadResult.url,
|
url: uploadResult.url,
|
||||||
|
size: uploadResult.size,
|
||||||
model: this.primaryModel,
|
model: this.primaryModel,
|
||||||
geminiParams,
|
geminiParams,
|
||||||
|
generatedImageData: generatedData,
|
||||||
...(generatedData.description && {
|
...(generatedData.description && {
|
||||||
description: generatedData.description,
|
description: generatedData.description,
|
||||||
}),
|
}),
|
||||||
|
|
@ -231,10 +234,25 @@ export class ImageGenService {
|
||||||
|
|
||||||
const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
|
const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
|
||||||
|
|
||||||
|
// Extract image dimensions from buffer
|
||||||
|
let width = 1024; // Default fallback
|
||||||
|
let height = 1024; // Default fallback
|
||||||
|
try {
|
||||||
|
const dimensions = sizeOf(imageData.buffer);
|
||||||
|
if (dimensions.width && dimensions.height) {
|
||||||
|
width = dimensions.width;
|
||||||
|
height = dimensions.height;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to extract image dimensions, using defaults:', error);
|
||||||
|
}
|
||||||
|
|
||||||
const generatedData: GeneratedImageData = {
|
const generatedData: GeneratedImageData = {
|
||||||
buffer: imageData.buffer,
|
buffer: imageData.buffer,
|
||||||
mimeType: imageData.mimeType,
|
mimeType: imageData.mimeType,
|
||||||
fileExtension,
|
fileExtension,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
...(generatedDescription && { description: generatedDescription }),
|
...(generatedDescription && { description: generatedDescription }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
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';
|
||||||
|
import { isTechnicalAlias } from '@/utils/constants/aliases';
|
||||||
|
import {
|
||||||
|
validateAliasFormat,
|
||||||
|
validateAliasNotReserved,
|
||||||
|
} from '@/utils/validators';
|
||||||
|
import { ERROR_MESSAGES } from '@/utils/constants';
|
||||||
|
|
||||||
|
export class AliasService {
|
||||||
|
async resolve(
|
||||||
|
alias: string,
|
||||||
|
projectId: string,
|
||||||
|
flowId?: string
|
||||||
|
): Promise<AliasResolution | null> {
|
||||||
|
const formatResult = validateAliasFormat(alias);
|
||||||
|
if (!formatResult.valid) {
|
||||||
|
throw new Error(formatResult.error!.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTechnicalAlias(alias)) {
|
||||||
|
if (!flowId) {
|
||||||
|
throw new Error(ERROR_MESSAGES.TECHNICAL_ALIAS_REQUIRES_FLOW);
|
||||||
|
}
|
||||||
|
return await this.resolveTechnicalAlias(alias, flowId, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flowId) {
|
||||||
|
const flowResolution = await this.resolveFlowAlias(alias, flowId, projectId);
|
||||||
|
if (flowResolution) {
|
||||||
|
return flowResolution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.resolveProjectAlias(alias, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveTechnicalAlias(
|
||||||
|
alias: string,
|
||||||
|
flowId: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<AliasResolution | null> {
|
||||||
|
let image: Image | undefined;
|
||||||
|
|
||||||
|
switch (alias) {
|
||||||
|
case '@last':
|
||||||
|
image = await this.getLastGeneratedInFlow(flowId, projectId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '@first':
|
||||||
|
image = await this.getFirstGeneratedInFlow(flowId, projectId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '@upload':
|
||||||
|
image = await this.getLastUploadedInFlow(flowId, projectId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageId: image.id,
|
||||||
|
scope: 'technical',
|
||||||
|
flowId,
|
||||||
|
image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveFlowAlias(
|
||||||
|
alias: string,
|
||||||
|
flowId: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<AliasResolution | null> {
|
||||||
|
const flow = await db.query.flows.findFirst({
|
||||||
|
where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!flow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowAliases = flow.aliases as Record<string, string>;
|
||||||
|
const imageId = flowAliases[alias];
|
||||||
|
|
||||||
|
if (!imageId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await db.query.images.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(images.id, imageId),
|
||||||
|
eq(images.projectId, projectId),
|
||||||
|
isNull(images.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageId: image.id,
|
||||||
|
scope: 'flow',
|
||||||
|
flowId,
|
||||||
|
image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveProjectAlias(
|
||||||
|
alias: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<AliasResolution | null> {
|
||||||
|
// Project aliases can exist on images with or without flowId
|
||||||
|
// Per spec: images with project alias should be resolvable at project level
|
||||||
|
const image = await db.query.images.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(images.projectId, projectId),
|
||||||
|
eq(images.alias, alias),
|
||||||
|
isNull(images.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageId: image.id,
|
||||||
|
scope: 'project',
|
||||||
|
image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLastGeneratedInFlow(
|
||||||
|
flowId: string,
|
||||||
|
projectId: string
|
||||||
|
): 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({
|
||||||
|
where: and(
|
||||||
|
or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
|
||||||
|
eq(images.projectId, projectId),
|
||||||
|
eq(images.source, 'generated'),
|
||||||
|
isNull(images.deletedAt)
|
||||||
|
),
|
||||||
|
orderBy: [desc(images.createdAt)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getFirstGeneratedInFlow(
|
||||||
|
flowId: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<Image | undefined> {
|
||||||
|
// Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
|
||||||
|
const allImages = await db.query.images.findMany({
|
||||||
|
where: and(
|
||||||
|
or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
|
||||||
|
eq(images.projectId, projectId),
|
||||||
|
eq(images.source, 'generated'),
|
||||||
|
isNull(images.deletedAt)
|
||||||
|
),
|
||||||
|
orderBy: [images.createdAt],
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return allImages[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLastUploadedInFlow(
|
||||||
|
flowId: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<Image | undefined> {
|
||||||
|
// Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
|
||||||
|
return await db.query.images.findFirst({
|
||||||
|
where: and(
|
||||||
|
or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
|
||||||
|
eq(images.projectId, projectId),
|
||||||
|
eq(images.source, 'uploaded'),
|
||||||
|
isNull(images.deletedAt)
|
||||||
|
),
|
||||||
|
orderBy: [desc(images.createdAt)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateAliasForAssignment(alias: string, projectId: string, flowId?: string): Promise<void> {
|
||||||
|
const formatResult = validateAliasFormat(alias);
|
||||||
|
if (!formatResult.valid) {
|
||||||
|
throw new Error(formatResult.error!.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservedResult = validateAliasNotReserved(alias);
|
||||||
|
if (!reservedResult.valid) {
|
||||||
|
throw new Error(reservedResult.error!.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Conflict checks removed per Section 5.2 of api-refactoring-final.md
|
||||||
|
// Aliases now use override behavior - new requests take priority over existing aliases
|
||||||
|
// Flow alias conflicts are handled by JSONB field overwrite (no check needed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEPRECATED: Removed per Section 5.2 - aliases now use override behavior
|
||||||
|
// private async checkProjectAliasConflict(alias: string, projectId: string): Promise<void> {
|
||||||
|
// const existing = await db.query.images.findFirst({
|
||||||
|
// where: and(
|
||||||
|
// eq(images.projectId, projectId),
|
||||||
|
// eq(images.alias, alias),
|
||||||
|
// isNull(images.deletedAt),
|
||||||
|
// isNull(images.flowId)
|
||||||
|
// ),
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// if (existing) {
|
||||||
|
// throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// DEPRECATED: Removed per Section 5.2 - flow aliases now use override behavior
|
||||||
|
// Flow alias conflicts are naturally handled by JSONB field overwrite in assignFlowAlias()
|
||||||
|
// private async checkFlowAliasConflict(alias: string, flowId: string, projectId: string): Promise<void> {
|
||||||
|
// const flow = await db.query.flows.findFirst({
|
||||||
|
// where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)),
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// if (!flow) {
|
||||||
|
// throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// const flowAliases = flow.aliases as Record<string, string>;
|
||||||
|
// if (flowAliases[alias]) {
|
||||||
|
// throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
async resolveMultiple(
|
||||||
|
aliases: string[],
|
||||||
|
projectId: string,
|
||||||
|
flowId?: string
|
||||||
|
): Promise<Map<string, AliasResolution>> {
|
||||||
|
const resolutions = new Map<string, AliasResolution>();
|
||||||
|
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const resolution = await this.resolve(alias, projectId, flowId);
|
||||||
|
if (resolution) {
|
||||||
|
resolutions.set(alias, resolution);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolutions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveToImageIds(
|
||||||
|
aliases: string[],
|
||||||
|
projectId: string,
|
||||||
|
flowId?: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const imageIds: string[] = [];
|
||||||
|
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const resolution = await this.resolve(alias, projectId, flowId);
|
||||||
|
if (resolution) {
|
||||||
|
imageIds.push(resolution.imageId);
|
||||||
|
} else {
|
||||||
|
throw new Error(`${ERROR_MESSAGES.ALIAS_NOT_FOUND}: ${alias}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
import { eq, desc, count } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
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> {
|
||||||
|
const [flow] = await db.insert(flows).values(data).returning();
|
||||||
|
if (!flow) {
|
||||||
|
throw new Error('Failed to create flow record');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...flow,
|
||||||
|
generationCount: 0,
|
||||||
|
imageCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<Flow | null> {
|
||||||
|
const flow = await db.query.flows.findFirst({
|
||||||
|
where: eq(flows.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return flow || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByIdOrThrow(id: string): Promise<Flow> {
|
||||||
|
const flow = await this.getById(id);
|
||||||
|
if (!flow) {
|
||||||
|
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByIdWithCounts(id: string): Promise<FlowWithCounts> {
|
||||||
|
const flow = await this.getByIdOrThrow(id);
|
||||||
|
|
||||||
|
const [genCountResult, imgCountResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(generations)
|
||||||
|
.where(eq(generations.flowId, id)),
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(images)
|
||||||
|
.where(eq(images.flowId, id)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const generationCount = Number(genCountResult[0]?.count || 0);
|
||||||
|
const imageCount = Number(imgCountResult[0]?.count || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...flow,
|
||||||
|
generationCount,
|
||||||
|
imageCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
filters: FlowFilters,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): Promise<{ flows: FlowWithCounts[]; total: number }> {
|
||||||
|
const conditions = [
|
||||||
|
buildEqCondition(flows, 'projectId', filters.projectId),
|
||||||
|
];
|
||||||
|
|
||||||
|
const whereClause = buildWhereClause(conditions);
|
||||||
|
|
||||||
|
const [flowsList, countResult] = await Promise.all([
|
||||||
|
db.query.flows.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: [desc(flows.updatedAt)],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(flows)
|
||||||
|
.where(whereClause),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCount = countResult[0]?.count || 0;
|
||||||
|
|
||||||
|
const flowsWithCounts = await Promise.all(
|
||||||
|
flowsList.map(async (flow) => {
|
||||||
|
const [genCountResult, imgCountResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(generations)
|
||||||
|
.where(eq(generations.flowId, flow.id)),
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(images)
|
||||||
|
.where(eq(images.flowId, flow.id)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...flow,
|
||||||
|
generationCount: Number(genCountResult[0]?.count || 0),
|
||||||
|
imageCount: Number(imgCountResult[0]?.count || 0),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
flows: flowsWithCounts,
|
||||||
|
total: Number(totalCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAliases(
|
||||||
|
id: string,
|
||||||
|
aliasUpdates: Record<string, string>
|
||||||
|
): Promise<FlowWithCounts> {
|
||||||
|
const flow = await this.getByIdOrThrow(id);
|
||||||
|
|
||||||
|
const currentAliases = (flow.aliases as Record<string, string>) || {};
|
||||||
|
const updatedAliases = { ...currentAliases, ...aliasUpdates };
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(flows)
|
||||||
|
.set({
|
||||||
|
aliases: updatedAliases,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(flows.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.getByIdWithCounts(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAlias(id: string, alias: string): Promise<FlowWithCounts> {
|
||||||
|
const flow = await this.getByIdOrThrow(id);
|
||||||
|
|
||||||
|
const currentAliases = (flow.aliases as Record<string, string>) || {};
|
||||||
|
const { [alias]: removed, ...remainingAliases } = currentAliases;
|
||||||
|
|
||||||
|
if (removed === undefined) {
|
||||||
|
throw new Error(`Alias '${alias}' not found in flow`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(flows)
|
||||||
|
.set({
|
||||||
|
aliases: remainingAliases,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(flows.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFlowGenerations(
|
||||||
|
flowId: string,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): Promise<{ generations: any[]; total: number }> {
|
||||||
|
const whereClause = eq(generations.flowId, flowId);
|
||||||
|
|
||||||
|
const [generationsList, countResult] = await Promise.all([
|
||||||
|
db.query.generations.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: [desc(generations.createdAt)],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
with: {
|
||||||
|
outputImage: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(generations)
|
||||||
|
.where(whereClause),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCount = countResult[0]?.count || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
generations: generationsList,
|
||||||
|
total: Number(totalCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFlowImages(
|
||||||
|
flowId: string,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): Promise<{ images: any[]; total: number }> {
|
||||||
|
const whereClause = eq(images.flowId, flowId);
|
||||||
|
|
||||||
|
const [imagesList, countResult] = await Promise.all([
|
||||||
|
db.query.images.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: [desc(images.createdAt)],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(images)
|
||||||
|
.where(whereClause),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCount = countResult[0]?.count || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
images: imagesList,
|
||||||
|
total: Number(totalCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,674 @@
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { eq, desc, count, and, isNull, inArray } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { generations, flows, images } from '@banatie/database';
|
||||||
|
import type {
|
||||||
|
Generation,
|
||||||
|
NewGeneration,
|
||||||
|
GenerationWithRelations,
|
||||||
|
GenerationFilters,
|
||||||
|
} from '@/types/models';
|
||||||
|
import { ImageService } from './ImageService';
|
||||||
|
import { AliasService } from './AliasService';
|
||||||
|
import { ImageGenService } from '../ImageGenService';
|
||||||
|
import { StorageFactory } from '../StorageFactory';
|
||||||
|
import { buildWhereClause, buildEqCondition } from '@/utils/helpers';
|
||||||
|
import { ERROR_MESSAGES, GENERATION_LIMITS } from '@/utils/constants';
|
||||||
|
import { extractAliasesFromPrompt } from '@/utils/validators';
|
||||||
|
import type { ReferenceImage } from '@/types/api';
|
||||||
|
|
||||||
|
export interface CreateGenerationParams {
|
||||||
|
projectId: string;
|
||||||
|
apiKeyId: string;
|
||||||
|
prompt: string;
|
||||||
|
referenceImages?: string[] | undefined; // Aliases to resolve
|
||||||
|
aspectRatio?: string | undefined;
|
||||||
|
flowId?: string | undefined;
|
||||||
|
alias?: string | undefined;
|
||||||
|
flowAlias?: string | undefined;
|
||||||
|
autoEnhance?: boolean | undefined;
|
||||||
|
enhancedPrompt?: string | undefined;
|
||||||
|
meta?: Record<string, unknown> | undefined;
|
||||||
|
requestId?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerationService {
|
||||||
|
private imageService: ImageService;
|
||||||
|
private aliasService: AliasService;
|
||||||
|
private imageGenService: ImageGenService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.imageService = new ImageService();
|
||||||
|
this.aliasService = new AliasService();
|
||||||
|
|
||||||
|
const geminiApiKey = process.env['GEMINI_API_KEY'];
|
||||||
|
if (!geminiApiKey) {
|
||||||
|
throw new Error('GEMINI_API_KEY environment variable is required');
|
||||||
|
}
|
||||||
|
this.imageGenService = new ImageGenService(geminiApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(params: CreateGenerationParams): Promise<GenerationWithRelations> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Auto-detect aliases from prompt and merge with manual references
|
||||||
|
const autoDetectedAliases = extractAliasesFromPrompt(params.prompt);
|
||||||
|
const manualReferences = params.referenceImages || [];
|
||||||
|
|
||||||
|
// Merge: manual references first, then auto-detected (remove duplicates)
|
||||||
|
const allReferences = Array.from(new Set([...manualReferences, ...autoDetectedAliases]));
|
||||||
|
|
||||||
|
// FlowId logic (Section 10.1 - UPDATED FOR LAZY PATTERN):
|
||||||
|
// - If undefined → generate UUID for pendingFlowId, flowId = null (lazy)
|
||||||
|
// - If null → flowId = null, pendingFlowId = null (explicitly no flow)
|
||||||
|
// - If string → flowId = string, pendingFlowId = null (use provided, create if needed)
|
||||||
|
let finalFlowId: string | null;
|
||||||
|
let pendingFlowId: string | null = null;
|
||||||
|
|
||||||
|
if (params.flowId === undefined) {
|
||||||
|
// Lazy pattern: defer flow creation until needed
|
||||||
|
pendingFlowId = randomUUID();
|
||||||
|
finalFlowId = null;
|
||||||
|
} else if (params.flowId === null) {
|
||||||
|
// Explicitly no flow
|
||||||
|
finalFlowId = null;
|
||||||
|
pendingFlowId = null;
|
||||||
|
} else {
|
||||||
|
// Specific flowId provided - ensure flow exists (eager creation)
|
||||||
|
finalFlowId = params.flowId;
|
||||||
|
pendingFlowId = null;
|
||||||
|
|
||||||
|
// 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: params.projectId,
|
||||||
|
aliases: {},
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link any pending generations to this new flow
|
||||||
|
await this.linkPendingGenerationsToFlow(finalFlowId, params.projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt semantics (Section 2.1):
|
||||||
|
// - originalPrompt: ALWAYS contains user's original input
|
||||||
|
// - prompt: Enhanced version if autoEnhance=true, otherwise same as originalPrompt
|
||||||
|
const usedPrompt = params.enhancedPrompt || params.prompt;
|
||||||
|
const preservedOriginal = params.prompt; // Always store original
|
||||||
|
|
||||||
|
const generationRecord: NewGeneration = {
|
||||||
|
projectId: params.projectId,
|
||||||
|
flowId: finalFlowId,
|
||||||
|
pendingFlowId: pendingFlowId,
|
||||||
|
apiKeyId: params.apiKeyId,
|
||||||
|
status: 'pending',
|
||||||
|
prompt: usedPrompt, // Prompt actually used for generation
|
||||||
|
originalPrompt: preservedOriginal, // User's original (only if enhanced)
|
||||||
|
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||||
|
referencedImages: null,
|
||||||
|
requestId: params.requestId || null,
|
||||||
|
meta: params.meta || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const [generation] = await db
|
||||||
|
.insert(generations)
|
||||||
|
.values(generationRecord)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!generation) {
|
||||||
|
throw new Error('Failed to create generation record');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.updateStatus(generation.id, 'processing');
|
||||||
|
|
||||||
|
let referenceImageBuffers: ReferenceImage[] = [];
|
||||||
|
let referencedImagesMetadata: Array<{ imageId: string; alias: string }> = [];
|
||||||
|
|
||||||
|
if (allReferences.length > 0) {
|
||||||
|
const resolved = await this.resolveReferenceImages(
|
||||||
|
allReferences,
|
||||||
|
params.projectId,
|
||||||
|
params.flowId
|
||||||
|
);
|
||||||
|
referenceImageBuffers = resolved.buffers;
|
||||||
|
referencedImagesMetadata = resolved.metadata;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(generations)
|
||||||
|
.set({ referencedImages: referencedImagesMetadata })
|
||||||
|
.where(eq(generations.id, generation.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const genResult = await this.imageGenService.generateImage({
|
||||||
|
prompt: usedPrompt, // Use the prompt that was stored (enhanced or original)
|
||||||
|
filename: `gen_${generation.id}`,
|
||||||
|
referenceImages: referenceImageBuffers,
|
||||||
|
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||||
|
orgId: 'default',
|
||||||
|
projectId: params.projectId,
|
||||||
|
meta: params.meta || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!genResult.success) {
|
||||||
|
const processingTime = Date.now() - startTime;
|
||||||
|
await this.updateStatus(generation.id, 'failed', {
|
||||||
|
errorMessage: genResult.error || 'Generation failed',
|
||||||
|
processingTimeMs: processingTime,
|
||||||
|
});
|
||||||
|
throw new Error(genResult.error || 'Generation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = genResult.filepath!;
|
||||||
|
// TODO: Add file hash computation when we have a helper to download by storageKey
|
||||||
|
const fileHash = null;
|
||||||
|
|
||||||
|
const imageRecord = await this.imageService.create({
|
||||||
|
projectId: params.projectId,
|
||||||
|
flowId: finalFlowId,
|
||||||
|
generationId: generation.id,
|
||||||
|
apiKeyId: params.apiKeyId,
|
||||||
|
storageKey,
|
||||||
|
storageUrl: genResult.url!,
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
fileSize: genResult.size || 0,
|
||||||
|
fileHash,
|
||||||
|
source: 'generated',
|
||||||
|
alias: null,
|
||||||
|
meta: params.meta || {},
|
||||||
|
width: genResult.generatedImageData?.width ?? null,
|
||||||
|
height: genResult.generatedImageData?.height ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reassign project alias if provided (override behavior per Section 5.2)
|
||||||
|
if (params.alias) {
|
||||||
|
await this.imageService.reassignProjectAlias(
|
||||||
|
params.alias,
|
||||||
|
imageRecord.id,
|
||||||
|
params.projectId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eager flow creation if flowAlias is provided (Section 4.2)
|
||||||
|
if (params.flowAlias) {
|
||||||
|
// If we have pendingFlowId, create flow and link pending generations
|
||||||
|
const flowIdToUse = pendingFlowId || finalFlowId;
|
||||||
|
|
||||||
|
if (!flowIdToUse) {
|
||||||
|
throw new Error('Cannot create flow: no flowId available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if flow exists, create if not
|
||||||
|
const existingFlow = await db.query.flows.findFirst({
|
||||||
|
where: eq(flows.id, flowIdToUse),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingFlow) {
|
||||||
|
await db.insert(flows).values({
|
||||||
|
id: flowIdToUse,
|
||||||
|
projectId: params.projectId,
|
||||||
|
aliases: {},
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link any pending generations to this new flow
|
||||||
|
await this.linkPendingGenerationsToFlow(flowIdToUse, params.projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assignFlowAlias(flowIdToUse, params.flowAlias, imageRecord.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update flow timestamp if flow was created (either from finalFlowId or pendingFlowId converted to flow)
|
||||||
|
const actualFlowId = finalFlowId || (pendingFlowId && params.flowAlias ? pendingFlowId : null);
|
||||||
|
if (actualFlowId) {
|
||||||
|
await db
|
||||||
|
.update(flows)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(flows.id, actualFlowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const processingTime = Date.now() - startTime;
|
||||||
|
await this.updateStatus(generation.id, 'success', {
|
||||||
|
outputImageId: imageRecord.id,
|
||||||
|
processingTimeMs: processingTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.getByIdWithRelations(generation.id);
|
||||||
|
} catch (error) {
|
||||||
|
const processingTime = Date.now() - startTime;
|
||||||
|
await this.updateStatus(generation.id, 'failed', {
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
processingTimeMs: processingTime,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveReferenceImages(
|
||||||
|
aliases: string[],
|
||||||
|
projectId: string,
|
||||||
|
flowId?: string
|
||||||
|
): Promise<{
|
||||||
|
buffers: ReferenceImage[];
|
||||||
|
metadata: Array<{ imageId: string; alias: string }>;
|
||||||
|
}> {
|
||||||
|
const resolutions = await this.aliasService.resolveMultiple(aliases, projectId, flowId);
|
||||||
|
|
||||||
|
const buffers: ReferenceImage[] = [];
|
||||||
|
const metadata: Array<{ imageId: string; alias: string }> = [];
|
||||||
|
|
||||||
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
|
||||||
|
for (const [alias, resolution] of resolutions) {
|
||||||
|
if (!resolution.image) {
|
||||||
|
throw new Error(`${ERROR_MESSAGES.ALIAS_NOT_FOUND}: ${alias}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = resolution.image.storageKey.split('/');
|
||||||
|
if (parts.length < 4) {
|
||||||
|
throw new Error(`Invalid storage key format: ${resolution.image.storageKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = parts[0]!;
|
||||||
|
const projId = parts[1]!;
|
||||||
|
const category = parts[2]! as 'uploads' | 'generated' | 'references';
|
||||||
|
const filename = parts.slice(3).join('/');
|
||||||
|
|
||||||
|
const buffer = await storageService.downloadFile(
|
||||||
|
orgId,
|
||||||
|
projId,
|
||||||
|
category,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
|
||||||
|
buffers.push({
|
||||||
|
buffer,
|
||||||
|
mimetype: resolution.image.mimeType,
|
||||||
|
originalname: filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
metadata.push({
|
||||||
|
imageId: resolution.imageId,
|
||||||
|
alias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buffers, metadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assignFlowAlias(
|
||||||
|
flowId: string,
|
||||||
|
flowAlias: string,
|
||||||
|
imageId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const flow = await db.query.flows.findFirst({
|
||||||
|
where: eq(flows.id, flowId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!flow) {
|
||||||
|
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAliases = (flow.aliases as Record<string, string>) || {};
|
||||||
|
const updatedAliases = { ...currentAliases };
|
||||||
|
|
||||||
|
// Assign the flow alias to the image
|
||||||
|
updatedAliases[flowAlias] = imageId;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(flows)
|
||||||
|
.set({ aliases: updatedAliases, updatedAt: new Date() })
|
||||||
|
.where(eq(flows.id, flowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async linkPendingGenerationsToFlow(
|
||||||
|
flowId: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Find all generations with pendingFlowId matching this flowId
|
||||||
|
const pendingGens = await db.query.generations.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(generations.pendingFlowId, flowId),
|
||||||
|
eq(generations.projectId, projectId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingGens.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update generations: set flowId and clear pendingFlowId
|
||||||
|
await db
|
||||||
|
.update(generations)
|
||||||
|
.set({
|
||||||
|
flowId: flowId,
|
||||||
|
pendingFlowId: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(generations.pendingFlowId, flowId),
|
||||||
|
eq(generations.projectId, projectId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also update associated images to have the flowId
|
||||||
|
const generationIds = pendingGens.map(g => g.id);
|
||||||
|
if (generationIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(images)
|
||||||
|
.set({
|
||||||
|
flowId: flowId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(images.projectId, projectId),
|
||||||
|
isNull(images.flowId),
|
||||||
|
inArray(images.generationId, generationIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateStatus(
|
||||||
|
id: string,
|
||||||
|
status: 'pending' | 'processing' | 'success' | 'failed',
|
||||||
|
additionalUpdates?: {
|
||||||
|
errorMessage?: string;
|
||||||
|
outputImageId?: string;
|
||||||
|
processingTimeMs?: number;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(generations)
|
||||||
|
.set({
|
||||||
|
status,
|
||||||
|
...additionalUpdates,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(generations.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<Generation | null> {
|
||||||
|
const generation = await db.query.generations.findFirst({
|
||||||
|
where: eq(generations.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return generation || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByIdWithRelations(id: string): Promise<GenerationWithRelations> {
|
||||||
|
const generation = await db.query.generations.findFirst({
|
||||||
|
where: eq(generations.id, id),
|
||||||
|
with: {
|
||||||
|
outputImage: true,
|
||||||
|
flow: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!generation) {
|
||||||
|
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation.referencedImages && Array.isArray(generation.referencedImages)) {
|
||||||
|
const refImageIds = (generation.referencedImages as Array<{ imageId: string; alias: string }>)
|
||||||
|
.map((ref) => ref.imageId);
|
||||||
|
const refImages = await this.imageService.getMultipleByIds(refImageIds);
|
||||||
|
return {
|
||||||
|
...generation,
|
||||||
|
referenceImages: refImages,
|
||||||
|
} as GenerationWithRelations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return generation as GenerationWithRelations;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
filters: GenerationFilters,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): Promise<{ generations: GenerationWithRelations[]; total: number }> {
|
||||||
|
const conditions = [
|
||||||
|
buildEqCondition(generations, 'projectId', filters.projectId),
|
||||||
|
buildEqCondition(generations, 'flowId', filters.flowId),
|
||||||
|
buildEqCondition(generations, 'status', filters.status),
|
||||||
|
];
|
||||||
|
|
||||||
|
const whereClause = buildWhereClause(conditions);
|
||||||
|
|
||||||
|
const [generationsList, countResult] = await Promise.all([
|
||||||
|
db.query.generations.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: [desc(generations.createdAt)],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
with: {
|
||||||
|
outputImage: true,
|
||||||
|
flow: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(generations)
|
||||||
|
.where(whereClause),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCount = countResult[0]?.count || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
generations: generationsList as GenerationWithRelations[],
|
||||||
|
total: Number(totalCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate an existing generation (Section 3)
|
||||||
|
* - Allows regeneration for any status (no status checks)
|
||||||
|
* - Uses exact same parameters as original
|
||||||
|
* - Updates existing image (same ID, path, URL)
|
||||||
|
* - No retry count logic
|
||||||
|
*/
|
||||||
|
async regenerate(id: string): Promise<GenerationWithRelations> {
|
||||||
|
const generation = await this.getById(id);
|
||||||
|
if (!generation) {
|
||||||
|
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!generation.outputImageId) {
|
||||||
|
throw new Error('Cannot regenerate generation without output image');
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update status to processing
|
||||||
|
await this.updateStatus(id, 'processing');
|
||||||
|
|
||||||
|
// Use EXACT same parameters as original (no overrides)
|
||||||
|
const genResult = await this.imageGenService.generateImage({
|
||||||
|
prompt: generation.prompt,
|
||||||
|
filename: `gen_${id}`,
|
||||||
|
referenceImages: [], // TODO: Re-resolve referenced images if needed
|
||||||
|
aspectRatio: generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||||
|
orgId: 'default',
|
||||||
|
projectId: generation.projectId,
|
||||||
|
meta: generation.meta as Record<string, unknown> || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!genResult.success) {
|
||||||
|
const processingTime = Date.now() - startTime;
|
||||||
|
await this.updateStatus(id, 'failed', {
|
||||||
|
errorMessage: genResult.error || 'Regeneration failed',
|
||||||
|
processingTimeMs: processingTime,
|
||||||
|
});
|
||||||
|
throw new Error(genResult.error || 'Regeneration failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Physical file in MinIO is overwritten by ImageGenService
|
||||||
|
// Image record preserves: imageId, storageKey, storageUrl, alias, createdAt
|
||||||
|
// Image record updates: fileSize (if changed), updatedAt
|
||||||
|
|
||||||
|
const processingTime = Date.now() - startTime;
|
||||||
|
await this.updateStatus(id, 'success', {
|
||||||
|
processingTimeMs: processingTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.getByIdWithRelations(id);
|
||||||
|
} catch (error) {
|
||||||
|
const processingTime = Date.now() - startTime;
|
||||||
|
await this.updateStatus(id, 'failed', {
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
processingTimeMs: processingTime,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep retry() for backward compatibility, delegate to regenerate()
|
||||||
|
async retry(id: string, overrides?: { prompt?: string; aspectRatio?: string }): Promise<GenerationWithRelations> {
|
||||||
|
// Ignore overrides, regenerate with original parameters
|
||||||
|
return await this.regenerate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
updates: {
|
||||||
|
prompt?: string;
|
||||||
|
aspectRatio?: string;
|
||||||
|
flowId?: string | null;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
): Promise<GenerationWithRelations> {
|
||||||
|
const generation = await this.getById(id);
|
||||||
|
if (!generation) {
|
||||||
|
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if generative parameters changed (prompt or aspectRatio)
|
||||||
|
const shouldRegenerate =
|
||||||
|
(updates.prompt !== undefined && updates.prompt !== generation.prompt) ||
|
||||||
|
(updates.aspectRatio !== undefined && updates.aspectRatio !== generation.aspectRatio);
|
||||||
|
|
||||||
|
// Handle flowId change (Section 9.2)
|
||||||
|
if (updates.flowId !== undefined && updates.flowId !== null) {
|
||||||
|
// If flowId provided and not null, create flow if it doesn't exist (eager creation)
|
||||||
|
const existingFlow = await db.query.flows.findFirst({
|
||||||
|
where: eq(flows.id, updates.flowId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingFlow) {
|
||||||
|
await db.insert(flows).values({
|
||||||
|
id: updates.flowId,
|
||||||
|
projectId: generation.projectId,
|
||||||
|
aliases: {},
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database fields
|
||||||
|
const updateData: Partial<NewGeneration> = {};
|
||||||
|
if (updates.prompt !== undefined) {
|
||||||
|
updateData.prompt = updates.prompt; // Update the prompt used for generation
|
||||||
|
}
|
||||||
|
if (updates.aspectRatio !== undefined) {
|
||||||
|
updateData.aspectRatio = updates.aspectRatio;
|
||||||
|
}
|
||||||
|
if (updates.flowId !== undefined) {
|
||||||
|
updateData.flowId = updates.flowId;
|
||||||
|
}
|
||||||
|
if (updates.meta !== undefined) {
|
||||||
|
updateData.meta = updates.meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
await db
|
||||||
|
.update(generations)
|
||||||
|
.set({ ...updateData, updatedAt: new Date() })
|
||||||
|
.where(eq(generations.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If generative parameters changed, trigger regeneration
|
||||||
|
if (shouldRegenerate && generation.outputImageId) {
|
||||||
|
// Update status to processing
|
||||||
|
await this.updateStatus(id, 'processing');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use updated prompt/aspectRatio or fall back to existing
|
||||||
|
const promptToUse = updates.prompt || generation.prompt;
|
||||||
|
const aspectRatioToUse = updates.aspectRatio || generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO;
|
||||||
|
|
||||||
|
// Regenerate image
|
||||||
|
const genResult = await this.imageGenService.generateImage({
|
||||||
|
prompt: promptToUse,
|
||||||
|
filename: `gen_${id}`,
|
||||||
|
referenceImages: [],
|
||||||
|
aspectRatio: aspectRatioToUse,
|
||||||
|
orgId: 'default',
|
||||||
|
projectId: generation.projectId,
|
||||||
|
meta: updates.meta || generation.meta || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!genResult.success) {
|
||||||
|
await this.updateStatus(id, 'failed', {
|
||||||
|
errorMessage: genResult.error || 'Regeneration failed',
|
||||||
|
});
|
||||||
|
throw new Error(genResult.error || 'Regeneration failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Physical file in MinIO is overwritten by ImageGenService
|
||||||
|
// TODO: Update fileSize and other metadata when ImageService.update() supports it
|
||||||
|
|
||||||
|
await this.updateStatus(id, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
await this.updateStatus(id, 'failed', {
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,364 @@
|
||||||
|
import { eq, and, isNull, desc, count, inArray, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
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;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.aliasService = new AliasService();
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: NewImage): Promise<Image> {
|
||||||
|
const [image] = await db.insert(images).values(data).returning();
|
||||||
|
if (!image) {
|
||||||
|
throw new Error('Failed to create image record');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update flow timestamp if image is part of a flow
|
||||||
|
if (image.flowId) {
|
||||||
|
await db
|
||||||
|
.update(flows)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(flows.id, image.flowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string, includeDeleted = false): Promise<Image | null> {
|
||||||
|
const image = await db.query.images.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(images.id, id),
|
||||||
|
includeDeleted ? undefined : isNull(images.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return image || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByIdOrThrow(id: string, includeDeleted = false): Promise<Image> {
|
||||||
|
const image = await this.getById(id, includeDeleted);
|
||||||
|
if (!image) {
|
||||||
|
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
filters: ImageFilters,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): Promise<{ images: Image[]; total: number }> {
|
||||||
|
const conditions = [
|
||||||
|
buildEqCondition(images, 'projectId', filters.projectId),
|
||||||
|
buildEqCondition(images, 'flowId', filters.flowId),
|
||||||
|
buildEqCondition(images, 'source', filters.source),
|
||||||
|
buildEqCondition(images, 'alias', filters.alias),
|
||||||
|
withoutDeleted(images, filters.deleted),
|
||||||
|
];
|
||||||
|
|
||||||
|
const whereClause = buildWhereClause(conditions);
|
||||||
|
|
||||||
|
const [imagesList, countResult] = await Promise.all([
|
||||||
|
db.query.images.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: [desc(images.createdAt)],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(images)
|
||||||
|
.where(whereClause),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCount = countResult[0]?.count || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
images: imagesList,
|
||||||
|
total: Number(totalCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
updates: {
|
||||||
|
alias?: string | null;
|
||||||
|
focalPoint?: { x: number; y: number };
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
): Promise<Image> {
|
||||||
|
const existing = await this.getByIdOrThrow(id);
|
||||||
|
|
||||||
|
if (updates.alias && updates.alias !== existing.alias) {
|
||||||
|
await this.aliasService.validateAliasForAssignment(
|
||||||
|
updates.alias,
|
||||||
|
existing.projectId,
|
||||||
|
existing.flowId || undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(images)
|
||||||
|
.set({
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(images.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(id: string): Promise<Image> {
|
||||||
|
const [deleted] = await db
|
||||||
|
.update(images)
|
||||||
|
.set({
|
||||||
|
deletedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(images.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
// 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) {
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignProjectAlias(imageId: string, alias: string): Promise<Image> {
|
||||||
|
const image = await this.getByIdOrThrow(imageId);
|
||||||
|
|
||||||
|
if (image.flowId) {
|
||||||
|
throw new Error('Cannot assign project alias to flow-scoped image');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.aliasService.validateAliasForAssignment(
|
||||||
|
alias,
|
||||||
|
image.projectId
|
||||||
|
);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(images)
|
||||||
|
.set({
|
||||||
|
alias,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(images.id, imageId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassign a project-scoped alias to a new image
|
||||||
|
* Clears the alias from any existing image and assigns it to the new image
|
||||||
|
* Implements override behavior per Section 5.2 of api-refactoring-final.md
|
||||||
|
*
|
||||||
|
* @param alias - The alias to reassign (e.g., "@hero")
|
||||||
|
* @param newImageId - ID of the image to receive the alias
|
||||||
|
* @param projectId - Project ID for scope validation
|
||||||
|
*/
|
||||||
|
async reassignProjectAlias(
|
||||||
|
alias: string,
|
||||||
|
newImageId: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Step 1: Clear alias from any existing image with this alias
|
||||||
|
// Project aliases can exist on images with or without flowId
|
||||||
|
await db
|
||||||
|
.update(images)
|
||||||
|
.set({
|
||||||
|
alias: null,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(images.projectId, projectId),
|
||||||
|
eq(images.alias, alias),
|
||||||
|
isNull(images.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: Assign alias to new image
|
||||||
|
await db
|
||||||
|
.update(images)
|
||||||
|
.set({
|
||||||
|
alias: alias,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(images.id, newImageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByStorageKey(storageKey: string): Promise<Image | null> {
|
||||||
|
const image = await db.query.images.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(images.storageKey, storageKey),
|
||||||
|
isNull(images.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return image || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByFileHash(fileHash: string, projectId: string): Promise<Image | null> {
|
||||||
|
const image = await db.query.images.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(images.fileHash, fileHash),
|
||||||
|
eq(images.projectId, projectId),
|
||||||
|
isNull(images.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return image || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMultipleByIds(ids: string[]): Promise<Image[]> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.query.images.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(images.id, ids),
|
||||||
|
isNull(images.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link all pending images to a flow
|
||||||
|
* Called when flow is created to attach all images with matching pendingFlowId
|
||||||
|
*/
|
||||||
|
async linkPendingImagesToFlow(
|
||||||
|
flowId: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Find all images with pendingFlowId matching this flowId
|
||||||
|
const pendingImages = await db.query.images.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(images.pendingFlowId, flowId),
|
||||||
|
eq(images.projectId, projectId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingImages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update images: set flowId and clear pendingFlowId
|
||||||
|
await db
|
||||||
|
.update(images)
|
||||||
|
.set({
|
||||||
|
flowId: flowId,
|
||||||
|
pendingFlowId: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(images.pendingFlowId, flowId),
|
||||||
|
eq(images.projectId, projectId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { eq, desc, count, and, isNull, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { liveScopes, images } from '@banatie/database';
|
||||||
|
import type { LiveScope, NewLiveScope, LiveScopeFilters, LiveScopeWithStats } from '@/types/models';
|
||||||
|
import { buildWhereClause, buildEqCondition } from '@/utils/helpers';
|
||||||
|
import { ERROR_MESSAGES } from '@/utils/constants';
|
||||||
|
|
||||||
|
export class LiveScopeService {
|
||||||
|
/**
|
||||||
|
* Create new live scope
|
||||||
|
* @param data - New scope data (projectId, slug, settings)
|
||||||
|
* @returns Created scope record
|
||||||
|
*/
|
||||||
|
async create(data: NewLiveScope): Promise<LiveScope> {
|
||||||
|
const [scope] = await db.insert(liveScopes).values(data).returning();
|
||||||
|
if (!scope) {
|
||||||
|
throw new Error('Failed to create live scope record');
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scope by ID
|
||||||
|
* @param id - Scope UUID
|
||||||
|
* @returns Scope record or null
|
||||||
|
*/
|
||||||
|
async getById(id: string): Promise<LiveScope | null> {
|
||||||
|
const scope = await db.query.liveScopes.findFirst({
|
||||||
|
where: eq(liveScopes.id, id),
|
||||||
|
});
|
||||||
|
return scope || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scope by slug within a project
|
||||||
|
* @param projectId - Project UUID
|
||||||
|
* @param slug - Scope slug
|
||||||
|
* @returns Scope record or null
|
||||||
|
*/
|
||||||
|
async getBySlug(projectId: string, slug: string): Promise<LiveScope | null> {
|
||||||
|
const scope = await db.query.liveScopes.findFirst({
|
||||||
|
where: and(eq(liveScopes.projectId, projectId), eq(liveScopes.slug, slug)),
|
||||||
|
});
|
||||||
|
return scope || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scope by ID or throw error
|
||||||
|
* @param id - Scope UUID
|
||||||
|
* @returns Scope record
|
||||||
|
* @throws Error if not found
|
||||||
|
*/
|
||||||
|
async getByIdOrThrow(id: string): Promise<LiveScope> {
|
||||||
|
const scope = await this.getById(id);
|
||||||
|
if (!scope) {
|
||||||
|
throw new Error('Live scope not found');
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scope by slug or throw error
|
||||||
|
* @param projectId - Project UUID
|
||||||
|
* @param slug - Scope slug
|
||||||
|
* @returns Scope record
|
||||||
|
* @throws Error if not found
|
||||||
|
*/
|
||||||
|
async getBySlugOrThrow(projectId: string, slug: string): Promise<LiveScope> {
|
||||||
|
const scope = await this.getBySlug(projectId, slug);
|
||||||
|
if (!scope) {
|
||||||
|
throw new Error('Live scope not found');
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scope with computed statistics
|
||||||
|
* @param id - Scope UUID
|
||||||
|
* @returns Scope with currentGenerations count and lastGeneratedAt
|
||||||
|
*/
|
||||||
|
async getByIdWithStats(id: string): Promise<LiveScopeWithStats> {
|
||||||
|
const scope = await this.getByIdOrThrow(id);
|
||||||
|
|
||||||
|
// Count images in this scope (use meta field: { scope: slug, isLiveUrl: true })
|
||||||
|
const scopeImages = await db.query.images.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(images.projectId, scope.projectId),
|
||||||
|
isNull(images.deletedAt),
|
||||||
|
sql`${images.meta}->>'scope' = ${scope.slug}`,
|
||||||
|
sql`(${images.meta}->>'isLiveUrl')::boolean = true`,
|
||||||
|
),
|
||||||
|
orderBy: [desc(images.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentGenerations = scopeImages.length;
|
||||||
|
const lastGeneratedAt = scopeImages.length > 0 ? scopeImages[0]!.createdAt : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...scope,
|
||||||
|
currentGenerations,
|
||||||
|
lastGeneratedAt,
|
||||||
|
images: scopeImages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scope by slug with computed statistics
|
||||||
|
* @param projectId - Project UUID
|
||||||
|
* @param slug - Scope slug
|
||||||
|
* @returns Scope with statistics
|
||||||
|
*/
|
||||||
|
async getBySlugWithStats(projectId: string, slug: string): Promise<LiveScopeWithStats> {
|
||||||
|
const scope = await this.getBySlugOrThrow(projectId, slug);
|
||||||
|
return this.getByIdWithStats(scope.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List scopes in a project with pagination
|
||||||
|
* @param filters - Query filters (projectId, optional slug)
|
||||||
|
* @param limit - Max results to return
|
||||||
|
* @param offset - Number of results to skip
|
||||||
|
* @returns Array of scopes with stats and total count
|
||||||
|
*/
|
||||||
|
async list(
|
||||||
|
filters: LiveScopeFilters,
|
||||||
|
limit: number,
|
||||||
|
offset: number,
|
||||||
|
): Promise<{ scopes: LiveScopeWithStats[]; total: number }> {
|
||||||
|
const conditions = [
|
||||||
|
buildEqCondition(liveScopes, 'projectId', filters.projectId),
|
||||||
|
buildEqCondition(liveScopes, 'slug', filters.slug),
|
||||||
|
];
|
||||||
|
|
||||||
|
const whereClause = buildWhereClause(conditions);
|
||||||
|
|
||||||
|
const [scopesList, countResult] = await Promise.all([
|
||||||
|
db.query.liveScopes.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: [desc(liveScopes.createdAt)],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
|
db.select({ count: count() }).from(liveScopes).where(whereClause),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCount = countResult[0]?.count || 0;
|
||||||
|
|
||||||
|
// Compute stats for each scope
|
||||||
|
const scopesWithStats = await Promise.all(
|
||||||
|
scopesList.map(async (scope) => {
|
||||||
|
const scopeImages = await db.query.images.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(images.projectId, scope.projectId),
|
||||||
|
isNull(images.deletedAt),
|
||||||
|
sql`${images.meta}->>'scope' = ${scope.slug}`,
|
||||||
|
sql`(${images.meta}->>'isLiveUrl')::boolean = true`,
|
||||||
|
),
|
||||||
|
orderBy: [desc(images.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...scope,
|
||||||
|
currentGenerations: scopeImages.length,
|
||||||
|
lastGeneratedAt: scopeImages.length > 0 ? scopeImages[0]!.createdAt : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scopes: scopesWithStats,
|
||||||
|
total: Number(totalCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update scope settings
|
||||||
|
* @param id - Scope UUID
|
||||||
|
* @param updates - Fields to update (allowNewGenerations, newGenerationsLimit, meta)
|
||||||
|
* @returns Updated scope record
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
updates: {
|
||||||
|
allowNewGenerations?: boolean;
|
||||||
|
newGenerationsLimit?: number;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<LiveScope> {
|
||||||
|
// Verify scope exists
|
||||||
|
await this.getByIdOrThrow(id);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(liveScopes)
|
||||||
|
.set({
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(liveScopes.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Failed to update live scope');
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete scope (hard delete)
|
||||||
|
* Note: Images in this scope are preserved with meta.scope field
|
||||||
|
* @param id - Scope UUID
|
||||||
|
*/
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await db.delete(liveScopes).where(eq(liveScopes.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if scope can accept new generations
|
||||||
|
* @param scope - Scope record
|
||||||
|
* @param currentCount - Current number of generations (optional, will query if not provided)
|
||||||
|
* @returns true if new generations are allowed
|
||||||
|
*/
|
||||||
|
async canGenerateNew(scope: LiveScope, currentCount?: number): Promise<boolean> {
|
||||||
|
if (!scope.allowNewGenerations) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCount === undefined) {
|
||||||
|
const stats = await this.getByIdWithStats(scope.id);
|
||||||
|
currentCount = stats.currentGenerations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentCount < scope.newGenerationsLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create scope automatically (lazy creation) with project defaults
|
||||||
|
* @param projectId - Project UUID
|
||||||
|
* @param slug - Scope slug
|
||||||
|
* @param projectDefaults - Default settings from project (allowNewGenerations, limit)
|
||||||
|
* @returns Created scope or existing scope if already exists
|
||||||
|
*/
|
||||||
|
async createOrGet(
|
||||||
|
projectId: string,
|
||||||
|
slug: string,
|
||||||
|
projectDefaults: {
|
||||||
|
allowNewLiveScopes: boolean;
|
||||||
|
newLiveScopesGenerationLimit: number;
|
||||||
|
},
|
||||||
|
): Promise<LiveScope> {
|
||||||
|
// Check if scope already exists
|
||||||
|
const existing = await this.getBySlug(projectId, slug);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if project allows new scope creation
|
||||||
|
if (!projectDefaults.allowNewLiveScopes) {
|
||||||
|
throw new Error(ERROR_MESSAGES.SCOPE_CREATION_DISABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new scope with project defaults
|
||||||
|
return this.create({
|
||||||
|
projectId,
|
||||||
|
slug,
|
||||||
|
allowNewGenerations: true,
|
||||||
|
newGenerationsLimit: projectDefaults.newLiveScopesGenerationLimit,
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { promptUrlCache } from '@banatie/database';
|
||||||
|
import type { PromptUrlCacheEntry, NewPromptUrlCacheEntry } from '@/types/models';
|
||||||
|
import { computeSHA256 } from '@/utils/helpers';
|
||||||
|
|
||||||
|
export class PromptCacheService {
|
||||||
|
/**
|
||||||
|
* Compute SHA-256 hash of prompt for cache lookup
|
||||||
|
*/
|
||||||
|
computePromptHash(prompt: string): string {
|
||||||
|
return computeSHA256(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if prompt exists in cache for a project
|
||||||
|
*/
|
||||||
|
async getCachedEntry(
|
||||||
|
promptHash: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<PromptUrlCacheEntry | null> {
|
||||||
|
const entry = await db.query.promptUrlCache.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(promptUrlCache.promptHash, promptHash),
|
||||||
|
eq(promptUrlCache.projectId, projectId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return entry || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new cache entry
|
||||||
|
*/
|
||||||
|
async createCacheEntry(data: NewPromptUrlCacheEntry): Promise<PromptUrlCacheEntry> {
|
||||||
|
const [entry] = await db.insert(promptUrlCache).values(data).returning();
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('Failed to create cache entry');
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update hit count and last hit time for a cache entry
|
||||||
|
*/
|
||||||
|
async recordCacheHit(id: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(promptUrlCache)
|
||||||
|
.set({
|
||||||
|
hitCount: sql`${promptUrlCache.hitCount} + 1`,
|
||||||
|
lastHitAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(promptUrlCache.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics for a project
|
||||||
|
*/
|
||||||
|
async getCacheStats(projectId: string): Promise<{
|
||||||
|
totalEntries: number;
|
||||||
|
totalHits: number;
|
||||||
|
avgHitCount: number;
|
||||||
|
}> {
|
||||||
|
const entries = await db.query.promptUrlCache.findMany({
|
||||||
|
where: eq(promptUrlCache.projectId, projectId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalEntries = entries.length;
|
||||||
|
const totalHits = entries.reduce((sum, entry) => sum + entry.hitCount, 0);
|
||||||
|
const avgHitCount = totalEntries > 0 ? totalHits / totalEntries : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalEntries,
|
||||||
|
totalHits,
|
||||||
|
avgHitCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear old cache entries (can be called periodically)
|
||||||
|
*/
|
||||||
|
async clearOldEntries(daysOld: number): Promise<number> {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.delete(promptUrlCache)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(promptUrlCache.hitCount, 0),
|
||||||
|
// Only delete entries with 0 hits that are old
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export * from './AliasService';
|
||||||
|
export * from './ImageService';
|
||||||
|
export * from './GenerationService';
|
||||||
|
export * from './FlowService';
|
||||||
|
export * from './PromptCacheService';
|
||||||
|
export * from './LiveScopeService';
|
||||||
|
|
@ -94,6 +94,7 @@ export interface ImageGenerationResult {
|
||||||
filename?: string;
|
filename?: string;
|
||||||
filepath?: string;
|
filepath?: string;
|
||||||
url?: string; // API URL for accessing the image
|
url?: string; // API URL for accessing the image
|
||||||
|
size?: number; // File size in bytes
|
||||||
description?: string;
|
description?: string;
|
||||||
model: string;
|
model: string;
|
||||||
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
|
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
|
||||||
|
|
@ -108,6 +109,8 @@ export interface GeneratedImageData {
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
fileExtension: string;
|
fileExtension: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logging types
|
// Logging types
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import type { generations, images, flows, promptUrlCache, liveScopes } from '@banatie/database';
|
||||||
|
|
||||||
|
// Database model types (inferred from Drizzle schema)
|
||||||
|
export type Generation = typeof generations.$inferSelect;
|
||||||
|
export type Image = typeof images.$inferSelect;
|
||||||
|
export type Flow = typeof flows.$inferSelect;
|
||||||
|
export type PromptUrlCacheEntry = typeof promptUrlCache.$inferSelect;
|
||||||
|
export type LiveScope = typeof liveScopes.$inferSelect;
|
||||||
|
|
||||||
|
// Insert types (for creating new records)
|
||||||
|
export type NewGeneration = typeof generations.$inferInsert;
|
||||||
|
export type NewImage = typeof images.$inferInsert;
|
||||||
|
export type NewFlow = typeof flows.$inferInsert;
|
||||||
|
export type NewPromptUrlCacheEntry = typeof promptUrlCache.$inferInsert;
|
||||||
|
export type NewLiveScope = typeof liveScopes.$inferInsert;
|
||||||
|
|
||||||
|
// Generation status enum (matches DB schema)
|
||||||
|
export type GenerationStatus = 'pending' | 'processing' | 'success' | 'failed';
|
||||||
|
|
||||||
|
// Image source enum (matches DB schema)
|
||||||
|
export type ImageSource = 'generated' | 'uploaded';
|
||||||
|
|
||||||
|
// Alias scope types (for resolution)
|
||||||
|
export type AliasScope = 'technical' | 'flow' | 'project';
|
||||||
|
|
||||||
|
// Alias resolution result
|
||||||
|
export interface AliasResolution {
|
||||||
|
imageId: string;
|
||||||
|
scope: AliasScope;
|
||||||
|
flowId?: string;
|
||||||
|
image?: Image;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced generation with related data
|
||||||
|
export interface GenerationWithRelations extends Generation {
|
||||||
|
outputImage?: Image;
|
||||||
|
referenceImages?: Image[];
|
||||||
|
flow?: Flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced image with related data
|
||||||
|
export interface ImageWithRelations extends Image {
|
||||||
|
generation?: Generation;
|
||||||
|
usedInGenerations?: Generation[];
|
||||||
|
flow?: Flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced flow with computed counts
|
||||||
|
export interface FlowWithCounts extends Flow {
|
||||||
|
generationCount: number;
|
||||||
|
imageCount: number;
|
||||||
|
generations?: Generation[];
|
||||||
|
images?: Image[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced live scope with computed stats
|
||||||
|
export interface LiveScopeWithStats extends LiveScope {
|
||||||
|
currentGenerations: number;
|
||||||
|
lastGeneratedAt: Date | null;
|
||||||
|
images?: Image[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination metadata
|
||||||
|
export interface PaginationMeta {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query filters for images
|
||||||
|
export interface ImageFilters {
|
||||||
|
projectId: string;
|
||||||
|
flowId?: string | undefined;
|
||||||
|
source?: ImageSource | undefined;
|
||||||
|
alias?: string | undefined;
|
||||||
|
deleted?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query filters for generations
|
||||||
|
export interface GenerationFilters {
|
||||||
|
projectId: string;
|
||||||
|
flowId?: string | undefined;
|
||||||
|
status?: GenerationStatus | undefined;
|
||||||
|
deleted?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query filters for flows
|
||||||
|
export interface FlowFilters {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query filters for live scopes
|
||||||
|
export interface LiveScopeFilters {
|
||||||
|
projectId: string;
|
||||||
|
slug?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache statistics
|
||||||
|
export interface CacheStats {
|
||||||
|
hits: number;
|
||||||
|
misses: number;
|
||||||
|
hitRate: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import type { ImageSource } from './models';
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// GENERATION ENDPOINTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface CreateGenerationRequest {
|
||||||
|
prompt: string;
|
||||||
|
referenceImages?: string[]; // Array of aliases to resolve
|
||||||
|
aspectRatio?: string; // e.g., "1:1", "16:9", "3:2", "9:16"
|
||||||
|
flowId?: string;
|
||||||
|
alias?: string; // Alias to assign to generated image
|
||||||
|
flowAlias?: string; // Flow-scoped alias to assign
|
||||||
|
autoEnhance?: boolean;
|
||||||
|
enhancementOptions?: {
|
||||||
|
template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general';
|
||||||
|
};
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListGenerationsQuery {
|
||||||
|
flowId?: string;
|
||||||
|
status?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
includeDeleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetryGenerationRequest {
|
||||||
|
prompt?: string; // Optional: override original prompt
|
||||||
|
aspectRatio?: string; // Optional: override original aspect ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGenerationRequest {
|
||||||
|
prompt?: string; // Change prompt (triggers regeneration)
|
||||||
|
aspectRatio?: string; // Change aspect ratio (triggers regeneration)
|
||||||
|
flowId?: string | null; // Change/remove/add flow association (null to detach)
|
||||||
|
meta?: Record<string, unknown>; // Update metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// IMAGE ENDPOINTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface UploadImageRequest {
|
||||||
|
alias?: string; // Project-scoped alias
|
||||||
|
flowId?: string;
|
||||||
|
flowAlias?: string; // Flow-scoped alias
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListImagesQuery {
|
||||||
|
flowId?: string;
|
||||||
|
source?: ImageSource;
|
||||||
|
alias?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
includeDeleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateImageRequest {
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteImageQuery {
|
||||||
|
hard?: boolean; // If true, perform hard delete; otherwise soft delete
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// FLOW ENDPOINTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface CreateFlowRequest {
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListFlowsQuery {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFlowAliasesRequest {
|
||||||
|
aliases: Record<string, string>; // { alias: imageId }
|
||||||
|
merge?: boolean; // If true, merge with existing; otherwise replace
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// LIVE GENERATION ENDPOINT
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface LiveGenerationQuery {
|
||||||
|
prompt: string;
|
||||||
|
aspectRatio?: string;
|
||||||
|
autoEnhance?: boolean;
|
||||||
|
template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// LIVE SCOPE ENDPOINTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface CreateLiveScopeRequest {
|
||||||
|
slug: string;
|
||||||
|
allowNewGenerations?: boolean;
|
||||||
|
newGenerationsLimit?: number;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListLiveScopesQuery {
|
||||||
|
slug?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLiveScopeRequest {
|
||||||
|
allowNewGenerations?: boolean;
|
||||||
|
newGenerationsLimit?: number;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegenerateScopeRequest {
|
||||||
|
imageId?: string; // Optional: regenerate specific image
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ANALYTICS ENDPOINTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface AnalyticsSummaryQuery {
|
||||||
|
flowId?: string;
|
||||||
|
startDate?: string; // ISO date string
|
||||||
|
endDate?: string; // ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTimelineQuery {
|
||||||
|
flowId?: string;
|
||||||
|
startDate?: string; // ISO date string
|
||||||
|
endDate?: string; // ISO date string
|
||||||
|
granularity?: 'hour' | 'day' | 'week';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// COMMON TYPES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface PaginationQuery {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
import type {
|
||||||
|
Image,
|
||||||
|
GenerationWithRelations,
|
||||||
|
FlowWithCounts,
|
||||||
|
LiveScopeWithStats,
|
||||||
|
PaginationMeta,
|
||||||
|
AliasScope,
|
||||||
|
} from './models';
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// COMMON RESPONSE TYPES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T[];
|
||||||
|
pagination: PaginationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// GENERATION RESPONSES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface GenerationResponse {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
flowId: string | null;
|
||||||
|
prompt: string; // Prompt actually used for generation
|
||||||
|
originalPrompt: string | null; // User's original input (always populated for new generations)
|
||||||
|
autoEnhance: boolean; // Whether prompt enhancement was applied
|
||||||
|
aspectRatio: string | null;
|
||||||
|
status: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
retryCount: number;
|
||||||
|
processingTimeMs: number | null;
|
||||||
|
cost: number | null;
|
||||||
|
outputImageId: string | null;
|
||||||
|
outputImage?: ImageResponse | undefined;
|
||||||
|
referencedImages?: Array<{ imageId: string; alias: string }> | undefined;
|
||||||
|
referenceImages?: ImageResponse[] | undefined;
|
||||||
|
apiKeyId: string | null;
|
||||||
|
meta: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateGenerationResponse = ApiResponse<GenerationResponse>;
|
||||||
|
export type GetGenerationResponse = ApiResponse<GenerationResponse>;
|
||||||
|
export type ListGenerationsResponse = PaginatedResponse<GenerationResponse>;
|
||||||
|
export type RetryGenerationResponse = ApiResponse<GenerationResponse>;
|
||||||
|
export type DeleteGenerationResponse = ApiResponse<{ id: string; deletedAt: string }>;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// IMAGE RESPONSES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface ImageResponse {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
flowId: string | null;
|
||||||
|
storageKey: string;
|
||||||
|
storageUrl: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileSize: number;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
source: string;
|
||||||
|
alias: string | null;
|
||||||
|
focalPoint: { x: number; y: number } | null;
|
||||||
|
fileHash: string | null;
|
||||||
|
generationId: string | null;
|
||||||
|
apiKeyId: string | null;
|
||||||
|
meta: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AliasResolutionResponse {
|
||||||
|
alias: string;
|
||||||
|
imageId: string;
|
||||||
|
scope: AliasScope;
|
||||||
|
flowId?: string | undefined;
|
||||||
|
image: ImageResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadImageResponse = ApiResponse<ImageResponse>;
|
||||||
|
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 }>; // Hard delete, no deletedAt
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// FLOW RESPONSES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface FlowResponse {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
aliases: Record<string, string>;
|
||||||
|
generationCount: number;
|
||||||
|
imageCount: number;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowWithDetailsResponse extends FlowResponse {
|
||||||
|
generations?: GenerationResponse[];
|
||||||
|
images?: ImageResponse[];
|
||||||
|
resolvedAliases?: Record<string, ImageResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateFlowResponse = ApiResponse<FlowResponse>;
|
||||||
|
export type GetFlowResponse = ApiResponse<FlowResponse>;
|
||||||
|
export type ListFlowsResponse = PaginatedResponse<FlowResponse>;
|
||||||
|
export type UpdateFlowAliasesResponse = ApiResponse<FlowResponse>;
|
||||||
|
export type DeleteFlowAliasResponse = ApiResponse<FlowResponse>;
|
||||||
|
export type DeleteFlowResponse = ApiResponse<{ id: string }>;
|
||||||
|
export type ListFlowGenerationsResponse = PaginatedResponse<GenerationResponse>;
|
||||||
|
export type ListFlowImagesResponse = PaginatedResponse<ImageResponse>;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// LIVE SCOPE RESPONSES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface LiveScopeResponse {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
slug: string;
|
||||||
|
allowNewGenerations: boolean;
|
||||||
|
newGenerationsLimit: number;
|
||||||
|
currentGenerations: number;
|
||||||
|
lastGeneratedAt: string | null;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveScopeWithImagesResponse extends LiveScopeResponse {
|
||||||
|
images?: ImageResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateLiveScopeResponse = ApiResponse<LiveScopeResponse>;
|
||||||
|
export type GetLiveScopeResponse = ApiResponse<LiveScopeResponse>;
|
||||||
|
export type ListLiveScopesResponse = PaginatedResponse<LiveScopeResponse>;
|
||||||
|
export type UpdateLiveScopeResponse = ApiResponse<LiveScopeResponse>;
|
||||||
|
export type DeleteLiveScopeResponse = ApiResponse<{ id: string }>;
|
||||||
|
export type RegenerateScopeResponse = ApiResponse<{ regenerated: number; images: ImageResponse[] }>;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// LIVE GENERATION RESPONSE
|
||||||
|
// ========================================
|
||||||
|
// Note: Live generation streams image bytes directly
|
||||||
|
// Response headers include:
|
||||||
|
// - Content-Type: image/jpeg
|
||||||
|
// - Cache-Control: public, max-age=31536000
|
||||||
|
// - X-Cache-Status: HIT | MISS
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ANALYTICS RESPONSES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
projectId: string;
|
||||||
|
flowId?: string;
|
||||||
|
timeRange: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
};
|
||||||
|
generations: {
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
pending: number;
|
||||||
|
successRate: number;
|
||||||
|
};
|
||||||
|
images: {
|
||||||
|
total: number;
|
||||||
|
generated: number;
|
||||||
|
uploaded: number;
|
||||||
|
};
|
||||||
|
performance: {
|
||||||
|
avgProcessingTimeMs: number;
|
||||||
|
totalCostCents: number;
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
hits: number;
|
||||||
|
misses: number;
|
||||||
|
hitRate: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTimelineData {
|
||||||
|
timestamp: string;
|
||||||
|
generationsTotal: number;
|
||||||
|
generationsSuccess: number;
|
||||||
|
generationsFailed: number;
|
||||||
|
avgProcessingTimeMs: number;
|
||||||
|
costCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTimeline {
|
||||||
|
projectId: string;
|
||||||
|
flowId?: string;
|
||||||
|
granularity: 'hour' | 'day' | 'week';
|
||||||
|
timeRange: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
};
|
||||||
|
data: AnalyticsTimelineData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetAnalyticsSummaryResponse = ApiResponse<AnalyticsSummary>;
|
||||||
|
export type GetAnalyticsTimelineResponse = ApiResponse<AnalyticsTimeline>;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ERROR RESPONSES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
success: false;
|
||||||
|
error: {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// HELPER TYPE CONVERTERS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export const toGenerationResponse = (gen: GenerationWithRelations): GenerationResponse => ({
|
||||||
|
id: gen.id,
|
||||||
|
projectId: gen.projectId,
|
||||||
|
flowId: gen.flowId ?? gen.pendingFlowId ?? null, // Return actual flowId or pendingFlowId for client
|
||||||
|
prompt: gen.prompt, // Prompt actually used
|
||||||
|
originalPrompt: gen.originalPrompt, // User's original (always populated)
|
||||||
|
autoEnhance: gen.prompt !== gen.originalPrompt, // True if prompts differ (enhancement happened)
|
||||||
|
aspectRatio: gen.aspectRatio,
|
||||||
|
status: gen.status,
|
||||||
|
errorMessage: gen.errorMessage,
|
||||||
|
retryCount: gen.retryCount,
|
||||||
|
processingTimeMs: gen.processingTimeMs,
|
||||||
|
cost: gen.cost,
|
||||||
|
outputImageId: gen.outputImageId,
|
||||||
|
outputImage: gen.outputImage ? toImageResponse(gen.outputImage) : undefined,
|
||||||
|
referencedImages: gen.referencedImages as Array<{ imageId: string; alias: string }> | undefined,
|
||||||
|
referenceImages: gen.referenceImages?.map((img) => toImageResponse(img)),
|
||||||
|
apiKeyId: gen.apiKeyId,
|
||||||
|
meta: gen.meta as Record<string, unknown>,
|
||||||
|
createdAt: gen.createdAt.toISOString(),
|
||||||
|
updatedAt: gen.updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toImageResponse = (img: Image): ImageResponse => ({
|
||||||
|
id: img.id,
|
||||||
|
projectId: img.projectId,
|
||||||
|
flowId: img.flowId ?? img.pendingFlowId ?? null, // Return actual flowId or pendingFlowId for client
|
||||||
|
storageKey: img.storageKey,
|
||||||
|
storageUrl: img.storageUrl,
|
||||||
|
mimeType: img.mimeType,
|
||||||
|
fileSize: img.fileSize,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
source: img.source,
|
||||||
|
alias: img.alias,
|
||||||
|
focalPoint: img.focalPoint as { x: number; y: number } | null,
|
||||||
|
fileHash: img.fileHash,
|
||||||
|
generationId: img.generationId,
|
||||||
|
apiKeyId: img.apiKeyId,
|
||||||
|
meta: img.meta as Record<string, unknown>,
|
||||||
|
createdAt: img.createdAt.toISOString(),
|
||||||
|
updatedAt: img.updatedAt.toISOString(),
|
||||||
|
deletedAt: img.deletedAt?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toFlowResponse = (flow: FlowWithCounts): FlowResponse => ({
|
||||||
|
id: flow.id,
|
||||||
|
projectId: flow.projectId,
|
||||||
|
aliases: flow.aliases as Record<string, string>,
|
||||||
|
generationCount: flow.generationCount,
|
||||||
|
imageCount: flow.imageCount,
|
||||||
|
meta: flow.meta as Record<string, unknown>,
|
||||||
|
createdAt: flow.createdAt.toISOString(),
|
||||||
|
updatedAt: flow.updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toLiveScopeResponse = (scope: LiveScopeWithStats): LiveScopeResponse => ({
|
||||||
|
id: scope.id,
|
||||||
|
projectId: scope.projectId,
|
||||||
|
slug: scope.slug,
|
||||||
|
allowNewGenerations: scope.allowNewGenerations,
|
||||||
|
newGenerationsLimit: scope.newGenerationsLimit,
|
||||||
|
currentGenerations: scope.currentGenerations,
|
||||||
|
lastGeneratedAt: scope.lastGeneratedAt?.toISOString() ?? null,
|
||||||
|
meta: scope.meta as Record<string, unknown>,
|
||||||
|
createdAt: scope.createdAt.toISOString(),
|
||||||
|
updatedAt: scope.updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
export const TECHNICAL_ALIASES = ['@last', '@first', '@upload'] as const;
|
||||||
|
|
||||||
|
export const RESERVED_ALIASES = [
|
||||||
|
...TECHNICAL_ALIASES,
|
||||||
|
'@all',
|
||||||
|
'@latest',
|
||||||
|
'@oldest',
|
||||||
|
'@random',
|
||||||
|
'@next',
|
||||||
|
'@prev',
|
||||||
|
'@previous',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ALIAS_PATTERN = /^@[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
|
export const ALIAS_MAX_LENGTH = 50;
|
||||||
|
|
||||||
|
export type TechnicalAlias = (typeof TECHNICAL_ALIASES)[number];
|
||||||
|
export type ReservedAlias = (typeof RESERVED_ALIASES)[number];
|
||||||
|
|
||||||
|
export const isTechnicalAlias = (alias: string): alias is TechnicalAlias => {
|
||||||
|
return TECHNICAL_ALIASES.includes(alias as TechnicalAlias);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isReservedAlias = (alias: string): alias is ReservedAlias => {
|
||||||
|
return RESERVED_ALIASES.includes(alias as ReservedAlias);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isValidAliasFormat = (alias: string): boolean => {
|
||||||
|
return ALIAS_PATTERN.test(alias) && alias.length <= ALIAS_MAX_LENGTH;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
export const ERROR_MESSAGES = {
|
||||||
|
// Authentication & Authorization
|
||||||
|
INVALID_API_KEY: 'Invalid or expired API key',
|
||||||
|
MISSING_API_KEY: 'API key is required',
|
||||||
|
UNAUTHORIZED: 'Unauthorized access',
|
||||||
|
MASTER_KEY_REQUIRED: 'Master key required for this operation',
|
||||||
|
PROJECT_KEY_REQUIRED: 'Project key required for this operation',
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
INVALID_ALIAS_FORMAT: 'Alias must start with @ and contain only alphanumeric characters, hyphens, and underscores',
|
||||||
|
RESERVED_ALIAS: 'This alias is reserved and cannot be used',
|
||||||
|
ALIAS_CONFLICT: 'An image with this alias already exists in this scope',
|
||||||
|
INVALID_PAGINATION: 'Invalid pagination parameters',
|
||||||
|
INVALID_UUID: 'Invalid UUID format',
|
||||||
|
INVALID_ASPECT_RATIO: 'Invalid aspect ratio format',
|
||||||
|
INVALID_FOCAL_POINT: 'Focal point coordinates must be between 0.0 and 1.0',
|
||||||
|
|
||||||
|
// Not Found
|
||||||
|
GENERATION_NOT_FOUND: 'Generation not found',
|
||||||
|
IMAGE_NOT_FOUND: 'Image not found',
|
||||||
|
FLOW_NOT_FOUND: 'Flow not found',
|
||||||
|
ALIAS_NOT_FOUND: 'Alias not found',
|
||||||
|
PROJECT_NOT_FOUND: 'Project not found',
|
||||||
|
|
||||||
|
// Resource Limits
|
||||||
|
MAX_REFERENCE_IMAGES_EXCEEDED: 'Maximum number of reference images exceeded',
|
||||||
|
MAX_FILE_SIZE_EXCEEDED: 'File size exceeds maximum allowed size',
|
||||||
|
RATE_LIMIT_EXCEEDED: 'Rate limit exceeded',
|
||||||
|
MAX_ALIASES_EXCEEDED: 'Maximum number of aliases per flow exceeded',
|
||||||
|
|
||||||
|
// Generation Errors
|
||||||
|
GENERATION_FAILED: 'Image generation failed',
|
||||||
|
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',
|
||||||
|
FLOW_HAS_NO_UPLOADS: 'Flow has no uploaded images',
|
||||||
|
ALIAS_NOT_IN_FLOW: 'Alias not found in flow',
|
||||||
|
|
||||||
|
// General
|
||||||
|
INTERNAL_SERVER_ERROR: 'Internal server error',
|
||||||
|
INVALID_REQUEST: 'Invalid request',
|
||||||
|
OPERATION_FAILED: 'Operation failed',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ERROR_CODES = {
|
||||||
|
// Authentication & Authorization
|
||||||
|
INVALID_API_KEY: 'INVALID_API_KEY',
|
||||||
|
MISSING_API_KEY: 'MISSING_API_KEY',
|
||||||
|
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||||
|
MASTER_KEY_REQUIRED: 'MASTER_KEY_REQUIRED',
|
||||||
|
PROJECT_KEY_REQUIRED: 'PROJECT_KEY_REQUIRED',
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||||
|
INVALID_ALIAS_FORMAT: 'INVALID_ALIAS_FORMAT',
|
||||||
|
RESERVED_ALIAS: 'RESERVED_ALIAS',
|
||||||
|
ALIAS_CONFLICT: 'ALIAS_CONFLICT',
|
||||||
|
INVALID_PAGINATION: 'INVALID_PAGINATION',
|
||||||
|
INVALID_UUID: 'INVALID_UUID',
|
||||||
|
INVALID_ASPECT_RATIO: 'INVALID_ASPECT_RATIO',
|
||||||
|
INVALID_FOCAL_POINT: 'INVALID_FOCAL_POINT',
|
||||||
|
|
||||||
|
// Not Found
|
||||||
|
NOT_FOUND: 'NOT_FOUND',
|
||||||
|
GENERATION_NOT_FOUND: 'GENERATION_NOT_FOUND',
|
||||||
|
IMAGE_NOT_FOUND: 'IMAGE_NOT_FOUND',
|
||||||
|
FLOW_NOT_FOUND: 'FLOW_NOT_FOUND',
|
||||||
|
ALIAS_NOT_FOUND: 'ALIAS_NOT_FOUND',
|
||||||
|
PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND',
|
||||||
|
|
||||||
|
// Resource Limits
|
||||||
|
RESOURCE_LIMIT_EXCEEDED: 'RESOURCE_LIMIT_EXCEEDED',
|
||||||
|
MAX_REFERENCE_IMAGES_EXCEEDED: 'MAX_REFERENCE_IMAGES_EXCEEDED',
|
||||||
|
MAX_FILE_SIZE_EXCEEDED: 'MAX_FILE_SIZE_EXCEEDED',
|
||||||
|
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
MAX_ALIASES_EXCEEDED: 'MAX_ALIASES_EXCEEDED',
|
||||||
|
|
||||||
|
// Generation Errors
|
||||||
|
GENERATION_FAILED: 'GENERATION_FAILED',
|
||||||
|
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',
|
||||||
|
FLOW_HAS_NO_UPLOADS: 'FLOW_HAS_NO_UPLOADS',
|
||||||
|
ALIAS_NOT_IN_FLOW: 'ALIAS_NOT_IN_FLOW',
|
||||||
|
|
||||||
|
// General
|
||||||
|
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||||
|
INVALID_REQUEST: 'INVALID_REQUEST',
|
||||||
|
OPERATION_FAILED: 'OPERATION_FAILED',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||||
|
export type ErrorMessage = (typeof ERROR_MESSAGES)[keyof typeof ERROR_MESSAGES];
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './aliases';
|
||||||
|
export * from './limits';
|
||||||
|
export * from './errors';
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
export const RATE_LIMITS = {
|
||||||
|
master: {
|
||||||
|
requests: {
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 1000,
|
||||||
|
},
|
||||||
|
generations: {
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
requests: {
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 500,
|
||||||
|
},
|
||||||
|
generations: {
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PAGINATION_LIMITS = {
|
||||||
|
DEFAULT_LIMIT: 20,
|
||||||
|
MAX_LIMIT: 100,
|
||||||
|
MIN_LIMIT: 1,
|
||||||
|
DEFAULT_OFFSET: 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const IMAGE_LIMITS = {
|
||||||
|
MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB
|
||||||
|
MAX_REFERENCE_IMAGES: 3,
|
||||||
|
MAX_WIDTH: 8192,
|
||||||
|
MAX_HEIGHT: 8192,
|
||||||
|
ALLOWED_MIME_TYPES: ['image/jpeg', 'image/png', 'image/webp'] as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const GENERATION_LIMITS = {
|
||||||
|
MAX_PROMPT_LENGTH: 5000,
|
||||||
|
MAX_RETRY_COUNT: 3,
|
||||||
|
DEFAULT_ASPECT_RATIO: '1:1',
|
||||||
|
ALLOWED_ASPECT_RATIOS: ['1:1', '16:9', '9:16', '3:2', '2:3', '4:3', '3:4'] as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const FLOW_LIMITS = {
|
||||||
|
MAX_NAME_LENGTH: 100,
|
||||||
|
MAX_DESCRIPTION_LENGTH: 500,
|
||||||
|
MAX_ALIASES_PER_FLOW: 50,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CACHE_LIMITS = {
|
||||||
|
PRESIGNED_URL_EXPIRY: 24 * 60 * 60, // 24 hours in seconds
|
||||||
|
CACHE_MAX_AGE: 365 * 24 * 60 * 60, // 1 year in seconds
|
||||||
|
} as const;
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute cache key for live URL generation (Section 8.7)
|
||||||
|
*
|
||||||
|
* Cache key format: SHA-256 hash of (projectId + scope + prompt + params)
|
||||||
|
*
|
||||||
|
* @param projectId - Project UUID
|
||||||
|
* @param scope - Live scope slug
|
||||||
|
* @param prompt - User prompt
|
||||||
|
* @param params - Additional generation parameters (aspectRatio, etc.)
|
||||||
|
* @returns SHA-256 hash string
|
||||||
|
*/
|
||||||
|
export const computeLiveUrlCacheKey = (
|
||||||
|
projectId: string,
|
||||||
|
scope: string,
|
||||||
|
prompt: string,
|
||||||
|
params: {
|
||||||
|
aspectRatio?: string;
|
||||||
|
autoEnhance?: boolean;
|
||||||
|
template?: string;
|
||||||
|
} = {},
|
||||||
|
): string => {
|
||||||
|
// Normalize parameters to ensure consistent cache keys
|
||||||
|
const normalizedParams = {
|
||||||
|
aspectRatio: params.aspectRatio || '1:1',
|
||||||
|
autoEnhance: params.autoEnhance ?? false,
|
||||||
|
template: params.template || 'general',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create cache key string
|
||||||
|
const cacheKeyString = [
|
||||||
|
projectId,
|
||||||
|
scope,
|
||||||
|
prompt.trim().toLowerCase(), // Normalize prompt
|
||||||
|
normalizedParams.aspectRatio,
|
||||||
|
normalizedParams.autoEnhance.toString(),
|
||||||
|
normalizedParams.template,
|
||||||
|
].join('::');
|
||||||
|
|
||||||
|
// Compute SHA-256 hash
|
||||||
|
return crypto.createHash('sha256').update(cacheKeyString).digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute prompt hash for prompt URL cache (Section 5 - already implemented)
|
||||||
|
*
|
||||||
|
* @param prompt - User prompt
|
||||||
|
* @returns SHA-256 hash string
|
||||||
|
*/
|
||||||
|
export const computePromptHash = (prompt: string): string => {
|
||||||
|
return crypto.createHash('sha256').update(prompt.trim().toLowerCase()).digest('hex');
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
export const computeSHA256 = (data: string | Buffer): string => {
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeCacheKey = (prompt: string, params: Record<string, unknown>): string => {
|
||||||
|
const sortedKeys = Object.keys(params).sort();
|
||||||
|
const sortedParams: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
sortedParams[key] = params[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = prompt + JSON.stringify(sortedParams);
|
||||||
|
return computeSHA256(combined);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeFileHash = (buffer: Buffer): string => {
|
||||||
|
return computeSHA256(buffer);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './paginationBuilder';
|
||||||
|
export * from './hashHelper';
|
||||||
|
export * from './queryHelper';
|
||||||
|
export * from './cacheKeyHelper';
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { PaginationMeta } from '@/types/models';
|
||||||
|
import type { PaginatedResponse } from '@/types/responses';
|
||||||
|
|
||||||
|
export const buildPaginationMeta = (
|
||||||
|
total: number,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): PaginationMeta => {
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: offset + limit < total,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPaginatedResponse = <T>(
|
||||||
|
data: T[],
|
||||||
|
total: number,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): PaginatedResponse<T> => {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
pagination: buildPaginationMeta(total, limit, offset),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { and, eq, isNull, SQL } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const buildWhereClause = (conditions: (SQL | undefined)[]): SQL | undefined => {
|
||||||
|
const validConditions = conditions.filter((c): c is SQL => c !== undefined);
|
||||||
|
|
||||||
|
if (validConditions.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validConditions.length === 1) {
|
||||||
|
return validConditions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return and(...validConditions);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withoutDeleted = <T extends { deletedAt: any }>(
|
||||||
|
table: T,
|
||||||
|
includeDeleted = false
|
||||||
|
): SQL | undefined => {
|
||||||
|
if (includeDeleted) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return isNull(table.deletedAt as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildEqCondition = <T, K extends keyof T>(
|
||||||
|
table: T,
|
||||||
|
column: K,
|
||||||
|
value: unknown
|
||||||
|
): SQL | undefined => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return eq(table[column] as any, value);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import {
|
||||||
|
ALIAS_PATTERN,
|
||||||
|
ALIAS_MAX_LENGTH,
|
||||||
|
isReservedAlias,
|
||||||
|
isTechnicalAlias,
|
||||||
|
isValidAliasFormat
|
||||||
|
} from '../constants/aliases';
|
||||||
|
import { ERROR_MESSAGES, ERROR_CODES } from '../constants/errors';
|
||||||
|
|
||||||
|
export interface AliasValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: {
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateAliasFormat = (alias: string): AliasValidationResult => {
|
||||||
|
if (!alias) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: 'Alias is required',
|
||||||
|
code: ERROR_CODES.VALIDATION_ERROR,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!alias.startsWith('@')) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.INVALID_ALIAS_FORMAT,
|
||||||
|
code: ERROR_CODES.INVALID_ALIAS_FORMAT,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alias.length > ALIAS_MAX_LENGTH) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: `Alias must not exceed ${ALIAS_MAX_LENGTH} characters`,
|
||||||
|
code: ERROR_CODES.VALIDATION_ERROR,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALIAS_PATTERN.test(alias)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.INVALID_ALIAS_FORMAT,
|
||||||
|
code: ERROR_CODES.INVALID_ALIAS_FORMAT,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateAliasNotReserved = (alias: string): AliasValidationResult => {
|
||||||
|
if (isReservedAlias(alias)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.RESERVED_ALIAS,
|
||||||
|
code: ERROR_CODES.RESERVED_ALIAS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateAliasForAssignment = (alias: string): AliasValidationResult => {
|
||||||
|
const formatResult = validateAliasFormat(alias);
|
||||||
|
if (!formatResult.valid) {
|
||||||
|
return formatResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateAliasNotReserved(alias);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateTechnicalAliasWithFlow = (
|
||||||
|
alias: string,
|
||||||
|
flowId?: string
|
||||||
|
): AliasValidationResult => {
|
||||||
|
if (isTechnicalAlias(alias) && !flowId) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.TECHNICAL_ALIAS_REQUIRES_FLOW,
|
||||||
|
code: ERROR_CODES.TECHNICAL_ALIAS_REQUIRES_FLOW,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all aliases from a prompt text
|
||||||
|
* Pattern: space followed by @ followed by alphanumeric, dash, or underscore
|
||||||
|
* Example: "Create image based on @hero and @background" -> ["@hero", "@background"]
|
||||||
|
*/
|
||||||
|
export const extractAliasesFromPrompt = (prompt: string): string[] => {
|
||||||
|
if (!prompt || typeof prompt !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern: space then @ then word characters (including dash and underscore)
|
||||||
|
// Also match @ at the beginning of the string
|
||||||
|
const aliasPattern = /(?:^|\s)(@[\w-]+)/g;
|
||||||
|
const matches: string[] = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = aliasPattern.exec(prompt)) !== null) {
|
||||||
|
const alias = match[1]!;
|
||||||
|
// Validate format and max length
|
||||||
|
if (isValidAliasFormat(alias)) {
|
||||||
|
matches.push(alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicates while preserving order
|
||||||
|
return Array.from(new Set(matches));
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './aliasValidator';
|
||||||
|
export * from './paginationValidator';
|
||||||
|
export * from './queryValidator';
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { PAGINATION_LIMITS } from '../constants/limits';
|
||||||
|
import { ERROR_MESSAGES, ERROR_CODES } from '../constants/errors';
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
params?: PaginationParams;
|
||||||
|
error?: {
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateAndNormalizePagination = (
|
||||||
|
limit?: number | string,
|
||||||
|
offset?: number | string
|
||||||
|
): PaginationValidationResult => {
|
||||||
|
const parsedLimit =
|
||||||
|
typeof limit === 'string' ? parseInt(limit, 10) : limit ?? PAGINATION_LIMITS.DEFAULT_LIMIT;
|
||||||
|
const parsedOffset =
|
||||||
|
typeof offset === 'string' ? parseInt(offset, 10) : offset ?? PAGINATION_LIMITS.DEFAULT_OFFSET;
|
||||||
|
|
||||||
|
if (isNaN(parsedLimit) || isNaN(parsedOffset)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.INVALID_PAGINATION,
|
||||||
|
code: ERROR_CODES.INVALID_PAGINATION,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedLimit < PAGINATION_LIMITS.MIN_LIMIT || parsedLimit > PAGINATION_LIMITS.MAX_LIMIT) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: `Limit must be between ${PAGINATION_LIMITS.MIN_LIMIT} and ${PAGINATION_LIMITS.MAX_LIMIT}`,
|
||||||
|
code: ERROR_CODES.INVALID_PAGINATION,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedOffset < 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: 'Offset must be non-negative',
|
||||||
|
code: ERROR_CODES.INVALID_PAGINATION,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
params: {
|
||||||
|
limit: parsedLimit,
|
||||||
|
offset: parsedOffset,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { ERROR_MESSAGES, ERROR_CODES } from '../constants/errors';
|
||||||
|
import { GENERATION_LIMITS } from '../constants/limits';
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: {
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateUUID = (id: string): ValidationResult => {
|
||||||
|
const uuidPattern =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
if (!uuidPattern.test(id)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.INVALID_UUID,
|
||||||
|
code: ERROR_CODES.INVALID_UUID,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateAspectRatio = (aspectRatio: string): ValidationResult => {
|
||||||
|
if (!GENERATION_LIMITS.ALLOWED_ASPECT_RATIOS.includes(aspectRatio as any)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: `Invalid aspect ratio. Allowed values: ${GENERATION_LIMITS.ALLOWED_ASPECT_RATIOS.join(', ')}`,
|
||||||
|
code: ERROR_CODES.INVALID_ASPECT_RATIO,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateFocalPoint = (focalPoint: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}): ValidationResult => {
|
||||||
|
if (
|
||||||
|
focalPoint.x < 0 ||
|
||||||
|
focalPoint.x > 1 ||
|
||||||
|
focalPoint.y < 0 ||
|
||||||
|
focalPoint.y > 1
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: ERROR_MESSAGES.INVALID_FOCAL_POINT,
|
||||||
|
code: ERROR_CODES.INVALID_FOCAL_POINT,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateDateRange = (
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string
|
||||||
|
): ValidationResult => {
|
||||||
|
if (startDate && isNaN(Date.parse(startDate))) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: 'Invalid start date format',
|
||||||
|
code: ERROR_CODES.VALIDATION_ERROR,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate && isNaN(Date.parse(endDate))) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: 'Invalid end date format',
|
||||||
|
code: ERROR_CODES.VALIDATION_ERROR,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate && endDate && new Date(startDate) > new Date(endDate)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
message: 'Start date must be before end date',
|
||||||
|
code: ERROR_CODES.VALIDATION_ERROR,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
* Include the <InlineCode color="neutral">X-API-Key</InlineCode> header.
|
* Include the <InlineCode color="neutral">X-API-Key</InlineCode> header.
|
||||||
*
|
*
|
||||||
* // Parameter documentation
|
* // Parameter documentation
|
||||||
* The <InlineCode>autoEnhance</InlineCode> parameter defaults to false.
|
* The <InlineCode>autoEnhance</InlineCode> parameter defaults to true.
|
||||||
*
|
*
|
||||||
* // Error messages
|
* // Error messages
|
||||||
* If you receive <InlineCode color="error">401 Unauthorized</InlineCode>, check your API key.
|
* If you receive <InlineCode color="error">401 Unauthorized</InlineCode>, check your API key.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,840 @@
|
||||||
|
# Banatie REST API Implementation Plan
|
||||||
|
|
||||||
|
**Version:** 2.0
|
||||||
|
**Status:** Ready for Implementation
|
||||||
|
**Executor:** Claude Code
|
||||||
|
**Database Schema:** v2.0 (banatie-database-design.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
REST API for Banatie image generation service. All endpoints use `/api/v1/` prefix for versioning.
|
||||||
|
|
||||||
|
**Core Features:**
|
||||||
|
- AI image generation with Google Gemini Flash
|
||||||
|
- Dual alias system (project-scoped + flow-scoped)
|
||||||
|
- Technical aliases (@last, @first, @upload)
|
||||||
|
- Flow-based generation chains
|
||||||
|
- Live generation endpoint with caching
|
||||||
|
- Upload and reference images
|
||||||
|
|
||||||
|
**Authentication:** API keys only (`bnt_` prefix)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All endpoints require API key in header:
|
||||||
|
|
||||||
|
```
|
||||||
|
X-API-Key: bnt_xxx...
|
||||||
|
```
|
||||||
|
|
||||||
|
**API Key Types:**
|
||||||
|
- `master`: Full access to all projects in organization
|
||||||
|
- `project`: Access to specific project only
|
||||||
|
|
||||||
|
**Unauthorized Response (401):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": "Invalid or missing API key"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Foundation
|
||||||
|
**Goal:** Core utilities and services
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- Create TypeScript type definitions for all models
|
||||||
|
- Build validation utilities (alias format, pagination, query params)
|
||||||
|
- Build helper utilities (pagination, hash, query helpers)
|
||||||
|
- Create `AliasService` with 3-tier resolution (technical → flow → project)
|
||||||
|
|
||||||
|
**Git Commit:**
|
||||||
|
```
|
||||||
|
feat: add foundation utilities and alias service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Core Generation Flow
|
||||||
|
**Goal:** Main generation endpoints
|
||||||
|
|
||||||
|
**Services:**
|
||||||
|
- `ImageService` - CRUD operations with soft delete
|
||||||
|
- `GenerationService` - Full lifecycle management
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `POST /api/v1/generations` - Create with reference images & dual aliases
|
||||||
|
- `GET /api/v1/generations` - List with filters
|
||||||
|
- `GET /api/v1/generations/:id` - Get details with related data
|
||||||
|
|
||||||
|
**Git Commit:**
|
||||||
|
```
|
||||||
|
feat: implement core generation endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Flow Management
|
||||||
|
**Goal:** Flow operations
|
||||||
|
|
||||||
|
**Services:**
|
||||||
|
- `FlowService` - CRUD with computed counts & alias management
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `POST /api/v1/flows` - Create flow
|
||||||
|
- `GET /api/v1/flows` - List flows with computed counts
|
||||||
|
- `GET /api/v1/flows/:id` - Get details with generations and images
|
||||||
|
- `PUT /api/v1/flows/:id/aliases` - Update flow aliases
|
||||||
|
- `DELETE /api/v1/flows/:id/aliases/:alias` - Remove specific alias
|
||||||
|
- `DELETE /api/v1/flows/:id` - Delete flow
|
||||||
|
|
||||||
|
**Git Commit:**
|
||||||
|
```
|
||||||
|
feat: implement flow management endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Enhanced Image Management
|
||||||
|
**Goal:** Complete image operations
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `POST /api/v1/images/upload` - Upload with alias, flow, metadata
|
||||||
|
- `GET /api/v1/images` - List with filters
|
||||||
|
- `GET /api/v1/images/:id` - Get details with usage info
|
||||||
|
- `GET /api/v1/images/resolve/:alias` - Resolve alias with precedence
|
||||||
|
- `PUT /api/v1/images/:id` - Update metadata
|
||||||
|
- `DELETE /api/v1/images/:id` - Soft/hard delete
|
||||||
|
|
||||||
|
**Git Commit:**
|
||||||
|
```
|
||||||
|
feat: implement image management endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Generation Refinements
|
||||||
|
**Goal:** Additional generation operations
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `POST /api/v1/generations/:id/retry` - Retry failed generation
|
||||||
|
- `DELETE /api/v1/generations/:id` - Delete generation
|
||||||
|
|
||||||
|
**Git Commit:**
|
||||||
|
```
|
||||||
|
feat: add generation retry and delete endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Live Generation
|
||||||
|
**Goal:** URL-based generation with caching
|
||||||
|
|
||||||
|
**Services:**
|
||||||
|
- `PromptCacheService` - SHA-256 caching with hit tracking
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `GET /api/v1/live` - Generate image via URL with streaming proxy
|
||||||
|
|
||||||
|
**Important:** Stream image directly from MinIO (no 302 redirect) for better performance.
|
||||||
|
|
||||||
|
**Git Commit:**
|
||||||
|
```
|
||||||
|
feat: implement live generation endpoint with caching
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7: Analytics
|
||||||
|
**Goal:** Project statistics and metrics
|
||||||
|
|
||||||
|
**Services:**
|
||||||
|
- `AnalyticsService` - Aggregation queries
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `GET /api/v1/analytics/summary` - Project statistics
|
||||||
|
- `GET /api/v1/analytics/generations/timeline` - Time-series data
|
||||||
|
|
||||||
|
**Git Commit:**
|
||||||
|
```
|
||||||
|
feat: add analytics endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 8: Testing & Documentation
|
||||||
|
**Goal:** Quality assurance
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- Unit tests for all services (target >80% coverage)
|
||||||
|
- Integration tests for critical flows
|
||||||
|
- Error handling consistency review
|
||||||
|
- Update API documentation
|
||||||
|
|
||||||
|
**Git Commit:**
|
||||||
|
```
|
||||||
|
test: add comprehensive test coverage and documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints Specification
|
||||||
|
|
||||||
|
### GENERATIONS
|
||||||
|
|
||||||
|
#### POST /api/v1/generations
|
||||||
|
|
||||||
|
Create new image generation.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
prompt: string; // Required: 1-2000 chars
|
||||||
|
aspectRatio?: string; // Optional: '16:9', '1:1', '4:3', '9:16'
|
||||||
|
width?: number; // Optional: 1-8192
|
||||||
|
height?: number; // Optional: 1-8192
|
||||||
|
referenceImages?: string[]; // Optional: ['@logo', '@product', '@last']
|
||||||
|
flowId?: string; // Optional: Add to existing flow
|
||||||
|
assignAlias?: string; // Optional: Project-scoped alias '@brand'
|
||||||
|
assignFlowAlias?: string; // Optional: Flow-scoped alias '@hero' (requires flowId)
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
generation: Generation;
|
||||||
|
image?: Image; // If generation completed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** 400, 401, 404, 422, 429, 500
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /api/v1/generations
|
||||||
|
|
||||||
|
List generations with filtering.
|
||||||
|
|
||||||
|
**Query Params:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
flowId?: string;
|
||||||
|
status?: 'pending' | 'processing' | 'success' | 'failed';
|
||||||
|
limit?: number; // Default: 20, max: 100
|
||||||
|
offset?: number; // Default: 0
|
||||||
|
sortBy?: 'createdAt' | 'updatedAt';
|
||||||
|
order?: 'asc' | 'desc'; // Default: desc
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
generations: Generation[];
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /api/v1/generations/:id
|
||||||
|
|
||||||
|
Get generation details.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
generation: Generation;
|
||||||
|
image?: Image;
|
||||||
|
referencedImages: Image[];
|
||||||
|
flow?: FlowSummary;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/v1/generations/:id/retry
|
||||||
|
|
||||||
|
Retry failed generation.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
generation: Generation; // New generation with incremented retry_count
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** 404, 422
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### DELETE /api/v1/generations/:id
|
||||||
|
|
||||||
|
Delete generation.
|
||||||
|
|
||||||
|
**Query Params:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
hard?: boolean; // Default: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (204):** No content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### IMAGES
|
||||||
|
|
||||||
|
#### POST /api/v1/images/upload
|
||||||
|
|
||||||
|
Upload image file.
|
||||||
|
|
||||||
|
**Request:** multipart/form-data
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
file: File; // Required, max 5MB
|
||||||
|
alias?: string; // Project-scoped: '@logo'
|
||||||
|
flowAlias?: string; // Flow-scoped: '@hero' (requires flowId)
|
||||||
|
flowId?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[]; // JSON array as string
|
||||||
|
focalPoint?: string; // JSON: '{"x":0.5,"y":0.5}'
|
||||||
|
meta?: string; // JSON object as string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
image: Image;
|
||||||
|
flow?: FlowSummary; // If flowAlias assigned
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** 400, 409, 422
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /api/v1/images
|
||||||
|
|
||||||
|
List images.
|
||||||
|
|
||||||
|
**Query Params:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
flowId?: string;
|
||||||
|
source?: 'generated' | 'uploaded';
|
||||||
|
alias?: string;
|
||||||
|
limit?: number; // Default: 20, max: 100
|
||||||
|
offset?: number;
|
||||||
|
sortBy?: 'createdAt' | 'fileSize';
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
images: Image[];
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /api/v1/images/:id
|
||||||
|
|
||||||
|
Get image details.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
image: Image;
|
||||||
|
generation?: Generation;
|
||||||
|
usedInGenerations: GenerationSummary[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /api/v1/images/resolve/:alias
|
||||||
|
|
||||||
|
Resolve alias to image.
|
||||||
|
|
||||||
|
**Query Params:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
flowId?: string; // Provide flow context
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
image: Image;
|
||||||
|
scope: 'flow' | 'project' | 'technical';
|
||||||
|
flow?: FlowSummary;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resolution Order:**
|
||||||
|
1. Technical aliases (@last, @first, @upload) if flowId provided
|
||||||
|
2. Flow aliases from flows.aliases if flowId provided
|
||||||
|
3. Project aliases from images.alias
|
||||||
|
|
||||||
|
**Errors:** 404
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/v1/images/:id
|
||||||
|
|
||||||
|
Update image metadata.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
alias?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
focalPoint?: { x: number; y: number };
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
image: Image;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** 404, 409, 422
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### DELETE /api/v1/images/:id
|
||||||
|
|
||||||
|
Delete image.
|
||||||
|
|
||||||
|
**Query Params:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
hard?: boolean; // Default: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (204):** No content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FLOWS
|
||||||
|
|
||||||
|
#### POST /api/v1/flows
|
||||||
|
|
||||||
|
Create new flow.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
flow: Flow;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /api/v1/flows
|
||||||
|
|
||||||
|
List flows.
|
||||||
|
|
||||||
|
**Query Params:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
limit?: number; // Default: 20, max: 100
|
||||||
|
offset?: number;
|
||||||
|
sortBy?: 'createdAt' | 'updatedAt';
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
flows: Array<Flow & {
|
||||||
|
generationCount: number; // Computed
|
||||||
|
imageCount: number; // Computed
|
||||||
|
}>;
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /api/v1/flows/:id
|
||||||
|
|
||||||
|
Get flow details.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
flow: Flow;
|
||||||
|
generations: Generation[]; // Ordered by created_at ASC
|
||||||
|
images: Image[];
|
||||||
|
resolvedAliases: Record<string, Image>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/v1/flows/:id/aliases
|
||||||
|
|
||||||
|
Update flow aliases.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
aliases: Record<string, string>; // { "@hero": "image-uuid" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
flow: Flow;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- Keys must match `^@[a-zA-Z0-9_-]+$`
|
||||||
|
- Values must be valid image UUIDs
|
||||||
|
- Cannot use reserved: @last, @first, @upload
|
||||||
|
|
||||||
|
**Errors:** 404, 422
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### DELETE /api/v1/flows/:id/aliases/:alias
|
||||||
|
|
||||||
|
Remove specific alias from flow.
|
||||||
|
|
||||||
|
**Response (204):** No content
|
||||||
|
|
||||||
|
**Errors:** 404
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### DELETE /api/v1/flows/:id
|
||||||
|
|
||||||
|
Delete flow.
|
||||||
|
|
||||||
|
**Response (204):** No content
|
||||||
|
|
||||||
|
**Note:** Cascades to images, sets NULL on generations.flow_id
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### LIVE GENERATION
|
||||||
|
|
||||||
|
#### GET /api/v1/live
|
||||||
|
|
||||||
|
Generate image via URL with caching and streaming.
|
||||||
|
|
||||||
|
**Query Params:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
prompt: string; // Required
|
||||||
|
aspectRatio?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
reference?: string | string[]; // '@logo' or ['@logo','@style']
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Image stream with headers
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Content-Type: image/jpeg
|
||||||
|
Cache-Control: public, max-age=31536000
|
||||||
|
X-Cache-Status: HIT | MISS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. Compute cache key: SHA256(prompt + sorted params)
|
||||||
|
2. Check prompt_url_cache table
|
||||||
|
3. If HIT: increment hit_count, stream from MinIO
|
||||||
|
4. If MISS: generate, cache, stream from MinIO
|
||||||
|
5. Stream image bytes directly (no 302 redirect)
|
||||||
|
|
||||||
|
**Errors:** 400, 404, 500
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ANALYTICS
|
||||||
|
|
||||||
|
#### GET /api/v1/analytics/summary
|
||||||
|
|
||||||
|
Get project statistics.
|
||||||
|
|
||||||
|
**Query Params:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
startDate?: string; // ISO 8601
|
||||||
|
endDate?: string;
|
||||||
|
flowId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
period: { startDate: string; endDate: string };
|
||||||
|
metrics: {
|
||||||
|
totalGenerations: number;
|
||||||
|
successfulGenerations: number;
|
||||||
|
failedGenerations: number;
|
||||||
|
successRate: number;
|
||||||
|
totalImages: number;
|
||||||
|
uploadedImages: number;
|
||||||
|
generatedImages: number;
|
||||||
|
avgProcessingTimeMs: number;
|
||||||
|
totalCacheHits: number;
|
||||||
|
cacheHitRate: number;
|
||||||
|
totalCost: number;
|
||||||
|
};
|
||||||
|
flows: FlowSummary[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /api/v1/analytics/generations/timeline
|
||||||
|
|
||||||
|
Get generation statistics over time.
|
||||||
|
|
||||||
|
**Query Params:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
flowId?: string;
|
||||||
|
groupBy?: 'hour' | 'day' | 'week'; // Default: day
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
data: Array<{
|
||||||
|
timestamp: string;
|
||||||
|
total: number;
|
||||||
|
successful: number;
|
||||||
|
failed: number;
|
||||||
|
avgProcessingTimeMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Guidelines
|
||||||
|
|
||||||
|
### Alias Resolution Algorithm
|
||||||
|
|
||||||
|
**Priority Order:**
|
||||||
|
1. Technical aliases (@last, @first, @upload) - compute from flow data
|
||||||
|
2. Flow-scoped aliases - from flows.aliases JSONB
|
||||||
|
3. Project-scoped aliases - from images.alias column
|
||||||
|
|
||||||
|
**Technical Aliases:**
|
||||||
|
- `@last`: Latest generation output in flow (any status)
|
||||||
|
- `@first`: First generation output in flow
|
||||||
|
- `@upload`: Latest uploaded image in flow
|
||||||
|
|
||||||
|
### Dual Alias Assignment
|
||||||
|
|
||||||
|
When creating generation or uploading image:
|
||||||
|
- `assignAlias` → set images.alias (project scope)
|
||||||
|
- `assignFlowAlias` → add to flows.aliases (flow scope)
|
||||||
|
- Both can be assigned simultaneously
|
||||||
|
|
||||||
|
### Flow Updates
|
||||||
|
|
||||||
|
Update `flows.updated_at` on:
|
||||||
|
- New generation created with flowId
|
||||||
|
- New image uploaded with flowId
|
||||||
|
- Flow aliases modified
|
||||||
|
|
||||||
|
### Audit Trail
|
||||||
|
|
||||||
|
Track `api_key_id` in:
|
||||||
|
- `images.api_key_id` - who uploaded/generated
|
||||||
|
- `generations.api_key_id` - who requested
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
In-memory rate limiting (defer Redis for MVP):
|
||||||
|
- Master key: 1000 req/hour, 100 generations/hour
|
||||||
|
- Project key: 500 req/hour, 50 generations/hour
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
X-RateLimit-Limit: 500
|
||||||
|
X-RateLimit-Remaining: 487
|
||||||
|
X-RateLimit-Reset: 1698765432
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response Format
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MinIO Integration
|
||||||
|
|
||||||
|
Use streaming for `/api/v1/live`:
|
||||||
|
```typescript
|
||||||
|
const stream = await minioClient.getObject(bucket, storageKey);
|
||||||
|
res.set('Content-Type', mimeType);
|
||||||
|
stream.pipe(res);
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate presigned URLs for other endpoints:
|
||||||
|
```typescript
|
||||||
|
const url = await minioClient.presignedGetObject(bucket, storageKey, 24 * 60 * 60);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
**Alias Format:**
|
||||||
|
- Pattern: `^@[a-zA-Z0-9_-]+$`
|
||||||
|
- Reserved: @last, @first, @upload
|
||||||
|
- Length: 3-100 chars
|
||||||
|
|
||||||
|
**File Upload:**
|
||||||
|
- Max size: 5MB
|
||||||
|
- MIME types: image/jpeg, image/png, image/webp
|
||||||
|
- Max dimensions: 8192x8192
|
||||||
|
|
||||||
|
**Prompt:**
|
||||||
|
- Min: 1 char
|
||||||
|
- Max: 2000 chars
|
||||||
|
|
||||||
|
**Aspect Ratio:**
|
||||||
|
- Pattern: `^\d+:\d+$`
|
||||||
|
- Examples: 16:9, 1:1, 4:3, 9:16
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Architecture
|
||||||
|
|
||||||
|
### Core Services
|
||||||
|
|
||||||
|
**AliasService:**
|
||||||
|
- Resolve aliases with 3-tier precedence
|
||||||
|
- Compute technical aliases
|
||||||
|
- Validate alias format
|
||||||
|
|
||||||
|
**ImageService:**
|
||||||
|
- CRUD operations
|
||||||
|
- Soft delete support
|
||||||
|
- Usage tracking
|
||||||
|
|
||||||
|
**GenerationService:**
|
||||||
|
- Generation lifecycle
|
||||||
|
- Status transitions
|
||||||
|
- Error handling
|
||||||
|
- Retry logic
|
||||||
|
|
||||||
|
**FlowService:**
|
||||||
|
- Flow CRUD
|
||||||
|
- Alias management
|
||||||
|
- Computed counts
|
||||||
|
|
||||||
|
**PromptCacheService:**
|
||||||
|
- Cache key computation (SHA-256)
|
||||||
|
- Hit tracking
|
||||||
|
- Cache lookup
|
||||||
|
|
||||||
|
**AnalyticsService:**
|
||||||
|
- Aggregation queries
|
||||||
|
- Time-series grouping
|
||||||
|
|
||||||
|
### Reusable Utilities
|
||||||
|
|
||||||
|
**Validators:**
|
||||||
|
- Alias format
|
||||||
|
- Pagination params
|
||||||
|
- Query filters
|
||||||
|
|
||||||
|
**Helpers:**
|
||||||
|
- Pagination builder
|
||||||
|
- SHA-256 hashing
|
||||||
|
- Query helpers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
**Unit Tests:**
|
||||||
|
- All services must have unit tests
|
||||||
|
- Target coverage: >80%
|
||||||
|
- Mock database calls
|
||||||
|
|
||||||
|
**Integration Tests:**
|
||||||
|
- Critical flows end-to-end
|
||||||
|
- Real database transactions
|
||||||
|
- API endpoint testing with supertest
|
||||||
|
|
||||||
|
**Test Scenarios:**
|
||||||
|
- Alias resolution precedence
|
||||||
|
- Flow-scoped vs project-scoped aliases
|
||||||
|
- Technical alias computation
|
||||||
|
- Dual alias assignment
|
||||||
|
- Cache hit/miss behavior
|
||||||
|
- Error handling
|
||||||
|
- Rate limiting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
✅ All endpoints functional per specification
|
||||||
|
✅ >80% test coverage on services
|
||||||
|
✅ Consistent error handling across all endpoints
|
||||||
|
✅ All validation rules implemented
|
||||||
|
✅ Rate limiting working
|
||||||
|
✅ Documentation updated
|
||||||
|
✅ Git commits after each phase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document Version: 2.0*
|
||||||
|
*Created: 2025-11-09*
|
||||||
|
*Target: Claude Code Implementation*
|
||||||
|
*Database Schema: v2.0*
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,607 @@
|
||||||
|
# Banatie Database Design
|
||||||
|
|
||||||
|
## 📊 Database Schema for AI Image Generation System
|
||||||
|
|
||||||
|
This document describes the complete database structure for Banatie - an AI-powered image generation service with support for named references, flows, and prompt URL caching.
|
||||||
|
|
||||||
|
**Version:** 2.0
|
||||||
|
**Last Updated:** 2025-10-26
|
||||||
|
**Status:** Approved for Implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture Overview
|
||||||
|
|
||||||
|
### Core Principles
|
||||||
|
|
||||||
|
1. **Dual Alias System**: Project-level (global) and Flow-level (temporary) scopes
|
||||||
|
2. **Technical Aliases Computed**: `@last`, `@first`, `@upload` are calculated programmatically
|
||||||
|
3. **Audit Trail**: Complete history of all generations with performance metrics
|
||||||
|
4. **Referential Integrity**: Proper foreign keys and cascade rules
|
||||||
|
5. **Simplicity First**: Minimal tables, JSONB for flexibility
|
||||||
|
|
||||||
|
### Scope Resolution Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Flow-scoped aliases (@hero in flow) → Project-scoped aliases (@logo global) → Technical aliases (@last, @first)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Existing Tables (Unchanged)
|
||||||
|
|
||||||
|
### 1. ORGANIZATIONS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
organizations {
|
||||||
|
id: UUID (PK)
|
||||||
|
name: TEXT
|
||||||
|
slug: TEXT UNIQUE
|
||||||
|
email: TEXT UNIQUE
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
updated_at: TIMESTAMP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Top-level entity for multi-tenant system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. PROJECTS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
projects {
|
||||||
|
id: UUID (PK)
|
||||||
|
organization_id: UUID (FK -> organizations) CASCADE
|
||||||
|
name: TEXT
|
||||||
|
slug: TEXT
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
UNIQUE INDEX(organization_id, slug)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Container for all project-specific data (images, generations, flows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. API_KEYS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
api_keys {
|
||||||
|
id: UUID (PK)
|
||||||
|
key_hash: TEXT UNIQUE
|
||||||
|
key_prefix: TEXT DEFAULT 'bnt_'
|
||||||
|
key_type: ENUM('master', 'project')
|
||||||
|
|
||||||
|
organization_id: UUID (FK -> organizations) CASCADE
|
||||||
|
project_id: UUID (FK -> projects) CASCADE
|
||||||
|
|
||||||
|
scopes: JSONB DEFAULT ['generate']
|
||||||
|
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
expires_at: TIMESTAMP
|
||||||
|
last_used_at: TIMESTAMP
|
||||||
|
is_active: BOOLEAN DEFAULT true
|
||||||
|
|
||||||
|
name: TEXT
|
||||||
|
created_by: UUID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Authentication and authorization for API access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆕 New Tables
|
||||||
|
|
||||||
|
### 4. FLOWS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
flows {
|
||||||
|
id: UUID (PK)
|
||||||
|
project_id: UUID (FK -> projects) CASCADE
|
||||||
|
|
||||||
|
// Flow-scoped named aliases (user-assigned only)
|
||||||
|
// Technical aliases (@last, @first, @upload) computed programmatically
|
||||||
|
// Format: { "@hero": "image-uuid", "@product": "image-uuid" }
|
||||||
|
aliases: JSONB DEFAULT {}
|
||||||
|
|
||||||
|
meta: JSONB DEFAULT {}
|
||||||
|
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
// Updates on every generation/upload activity within this flow
|
||||||
|
updated_at: TIMESTAMP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Temporary chains of generations with flow-scoped references
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
- No `status` field - computed from generations
|
||||||
|
- No `name`/`description` - flows are programmatic, not user-facing
|
||||||
|
- No `expires_at` - cleanup handled programmatically via `created_at`
|
||||||
|
- `aliases` stores only user-assigned aliases, not technical ones
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_flows_project ON flows(project_id, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. IMAGES
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
images {
|
||||||
|
id: UUID (PK)
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project_id: UUID (FK -> projects) CASCADE
|
||||||
|
generation_id: UUID (FK -> generations) SET NULL
|
||||||
|
flow_id: UUID (FK -> flows) CASCADE
|
||||||
|
api_key_id: UUID (FK -> api_keys) SET NULL
|
||||||
|
|
||||||
|
// Storage (MinIO path format: orgSlug/projectSlug/category/YYYY-MM/filename.ext)
|
||||||
|
storage_key: VARCHAR(500) UNIQUE
|
||||||
|
storage_url: TEXT
|
||||||
|
|
||||||
|
// File metadata
|
||||||
|
mime_type: VARCHAR(100)
|
||||||
|
file_size: INTEGER
|
||||||
|
file_hash: VARCHAR(64) // SHA-256 for deduplication
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
width: INTEGER
|
||||||
|
height: INTEGER
|
||||||
|
aspect_ratio: VARCHAR(10)
|
||||||
|
|
||||||
|
// Focal point for image transformations (imageflow)
|
||||||
|
// Normalized coordinates: { "x": 0.5, "y": 0.3 } where 0.0-1.0
|
||||||
|
focal_point: JSONB
|
||||||
|
|
||||||
|
// Source
|
||||||
|
source: ENUM('generated', 'uploaded')
|
||||||
|
|
||||||
|
// Project-level alias (global scope)
|
||||||
|
// Flow-level aliases stored in flows.aliases
|
||||||
|
alias: VARCHAR(100) // @product, @logo
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
description: TEXT
|
||||||
|
tags: TEXT[]
|
||||||
|
meta: JSONB DEFAULT {}
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
updated_at: TIMESTAMP
|
||||||
|
deleted_at: TIMESTAMP // Soft delete
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Centralized storage for all images (uploaded + generated)
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
- `flow_id` enables flow-scoped uploads
|
||||||
|
- `alias` is for project-scope only (global across project)
|
||||||
|
- Flow-scoped aliases stored in `flows.aliases` table
|
||||||
|
- `focal_point` for imageflow server integration
|
||||||
|
- `api_key_id` for audit trail of who created the image
|
||||||
|
- Soft delete via `deleted_at` for recovery
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
```sql
|
||||||
|
CHECK (source = 'uploaded' AND generation_id IS NULL)
|
||||||
|
OR (source = 'generated' AND generation_id IS NOT NULL)
|
||||||
|
|
||||||
|
CHECK alias IS NULL OR alias ~ '^@[a-zA-Z0-9_-]+$'
|
||||||
|
|
||||||
|
CHECK file_size > 0
|
||||||
|
|
||||||
|
CHECK (width IS NULL OR (width > 0 AND width <= 8192))
|
||||||
|
AND (height IS NULL OR (height > 0 AND height <= 8192))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_images_project_alias
|
||||||
|
ON images(project_id, alias)
|
||||||
|
WHERE alias IS NOT NULL AND deleted_at IS NULL AND flow_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_images_project_source
|
||||||
|
ON images(project_id, source, created_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_images_flow ON images(flow_id) WHERE flow_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_images_generation ON images(generation_id);
|
||||||
|
CREATE INDEX idx_images_storage_key ON images(storage_key);
|
||||||
|
CREATE INDEX idx_images_hash ON images(file_hash);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. GENERATIONS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
generations {
|
||||||
|
id: UUID (PK)
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project_id: UUID (FK -> projects) CASCADE
|
||||||
|
flow_id: UUID (FK -> flows) SET NULL
|
||||||
|
api_key_id: UUID (FK -> api_keys) SET NULL
|
||||||
|
|
||||||
|
// Status
|
||||||
|
status: ENUM('pending', 'processing', 'success', 'failed') DEFAULT 'pending'
|
||||||
|
|
||||||
|
// Prompts
|
||||||
|
original_prompt: TEXT
|
||||||
|
enhanced_prompt: TEXT // AI-enhanced version (if enabled)
|
||||||
|
|
||||||
|
// Generation parameters
|
||||||
|
aspect_ratio: VARCHAR(10)
|
||||||
|
width: INTEGER
|
||||||
|
height: INTEGER
|
||||||
|
|
||||||
|
// AI Model
|
||||||
|
model_name: VARCHAR(100) DEFAULT 'gemini-flash-image-001'
|
||||||
|
model_version: VARCHAR(50)
|
||||||
|
|
||||||
|
// Result
|
||||||
|
output_image_id: UUID (FK -> images) SET NULL
|
||||||
|
|
||||||
|
// Referenced images used in generation
|
||||||
|
// Format: [{ "imageId": "uuid", "alias": "@product" }, ...]
|
||||||
|
referenced_images: JSONB
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
error_message: TEXT
|
||||||
|
error_code: VARCHAR(50)
|
||||||
|
retry_count: INTEGER DEFAULT 0
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
processing_time_ms: INTEGER
|
||||||
|
cost: INTEGER // In cents (USD)
|
||||||
|
|
||||||
|
// Request context
|
||||||
|
request_id: UUID // For log correlation
|
||||||
|
user_agent: TEXT
|
||||||
|
ip_address: INET
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
meta: JSONB DEFAULT {}
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
updated_at: TIMESTAMP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Complete audit trail of all image generations
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
- `referenced_images` as JSONB instead of M:N table (simpler, sufficient for reference info)
|
||||||
|
- No `parent_generation_id` - not needed for MVP
|
||||||
|
- No `final_prompt` - redundant with `enhanced_prompt` or `original_prompt`
|
||||||
|
- No `completed_at` - use `updated_at` when `status` changes to success/failed
|
||||||
|
- `api_key_id` for audit trail of who made the request
|
||||||
|
- Technical aliases resolved programmatically, not stored
|
||||||
|
|
||||||
|
**Referenced Images Format:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "imageId": "uuid-1", "alias": "@product" },
|
||||||
|
{ "imageId": "uuid-2", "alias": "@style" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
```sql
|
||||||
|
CHECK (status = 'success' AND output_image_id IS NOT NULL)
|
||||||
|
OR (status != 'success')
|
||||||
|
|
||||||
|
CHECK (status = 'failed' AND error_message IS NOT NULL)
|
||||||
|
OR (status != 'failed')
|
||||||
|
|
||||||
|
CHECK retry_count >= 0
|
||||||
|
|
||||||
|
CHECK processing_time_ms IS NULL OR processing_time_ms >= 0
|
||||||
|
|
||||||
|
CHECK cost IS NULL OR cost >= 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_generations_project_status
|
||||||
|
ON generations(project_id, status, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX idx_generations_flow
|
||||||
|
ON generations(flow_id, created_at DESC)
|
||||||
|
WHERE flow_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_generations_output ON generations(output_image_id);
|
||||||
|
CREATE INDEX idx_generations_request ON generations(request_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. PROMPT_URL_CACHE
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
prompt_url_cache {
|
||||||
|
id: UUID (PK)
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project_id: UUID (FK -> projects) CASCADE
|
||||||
|
generation_id: UUID (FK -> generations) CASCADE
|
||||||
|
image_id: UUID (FK -> images) CASCADE
|
||||||
|
|
||||||
|
// Cache keys (SHA-256 hashes)
|
||||||
|
prompt_hash: VARCHAR(64)
|
||||||
|
query_params_hash: VARCHAR(64)
|
||||||
|
|
||||||
|
// Original request (for debugging/reconstruction)
|
||||||
|
original_prompt: TEXT
|
||||||
|
request_params: JSONB // { width, height, aspectRatio, template, ... }
|
||||||
|
|
||||||
|
// Cache statistics
|
||||||
|
hit_count: INTEGER DEFAULT 0
|
||||||
|
last_hit_at: TIMESTAMP
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Deduplication and caching for Prompt URL feature
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
- Composite unique key: `project_id + prompt_hash + query_params_hash`
|
||||||
|
- No `expires_at` - cache lives forever unless manually cleared
|
||||||
|
- Tracks `hit_count` for analytics
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
```sql
|
||||||
|
CHECK hit_count >= 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_cache_key
|
||||||
|
ON prompt_url_cache(project_id, prompt_hash, query_params_hash);
|
||||||
|
|
||||||
|
CREATE INDEX idx_cache_generation ON prompt_url_cache(generation_id);
|
||||||
|
CREATE INDEX idx_cache_image ON prompt_url_cache(image_id);
|
||||||
|
CREATE INDEX idx_cache_hits
|
||||||
|
ON prompt_url_cache(project_id, hit_count DESC, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Relationships Summary
|
||||||
|
|
||||||
|
### One-to-Many (1:M)
|
||||||
|
|
||||||
|
1. **organizations → projects** (CASCADE)
|
||||||
|
2. **organizations → api_keys** (CASCADE)
|
||||||
|
3. **projects → api_keys** (CASCADE)
|
||||||
|
4. **projects → flows** (CASCADE)
|
||||||
|
5. **projects → images** (CASCADE)
|
||||||
|
6. **projects → generations** (CASCADE)
|
||||||
|
7. **projects → prompt_url_cache** (CASCADE)
|
||||||
|
8. **flows → images** (CASCADE)
|
||||||
|
9. **flows → generations** (SET NULL)
|
||||||
|
10. **generations → images** (SET NULL) - output image
|
||||||
|
11. **api_keys → images** (SET NULL) - who created
|
||||||
|
12. **api_keys → generations** (SET NULL) - who requested
|
||||||
|
|
||||||
|
### Cascade Rules
|
||||||
|
|
||||||
|
**ON DELETE CASCADE:**
|
||||||
|
- Deleting organization → deletes all projects, api_keys
|
||||||
|
- Deleting project → deletes all flows, images, generations, cache
|
||||||
|
- Deleting flow → deletes all flow-scoped images
|
||||||
|
- Deleting generation → nothing (orphaned references OK)
|
||||||
|
|
||||||
|
**ON DELETE SET NULL:**
|
||||||
|
- Deleting generation → sets `images.generation_id` to NULL
|
||||||
|
- Deleting image → sets `generations.output_image_id` to NULL
|
||||||
|
- Deleting flow → sets `generations.flow_id` to NULL
|
||||||
|
- Deleting api_key → sets audit references to NULL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Alias System
|
||||||
|
|
||||||
|
### Two-Tier Alias Scope
|
||||||
|
|
||||||
|
#### Project-Scoped (Global)
|
||||||
|
- **Storage:** `images.alias` column
|
||||||
|
- **Lifetime:** Permanent (until image deleted)
|
||||||
|
- **Visibility:** Across entire project
|
||||||
|
- **Examples:** `@logo`, `@brand`, `@header`
|
||||||
|
- **Use Case:** Reusable brand assets
|
||||||
|
|
||||||
|
#### Flow-Scoped (Temporary)
|
||||||
|
- **Storage:** `flows.aliases` JSONB
|
||||||
|
- **Lifetime:** Duration of flow
|
||||||
|
- **Visibility:** Only within specific flow
|
||||||
|
- **Examples:** `@hero`, `@product`, `@variant`
|
||||||
|
- **Use Case:** Conversational generation chains
|
||||||
|
|
||||||
|
#### Technical Aliases (Computed)
|
||||||
|
- **Storage:** None (computed on-the-fly)
|
||||||
|
- **Types:**
|
||||||
|
- `@last` - Last generation in flow (any status)
|
||||||
|
- `@first` - First generation in flow
|
||||||
|
- `@upload` - Last uploaded image in flow
|
||||||
|
- **Implementation:** Query-based resolution
|
||||||
|
|
||||||
|
### Resolution Algorithm
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Check if technical alias (@last, @first, @upload) → compute from flow data
|
||||||
|
2. Check flow.aliases for flow-scoped alias → return if found
|
||||||
|
3. Check images.alias for project-scoped alias → return if found
|
||||||
|
4. Return null (alias not found)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Dual Alias Assignment
|
||||||
|
|
||||||
|
### Uploads
|
||||||
|
```typescript
|
||||||
|
POST /api/images/upload
|
||||||
|
{
|
||||||
|
file: <binary>,
|
||||||
|
alias: "@product", // Project-scoped (optional)
|
||||||
|
flowAlias: "@hero", // Flow-scoped (optional)
|
||||||
|
flowId: "uuid" // Required if flowAlias provided
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- If `alias` provided → set `images.alias = "@product"`
|
||||||
|
- If `flowAlias` provided → add to `flows.aliases["@hero"] = imageId`
|
||||||
|
- Can have both simultaneously
|
||||||
|
|
||||||
|
### Generations
|
||||||
|
```typescript
|
||||||
|
POST /api/generations
|
||||||
|
{
|
||||||
|
prompt: "hero image",
|
||||||
|
assignAlias: "@brand", // Project-scoped (optional)
|
||||||
|
assignFlowAlias: "@hero", // Flow-scoped (optional)
|
||||||
|
flowId: "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result (after successful generation):**
|
||||||
|
- If `assignAlias` → set `images.alias = "@brand"` on output image
|
||||||
|
- If `assignFlowAlias` → add to `flows.aliases["@hero"] = outputImageId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Performance Optimizations
|
||||||
|
|
||||||
|
### Critical Indexes
|
||||||
|
|
||||||
|
All indexes listed in individual table sections above. Key performance considerations:
|
||||||
|
|
||||||
|
1. **Alias Lookup:** Partial index on `images(project_id, alias)` WHERE conditions
|
||||||
|
2. **Flow Activity:** Composite index on `generations(flow_id, created_at)`
|
||||||
|
3. **Cache Hit:** Unique composite on `prompt_url_cache(project_id, prompt_hash, query_params_hash)`
|
||||||
|
4. **Audit Queries:** Indexes on `api_key_id` columns
|
||||||
|
|
||||||
|
### Denormalization
|
||||||
|
|
||||||
|
**Avoided intentionally:**
|
||||||
|
- No counters (image_count, generation_count)
|
||||||
|
- Computed via COUNT(*) queries with proper indexes
|
||||||
|
- Simpler, more reliable, less trigger overhead
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧹 Data Lifecycle
|
||||||
|
|
||||||
|
### Soft Delete
|
||||||
|
|
||||||
|
**Tables with soft delete:**
|
||||||
|
- `images` - via `deleted_at` column
|
||||||
|
|
||||||
|
**Cleanup strategy:**
|
||||||
|
- Hard delete after 30 days of soft delete
|
||||||
|
- Implemented via cron job or manual cleanup script
|
||||||
|
|
||||||
|
### Hard Delete
|
||||||
|
|
||||||
|
**Tables with hard delete:**
|
||||||
|
- `generations` - cascade deletes
|
||||||
|
- `flows` - cascade deletes
|
||||||
|
- `prompt_url_cache` - cascade deletes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security & Audit
|
||||||
|
|
||||||
|
### API Key Tracking
|
||||||
|
|
||||||
|
All mutations tracked via `api_key_id`:
|
||||||
|
- `images.api_key_id` - who uploaded/generated
|
||||||
|
- `generations.api_key_id` - who requested generation
|
||||||
|
|
||||||
|
### Request Correlation
|
||||||
|
|
||||||
|
- `generations.request_id` - correlate with application logs
|
||||||
|
- `generations.user_agent` - client identification
|
||||||
|
- `generations.ip_address` - rate limiting, abuse prevention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Migration Strategy
|
||||||
|
|
||||||
|
### Phase 1: Core Tables
|
||||||
|
1. Create `flows` table
|
||||||
|
2. Create `images` table
|
||||||
|
3. Create `generations` table
|
||||||
|
4. Add all indexes and constraints
|
||||||
|
5. Migrate existing MinIO data to `images` table
|
||||||
|
|
||||||
|
### Phase 2: Advanced Features
|
||||||
|
1. Create `prompt_url_cache` table
|
||||||
|
2. Add indexes
|
||||||
|
3. Implement cache warming for existing data (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Design Decisions Log
|
||||||
|
|
||||||
|
### Why JSONB for `flows.aliases`?
|
||||||
|
- Simple key-value structure
|
||||||
|
- No need for JOINs
|
||||||
|
- Flexible schema
|
||||||
|
- Atomic updates
|
||||||
|
- Trade-off: No referential integrity (acceptable for temporary data)
|
||||||
|
|
||||||
|
### Why JSONB for `generations.referenced_images`?
|
||||||
|
- Reference info is append-only
|
||||||
|
- No need for complex queries on references
|
||||||
|
- Simpler schema (one less table)
|
||||||
|
- Trade-off: No CASCADE on image deletion (acceptable)
|
||||||
|
|
||||||
|
### Why no `namespaces`?
|
||||||
|
- Adds complexity without clear benefit for MVP
|
||||||
|
- Flow-scoped + project-scoped aliases sufficient
|
||||||
|
- Can add later if needed
|
||||||
|
|
||||||
|
### Why no `generation_groups`?
|
||||||
|
- Not needed for core functionality
|
||||||
|
- Grouping can be done via tags or meta JSONB
|
||||||
|
- Can add later if analytics requires it
|
||||||
|
|
||||||
|
### Why `focal_point` as JSONB?
|
||||||
|
- Imageflow server expects normalized coordinates
|
||||||
|
- Format: `{ "x": 0.0-1.0, "y": 0.0-1.0 }`
|
||||||
|
- JSONB allows future extension (e.g., multiple focal points)
|
||||||
|
|
||||||
|
### Why track `api_key_id` in images/generations?
|
||||||
|
- Essential for audit trail
|
||||||
|
- Cost attribution per key
|
||||||
|
- Usage analytics
|
||||||
|
- Abuse detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 References
|
||||||
|
|
||||||
|
- **Imageflow Focal Points:** https://docs.imageflow.io/querystring/focal-point
|
||||||
|
- **Drizzle ORM:** https://orm.drizzle.team/
|
||||||
|
- **PostgreSQL JSONB:** https://www.postgresql.org/docs/current/datatype-json.html
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document Version: 2.0*
|
||||||
|
*Last Updated: 2025-10-26*
|
||||||
|
*Status: Ready for Implementation*
|
||||||
|
|
@ -1,664 +0,0 @@
|
||||||
# Banatie API Reference
|
|
||||||
|
|
||||||
Banatie is a REST API service for AI-powered image generation using the Gemini Flash Image model.
|
|
||||||
|
|
||||||
## Base URL
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
All API endpoints (except `/health`, `/api/info`, and `/api/bootstrap/*`) require authentication via API key.
|
|
||||||
|
|
||||||
### API Key Types
|
|
||||||
|
|
||||||
1. **Master Keys** - Full admin access, never expire, can create/revoke other keys
|
|
||||||
2. **Project Keys** - Standard access for image generation, expire in 90 days
|
|
||||||
|
|
||||||
### Using API Keys
|
|
||||||
|
|
||||||
Include your API key in the `X-API-Key` header:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3000/api/generate \
|
|
||||||
-H "X-API-Key: bnt_your_key_here" \
|
|
||||||
-F "prompt=..." \
|
|
||||||
-F "filename=..."
|
|
||||||
```
|
|
||||||
|
|
||||||
### Getting Your First API Key
|
|
||||||
|
|
||||||
1. **Bootstrap** - Create initial master key (one-time only):
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3000/api/bootstrap/initial-key
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create Project Key** - Use master key to create project keys:
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3000/api/admin/keys \
|
|
||||||
-H "X-API-Key: YOUR_MASTER_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"type": "project", "projectId": "my-project", "name": "My Project Key"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** Save keys securely when created - they cannot be retrieved later!
|
|
||||||
|
|
||||||
## Content Types
|
|
||||||
|
|
||||||
- **Request**: `multipart/form-data` for file uploads, `application/json` for JSON endpoints
|
|
||||||
- **Response**: `application/json`
|
|
||||||
|
|
||||||
## Rate Limits
|
|
||||||
|
|
||||||
All authenticated endpoints (those requiring API keys) are rate limited:
|
|
||||||
|
|
||||||
- **Per API Key:** 100 requests per hour
|
|
||||||
- **Applies to:**
|
|
||||||
- `POST /api/generate`
|
|
||||||
- `POST /api/text-to-image`
|
|
||||||
- `POST /api/upload`
|
|
||||||
- `POST /api/enhance`
|
|
||||||
- **Not rate limited:**
|
|
||||||
- Public endpoints (`GET /health`, `GET /api/info`)
|
|
||||||
- Bootstrap endpoint (`POST /api/bootstrap/initial-key`)
|
|
||||||
- Admin endpoints (require master key, but no rate limit)
|
|
||||||
- Image serving endpoints (`GET /api/images/:orgId/:projectId/:category/:filename`)
|
|
||||||
|
|
||||||
Rate limit information included in response headers:
|
|
||||||
- `X-RateLimit-Limit`: Maximum requests per window
|
|
||||||
- `X-RateLimit-Remaining`: Requests remaining
|
|
||||||
- `X-RateLimit-Reset`: When the limit resets (ISO 8601)
|
|
||||||
|
|
||||||
**429 Too Many Requests:** Returned when limit exceeded with `Retry-After` header
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
|
|
||||||
| Endpoint | Method | Authentication | Rate Limit | Description |
|
|
||||||
|----------|--------|----------------|------------|-------------|
|
|
||||||
| `/health` | GET | None | No | Health check |
|
|
||||||
| `/api/info` | GET | None | No | API information |
|
|
||||||
| `/api/bootstrap/initial-key` | POST | None (one-time) | No | Create first master key |
|
|
||||||
| `/api/admin/keys` | POST | Master Key | No | Create new API keys |
|
|
||||||
| `/api/admin/keys` | GET | Master Key | No | List all API keys |
|
|
||||||
| `/api/admin/keys/:keyId` | DELETE | Master Key | No | Revoke API key |
|
|
||||||
| `/api/generate` | POST | API Key | 100/hour | Generate images with files |
|
|
||||||
| `/api/text-to-image` | POST | API Key | 100/hour | Generate images (JSON only) |
|
|
||||||
| `/api/upload` | POST | API Key | 100/hour | Upload single image file |
|
|
||||||
| `/api/enhance` | POST | API Key | 100/hour | Enhance text prompts |
|
|
||||||
| `/api/images/:orgId/:projectId/:category/:filename` | GET | None | No | Serve specific image file |
|
|
||||||
| `/api/images/generated` | GET | API Key | 100/hour | List generated images |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Authentication & Admin
|
|
||||||
|
|
||||||
#### `POST /api/bootstrap/initial-key`
|
|
||||||
|
|
||||||
Create the first master API key. This endpoint works only once when no keys exist.
|
|
||||||
|
|
||||||
**Authentication:** None required (public endpoint, one-time use)
|
|
||||||
|
|
||||||
**Response (201):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"apiKey": "bnt_...",
|
|
||||||
"type": "master",
|
|
||||||
"name": "Initial Master Key",
|
|
||||||
"expiresAt": null,
|
|
||||||
"message": "IMPORTANT: Save this key securely. You will not see it again!"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error (403):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Bootstrap not allowed",
|
|
||||||
"message": "API keys already exist. Use /api/admin/keys to create new keys."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `POST /api/admin/keys`
|
|
||||||
|
|
||||||
Create a new API key (master or project).
|
|
||||||
|
|
||||||
**Authentication:** Master key required via `X-API-Key` header
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "master | project",
|
|
||||||
"projectId": "required-for-project-keys",
|
|
||||||
"name": "optional-friendly-name",
|
|
||||||
"expiresInDays": 90
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (201):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"apiKey": "bnt_...",
|
|
||||||
"metadata": {
|
|
||||||
"id": "uuid",
|
|
||||||
"type": "project",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"name": "My Project Key",
|
|
||||||
"expiresAt": "2025-12-29T17:08:02.536Z",
|
|
||||||
"scopes": ["generate", "read"],
|
|
||||||
"createdAt": "2025-09-30T17:08:02.553Z"
|
|
||||||
},
|
|
||||||
"message": "IMPORTANT: Save this key securely. You will not see it again!"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `GET /api/admin/keys`
|
|
||||||
|
|
||||||
List all API keys.
|
|
||||||
|
|
||||||
**Authentication:** Master key required
|
|
||||||
|
|
||||||
**Response (200):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"id": "uuid",
|
|
||||||
"type": "master",
|
|
||||||
"projectId": null,
|
|
||||||
"name": "Initial Master Key",
|
|
||||||
"scopes": ["*"],
|
|
||||||
"isActive": true,
|
|
||||||
"createdAt": "2025-09-30T17:01:23.456Z",
|
|
||||||
"expiresAt": null,
|
|
||||||
"lastUsedAt": "2025-09-30T17:08:45.123Z",
|
|
||||||
"createdBy": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `DELETE /api/admin/keys/:keyId`
|
|
||||||
|
|
||||||
Revoke an API key (soft delete).
|
|
||||||
|
|
||||||
**Authentication:** Master key required
|
|
||||||
|
|
||||||
**Response (200):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "API key revoked successfully",
|
|
||||||
"keyId": "uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
|
|
||||||
#### `GET /health`
|
|
||||||
|
|
||||||
Health check endpoint with server status.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "healthy",
|
|
||||||
"timestamp": "2023-11-20T10:00:00.000Z",
|
|
||||||
"uptime": 12345.67,
|
|
||||||
"environment": "development",
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### API Information
|
|
||||||
|
|
||||||
#### `GET /api/info`
|
|
||||||
|
|
||||||
Returns API metadata and configuration limits.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Banatie - Nano Banana Image Generation API",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "REST API service for AI-powered image generation using Gemini Flash Image model",
|
|
||||||
"endpoints": {
|
|
||||||
"GET /health": "Health check",
|
|
||||||
"GET /api/info": "API information",
|
|
||||||
"POST /api/generate": "Generate images from text prompt with optional reference images",
|
|
||||||
"POST /api/text-to-image": "Generate images from text prompt only (JSON)",
|
|
||||||
"POST /api/enhance": "Enhance and optimize prompts for better image generation"
|
|
||||||
},
|
|
||||||
"limits": {
|
|
||||||
"maxFileSize": "5MB",
|
|
||||||
"maxFiles": 3,
|
|
||||||
"supportedFormats": ["PNG", "JPEG", "JPG", "WebP"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Generate Image
|
|
||||||
|
|
||||||
#### `POST /api/generate`
|
|
||||||
|
|
||||||
Generate images from text prompts with optional reference images.
|
|
||||||
|
|
||||||
**Authentication:** API key required (master or project)
|
|
||||||
**Rate Limit:** 100 requests per hour per API key
|
|
||||||
|
|
||||||
**Content-Type:** `multipart/form-data`
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `prompt` | string | Yes | Text description of the image to generate (1-5000 chars) |
|
|
||||||
| `filename` | string | Yes | Desired filename for the generated image |
|
|
||||||
| `files` | file[] | No | Reference images (max 3 files, 5MB each) |
|
|
||||||
| `autoEnhance` | boolean | No | Enable automatic prompt enhancement |
|
|
||||||
| `enhancementOptions` | object | No | Enhancement configuration options |
|
|
||||||
|
|
||||||
**Enhancement Options:**
|
|
||||||
|
|
||||||
| Field | Type | Options | Default | Description |
|
|
||||||
|-------|------|---------|---------|-------------|
|
|
||||||
| `template` | string | `photorealistic`, `illustration`, `minimalist`, `sticker`, `product`, `comic`, `general` | `photorealistic` | Prompt engineering template to apply |
|
|
||||||
|
|
||||||
**Example Request:**
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3000/api/generate \
|
|
||||||
-H "X-API-Key: bnt_your_api_key_here" \
|
|
||||||
-F "prompt=A majestic mountain landscape at sunset" \
|
|
||||||
-F "filename=mountain-sunset" \
|
|
||||||
-F "autoEnhance=true" \
|
|
||||||
-F "files=@reference1.jpg" \
|
|
||||||
-F "files=@reference2.png"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Success Response (200):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Image generated successfully",
|
|
||||||
"data": {
|
|
||||||
"filename": "mountain-sunset-20231120-100000.png",
|
|
||||||
"filepath": "./results/mountain-sunset-20231120-100000.png",
|
|
||||||
"description": "Generated image description",
|
|
||||||
"model": "gemini-1.5-flash",
|
|
||||||
"generatedAt": "2023-11-20T10:00:00.000Z",
|
|
||||||
"promptEnhancement": {
|
|
||||||
"originalPrompt": "A mountain landscape",
|
|
||||||
"enhancedPrompt": "A majestic mountain landscape at golden hour with dramatic lighting",
|
|
||||||
"detectedLanguage": "en",
|
|
||||||
"appliedTemplate": "scenic_landscape",
|
|
||||||
"enhancements": ["lighting_enhancement", "composition_improvement"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Response (400/500):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"message": "Image generation failed",
|
|
||||||
"error": "Validation failed: Prompt is required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Text-to-Image (JSON)
|
|
||||||
|
|
||||||
#### `POST /api/text-to-image`
|
|
||||||
|
|
||||||
Generate images from text prompts only using JSON payload. Simplified endpoint for text-only requests without file uploads.
|
|
||||||
|
|
||||||
**Authentication:** API key required (master or project)
|
|
||||||
**Rate Limit:** 100 requests per hour per API key
|
|
||||||
|
|
||||||
**Content-Type:** `application/json`
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"prompt": "A beautiful sunset over mountains",
|
|
||||||
"filename": "sunset_image",
|
|
||||||
"aspectRatio": "16:9",
|
|
||||||
"autoEnhance": true,
|
|
||||||
"enhancementOptions": {
|
|
||||||
"template": "photorealistic",
|
|
||||||
"mood": "peaceful",
|
|
||||||
"lighting": "golden hour"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Default | Description |
|
|
||||||
|-------|------|----------|---------|-------------|
|
|
||||||
| `prompt` | string | Yes | - | Text description of the image to generate (3-2000 chars) |
|
|
||||||
| `filename` | string | Yes | - | Desired filename for the generated image (alphanumeric, underscore, hyphen only) |
|
|
||||||
| `aspectRatio` | string | No | `"1:1"` | Image aspect ratio (`"1:1"`, `"2:3"`, `"3:2"`, `"3:4"`, `"4:3"`, `"4:5"`, `"5:4"`, `"9:16"`, `"16:9"`, `"21:9"`) |
|
|
||||||
| `autoEnhance` | boolean | No | `true` | Enable automatic prompt enhancement (set to `false` to use prompt as-is) |
|
|
||||||
| `enhancementOptions` | object | No | - | Enhancement configuration options |
|
|
||||||
| `meta` | object | No | - | Metadata for request tracking |
|
|
||||||
|
|
||||||
**Enhancement Options:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Default | Description |
|
|
||||||
|-------|------|----------|---------|-------------|
|
|
||||||
| `template` | string | No | `"photorealistic"` | Prompt engineering template: `"photorealistic"`, `"illustration"`, `"minimalist"`, `"sticker"`, `"product"`, `"comic"`, `"general"` |
|
|
||||||
|
|
||||||
**Meta Object:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `tags` | string[] | No | Array of string tags for tracking/grouping requests (not stored, only logged) |
|
|
||||||
|
|
||||||
**Example Request:**
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3000/api/text-to-image \
|
|
||||||
-H "X-API-Key: bnt_your_api_key_here" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"prompt": "A beautiful sunset over mountains with golden clouds",
|
|
||||||
"filename": "test_sunset",
|
|
||||||
"aspectRatio": "16:9",
|
|
||||||
"autoEnhance": true,
|
|
||||||
"enhancementOptions": {
|
|
||||||
"template": "photorealistic"
|
|
||||||
},
|
|
||||||
"meta": {
|
|
||||||
"tags": ["demo", "sunset"]
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Success Response (200):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Image generated successfully",
|
|
||||||
"data": {
|
|
||||||
"filename": "test_sunset.png",
|
|
||||||
"filepath": "results/test_sunset.png",
|
|
||||||
"description": "Here's a beautiful sunset over mountains with golden clouds for you!",
|
|
||||||
"model": "Nano Banana",
|
|
||||||
"generatedAt": "2025-09-26T15:04:27.705Z",
|
|
||||||
"promptEnhancement": {
|
|
||||||
"originalPrompt": "A beautiful sunset over mountains",
|
|
||||||
"enhancedPrompt": "A breathtaking photorealistic sunset over majestic mountains...",
|
|
||||||
"detectedLanguage": "English",
|
|
||||||
"appliedTemplate": "landscape",
|
|
||||||
"enhancements": ["lighting_enhancement", "composition_improvement"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Response (400/500):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"message": "Validation failed",
|
|
||||||
"error": "Prompt is required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Differences from /api/generate:**
|
|
||||||
- **JSON only**: No file upload support
|
|
||||||
- **Faster**: No multipart parsing overhead
|
|
||||||
- **Simpler testing**: Easy to use with curl or API clients
|
|
||||||
- **Same features**: Supports all enhancement options
|
|
||||||
- **Auto-enhance by default**: `autoEnhance` defaults to `true`, set explicitly to `false` to use prompt as-is
|
|
||||||
|
|
||||||
**Template Descriptions:**
|
|
||||||
- `photorealistic`: Photography-focused with camera angles, lens types, lighting, and fine details
|
|
||||||
- `illustration`: Art style specifications with line work, color palette, and shading techniques
|
|
||||||
- `minimalist`: Emphasis on negative space, simple composition, and subtle elements
|
|
||||||
- `sticker`: Bold outlines, kawaii style, clean design, transparent background style
|
|
||||||
- `product`: Studio lighting setups, commercial photography terms, surfaces, and angles
|
|
||||||
- `comic`: Panel style, art technique, mood, and dialogue/caption integration
|
|
||||||
- `general`: Balanced approach with clear descriptions and artistic detail
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Upload File
|
|
||||||
|
|
||||||
#### `POST /api/upload`
|
|
||||||
|
|
||||||
Upload a single image file to project storage.
|
|
||||||
|
|
||||||
**Authentication:** Project API key required (master keys not allowed)
|
|
||||||
**Rate Limit:** 100 requests per hour per API key
|
|
||||||
|
|
||||||
**Content-Type:** `multipart/form-data`
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `file` | file | Yes | Single image file (PNG, JPEG, JPG, WebP) |
|
|
||||||
| `metadata` | JSON | No | Optional metadata (description, tags) |
|
|
||||||
|
|
||||||
**File Specifications:**
|
|
||||||
- **Max file size:** 5MB
|
|
||||||
- **Supported formats:** PNG, JPEG, JPG, WebP
|
|
||||||
- **Max files per request:** 1
|
|
||||||
|
|
||||||
**Example Request:**
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3000/api/upload \
|
|
||||||
-H "X-API-Key: bnt_your_project_key_here" \
|
|
||||||
-F "file=@image.png" \
|
|
||||||
-F 'metadata={"description":"Product photo","tags":["demo","test"]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Success Response (200):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "File uploaded successfully",
|
|
||||||
"data": {
|
|
||||||
"filename": "image-1728561234567-a1b2c3.png",
|
|
||||||
"originalName": "image.png",
|
|
||||||
"path": "org-slug/project-slug/uploads/image-1728561234567-a1b2c3.png",
|
|
||||||
"url": "http://localhost:3000/api/images/org-slug/project-slug/uploads/image-1728561234567-a1b2c3.png",
|
|
||||||
"size": 123456,
|
|
||||||
"contentType": "image/png",
|
|
||||||
"uploadedAt": "2025-10-10T12:00:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Response (400 - No file):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"message": "File upload failed",
|
|
||||||
"error": "No file provided"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Response (400 - Invalid file type):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"message": "File validation failed",
|
|
||||||
"error": "Unsupported file type: image/gif. Allowed: PNG, JPEG, WebP"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Response (400 - File too large):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"message": "File upload failed",
|
|
||||||
"error": "File too large. Maximum size: 5MB"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Storage Details:**
|
|
||||||
- Files are stored in MinIO under: `{orgSlug}/{projectSlug}/uploads/`
|
|
||||||
- Filenames are automatically made unique with timestamp and random suffix
|
|
||||||
- Original filename is preserved in response
|
|
||||||
- Uploaded files can be accessed via the returned URL
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Enhance Prompt
|
|
||||||
|
|
||||||
#### `POST /api/enhance`
|
|
||||||
|
|
||||||
Enhance and optimize text prompts for better image generation results.
|
|
||||||
|
|
||||||
**Authentication:** API key required (master or project)
|
|
||||||
**Rate Limit:** 100 requests per hour per API key
|
|
||||||
|
|
||||||
**Content-Type:** `application/json`
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"prompt": "A mountain landscape",
|
|
||||||
"options": {
|
|
||||||
"imageStyle": "photorealistic",
|
|
||||||
"aspectRatio": "landscape",
|
|
||||||
"mood": "serene and peaceful",
|
|
||||||
"lighting": "golden hour",
|
|
||||||
"cameraAngle": "wide shot",
|
|
||||||
"outputFormat": "detailed",
|
|
||||||
"negativePrompts": ["blurry", "low quality"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `prompt` | string | Yes | Original text prompt (1-5000 chars) |
|
|
||||||
| `options` | object | No | Enhancement configuration |
|
|
||||||
|
|
||||||
**Success Response (200):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"originalPrompt": "A mountain landscape",
|
|
||||||
"enhancedPrompt": "A breathtaking photorealistic mountain landscape during golden hour, featuring dramatic peaks and valleys with warm, soft lighting creating a serene and peaceful atmosphere, captured in a wide shot composition with rich detail and depth",
|
|
||||||
"detectedLanguage": "en",
|
|
||||||
"appliedTemplate": "scenic_landscape",
|
|
||||||
"metadata": {
|
|
||||||
"style": "photorealistic",
|
|
||||||
"aspectRatio": "landscape",
|
|
||||||
"enhancements": [
|
|
||||||
"lighting_enhancement",
|
|
||||||
"composition_improvement",
|
|
||||||
"atmosphere_addition",
|
|
||||||
"detail_specification"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Response (400/500):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"originalPrompt": "A mountain landscape",
|
|
||||||
"error": "Validation failed: Prompt is required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Codes
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| 400 | Bad Request - Invalid parameters or validation failure |
|
|
||||||
| 401 | Unauthorized - Missing, invalid, expired, or revoked API key |
|
|
||||||
| 403 | Forbidden - Insufficient permissions (e.g., master key required) |
|
|
||||||
| 404 | Not Found - Endpoint or resource does not exist |
|
|
||||||
| 429 | Too Many Requests - Rate limit exceeded |
|
|
||||||
| 500 | Internal Server Error - Server configuration or processing error |
|
|
||||||
|
|
||||||
## Common Error Messages
|
|
||||||
|
|
||||||
### Authentication Errors (401)
|
|
||||||
- `"Missing API key"` - No X-API-Key header provided
|
|
||||||
- `"Invalid API key"` - The provided API key is invalid, expired, or revoked
|
|
||||||
- **Affected endpoints:** `/api/generate`, `/api/text-to-image`, `/api/upload`, `/api/enhance`, `/api/admin/*`
|
|
||||||
|
|
||||||
### Authorization Errors (403)
|
|
||||||
- `"Master key required"` - This endpoint requires a master API key (not project key)
|
|
||||||
- `"Bootstrap not allowed"` - API keys already exist, cannot bootstrap again
|
|
||||||
- **Affected endpoints:** `/api/admin/*`, `/api/bootstrap/initial-key`
|
|
||||||
|
|
||||||
### Validation Errors (400)
|
|
||||||
- `"Prompt is required"` - Missing or empty prompt parameter
|
|
||||||
- `"Reference image validation failed"` - Invalid file format or size
|
|
||||||
- `"Validation failed"` - Parameter validation error
|
|
||||||
|
|
||||||
### Rate Limiting Errors (429)
|
|
||||||
- `"Rate limit exceeded"` - Too many requests, retry after specified time
|
|
||||||
- **Applies to:** `/api/generate`, `/api/text-to-image`, `/api/upload`, `/api/enhance`
|
|
||||||
- **Rate limit:** 100 requests per hour per API key
|
|
||||||
- **Response includes:** `Retry-After` header with seconds until reset
|
|
||||||
|
|
||||||
### Server Errors
|
|
||||||
- `"Server configuration error"` - Missing GEMINI_API_KEY or database connection
|
|
||||||
- `"Image generation failed"` - AI service error
|
|
||||||
- `"Authentication failed"` - Error during authentication process
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Upload Specifications
|
|
||||||
|
|
||||||
**Supported Formats:** PNG, JPEG, JPG, WebP
|
|
||||||
**Maximum File Size:** 5MB per file
|
|
||||||
**Maximum Files:** 3 files per request
|
|
||||||
**Storage:** Temporary files in `./uploads/temp`, results in `./results`
|
|
||||||
|
|
||||||
## Request Headers
|
|
||||||
|
|
||||||
| Header | Value | Description |
|
|
||||||
|--------|-------|-------------|
|
|
||||||
| `X-API-Key` | string | API key for authentication (required for most endpoints) |
|
|
||||||
| `X-Request-ID` | string | Unique request identifier (auto-generated by server) |
|
|
||||||
|
|
||||||
## Response Headers
|
|
||||||
|
|
||||||
| Header | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `X-Request-ID` | Request identifier for tracking |
|
|
||||||
| `X-RateLimit-Limit` | Maximum requests allowed per window |
|
|
||||||
| `X-RateLimit-Remaining` | Requests remaining in current window |
|
|
||||||
| `X-RateLimit-Reset` | When the rate limit resets (ISO 8601) |
|
|
||||||
|
|
||||||
## CORS
|
|
||||||
|
|
||||||
Cross-origin requests supported from:
|
|
||||||
- `http://localhost:3001` (Landing Page)
|
|
||||||
- `http://localhost:3002` (Studio Platform)
|
|
||||||
- `http://localhost:3003` (Admin Dashboard)
|
|
||||||
|
|
||||||
Configure additional origins via `CORS_ORIGIN` environment variable.
|
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
# Banatie API - Administration & Authentication
|
||||||
|
|
||||||
|
## Authentication Overview
|
||||||
|
|
||||||
|
All API endpoints (except public endpoints and bootstrap) require authentication via API key in the `X-API-Key` header.
|
||||||
|
|
||||||
|
### API Key Types
|
||||||
|
|
||||||
|
**Master Keys**
|
||||||
|
- Full administrative access
|
||||||
|
- Never expire
|
||||||
|
- Can create and revoke other API keys
|
||||||
|
- Access to all admin endpoints
|
||||||
|
|
||||||
|
**Project Keys**
|
||||||
|
- Standard access for image generation
|
||||||
|
- Expire in 90 days by default
|
||||||
|
- Scoped to specific organization and project
|
||||||
|
- Rate limited (100 requests/hour)
|
||||||
|
|
||||||
|
### Header Format
|
||||||
|
|
||||||
|
```
|
||||||
|
X-API-Key: bnt_your_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Public Endpoints
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
|
||||||
|
Health check with server status.
|
||||||
|
|
||||||
|
**Authentication:** None
|
||||||
|
|
||||||
|
**Purpose:** Monitor API availability and uptime
|
||||||
|
|
||||||
|
**Returns:** Status, timestamp, uptime, environment, version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/info
|
||||||
|
|
||||||
|
API information and configuration limits.
|
||||||
|
|
||||||
|
**Authentication:** Optional (returns key info if authenticated)
|
||||||
|
|
||||||
|
**Purpose:** Discover API capabilities and limits
|
||||||
|
|
||||||
|
**Returns:** API name, version, endpoints list, file size/format limits
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bootstrap Endpoint
|
||||||
|
|
||||||
|
### POST /api/bootstrap/initial-key
|
||||||
|
|
||||||
|
Create the first master API key (one-time only).
|
||||||
|
|
||||||
|
**Authentication:** None (public, works only when database is empty)
|
||||||
|
|
||||||
|
**Purpose:** Initialize the API with first master key
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Only works when no API keys exist in database
|
||||||
|
- Returns master key value (save securely, shown only once)
|
||||||
|
- Subsequent calls return 403 Forbidden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Key Management
|
||||||
|
|
||||||
|
All endpoints require Master Key authentication.
|
||||||
|
|
||||||
|
### POST /api/admin/keys
|
||||||
|
|
||||||
|
Create new API key (master or project).
|
||||||
|
|
||||||
|
**Authentication:** Master Key required
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `type` - "master" or "project" (required)
|
||||||
|
- `projectId` - Project identifier (required for project keys)
|
||||||
|
- `organizationId` - Organization UUID (optional, auto-created)
|
||||||
|
- `organizationSlug` - Organization slug (optional, auto-created)
|
||||||
|
- `projectSlug` - Project slug (optional, auto-created)
|
||||||
|
- `name` - Friendly name for the key (optional)
|
||||||
|
- `expiresInDays` - Expiration days (optional, default: 90 for project keys)
|
||||||
|
|
||||||
|
**Purpose:** Generate new API keys for projects or admin users
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Automatically creates organization and project if they don't exist
|
||||||
|
- Returns API key value (save securely, shown only once)
|
||||||
|
- Master keys never expire, project keys expire in 90 days by default
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/admin/keys
|
||||||
|
|
||||||
|
List all API keys.
|
||||||
|
|
||||||
|
**Authentication:** Master Key required
|
||||||
|
|
||||||
|
**Purpose:** View all active and inactive API keys
|
||||||
|
|
||||||
|
**Returns:** Array of all keys with metadata (no sensitive key values), includes organization and project details
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Shows all keys regardless of active status
|
||||||
|
- Includes last used timestamp
|
||||||
|
- Does not return actual API key values (hashed in database)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE /api/admin/keys/:keyId
|
||||||
|
|
||||||
|
Revoke an API key.
|
||||||
|
|
||||||
|
**Authentication:** Master Key required
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `keyId` - UUID of the key to revoke (path parameter)
|
||||||
|
|
||||||
|
**Purpose:** Deactivate an API key (soft delete)
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Soft delete via `is_active` flag
|
||||||
|
- Revoked keys cannot be reactivated
|
||||||
|
- Key remains in database for audit trail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
### API Key Rate Limiting
|
||||||
|
|
||||||
|
Rate limits apply per API key to protected endpoints.
|
||||||
|
|
||||||
|
**Limits:**
|
||||||
|
- **Project Keys:** 100 requests per hour
|
||||||
|
- **Master Keys:** No rate limit on admin endpoints
|
||||||
|
|
||||||
|
**Affected Endpoints:**
|
||||||
|
- All `/api/v1/generations` endpoints (POST, PUT, regenerate)
|
||||||
|
- All `/api/v1/images` endpoints (POST upload, PUT)
|
||||||
|
- All `/api/v1/flows` endpoints (PUT, regenerate)
|
||||||
|
- All `/api/v1/live/scopes` endpoints (POST, PUT, regenerate, DELETE)
|
||||||
|
|
||||||
|
**Response Headers:**
|
||||||
|
- `X-RateLimit-Limit` - Maximum requests per window
|
||||||
|
- `X-RateLimit-Remaining` - Requests remaining
|
||||||
|
- `X-RateLimit-Reset` - Reset timestamp (ISO 8601)
|
||||||
|
|
||||||
|
**429 Too Many Requests:**
|
||||||
|
- Returned when limit exceeded
|
||||||
|
- Includes `Retry-After` header (seconds until reset)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### IP-Based Rate Limiting (Live URLs)
|
||||||
|
|
||||||
|
Separate rate limiting for public live URL generation endpoints.
|
||||||
|
|
||||||
|
**Limits:**
|
||||||
|
- **10 new generations per hour per IP address**
|
||||||
|
- Only cache MISS (new generations) count toward limit
|
||||||
|
- Cache HIT (cached images) do NOT count toward limit
|
||||||
|
|
||||||
|
**Affected Endpoints:**
|
||||||
|
- `GET /:orgSlug/:projectSlug/live/:scope` - Public live URL generation
|
||||||
|
|
||||||
|
**Purpose:**
|
||||||
|
- Prevent abuse of public live URL endpoints
|
||||||
|
- Separate from API key limits (for authenticated endpoints)
|
||||||
|
- Does not affect API key-authenticated endpoints
|
||||||
|
|
||||||
|
**Response Headers:**
|
||||||
|
- `X-RateLimit-Limit` - Maximum requests per window (10)
|
||||||
|
- `X-RateLimit-Remaining` - Requests remaining
|
||||||
|
- `X-RateLimit-Reset` - Seconds until reset
|
||||||
|
|
||||||
|
**429 Too Many Requests:**
|
||||||
|
- Returned when IP limit exceeded
|
||||||
|
- Includes `Retry-After` header (seconds until reset)
|
||||||
|
- Error code: `IP_RATE_LIMIT_EXCEEDED`
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Uses in-memory store with automatic cleanup
|
||||||
|
- Supports X-Forwarded-For header for proxy/load balancer setups
|
||||||
|
- IP limit resets every hour per IP address
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
### HTTP Status Codes
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| 401 | Unauthorized - Missing, invalid, expired, or revoked API key |
|
||||||
|
| 403 | Forbidden - Insufficient permissions (master key required) |
|
||||||
|
| 409 | Conflict - Resource already exists (e.g., duplicate scope slug) |
|
||||||
|
| 429 | Too Many Requests - Rate limit exceeded (API key or IP) |
|
||||||
|
|
||||||
|
### Authentication Error Codes
|
||||||
|
|
||||||
|
| Error Code | HTTP Status | Description |
|
||||||
|
|------------|-------------|-------------|
|
||||||
|
| `MISSING_API_KEY` | 401 | No X-API-Key header provided |
|
||||||
|
| `INVALID_API_KEY` | 401 | Key is invalid, expired, or revoked |
|
||||||
|
| `MASTER_KEY_REQUIRED` | 403 | Endpoint requires master key, project key insufficient |
|
||||||
|
| `BOOTSTRAP_NOT_ALLOWED` | 403 | Keys already exist, cannot bootstrap again |
|
||||||
|
|
||||||
|
### Rate Limiting Error Codes
|
||||||
|
|
||||||
|
| Error Code | HTTP Status | Description |
|
||||||
|
|------------|-------------|-------------|
|
||||||
|
| `RATE_LIMIT_EXCEEDED` | 429 | API key rate limit exceeded (100/hour) |
|
||||||
|
| `IP_RATE_LIMIT_EXCEEDED` | 429 | IP rate limit exceeded for live URLs (10/hour) |
|
||||||
|
|
||||||
|
### Live Scope Error Codes
|
||||||
|
|
||||||
|
| Error Code | HTTP Status | Description |
|
||||||
|
|------------|-------------|-------------|
|
||||||
|
| `SCOPE_INVALID_FORMAT` | 400 | Scope slug format invalid (must be alphanumeric + hyphens + underscores) |
|
||||||
|
| `SCOPE_ALREADY_EXISTS` | 409 | Scope with this slug already exists in project |
|
||||||
|
| `SCOPE_NOT_FOUND` | 404 | Scope does not exist or access denied |
|
||||||
|
| `IMAGE_NOT_IN_SCOPE` | 400 | Image does not belong to specified scope |
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- All error responses follow the format: `{ "success": false, "error": { "message": "...", "code": "..." } }`
|
||||||
|
- Rate limit errors include `Retry-After` header with seconds until reset
|
||||||
|
- Scope management endpoints require project key authentication
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
@base = http://localhost:3000
|
|
||||||
# Replace with your actual API key (e.g., bnt_abc123...)
|
|
||||||
@apiKey = bnt_d0da2d441cd2f22a0ec13897629b4438cc723f0bcb320d646a41ed05a985fdf8
|
|
||||||
# Replace with your master key for admin endpoints
|
|
||||||
@masterKey = bnt_71475a11d69344ff9db2236ff4f10cfca34512b29c7ac1a74f73c156d708e226
|
|
||||||
|
|
||||||
|
|
||||||
### Health
|
|
||||||
|
|
||||||
GET {{base}}/health
|
|
||||||
|
|
||||||
|
|
||||||
### Info
|
|
||||||
|
|
||||||
GET {{base}}/api/info
|
|
||||||
|
|
||||||
|
|
||||||
### Bootstrap - Create First Master Key (One-time only)
|
|
||||||
|
|
||||||
POST {{base}}/api/bootstrap/initial-key
|
|
||||||
|
|
||||||
|
|
||||||
### Admin - Create New API Key (Requires Master Key)
|
|
||||||
|
|
||||||
POST {{base}}/api/admin/keys
|
|
||||||
Content-Type: application/json
|
|
||||||
X-API-Key: {{masterKey}}
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "project",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"name": "My Project Key",
|
|
||||||
"expiresInDays": 90
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
### Admin - List All API Keys (Requires Master Key)
|
|
||||||
|
|
||||||
GET {{base}}/api/admin/keys
|
|
||||||
X-API-Key: {{masterKey}}
|
|
||||||
|
|
||||||
|
|
||||||
### Admin - Revoke API Key (Requires Master Key)
|
|
||||||
|
|
||||||
DELETE {{base}}/api/admin/keys/KEY_ID_HERE
|
|
||||||
X-API-Key: {{masterKey}}
|
|
||||||
|
|
||||||
|
|
||||||
### Generate Image from Text (Requires API Key)
|
|
||||||
|
|
||||||
POST {{base}}/api/text-to-image
|
|
||||||
Content-Type: application/json
|
|
||||||
X-API-Key: {{apiKey}}
|
|
||||||
|
|
||||||
{
|
|
||||||
"prompt": "A majestic eagle soaring over snow-capped mountains",
|
|
||||||
"filename": "test-eagle"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
### Generate Image - Text to Image (alternative format)
|
|
||||||
POST http://localhost:3000/api/text-to-image
|
|
||||||
Content-Type: application/json
|
|
||||||
X-API-Key: bnt_61ba018f01474491cbaacec4509220d7154fffcd011f005eece4dba7889fba99
|
|
||||||
|
|
||||||
{
|
|
||||||
"prompt": "фотография детской кроватки в стиле piratespunk",
|
|
||||||
"filename": "generated_image",
|
|
||||||
"autoEnhance": true
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,449 @@
|
||||||
|
# Advanced Image Generation
|
||||||
|
|
||||||
|
Advanced generation features: reference images, aliases, flows, and regeneration. For basic generation, see [image-generation.md](image-generation.md).
|
||||||
|
|
||||||
|
All endpoints require Project Key authentication via `X-API-Key` header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Images
|
||||||
|
|
||||||
|
Use existing images as style or content references for generation.
|
||||||
|
|
||||||
|
### Using References
|
||||||
|
|
||||||
|
Add `referenceImages` array to your generation request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "A product photo with the logo in the corner",
|
||||||
|
"referenceImages": ["@brand-logo", "@product-style"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
References can be:
|
||||||
|
- **Project aliases**: `@logo`, `@brand-style`
|
||||||
|
- **Flow aliases**: `@hero` (with flowId context)
|
||||||
|
- **Technical aliases**: `@last`, `@first`, `@upload`
|
||||||
|
- **Image UUIDs**: `550e8400-e29b-41d4-a716-446655440000`
|
||||||
|
|
||||||
|
### Auto-Detection from Prompt
|
||||||
|
|
||||||
|
Aliases in the prompt are automatically detected and used as references:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "Create a banner using @brand-logo with blue background"
|
||||||
|
}
|
||||||
|
// @brand-logo is auto-detected and added to referenceImages
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reference Limits
|
||||||
|
|
||||||
|
| Constraint | Limit |
|
||||||
|
|------------|-------|
|
||||||
|
| Max references | 3 images |
|
||||||
|
| Max file size | 5MB per image |
|
||||||
|
| Supported formats | PNG, JPEG, WebP |
|
||||||
|
|
||||||
|
### Response with References
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-...",
|
||||||
|
"prompt": "Create a banner using @brand-logo",
|
||||||
|
"referencedImages": [
|
||||||
|
{ "imageId": "7c4ccf47-...", "alias": "@brand-logo" }
|
||||||
|
],
|
||||||
|
"referenceImages": [
|
||||||
|
{
|
||||||
|
"id": "7c4ccf47-...",
|
||||||
|
"storageUrl": "http://...",
|
||||||
|
"alias": "@brand-logo"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alias Assignment
|
||||||
|
|
||||||
|
Assign aliases to generated images for easy referencing.
|
||||||
|
|
||||||
|
### Project-Scoped Alias
|
||||||
|
|
||||||
|
Use `alias` parameter to assign a project-wide alias:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "A hero banner image",
|
||||||
|
"alias": "@hero-banner"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The output image will be accessible via `@hero-banner` anywhere in the project.
|
||||||
|
|
||||||
|
### Flow-Scoped Alias
|
||||||
|
|
||||||
|
Use `flowAlias` parameter to assign a flow-specific alias:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "A hero image variation",
|
||||||
|
"flowId": "550e8400-...",
|
||||||
|
"flowAlias": "@best"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The alias `@best` is only accessible within this flow's context.
|
||||||
|
|
||||||
|
### Alias Format
|
||||||
|
|
||||||
|
| Rule | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Prefix | Must start with `@` |
|
||||||
|
| Characters | Alphanumeric, underscore, hyphen |
|
||||||
|
| Pattern | `@[a-zA-Z0-9_-]+` |
|
||||||
|
| Max length | 50 characters |
|
||||||
|
| Examples | `@logo`, `@hero-bg`, `@image_1` |
|
||||||
|
|
||||||
|
### Reserved Aliases
|
||||||
|
|
||||||
|
These aliases are computed automatically and cannot be assigned:
|
||||||
|
|
||||||
|
| Alias | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `@last` | Most recently generated image in flow |
|
||||||
|
| `@first` | First generated image in flow |
|
||||||
|
| `@upload` | Most recently uploaded image in flow |
|
||||||
|
|
||||||
|
### Override Behavior
|
||||||
|
|
||||||
|
When assigning an alias that already exists:
|
||||||
|
- The **new image gets the alias**
|
||||||
|
- The **old image loses the alias** (alias set to null)
|
||||||
|
- The old image is **not deleted**, just unlinked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-Tier Alias Resolution
|
||||||
|
|
||||||
|
Aliases are resolved in this order of precedence:
|
||||||
|
|
||||||
|
### 1. Technical Aliases (Highest Priority)
|
||||||
|
|
||||||
|
Computed on-the-fly, require flow context:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/images/@last?flowId=550e8400-...
|
||||||
|
```
|
||||||
|
|
||||||
|
| Alias | Returns |
|
||||||
|
|-------|---------|
|
||||||
|
| `@last` | Last generated image in flow |
|
||||||
|
| `@first` | First generated image in flow |
|
||||||
|
| `@upload` | Last uploaded image in flow |
|
||||||
|
|
||||||
|
### 2. Flow Aliases
|
||||||
|
|
||||||
|
Stored in flow's `aliases` JSONB field:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/images/@hero?flowId=550e8400-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Different flows can have the same alias pointing to different images.
|
||||||
|
|
||||||
|
### 3. Project Aliases (Lowest Priority)
|
||||||
|
|
||||||
|
Stored in image's `alias` column:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/images/@logo
|
||||||
|
```
|
||||||
|
|
||||||
|
Global across the project, unique per project.
|
||||||
|
|
||||||
|
### Resolution Example
|
||||||
|
|
||||||
|
```
|
||||||
|
// Request with flowId
|
||||||
|
GET /api/v1/images/@hero?flowId=abc-123
|
||||||
|
|
||||||
|
// Resolution order:
|
||||||
|
// 1. Is "@hero" a technical alias? No
|
||||||
|
// 2. Does flow abc-123 have "@hero" in aliases? Check flows.aliases JSONB
|
||||||
|
// 3. Does any image have alias = "@hero"? Check images.alias column
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow Integration
|
||||||
|
|
||||||
|
Flows organize related generations into chains.
|
||||||
|
|
||||||
|
### Lazy Flow Creation
|
||||||
|
|
||||||
|
When `flowId` is not provided, a pending flow ID is generated:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"prompt": "A red car"
|
||||||
|
// No flowId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "gen-123",
|
||||||
|
"flowId": "flow-456" // Auto-generated, flow record not created yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The flow record is created when:
|
||||||
|
- A second generation uses the same `flowId`
|
||||||
|
- A `flowAlias` is assigned to any generation in the flow
|
||||||
|
|
||||||
|
### Eager Flow Creation
|
||||||
|
|
||||||
|
When `flowAlias` is provided, the flow is created immediately:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "A hero banner",
|
||||||
|
"flowAlias": "@hero-flow"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### No Flow Association
|
||||||
|
|
||||||
|
To explicitly create without flow association:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "A standalone image",
|
||||||
|
"flowId": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### flowId Behavior Summary
|
||||||
|
|
||||||
|
| Value | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| `undefined` (not provided) | Auto-generate pendingFlowId, lazy creation |
|
||||||
|
| `null` (explicitly null) | No flow association |
|
||||||
|
| `"uuid-string"` | Use provided ID, create flow if doesn't exist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regeneration
|
||||||
|
|
||||||
|
### Regenerate Generation
|
||||||
|
|
||||||
|
Recreate an image using the exact same parameters:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/generations/:id/regenerate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Uses exact same prompt, aspect ratio, references
|
||||||
|
- **Preserves** output image ID and URL
|
||||||
|
- Works regardless of current status
|
||||||
|
- No request body needed
|
||||||
|
|
||||||
|
**Response:** Same as original generation with new image
|
||||||
|
|
||||||
|
### Update and Regenerate
|
||||||
|
|
||||||
|
Use PUT to modify parameters with smart regeneration:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v1/generations/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "A blue car instead",
|
||||||
|
"aspectRatio": "1:1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Smart Behavior:**
|
||||||
|
|
||||||
|
| Changed Field | Triggers Regeneration |
|
||||||
|
|---------------|----------------------|
|
||||||
|
| `prompt` | Yes |
|
||||||
|
| `aspectRatio` | Yes |
|
||||||
|
| `flowId` | No (metadata only) |
|
||||||
|
| `meta` | No (metadata only) |
|
||||||
|
|
||||||
|
### Flow Regenerate
|
||||||
|
|
||||||
|
Regenerate the most recent generation in a flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/flows/:id/regenerate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Finds the most recent generation in flow
|
||||||
|
- Regenerates with exact same parameters
|
||||||
|
- Returns error if flow has no generations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow Management
|
||||||
|
|
||||||
|
### List Flows
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/flows
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `limit` | number | `20` | Results per page (max: 100) |
|
||||||
|
| `offset` | number | `0` | Pagination offset |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "flow-456",
|
||||||
|
"projectId": "project-123",
|
||||||
|
"aliases": { "@hero": "img-789", "@best": "img-abc" },
|
||||||
|
"generationCount": 5,
|
||||||
|
"imageCount": 7,
|
||||||
|
"createdAt": "2025-11-28T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": { "limit": 20, "offset": 0, "total": 3, "hasMore": false }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/flows/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns flow with computed counts and aliases.
|
||||||
|
|
||||||
|
### List Flow Generations
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/flows/:id/generations
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns all generations in the flow, ordered by creation date (newest first).
|
||||||
|
|
||||||
|
### List Flow Images
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/flows/:id/images
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns all images in the flow (generated and uploaded).
|
||||||
|
|
||||||
|
### Update Flow Aliases
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v1/flows/:id/aliases
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aliases": {
|
||||||
|
"@hero": "image-id-123",
|
||||||
|
"@best": "image-id-456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:** Merges with existing aliases (does not replace all).
|
||||||
|
|
||||||
|
### Remove Flow Alias
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/flows/:id/aliases/:alias
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `DELETE /api/v1/flows/flow-456/aliases/@hero`
|
||||||
|
|
||||||
|
### Delete Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/flows/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full Request Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
// POST /api/v1/generations
|
||||||
|
{
|
||||||
|
"prompt": "A professional product photo using @brand-style and @product-template",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"autoEnhance": true,
|
||||||
|
"enhancementOptions": { "template": "product" },
|
||||||
|
"flowId": "campaign-flow-123",
|
||||||
|
"alias": "@latest-product",
|
||||||
|
"flowAlias": "@hero",
|
||||||
|
"meta": { "campaign": "summer-2025" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
1. `@brand-style` and `@product-template` resolved and used as references
|
||||||
|
2. Prompt enhanced using "product" template
|
||||||
|
3. Generation created in flow `campaign-flow-123`
|
||||||
|
4. Output image assigned project alias `@latest-product`
|
||||||
|
5. Output image assigned flow alias `@hero` in the flow
|
||||||
|
6. Custom metadata stored
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Fields (Additional)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `flowId` | string | Associated flow UUID |
|
||||||
|
| `alias` | string | Project-scoped alias (on outputImage) |
|
||||||
|
| `referencedImages` | array | Resolved references: `[{ imageId, alias }]` |
|
||||||
|
| `referenceImages` | array | Full image details of references |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
| HTTP Status | Code | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| 400 | `ALIAS_FORMAT_CHECK` | Alias must start with @ |
|
||||||
|
| 400 | `RESERVED_ALIAS` | Cannot use technical alias |
|
||||||
|
| 404 | `ALIAS_NOT_FOUND` | Referenced alias doesn't exist |
|
||||||
|
| 404 | `FLOW_NOT_FOUND` | Flow does not exist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Basic Generation](image-generation.md) - Simple generation
|
||||||
|
- [Image Upload](images-upload.md) - Upload with aliases
|
||||||
|
- [Live URLs](live-url.md) - CDN and live generation
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
# Image Generation API
|
||||||
|
|
||||||
|
Basic image generation using AI. For advanced features like references, aliases, and flows, see [image-generation-advanced.md](image-generation-advanced.md).
|
||||||
|
|
||||||
|
All endpoints require Project Key authentication via `X-API-Key` header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create Generation
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/generations
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate an AI image from a text prompt.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Default | Description |
|
||||||
|
|-----------|------|----------|---------|-------------|
|
||||||
|
| `prompt` | string | Yes | - | Text description of the image to generate |
|
||||||
|
| `aspectRatio` | string | No | `"1:1"` | Image aspect ratio |
|
||||||
|
| `autoEnhance` | boolean | No | `true` | Enable AI prompt enhancement |
|
||||||
|
| `enhancementOptions` | object | No | - | Enhancement configuration |
|
||||||
|
| `enhancementOptions.template` | string | No | `"general"` | Enhancement template |
|
||||||
|
| `meta` | object | No | `{}` | Custom metadata |
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "a red sports car on a mountain road",
|
||||||
|
"aspectRatio": "16:9",
|
||||||
|
"autoEnhance": true,
|
||||||
|
"enhancementOptions": {
|
||||||
|
"template": "photorealistic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `201 Created`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"projectId": "57c7f7f4-47de-4d70-9ebd-3807a0b63746",
|
||||||
|
"prompt": "A photorealistic establishing shot of a sleek red sports car...",
|
||||||
|
"originalPrompt": "a red sports car on a mountain road",
|
||||||
|
"autoEnhance": true,
|
||||||
|
"aspectRatio": "16:9",
|
||||||
|
"status": "pending",
|
||||||
|
"outputImageId": null,
|
||||||
|
"processingTimeMs": null,
|
||||||
|
"createdAt": "2025-11-28T10:00:00.000Z",
|
||||||
|
"updatedAt": "2025-11-28T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aspect Ratios
|
||||||
|
|
||||||
|
Supported aspect ratios for image generation:
|
||||||
|
|
||||||
|
| Aspect Ratio | Use Case |
|
||||||
|
|--------------|----------|
|
||||||
|
| `1:1` | Square images, social media posts, profile pictures |
|
||||||
|
| `16:9` | Landscape, hero banners, video thumbnails |
|
||||||
|
| `9:16` | Portrait, mobile screens, stories |
|
||||||
|
| `3:2` | Photography standard, print |
|
||||||
|
| `21:9` | Ultra-wide banners, cinematic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt Enhancement
|
||||||
|
|
||||||
|
By default, prompts are automatically enhanced by AI to produce better results.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
When `autoEnhance: true` (default):
|
||||||
|
- Your original prompt is preserved in `originalPrompt`
|
||||||
|
- AI enhances it with style details, lighting, composition
|
||||||
|
- The enhanced version is stored in `prompt` and used for generation
|
||||||
|
|
||||||
|
When `autoEnhance: false`:
|
||||||
|
- Both `prompt` and `originalPrompt` contain your original text
|
||||||
|
- No AI enhancement is applied
|
||||||
|
|
||||||
|
### Enhancement Templates
|
||||||
|
|
||||||
|
Use `enhancementOptions.template` to guide the enhancement style:
|
||||||
|
|
||||||
|
| Template | Description | Best For |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `general` | Balanced enhancement (default) | Most use cases |
|
||||||
|
| `photorealistic` | Photography terms, lighting, camera details | Realistic photos |
|
||||||
|
| `illustration` | Art style, composition, color palette | Artwork, drawings |
|
||||||
|
| `minimalist` | Clean, simple, essential elements | Logos, icons |
|
||||||
|
| `sticker` | Bold outlines, limited colors, vector style | Stickers, emojis |
|
||||||
|
| `product` | Studio lighting, materials, lifestyle context | E-commerce |
|
||||||
|
| `comic` | Action lines, expressions, panel composition | Comics, manga |
|
||||||
|
|
||||||
|
### Example: With Enhancement
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"prompt": "a cat",
|
||||||
|
"autoEnhance": true,
|
||||||
|
"enhancementOptions": { "template": "photorealistic" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{
|
||||||
|
"prompt": "A photorealistic close-up portrait of a domestic cat with soft fur, captured with an 85mm lens at f/1.8, natural window lighting creating soft shadows, detailed whiskers and expressive eyes, shallow depth of field with creamy bokeh background",
|
||||||
|
"originalPrompt": "a cat",
|
||||||
|
"autoEnhance": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Without Enhancement
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"prompt": "a cat sitting on a windowsill",
|
||||||
|
"autoEnhance": false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{
|
||||||
|
"prompt": "a cat sitting on a windowsill",
|
||||||
|
"originalPrompt": "a cat sitting on a windowsill",
|
||||||
|
"autoEnhance": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generation Status
|
||||||
|
|
||||||
|
Generations go through these status stages:
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `pending` | Generation created, waiting to start |
|
||||||
|
| `processing` | AI is generating the image |
|
||||||
|
| `success` | Image generated successfully |
|
||||||
|
| `failed` | Generation failed (see `errorMessage`) |
|
||||||
|
|
||||||
|
Poll the generation endpoint to check status:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/generations/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
When `status: "success"`, the `outputImageId` field contains the generated image ID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List Generations
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/generations
|
||||||
|
```
|
||||||
|
|
||||||
|
List all generations with optional filters.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `status` | string | - | Filter by status: `pending`, `processing`, `success`, `failed` |
|
||||||
|
| `limit` | number | `20` | Results per page (max: 100) |
|
||||||
|
| `offset` | number | `0` | Pagination offset |
|
||||||
|
| `includeDeleted` | boolean | `false` | Include soft-deleted records |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/generations?status=success&limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"prompt": "A photorealistic establishing shot...",
|
||||||
|
"originalPrompt": "a red sports car",
|
||||||
|
"autoEnhance": true,
|
||||||
|
"aspectRatio": "16:9",
|
||||||
|
"status": "success",
|
||||||
|
"outputImageId": "7c4ccf47-41ce-4718-afbc-8c553b2c631a",
|
||||||
|
"processingTimeMs": 8500,
|
||||||
|
"createdAt": "2025-11-28T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"limit": 10,
|
||||||
|
"offset": 0,
|
||||||
|
"total": 42,
|
||||||
|
"hasMore": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Generation
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/generations/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Get a single generation with full details.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"projectId": "57c7f7f4-47de-4d70-9ebd-3807a0b63746",
|
||||||
|
"prompt": "A photorealistic establishing shot of a sleek red sports car...",
|
||||||
|
"originalPrompt": "a red sports car on a mountain road",
|
||||||
|
"autoEnhance": true,
|
||||||
|
"aspectRatio": "16:9",
|
||||||
|
"status": "success",
|
||||||
|
"outputImageId": "7c4ccf47-41ce-4718-afbc-8c553b2c631a",
|
||||||
|
"outputImage": {
|
||||||
|
"id": "7c4ccf47-41ce-4718-afbc-8c553b2c631a",
|
||||||
|
"storageUrl": "http://localhost:9000/banatie/default/project-id/generated/image.png",
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"width": 1792,
|
||||||
|
"height": 1024,
|
||||||
|
"fileSize": 1909246
|
||||||
|
},
|
||||||
|
"processingTimeMs": 8500,
|
||||||
|
"retryCount": 0,
|
||||||
|
"errorMessage": null,
|
||||||
|
"meta": {},
|
||||||
|
"createdAt": "2025-11-28T10:00:00.000Z",
|
||||||
|
"updatedAt": "2025-11-28T10:00:08.500Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delete Generation
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/generations/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete a generation and its output image.
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Generation deleted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Generation record is hard deleted
|
||||||
|
- Output image is hard deleted (unless it has a project alias)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Fields
|
||||||
|
|
||||||
|
### Generation Response
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `id` | string | Generation UUID |
|
||||||
|
| `projectId` | string | Project UUID |
|
||||||
|
| `prompt` | string | Prompt used for generation (enhanced if applicable) |
|
||||||
|
| `originalPrompt` | string | Original user input |
|
||||||
|
| `autoEnhance` | boolean | Whether enhancement was applied |
|
||||||
|
| `aspectRatio` | string | Image aspect ratio |
|
||||||
|
| `status` | string | Generation status |
|
||||||
|
| `outputImageId` | string | Output image UUID (when successful) |
|
||||||
|
| `outputImage` | object | Output image details (when successful) |
|
||||||
|
| `processingTimeMs` | number | Generation time in milliseconds |
|
||||||
|
| `retryCount` | number | Number of retry attempts |
|
||||||
|
| `errorMessage` | string | Error details (when failed) |
|
||||||
|
| `meta` | object | Custom metadata |
|
||||||
|
| `createdAt` | string | ISO timestamp |
|
||||||
|
| `updatedAt` | string | ISO timestamp |
|
||||||
|
|
||||||
|
### Output Image
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `id` | string | Image UUID |
|
||||||
|
| `storageUrl` | string | Direct URL to image file |
|
||||||
|
| `mimeType` | string | Image MIME type |
|
||||||
|
| `width` | number | Image width in pixels |
|
||||||
|
| `height` | number | Image height in pixels |
|
||||||
|
| `fileSize` | number | File size in bytes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
| HTTP Status | Code | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| 400 | `VALIDATION_ERROR` | Invalid parameters |
|
||||||
|
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
|
||||||
|
| 404 | `GENERATION_NOT_FOUND` | Generation does not exist |
|
||||||
|
| 429 | `RATE_LIMIT_EXCEEDED` | Too many requests |
|
||||||
|
| 500 | `GENERATION_FAILED` | AI generation failed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limits
|
||||||
|
|
||||||
|
- **100 requests per hour** per API key
|
||||||
|
- Rate limit headers included in response:
|
||||||
|
- `X-RateLimit-Limit`: Maximum requests
|
||||||
|
- `X-RateLimit-Remaining`: Remaining requests
|
||||||
|
- `X-RateLimit-Reset`: Seconds until reset
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Advanced Generation](image-generation-advanced.md) - References, aliases, flows
|
||||||
|
- [Image Upload](images-upload.md) - Upload and manage images
|
||||||
|
- [Live URLs](live-url.md) - CDN and live generation
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
@base = http://localhost:3000
|
||||||
|
@apiKey = bnt_71e7e16732ac5e21f597edc56e99e8c3696e713552ec9d1f44dfeffb2ef7c495
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# GENERATIONS
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Create Generation
|
||||||
|
# Generate AI image with optional reference images and flow support
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A majestic eagle soaring over snow-capped mountains",
|
||||||
|
"aspectRatio": "16:9",
|
||||||
|
"alias": "@eagle-hero",
|
||||||
|
"flowAlias": "@hero",
|
||||||
|
"autoEnhance": true,
|
||||||
|
"meta": {
|
||||||
|
"tags": ["demo", "nature"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
"flowId": "flow-uuid-here",
|
||||||
|
generationID: "e14e0cc1-b3bc-4841-a6dc-f42c842d8d86"
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### List Generations
|
||||||
|
# Browse generation history with filters and pagination
|
||||||
|
GET {{base}}/api/v1/generations?limit=20&offset=0&status=success
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### Get Generation by ID
|
||||||
|
# View complete generation details including output and reference images
|
||||||
|
GET {{base}}/api/v1/generations/e14e0cc1-b3bc-4841-a6dc-f42c842d8d86
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### Retry Generation
|
||||||
|
# Recreate a failed generation with optional parameter overrides
|
||||||
|
POST {{base}}/api/v1/generations/e14e0cc1-b3bc-4841-a6dc-f42c842d8d86/retry
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Delete Generation
|
||||||
|
# Remove generation record and associated output image (soft delete)
|
||||||
|
DELETE {{base}}/api/v1/generations/generation-uuid-here
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# FLOWS
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Create Flow
|
||||||
|
# Initialize a new generation chain/workflow
|
||||||
|
POST {{base}}/api/v1/flows
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"name": "Product Images Campaign"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
### List Flows
|
||||||
|
# Browse all flows with computed generation and image counts
|
||||||
|
GET {{base}}/api/v1/flows?limit=20&offset=0
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### Get Flow by ID
|
||||||
|
# View flow metadata, aliases, and computed counts
|
||||||
|
GET {{base}}/api/v1/flows/flow-uuid-here
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### List Flow Generations
|
||||||
|
# View all generations associated with this flow
|
||||||
|
GET {{base}}/api/v1/flows/flow-uuid-here/generations?limit=20
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### List Flow Images
|
||||||
|
# View all images (generated and uploaded) in this flow
|
||||||
|
GET {{base}}/api/v1/flows/flow-uuid-here/images?limit=20
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### Update Flow Aliases
|
||||||
|
# Add or update flow-scoped aliases for image referencing
|
||||||
|
PUT {{base}}/api/v1/flows/flow-uuid-here/aliases
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"aliases": {
|
||||||
|
"@hero": "image-uuid-here",
|
||||||
|
"@background": "another-image-uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
### Delete Flow Alias
|
||||||
|
# Remove a single alias from flow's alias map
|
||||||
|
DELETE {{base}}/api/v1/flows/flow-uuid-here/aliases/@hero
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### Delete Flow
|
||||||
|
# Remove flow (hard delete, generations and images remain)
|
||||||
|
DELETE {{base}}/api/v1/flows/flow-uuid-here
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# IMAGES
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Upload Image
|
||||||
|
# Upload image with automatic database record creation and storage
|
||||||
|
POST {{base}}/api/v1/images/upload
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="image.png"
|
||||||
|
Content-Type: image/png
|
||||||
|
|
||||||
|
< ./path/to/image.png
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="alias"
|
||||||
|
|
||||||
|
@product-hero
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="flowId"
|
||||||
|
|
||||||
|
flow-uuid-here
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||||
|
|
||||||
|
|
||||||
|
### List Images
|
||||||
|
# Browse image library with optional filters
|
||||||
|
GET {{base}}/api/v1/images?limit=20&offset=0&source=generated
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### Resolve Alias
|
||||||
|
# Lookup image by alias with technical → flow → project precedence
|
||||||
|
GET {{base}}/api/v1/images/resolve/@last?flowId=flow-uuid-here
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### Get Image by ID
|
||||||
|
# View complete image metadata and details
|
||||||
|
GET {{base}}/api/v1/images/image-uuid-here
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
### Update Image Metadata
|
||||||
|
# Modify image metadata fields
|
||||||
|
PUT {{base}}/api/v1/images/image-uuid-here
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"alias": "@new-alias",
|
||||||
|
"focalPoint": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.3
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"description": "Updated description"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
### Assign Image Alias
|
||||||
|
# Set project-level alias for image referencing
|
||||||
|
PUT {{base}}/api/v1/images/image-uuid-here/alias
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"alias": "@product-hero"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
### Delete Image
|
||||||
|
# Mark image as deleted without removing from storage (soft delete)
|
||||||
|
DELETE {{base}}/api/v1/images/image-uuid-here
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# LIVE GENERATION
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Generate with Prompt Caching
|
||||||
|
# Generate images with intelligent caching based on prompt hash
|
||||||
|
# Returns raw image bytes (not JSON)
|
||||||
|
GET {{base}}/api/v1/live?prompt=грузовик едет по горной дороге&aspectRatio=16:9
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
# Image Upload & Management API
|
||||||
|
|
||||||
|
Upload images and manage your image library. For generation, see [image-generation.md](image-generation.md).
|
||||||
|
|
||||||
|
All endpoints require Project Key authentication via `X-API-Key` header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upload Image
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/images/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload an image file with optional alias and flow association.
|
||||||
|
|
||||||
|
**Content-Type:** `multipart/form-data`
|
||||||
|
|
||||||
|
**Form Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `file` | file | Yes | Image file (PNG, JPEG, WebP) |
|
||||||
|
| `alias` | string | No | Project-scoped alias (e.g., `@logo`) |
|
||||||
|
| `flowId` | string | No | Flow UUID to associate with |
|
||||||
|
| `flowAlias` | string | No | Flow-scoped alias (requires flowId) |
|
||||||
|
| `meta` | string | No | JSON string with custom metadata |
|
||||||
|
|
||||||
|
**File Constraints:**
|
||||||
|
|
||||||
|
| Constraint | Limit |
|
||||||
|
|------------|-------|
|
||||||
|
| Max file size | 5MB |
|
||||||
|
| Supported formats | PNG, JPEG, JPG, WebP |
|
||||||
|
| MIME types | `image/png`, `image/jpeg`, `image/webp` |
|
||||||
|
|
||||||
|
**Example Request (curl):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/v1/images/upload \
|
||||||
|
-H "X-API-Key: YOUR_PROJECT_KEY" \
|
||||||
|
-F "file=@logo.png" \
|
||||||
|
-F "alias=@brand-logo" \
|
||||||
|
-F 'meta={"tags": ["logo", "brand"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `201 Created`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "7c4ccf47-41ce-4718-afbc-8c553b2c631a",
|
||||||
|
"projectId": "57c7f7f4-47de-4d70-9ebd-3807a0b63746",
|
||||||
|
"flowId": null,
|
||||||
|
"storageKey": "default/project-id/uploads/2025-11/logo.png",
|
||||||
|
"storageUrl": "http://localhost:9000/banatie/default/project-id/uploads/logo.png",
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"fileSize": 45678,
|
||||||
|
"width": 512,
|
||||||
|
"height": 512,
|
||||||
|
"source": "uploaded",
|
||||||
|
"alias": "@brand-logo",
|
||||||
|
"focalPoint": null,
|
||||||
|
"meta": { "tags": ["logo", "brand"] },
|
||||||
|
"createdAt": "2025-11-28T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### flowId Behavior
|
||||||
|
|
||||||
|
| Value | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| Not provided | Auto-generate `pendingFlowId`, lazy flow creation |
|
||||||
|
| `null` | No flow association |
|
||||||
|
| `"uuid"` | Associate with specified flow |
|
||||||
|
|
||||||
|
### Upload with Flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Associate with existing flow
|
||||||
|
curl -X POST .../images/upload \
|
||||||
|
-F "file=@reference.png" \
|
||||||
|
-F "flowId=flow-123" \
|
||||||
|
-F "flowAlias=@reference"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List Images
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/images
|
||||||
|
```
|
||||||
|
|
||||||
|
List all images with filtering and pagination.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `flowId` | string | - | Filter by flow UUID |
|
||||||
|
| `source` | string | - | Filter by source: `generated`, `uploaded` |
|
||||||
|
| `alias` | string | - | Filter by exact alias match |
|
||||||
|
| `limit` | number | `20` | Results per page (max: 100) |
|
||||||
|
| `offset` | number | `0` | Pagination offset |
|
||||||
|
| `includeDeleted` | boolean | `false` | Include soft-deleted records |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/images?source=uploaded&limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "7c4ccf47-...",
|
||||||
|
"storageUrl": "http://...",
|
||||||
|
"source": "uploaded",
|
||||||
|
"alias": "@brand-logo",
|
||||||
|
"width": 512,
|
||||||
|
"height": 512,
|
||||||
|
"createdAt": "2025-11-28T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"limit": 10,
|
||||||
|
"offset": 0,
|
||||||
|
"total": 25,
|
||||||
|
"hasMore": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Image
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/images/:id_or_alias
|
||||||
|
```
|
||||||
|
|
||||||
|
Get a single image by UUID or alias.
|
||||||
|
|
||||||
|
**Path Parameter:**
|
||||||
|
- `id_or_alias` - Image UUID or `@alias`
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `flowId` | string | Flow context for alias resolution |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# By UUID
|
||||||
|
GET /api/v1/images/7c4ccf47-41ce-4718-afbc-8c553b2c631a
|
||||||
|
|
||||||
|
# By project alias
|
||||||
|
GET /api/v1/images/@brand-logo
|
||||||
|
|
||||||
|
# By technical alias (requires flowId)
|
||||||
|
GET /api/v1/images/@last?flowId=flow-123
|
||||||
|
|
||||||
|
# By flow alias
|
||||||
|
GET /api/v1/images/@hero?flowId=flow-123
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "7c4ccf47-41ce-4718-afbc-8c553b2c631a",
|
||||||
|
"projectId": "57c7f7f4-47de-4d70-9ebd-3807a0b63746",
|
||||||
|
"flowId": null,
|
||||||
|
"storageKey": "default/project-id/uploads/2025-11/logo.png",
|
||||||
|
"storageUrl": "http://localhost:9000/banatie/.../logo.png",
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"fileSize": 45678,
|
||||||
|
"width": 512,
|
||||||
|
"height": 512,
|
||||||
|
"source": "uploaded",
|
||||||
|
"alias": "@brand-logo",
|
||||||
|
"focalPoint": null,
|
||||||
|
"fileHash": null,
|
||||||
|
"generationId": null,
|
||||||
|
"meta": { "tags": ["logo", "brand"] },
|
||||||
|
"createdAt": "2025-11-28T10:00:00.000Z",
|
||||||
|
"updatedAt": "2025-11-28T10:00:00.000Z",
|
||||||
|
"deletedAt": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update Image Metadata
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v1/images/:id_or_alias
|
||||||
|
```
|
||||||
|
|
||||||
|
Update image metadata (focal point, custom metadata).
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `focalPoint` | object | Focal point: `{ x: 0.0-1.0, y: 0.0-1.0 }` |
|
||||||
|
| `meta` | object | Custom metadata |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// PUT /api/v1/images/@brand-logo
|
||||||
|
{
|
||||||
|
"focalPoint": { "x": 0.5, "y": 0.3 },
|
||||||
|
"meta": {
|
||||||
|
"description": "Updated brand logo",
|
||||||
|
"tags": ["logo", "brand", "2025"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Updated image object.
|
||||||
|
|
||||||
|
> **Note:** Alias assignment has its own dedicated endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assign Alias
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v1/images/:id_or_alias/alias
|
||||||
|
```
|
||||||
|
|
||||||
|
Assign or remove a project-scoped alias.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Assign alias
|
||||||
|
{ "alias": "@new-logo" }
|
||||||
|
|
||||||
|
// Remove alias
|
||||||
|
{ "alias": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Override Behavior:**
|
||||||
|
- If another image has this alias, it loses the alias
|
||||||
|
- The new image gets the alias
|
||||||
|
- Old image is preserved, just unlinked
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:3000/api/v1/images/7c4ccf47-.../alias \
|
||||||
|
-H "X-API-Key: YOUR_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"alias": "@primary-logo"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delete Image
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/images/:id_or_alias
|
||||||
|
```
|
||||||
|
|
||||||
|
Permanently delete an image and its storage file.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- **Hard delete** - image record permanently removed
|
||||||
|
- Storage file deleted from MinIO
|
||||||
|
- Cascading updates:
|
||||||
|
- Related generations: `outputImageId` set to null
|
||||||
|
- Flow aliases: image removed from flow's aliases
|
||||||
|
- Referenced images: removed from generation's referencedImages
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Image deleted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Warning:** This cannot be undone. The image file is permanently removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image Response Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `id` | string | Image UUID |
|
||||||
|
| `projectId` | string | Project UUID |
|
||||||
|
| `flowId` | string | Associated flow UUID (null if none) |
|
||||||
|
| `storageKey` | string | Internal storage path |
|
||||||
|
| `storageUrl` | string | **Direct URL to access image** |
|
||||||
|
| `mimeType` | string | Image MIME type |
|
||||||
|
| `fileSize` | number | File size in bytes |
|
||||||
|
| `width` | number | Image width in pixels |
|
||||||
|
| `height` | number | Image height in pixels |
|
||||||
|
| `source` | string | `"generated"` or `"uploaded"` |
|
||||||
|
| `alias` | string | Project-scoped alias (null if none) |
|
||||||
|
| `focalPoint` | object | `{ x, y }` coordinates (0.0-1.0) |
|
||||||
|
| `fileHash` | string | SHA-256 hash for deduplication |
|
||||||
|
| `generationId` | string | Source generation UUID (if generated) |
|
||||||
|
| `meta` | object | Custom metadata |
|
||||||
|
| `createdAt` | string | ISO timestamp |
|
||||||
|
| `updatedAt` | string | ISO timestamp |
|
||||||
|
| `deletedAt` | string | Soft delete timestamp (null if active) |
|
||||||
|
|
||||||
|
### Accessing Images
|
||||||
|
|
||||||
|
Use `storageUrl` for direct image access:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img src="http://localhost:9000/banatie/.../image.png" />
|
||||||
|
```
|
||||||
|
|
||||||
|
For public CDN access, see [Live URLs](live-url.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage Organization
|
||||||
|
|
||||||
|
Images are organized in MinIO storage:
|
||||||
|
|
||||||
|
```
|
||||||
|
bucket/
|
||||||
|
{orgId}/
|
||||||
|
{projectId}/
|
||||||
|
uploads/ # Uploaded images
|
||||||
|
2025-11/
|
||||||
|
image.png
|
||||||
|
generated/ # AI-generated images
|
||||||
|
2025-11/
|
||||||
|
gen_abc123.png
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
| HTTP Status | Code | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| 400 | `VALIDATION_ERROR` | Invalid parameters |
|
||||||
|
| 400 | `FILE_TOO_LARGE` | File exceeds 5MB limit |
|
||||||
|
| 400 | `UNSUPPORTED_FILE_TYPE` | Not PNG, JPEG, or WebP |
|
||||||
|
| 400 | `ALIAS_FORMAT_CHECK` | Alias must start with @ |
|
||||||
|
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
|
||||||
|
| 404 | `IMAGE_NOT_FOUND` | Image or alias doesn't exist |
|
||||||
|
| 404 | `ALIAS_NOT_FOUND` | Alias doesn't resolve to any image |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Basic Generation](image-generation.md) - Generate images
|
||||||
|
- [Advanced Generation](image-generation-advanced.md) - References, aliases, flows
|
||||||
|
- [Live URLs](live-url.md) - CDN and public access
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
# Live URL & CDN API
|
||||||
|
|
||||||
|
Public CDN endpoints for image serving and live URL generation. For authenticated API, see [image-generation.md](image-generation.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CDN Image Serving
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /cdn/:orgSlug/:projectSlug/img/:filenameOrAlias
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authentication:** None - Public endpoint
|
||||||
|
|
||||||
|
Serve images by filename or project-scoped alias.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `orgSlug` | Organization identifier |
|
||||||
|
| `projectSlug` | Project identifier |
|
||||||
|
| `filenameOrAlias` | Filename or `@alias` |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# By filename
|
||||||
|
GET /cdn/acme/website/img/hero-background.jpg
|
||||||
|
|
||||||
|
# By alias
|
||||||
|
GET /cdn/acme/website/img/@hero
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Raw image bytes (not JSON)
|
||||||
|
|
||||||
|
**Response Headers:**
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| `Content-Type` | `image/jpeg`, `image/png`, etc. |
|
||||||
|
| `Content-Length` | File size in bytes |
|
||||||
|
| `Cache-Control` | `public, max-age=31536000` (1 year) |
|
||||||
|
| `X-Image-Id` | Image UUID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live URL Generation
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /cdn/:orgSlug/:projectSlug/live/:scope
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authentication:** None - Public endpoint
|
||||||
|
|
||||||
|
Generate images on-demand via URL parameters with automatic caching.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `orgSlug` | Organization identifier |
|
||||||
|
| `projectSlug` | Project identifier |
|
||||||
|
| `scope` | Scope identifier (alphanumeric, hyphens, underscores) |
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Default | Description |
|
||||||
|
|-----------|------|----------|---------|-------------|
|
||||||
|
| `prompt` | string | Yes | - | Image description |
|
||||||
|
| `aspectRatio` | string | No | `"1:1"` | Aspect ratio |
|
||||||
|
| `autoEnhance` | boolean | No | `true` | Enable prompt enhancement |
|
||||||
|
| `template` | string | No | `"general"` | Enhancement template |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /cdn/acme/website/live/hero-section?prompt=mountain+landscape&aspectRatio=16:9
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Raw image bytes
|
||||||
|
|
||||||
|
### Cache Behavior
|
||||||
|
|
||||||
|
**Cache HIT** - Image exists in cache:
|
||||||
|
- Returns instantly
|
||||||
|
- No rate limit check
|
||||||
|
- Headers include `X-Cache-Status: HIT`
|
||||||
|
|
||||||
|
**Cache MISS** - New generation:
|
||||||
|
- Generates image using AI
|
||||||
|
- Stores in cache
|
||||||
|
- Counts toward rate limit
|
||||||
|
- Headers include `X-Cache-Status: MISS`
|
||||||
|
|
||||||
|
**Cache Key:** Computed from `projectId + scope + prompt + aspectRatio + autoEnhance + template`
|
||||||
|
|
||||||
|
### Response Headers
|
||||||
|
|
||||||
|
**Cache HIT:**
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| `Content-Type` | `image/jpeg` |
|
||||||
|
| `Cache-Control` | `public, max-age=31536000` |
|
||||||
|
| `X-Cache-Status` | `HIT` |
|
||||||
|
| `X-Scope` | Scope identifier |
|
||||||
|
| `X-Image-Id` | Image UUID |
|
||||||
|
|
||||||
|
**Cache MISS:**
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| `Content-Type` | `image/jpeg` |
|
||||||
|
| `Cache-Control` | `public, max-age=31536000` |
|
||||||
|
| `X-Cache-Status` | `MISS` |
|
||||||
|
| `X-Scope` | Scope identifier |
|
||||||
|
| `X-Generation-Id` | Generation UUID |
|
||||||
|
| `X-Image-Id` | Image UUID |
|
||||||
|
| `X-RateLimit-Limit` | `10` |
|
||||||
|
| `X-RateLimit-Remaining` | Remaining requests |
|
||||||
|
| `X-RateLimit-Reset` | Seconds until reset |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IP Rate Limiting
|
||||||
|
|
||||||
|
Live URLs are rate limited by IP address:
|
||||||
|
|
||||||
|
| Limit | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| New generations | 10 per hour per IP |
|
||||||
|
| Cache hits | Unlimited |
|
||||||
|
|
||||||
|
**Note:** Only cache MISS (new generations) count toward the limit. Cache HIT requests are not limited.
|
||||||
|
|
||||||
|
Rate limit headers are included on MISS responses:
|
||||||
|
- `X-RateLimit-Limit`: Maximum requests (10)
|
||||||
|
- `X-RateLimit-Remaining`: Remaining requests
|
||||||
|
- `X-RateLimit-Reset`: Seconds until reset
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Management
|
||||||
|
|
||||||
|
Scopes organize live URL generation budgets. All scope endpoints require Project Key authentication.
|
||||||
|
|
||||||
|
### Create Scope
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/live/scopes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Default | Description |
|
||||||
|
|-----------|------|----------|---------|-------------|
|
||||||
|
| `slug` | string | Yes | - | Unique identifier |
|
||||||
|
| `allowNewGenerations` | boolean | No | `true` | Allow new generations |
|
||||||
|
| `newGenerationsLimit` | number | No | `30` | Max generations in scope |
|
||||||
|
| `meta` | object | No | `{}` | Custom metadata |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slug": "hero-section",
|
||||||
|
"allowNewGenerations": true,
|
||||||
|
"newGenerationsLimit": 50,
|
||||||
|
"meta": { "description": "Hero section images" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `201 Created`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "scope-123",
|
||||||
|
"projectId": "project-456",
|
||||||
|
"slug": "hero-section",
|
||||||
|
"allowNewGenerations": true,
|
||||||
|
"newGenerationsLimit": 50,
|
||||||
|
"currentGenerations": 0,
|
||||||
|
"lastGeneratedAt": null,
|
||||||
|
"meta": { "description": "Hero section images" },
|
||||||
|
"createdAt": "2025-11-28T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lazy Scope Creation
|
||||||
|
|
||||||
|
Scopes are auto-created on first live URL request if `project.allowNewLiveScopes = true`:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /cdn/acme/website/live/new-scope?prompt=...
|
||||||
|
// Creates "new-scope" with default settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Scopes
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/live/scopes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `slug` | string | - | Filter by exact slug |
|
||||||
|
| `limit` | number | `20` | Results per page (max: 100) |
|
||||||
|
| `offset` | number | `0` | Pagination offset |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "scope-123",
|
||||||
|
"slug": "hero-section",
|
||||||
|
"allowNewGenerations": true,
|
||||||
|
"newGenerationsLimit": 50,
|
||||||
|
"currentGenerations": 12,
|
||||||
|
"lastGeneratedAt": "2025-11-28T09:30:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": { "limit": 20, "offset": 0, "total": 3, "hasMore": false }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Scope
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/live/scopes/:slug
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns scope with statistics (currentGenerations, lastGeneratedAt).
|
||||||
|
|
||||||
|
### Update Scope
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v1/live/scopes/:slug
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"allowNewGenerations": false,
|
||||||
|
"newGenerationsLimit": 100,
|
||||||
|
"meta": { "description": "Updated" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes take effect immediately for new requests.
|
||||||
|
|
||||||
|
### Regenerate Scope Images
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/live/scopes/:slug/regenerate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `imageId` | string | Specific image UUID (optional) |
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- If `imageId` provided: Regenerate only that image
|
||||||
|
- If `imageId` omitted: Regenerate all images in scope
|
||||||
|
|
||||||
|
Images are regenerated with exact same parameters. IDs and URLs are preserved.
|
||||||
|
|
||||||
|
### Delete Scope
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/live/scopes/:slug
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cascade Behavior:**
|
||||||
|
- Scope record is **hard deleted**
|
||||||
|
- All images in scope are **hard deleted** (with MinIO cleanup)
|
||||||
|
- Follows alias protection rules (aliased images may be kept)
|
||||||
|
|
||||||
|
> **Warning:** This permanently deletes all cached images in the scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Settings
|
||||||
|
|
||||||
|
| Setting | Type | Default | Description |
|
||||||
|
|---------|------|---------|-------------|
|
||||||
|
| `slug` | string | - | Unique identifier within project |
|
||||||
|
| `allowNewGenerations` | boolean | `true` | Whether new generations are allowed |
|
||||||
|
| `newGenerationsLimit` | number | `30` | Maximum generations in scope |
|
||||||
|
|
||||||
|
When `allowNewGenerations: false`:
|
||||||
|
- Cache HITs still work
|
||||||
|
- New prompts return 403 error
|
||||||
|
|
||||||
|
When `newGenerationsLimit` reached:
|
||||||
|
- Cache HITs still work
|
||||||
|
- New prompts return 429 error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authenticated Live Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/live?prompt=...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authentication:** Project Key required
|
||||||
|
|
||||||
|
Alternative to CDN endpoint with prompt caching by hash.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `prompt` | string | Yes | Image description |
|
||||||
|
|
||||||
|
**Cache Behavior:**
|
||||||
|
- Cache key: SHA-256 hash of prompt
|
||||||
|
- Cache stored in `prompt_url_cache` table
|
||||||
|
- Tracks hit count and last access
|
||||||
|
|
||||||
|
**Response Headers:**
|
||||||
|
- `X-Cache-Status`: `HIT` or `MISS`
|
||||||
|
- `X-Cache-Hit-Count`: Number of cache hits (on HIT)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
| HTTP Status | Code | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| 400 | `SCOPE_INVALID_FORMAT` | Invalid scope slug format |
|
||||||
|
| 403 | `SCOPE_CREATION_DISABLED` | New scope creation not allowed |
|
||||||
|
| 404 | `ORG_NOT_FOUND` | Organization not found |
|
||||||
|
| 404 | `PROJECT_NOT_FOUND` | Project not found |
|
||||||
|
| 404 | `SCOPE_NOT_FOUND` | Scope does not exist |
|
||||||
|
| 409 | `SCOPE_ALREADY_EXISTS` | Scope slug already in use |
|
||||||
|
| 429 | `IP_RATE_LIMIT_EXCEEDED` | IP rate limit (10/hour) exceeded |
|
||||||
|
| 429 | `SCOPE_GENERATION_LIMIT_EXCEEDED` | Scope limit reached |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Dynamic Hero Images
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img src="/cdn/acme/website/live/hero?prompt=professional+office+workspace&aspectRatio=16:9" />
|
||||||
|
```
|
||||||
|
|
||||||
|
First load generates, subsequent loads are cached.
|
||||||
|
|
||||||
|
### Product Placeholders
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img src="/cdn/acme/store/live/products?prompt=product+placeholder+gray+box&aspectRatio=1:1" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blog Post Images
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img src="/cdn/acme/blog/live/posts?prompt=abstract+technology+background&aspectRatio=16:9&template=illustration" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Basic Generation](image-generation.md) - API-based generation
|
||||||
|
- [Advanced Generation](image-generation-advanced.md) - References, aliases, flows
|
||||||
|
- [Image Upload](images-upload.md) - Upload and manage images
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
@base = http://localhost:3000
|
||||||
|
@apiKey = bnt_71e7e16732ac5e21f597edc56e99e8c3696e713552ec9d1f44dfeffb2ef7c495
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# IMAGE REFERENCES & ALIASES TESTING
|
||||||
|
# This file demonstrates the complete flow of:
|
||||||
|
# 1. Generating an image with an alias
|
||||||
|
# 2. Verifying the alias is assigned
|
||||||
|
# 3. Using that alias as a reference in another generation
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# STEP 1: Generate Simple Logo (1:1 aspect ratio)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# @name generateLogo
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A sleek and modern company logo featuring a stylized character @ turning it into a snail in blue and brown colors, minimalist design, vector art",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"assignAlias": "@logo-snail",
|
||||||
|
"autoEnhance": false
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@logoGenerationId = {{generateLogo.response.body.$.data.id}}
|
||||||
|
@logoImageId = {{generateLogo.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# STEP 2: Verify Logo Alias Assignment
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Resolve @logo Alias
|
||||||
|
# Confirm that @logo alias is properly assigned and retrieve image metadata
|
||||||
|
GET {{base}}/api/v1/images/resolve/@logo-snail
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Get Logo Generation Details
|
||||||
|
# View complete generation record with output image
|
||||||
|
GET {{base}}/api/v1/generations/{{logoGenerationId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Get Logo Image Details
|
||||||
|
# View image record directly by ID
|
||||||
|
GET {{base}}/api/v1/images/{{logoImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# STEP 3: Generate Lorry with Logo Reference
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# @name generateLorry
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A modern lorry truck driving on a winding mountain road during sunset, the truck has a large @logo-snail prominently displayed on its side panel, photorealistic style, golden hour lighting, detailed commercial vehicle, scenic mountain landscape",
|
||||||
|
"aspectRatio": "16:9",
|
||||||
|
"referenceImages": ["@logo-snail"],
|
||||||
|
"assignAlias": "@lorry",
|
||||||
|
"autoEnhance": false
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@lorryGenerationId = {{generateLorry.response.body.$.data.id}}
|
||||||
|
@lorryImageId = {{generateLorry.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### new
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Грузовик @lorry стоит на разгрузке в аэропорту рядом с огромным грузовым самолетом на фоне гор",
|
||||||
|
"aspectRatio": "16:9",
|
||||||
|
"referenceImages": ["@lorry"],
|
||||||
|
"assignAlias": "@airplane",
|
||||||
|
"autoEnhance": false
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# VERIFICATION: Check Both Generations
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### List All Generations
|
||||||
|
# View both logo and lorry generations in the project
|
||||||
|
GET {{base}}/api/v1/generations?limit=10&offset=0
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Resolve @lorry-branded Alias
|
||||||
|
# Confirm the lorry image alias is assigned
|
||||||
|
GET {{base}}/api/v1/images/resolve/@lorry
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Get Lorry Generation Details
|
||||||
|
# View complete generation with reference images
|
||||||
|
GET {{base}}/api/v1/generations/{{lorryGenerationId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### List All Images
|
||||||
|
# View both logo and lorry images
|
||||||
|
GET {{base}}/api/v1/images?limit=10&offset=0
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# BONUS: Test Technical Aliases
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Resolve @last (Most Recent Image)
|
||||||
|
# Should return the lorry image (most recently generated)
|
||||||
|
GET {{base}}/api/v1/images/resolve/@last
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Resolve @first (First Generated Image)
|
||||||
|
# Should return the logo image (first generated in this flow)
|
||||||
|
GET {{base}}/api/v1/images/resolve/@first
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:api": "tsx tests/api/run-all.ts",
|
||||||
"format": "prettier --write \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
"format": "prettier --write \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
||||||
"format:check": "prettier --check \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
"format:check": "prettier --check \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
||||||
"clean": "pnpm -r clean && rm -rf node_modules"
|
"clean": "pnpm -r clean && rm -rf node_modules"
|
||||||
|
|
@ -40,6 +41,8 @@
|
||||||
"kill-port": "^2.0.1",
|
"kill-port": "^2.0.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"@types/node": "^20.11.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { pgTable, uuid, jsonb, timestamp, index } from 'drizzle-orm/pg-core';
|
||||||
|
import { projects } from './projects';
|
||||||
|
|
||||||
|
export const flows = pgTable(
|
||||||
|
'flows',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
// Flow-scoped named aliases (user-assigned only)
|
||||||
|
// Technical aliases (@last, @first, @upload) computed programmatically
|
||||||
|
// Format: { "@hero": "image-uuid", "@product": "image-uuid" }
|
||||||
|
aliases: jsonb('aliases').$type<Record<string, string>>().notNull().default({}),
|
||||||
|
|
||||||
|
// Flexible metadata storage
|
||||||
|
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
// Updates on every generation/upload activity within this flow
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// Index for querying flows by project, ordered by most recent
|
||||||
|
projectCreatedAtIdx: index('idx_flows_project').on(table.projectId, table.createdAt.desc()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Flow = typeof flows.$inferSelect;
|
||||||
|
export type NewFlow = typeof flows.$inferInsert;
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
timestamp,
|
||||||
|
pgEnum,
|
||||||
|
index,
|
||||||
|
check,
|
||||||
|
type AnyPgColumn,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { projects } from './projects';
|
||||||
|
import { flows } from './flows';
|
||||||
|
import { apiKeys } from './apiKeys';
|
||||||
|
|
||||||
|
// Enum for generation status
|
||||||
|
export const generationStatusEnum = pgEnum('generation_status', [
|
||||||
|
'pending',
|
||||||
|
'processing',
|
||||||
|
'success',
|
||||||
|
'failed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Type for referenced images JSONB
|
||||||
|
export type ReferencedImage = {
|
||||||
|
imageId: string;
|
||||||
|
alias: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generations = pgTable(
|
||||||
|
'generations',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
flowId: uuid('flow_id').references(() => flows.id, { onDelete: 'set null' }),
|
||||||
|
pendingFlowId: text('pending_flow_id'), // Temporary UUID for lazy flow pattern
|
||||||
|
apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
// Status
|
||||||
|
status: generationStatusEnum('status').notNull().default('pending'),
|
||||||
|
|
||||||
|
// Prompts (Section 2.1: Reversed semantics)
|
||||||
|
// prompt: The prompt that was ACTUALLY USED for generation (enhanced OR original)
|
||||||
|
// originalPrompt: User's ORIGINAL input, only stored if autoEnhance was used
|
||||||
|
prompt: text('prompt').notNull(), // Prompt used for generation
|
||||||
|
originalPrompt: text('original_prompt'), // User's original (nullable, only if enhanced)
|
||||||
|
|
||||||
|
// Generation parameters
|
||||||
|
aspectRatio: varchar('aspect_ratio', { length: 10 }),
|
||||||
|
width: integer('width'),
|
||||||
|
height: integer('height'),
|
||||||
|
|
||||||
|
// AI Model
|
||||||
|
modelName: varchar('model_name', { length: 100 }).notNull().default('gemini-flash-image-001'),
|
||||||
|
modelVersion: varchar('model_version', { length: 50 }),
|
||||||
|
|
||||||
|
// Result
|
||||||
|
outputImageId: uuid('output_image_id').references(
|
||||||
|
(): AnyPgColumn => {
|
||||||
|
const { images } = require('./images');
|
||||||
|
return images.id;
|
||||||
|
},
|
||||||
|
{ onDelete: 'set null' },
|
||||||
|
),
|
||||||
|
|
||||||
|
// Referenced images used in generation
|
||||||
|
// Format: [{ "imageId": "uuid", "alias": "@product" }, ...]
|
||||||
|
referencedImages: jsonb('referenced_images').$type<ReferencedImage[]>(),
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
errorMessage: text('error_message'),
|
||||||
|
errorCode: varchar('error_code', { length: 50 }),
|
||||||
|
retryCount: integer('retry_count').notNull().default(0),
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
processingTimeMs: integer('processing_time_ms'),
|
||||||
|
cost: integer('cost'), // In cents (USD)
|
||||||
|
|
||||||
|
// Request context
|
||||||
|
requestId: uuid('request_id'),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
ipAddress: text('ip_address'),
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// CHECK constraints
|
||||||
|
statusSuccessCheck: check(
|
||||||
|
'status_success_check',
|
||||||
|
sql`(${table.status} = 'success' AND ${table.outputImageId} IS NOT NULL) OR (${table.status} != 'success')`,
|
||||||
|
),
|
||||||
|
statusFailedCheck: check(
|
||||||
|
'status_failed_check',
|
||||||
|
sql`(${table.status} = 'failed' AND ${table.errorMessage} IS NOT NULL) OR (${table.status} != 'failed')`,
|
||||||
|
),
|
||||||
|
retryCountCheck: check('retry_count_check', sql`${table.retryCount} >= 0`),
|
||||||
|
processingTimeCheck: check(
|
||||||
|
'processing_time_check',
|
||||||
|
sql`${table.processingTimeMs} IS NULL OR ${table.processingTimeMs} >= 0`,
|
||||||
|
),
|
||||||
|
costCheck: check('cost_check', sql`${table.cost} IS NULL OR ${table.cost} >= 0`),
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
// Index for querying generations by project and status
|
||||||
|
projectStatusIdx: index('idx_generations_project_status').on(
|
||||||
|
table.projectId,
|
||||||
|
table.status,
|
||||||
|
table.createdAt.desc(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Index for flow-scoped generations (partial index)
|
||||||
|
flowIdx: index('idx_generations_flow')
|
||||||
|
.on(table.flowId, table.createdAt.desc())
|
||||||
|
.where(sql`${table.flowId} IS NOT NULL`),
|
||||||
|
|
||||||
|
// Index for pending flow-scoped generations (partial index)
|
||||||
|
pendingFlowIdx: index('idx_generations_pending_flow')
|
||||||
|
.on(table.pendingFlowId, table.createdAt.desc())
|
||||||
|
.where(sql`${table.pendingFlowId} IS NOT NULL`),
|
||||||
|
|
||||||
|
// Index for output image lookup
|
||||||
|
outputIdx: index('idx_generations_output').on(table.outputImageId),
|
||||||
|
|
||||||
|
// Index for request correlation
|
||||||
|
requestIdx: index('idx_generations_request').on(table.requestId),
|
||||||
|
|
||||||
|
// Index for API key audit trail
|
||||||
|
apiKeyIdx: index('idx_generations_api_key').on(table.apiKeyId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Generation = typeof generations.$inferSelect;
|
||||||
|
export type NewGeneration = typeof generations.$inferInsert;
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
timestamp,
|
||||||
|
pgEnum,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
check,
|
||||||
|
type AnyPgColumn,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { projects } from './projects';
|
||||||
|
import { flows } from './flows';
|
||||||
|
import { apiKeys } from './apiKeys';
|
||||||
|
|
||||||
|
// Enum for image source
|
||||||
|
export const imageSourceEnum = pgEnum('image_source', ['generated', 'uploaded']);
|
||||||
|
|
||||||
|
// Type for focal point JSONB
|
||||||
|
export type FocalPoint = {
|
||||||
|
x: number; // 0.0 - 1.0
|
||||||
|
y: number; // 0.0 - 1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
export const images = pgTable(
|
||||||
|
'images',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
generationId: uuid('generation_id').references(
|
||||||
|
(): AnyPgColumn => {
|
||||||
|
const { generations } = require('./generations');
|
||||||
|
return generations.id;
|
||||||
|
},
|
||||||
|
{ onDelete: 'set null' },
|
||||||
|
),
|
||||||
|
flowId: uuid('flow_id').references(() => flows.id, { onDelete: 'cascade' }),
|
||||||
|
pendingFlowId: text('pending_flow_id'), // Temporary UUID for lazy flow pattern
|
||||||
|
apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
// Storage (MinIO path format: orgSlug/projectSlug/category/YYYY-MM/filename.ext)
|
||||||
|
storageKey: varchar('storage_key', { length: 500 }).notNull().unique(),
|
||||||
|
storageUrl: text('storage_url').notNull(),
|
||||||
|
|
||||||
|
// File metadata
|
||||||
|
mimeType: varchar('mime_type', { length: 100 }).notNull(),
|
||||||
|
fileSize: integer('file_size').notNull(),
|
||||||
|
fileHash: varchar('file_hash', { length: 64 }), // SHA-256 for deduplication
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
width: integer('width'),
|
||||||
|
height: integer('height'),
|
||||||
|
aspectRatio: varchar('aspect_ratio', { length: 10 }),
|
||||||
|
|
||||||
|
// Focal point for image transformations (imageflow)
|
||||||
|
// Normalized coordinates: { "x": 0.5, "y": 0.3 } where 0.0-1.0
|
||||||
|
focalPoint: jsonb('focal_point').$type<FocalPoint>(),
|
||||||
|
|
||||||
|
// Source
|
||||||
|
source: imageSourceEnum('source').notNull(),
|
||||||
|
|
||||||
|
// Project-level alias (global scope)
|
||||||
|
// Flow-level aliases stored in flows.aliases
|
||||||
|
alias: varchar('alias', { length: 100 }),
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
description: text('description'),
|
||||||
|
tags: text('tags').array(),
|
||||||
|
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
deletedAt: timestamp('deleted_at'), // Soft delete
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// CHECK constraints
|
||||||
|
sourceGeneratedCheck: check(
|
||||||
|
'source_generation_check',
|
||||||
|
sql`(${table.source} = 'uploaded' AND ${table.generationId} IS NULL) OR (${table.source} = 'generated' AND ${table.generationId} IS NOT NULL)`,
|
||||||
|
),
|
||||||
|
aliasFormatCheck: check(
|
||||||
|
'alias_format_check',
|
||||||
|
sql`${table.alias} IS NULL OR ${table.alias} ~ '^@[a-zA-Z0-9_-]+$'`,
|
||||||
|
),
|
||||||
|
fileSizeCheck: check('file_size_check', sql`${table.fileSize} > 0`),
|
||||||
|
widthCheck: check(
|
||||||
|
'width_check',
|
||||||
|
sql`${table.width} IS NULL OR (${table.width} > 0 AND ${table.width} <= 8192)`,
|
||||||
|
),
|
||||||
|
heightCheck: check(
|
||||||
|
'height_check',
|
||||||
|
sql`${table.height} IS NULL OR (${table.height} > 0 AND ${table.height} <= 8192)`,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
// Unique index for project-scoped aliases (partial index)
|
||||||
|
projectAliasIdx: uniqueIndex('idx_images_project_alias')
|
||||||
|
.on(table.projectId, table.alias)
|
||||||
|
.where(sql`${table.alias} IS NOT NULL AND ${table.deletedAt} IS NULL AND ${table.flowId} IS NULL`),
|
||||||
|
|
||||||
|
// Index for querying images by project and source (partial index)
|
||||||
|
projectSourceIdx: index('idx_images_project_source')
|
||||||
|
.on(table.projectId, table.source, table.createdAt.desc())
|
||||||
|
.where(sql`${table.deletedAt} IS NULL`),
|
||||||
|
|
||||||
|
// Index for flow-scoped images (partial index)
|
||||||
|
flowIdx: index('idx_images_flow')
|
||||||
|
.on(table.flowId)
|
||||||
|
.where(sql`${table.flowId} IS NOT NULL`),
|
||||||
|
|
||||||
|
// Index for pending flow lookups (lazy pattern)
|
||||||
|
pendingFlowIdx: index('idx_images_pending_flow')
|
||||||
|
.on(table.pendingFlowId, table.createdAt.desc())
|
||||||
|
.where(sql`${table.pendingFlowId} IS NOT NULL`),
|
||||||
|
|
||||||
|
// Index for generation lookup
|
||||||
|
generationIdx: index('idx_images_generation').on(table.generationId),
|
||||||
|
|
||||||
|
// Index for storage key lookup
|
||||||
|
storageKeyIdx: index('idx_images_storage_key').on(table.storageKey),
|
||||||
|
|
||||||
|
// Index for file hash (deduplication)
|
||||||
|
hashIdx: index('idx_images_hash').on(table.fileHash),
|
||||||
|
|
||||||
|
// Index for API key audit trail
|
||||||
|
apiKeyIdx: index('idx_images_api_key').on(table.apiKeyId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Image = typeof images.$inferSelect;
|
||||||
|
export type NewImage = typeof images.$inferInsert;
|
||||||
|
|
@ -2,11 +2,21 @@ import { relations } from 'drizzle-orm';
|
||||||
import { organizations } from './organizations';
|
import { organizations } from './organizations';
|
||||||
import { projects } from './projects';
|
import { projects } from './projects';
|
||||||
import { apiKeys } from './apiKeys';
|
import { apiKeys } from './apiKeys';
|
||||||
|
import { flows } from './flows';
|
||||||
|
import { images } from './images';
|
||||||
|
import { generations } from './generations';
|
||||||
|
import { promptUrlCache } from './promptUrlCache';
|
||||||
|
import { liveScopes } from './liveScopes';
|
||||||
|
|
||||||
// Export all tables
|
// Export all tables
|
||||||
export * from './organizations';
|
export * from './organizations';
|
||||||
export * from './projects';
|
export * from './projects';
|
||||||
export * from './apiKeys';
|
export * from './apiKeys';
|
||||||
|
export * from './flows';
|
||||||
|
export * from './images';
|
||||||
|
export * from './generations';
|
||||||
|
export * from './promptUrlCache';
|
||||||
|
export * from './liveScopes';
|
||||||
|
|
||||||
// Define relations
|
// Define relations
|
||||||
export const organizationsRelations = relations(organizations, ({ many }) => ({
|
export const organizationsRelations = relations(organizations, ({ many }) => ({
|
||||||
|
|
@ -20,9 +30,14 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({
|
||||||
references: [organizations.id],
|
references: [organizations.id],
|
||||||
}),
|
}),
|
||||||
apiKeys: many(apiKeys),
|
apiKeys: many(apiKeys),
|
||||||
|
flows: many(flows),
|
||||||
|
images: many(images),
|
||||||
|
generations: many(generations),
|
||||||
|
promptUrlCache: many(promptUrlCache),
|
||||||
|
liveScopes: many(liveScopes),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({
|
||||||
organization: one(organizations, {
|
organization: one(organizations, {
|
||||||
fields: [apiKeys.organizationId],
|
fields: [apiKeys.organizationId],
|
||||||
references: [organizations.id],
|
references: [organizations.id],
|
||||||
|
|
@ -31,4 +46,77 @@ export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
||||||
fields: [apiKeys.projectId],
|
fields: [apiKeys.projectId],
|
||||||
references: [projects.id],
|
references: [projects.id],
|
||||||
}),
|
}),
|
||||||
|
images: many(images),
|
||||||
|
generations: many(generations),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const flowsRelations = relations(flows, ({ one, many }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [flows.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
images: many(images),
|
||||||
|
generations: many(generations),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const imagesRelations = relations(images, ({ one, many }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [images.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
generation: one(generations, {
|
||||||
|
fields: [images.generationId],
|
||||||
|
references: [generations.id],
|
||||||
|
}),
|
||||||
|
flow: one(flows, {
|
||||||
|
fields: [images.flowId],
|
||||||
|
references: [flows.id],
|
||||||
|
}),
|
||||||
|
apiKey: one(apiKeys, {
|
||||||
|
fields: [images.apiKeyId],
|
||||||
|
references: [apiKeys.id],
|
||||||
|
}),
|
||||||
|
promptUrlCacheEntries: many(promptUrlCache),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const generationsRelations = relations(generations, ({ one, many }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [generations.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
flow: one(flows, {
|
||||||
|
fields: [generations.flowId],
|
||||||
|
references: [flows.id],
|
||||||
|
}),
|
||||||
|
apiKey: one(apiKeys, {
|
||||||
|
fields: [generations.apiKeyId],
|
||||||
|
references: [apiKeys.id],
|
||||||
|
}),
|
||||||
|
outputImage: one(images, {
|
||||||
|
fields: [generations.outputImageId],
|
||||||
|
references: [images.id],
|
||||||
|
}),
|
||||||
|
promptUrlCacheEntries: many(promptUrlCache),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const promptUrlCacheRelations = relations(promptUrlCache, ({ one }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [promptUrlCache.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
generation: one(generations, {
|
||||||
|
fields: [promptUrlCache.generationId],
|
||||||
|
references: [generations.id],
|
||||||
|
}),
|
||||||
|
image: one(images, {
|
||||||
|
fields: [promptUrlCache.imageId],
|
||||||
|
references: [images.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const liveScopesRelations = relations(liveScopes, ({ one }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [liveScopes.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { pgTable, uuid, text, boolean, integer, jsonb, timestamp, index, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { projects } from './projects';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live Scopes Table (Section 8.4)
|
||||||
|
*
|
||||||
|
* Live scopes organize and control image generation via CDN live URLs.
|
||||||
|
* Each scope represents a logical separation within a project (e.g., "hero-section", "product-gallery").
|
||||||
|
*
|
||||||
|
* Live URL format: /cdn/:orgSlug/:projectSlug/live/:scope?prompt=...&aspectRatio=...
|
||||||
|
*/
|
||||||
|
export const liveScopes = pgTable(
|
||||||
|
'live_scopes',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
// Scope identifier used in URLs (alphanumeric + hyphens + underscores)
|
||||||
|
// Must be unique within project
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
|
||||||
|
// Controls whether new generations can be triggered in this scope
|
||||||
|
// Already generated images are ALWAYS served publicly regardless of this setting
|
||||||
|
allowNewGenerations: boolean('allow_new_generations').notNull().default(true),
|
||||||
|
|
||||||
|
// Maximum number of generations allowed in this scope
|
||||||
|
// Only affects NEW generations, does not affect regeneration
|
||||||
|
newGenerationsLimit: integer('new_generations_limit').notNull().default(30),
|
||||||
|
|
||||||
|
// Flexible metadata storage
|
||||||
|
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// Unique constraint: slug must be unique within project
|
||||||
|
projectSlugUnique: unique('live_scopes_project_slug_unique').on(table.projectId, table.slug),
|
||||||
|
|
||||||
|
// Index for querying scopes by project
|
||||||
|
projectIdx: index('idx_live_scopes_project').on(table.projectId),
|
||||||
|
|
||||||
|
// Index for slug lookups within project
|
||||||
|
projectSlugIdx: index('idx_live_scopes_project_slug').on(table.projectId, table.slug),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type LiveScope = typeof liveScopes.$inferSelect;
|
||||||
|
export type NewLiveScope = typeof liveScopes.$inferInsert;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core';
|
import { pgTable, uuid, text, timestamp, unique, boolean, integer } from 'drizzle-orm/pg-core';
|
||||||
import { organizations } from './organizations';
|
import { organizations } from './organizations';
|
||||||
|
|
||||||
export const projects = pgTable(
|
export const projects = pgTable(
|
||||||
|
|
@ -13,6 +13,10 @@ export const projects = pgTable(
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organizations.id, { onDelete: 'cascade' }),
|
.references(() => organizations.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
// Live scope settings (Section 8.4)
|
||||||
|
allowNewLiveScopes: boolean('allow_new_live_scopes').notNull().default(true),
|
||||||
|
newLiveScopesGenerationLimit: integer('new_live_scopes_generation_limit').notNull().default(30),
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at')
|
updatedAt: timestamp('updated_at')
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
check,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { projects } from './projects';
|
||||||
|
import { generations } from './generations';
|
||||||
|
import { images } from './images';
|
||||||
|
|
||||||
|
export const promptUrlCache = pgTable(
|
||||||
|
'prompt_url_cache',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
generationId: uuid('generation_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => generations.id, { onDelete: 'cascade' }),
|
||||||
|
imageId: uuid('image_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => images.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
// Cache keys (SHA-256 hashes)
|
||||||
|
promptHash: varchar('prompt_hash', { length: 64 }).notNull(),
|
||||||
|
queryParamsHash: varchar('query_params_hash', { length: 64 }).notNull(),
|
||||||
|
|
||||||
|
// Original request (for debugging/reconstruction)
|
||||||
|
originalPrompt: text('original_prompt').notNull(),
|
||||||
|
requestParams: jsonb('request_params').$type<Record<string, unknown>>().notNull(),
|
||||||
|
|
||||||
|
// Cache statistics
|
||||||
|
hitCount: integer('hit_count').notNull().default(0),
|
||||||
|
lastHitAt: timestamp('last_hit_at'),
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// CHECK constraints
|
||||||
|
hitCountCheck: check('hit_count_check', sql`${table.hitCount} >= 0`),
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
// Unique composite index for cache lookup
|
||||||
|
cacheKeyIdx: uniqueIndex('idx_cache_key').on(
|
||||||
|
table.projectId,
|
||||||
|
table.promptHash,
|
||||||
|
table.queryParamsHash,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Index for generation lookup
|
||||||
|
generationIdx: index('idx_cache_generation').on(table.generationId),
|
||||||
|
|
||||||
|
// Index for image lookup
|
||||||
|
imageIdx: index('idx_cache_image').on(table.imageId),
|
||||||
|
|
||||||
|
// Index for cache hit analytics
|
||||||
|
hitsIdx: index('idx_cache_hits').on(
|
||||||
|
table.projectId,
|
||||||
|
table.hitCount.desc(),
|
||||||
|
table.createdAt.desc(),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PromptUrlCache = typeof promptUrlCache.$inferSelect;
|
||||||
|
export type NewPromptUrlCache = typeof promptUrlCache.$inferInsert;
|
||||||
|
|
@ -8,6 +8,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^20.11.0
|
||||||
|
version: 20.19.17
|
||||||
'@vitest/ui':
|
'@vitest/ui':
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(vitest@3.2.4)
|
version: 3.2.4(vitest@3.2.4)
|
||||||
|
|
@ -23,12 +26,15 @@ importers:
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.6.2
|
version: 3.6.2
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.7.0
|
||||||
|
version: 4.20.5
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.2
|
specifier: ^5.9.2
|
||||||
version: 5.9.2
|
version: 5.9.2
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(@types/node@24.5.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
version: 3.2.4(@types/node@20.19.17)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||||
|
|
||||||
apps/admin:
|
apps/admin:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -96,6 +102,9 @@ importers:
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.2.2
|
specifier: ^17.2.2
|
||||||
version: 17.2.2
|
version: 17.2.2
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: ^0.36.4
|
||||||
|
version: 0.36.4(@types/react@19.1.16)(postgres@3.4.7)(react@19.1.0)
|
||||||
express:
|
express:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0
|
version: 5.1.0
|
||||||
|
|
@ -108,6 +117,9 @@ importers:
|
||||||
helmet:
|
helmet:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.1.0
|
version: 8.1.0
|
||||||
|
image-size:
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.0.2
|
||||||
mime:
|
mime:
|
||||||
specifier: 3.0.0
|
specifier: 3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
|
@ -3339,6 +3351,11 @@ packages:
|
||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
image-size@2.0.2:
|
||||||
|
resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==}
|
||||||
|
engines: {node: '>=16.x'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -6888,13 +6905,13 @@ snapshots:
|
||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@vitest/mocker@3.2.4(vite@7.1.9(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))':
|
'@vitest/mocker@3.2.4(vite@7.1.9(@types/node@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 3.2.4
|
'@vitest/spy': 3.2.4
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.19
|
magic-string: 0.30.19
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.1.9(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
vite: 7.1.9(@types/node@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||||
|
|
||||||
'@vitest/pretty-format@3.2.4':
|
'@vitest/pretty-format@3.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -6925,7 +6942,7 @@ snapshots:
|
||||||
sirv: 3.0.2
|
sirv: 3.0.2
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vitest: 3.2.4(@types/node@24.5.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
vitest: 3.2.4(@types/node@20.19.17)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||||
|
|
||||||
'@vitest/utils@3.2.4':
|
'@vitest/utils@3.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -8433,6 +8450,8 @@ snapshots:
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
image-size@2.0.2: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
|
|
@ -10461,13 +10480,13 @@ snapshots:
|
||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-timer: 3.0.1
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
vite-node@3.2.4(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1):
|
vite-node@3.2.4(@types/node@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3(supports-color@5.5.0)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
vite: 7.1.9(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
vite: 7.1.9(@types/node@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- jiti
|
- jiti
|
||||||
|
|
@ -10482,7 +10501,7 @@ snapshots:
|
||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
vite@7.1.9(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1):
|
vite@7.1.9(@types/node@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.10
|
esbuild: 0.25.10
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
|
|
@ -10491,18 +10510,18 @@ snapshots:
|
||||||
rollup: 4.52.4
|
rollup: 4.52.4
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.5.2
|
'@types/node': 20.19.17
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
lightningcss: 1.30.1
|
lightningcss: 1.30.1
|
||||||
tsx: 4.20.5
|
tsx: 4.20.5
|
||||||
yaml: 2.8.1
|
yaml: 2.8.1
|
||||||
|
|
||||||
vitest@3.2.4(@types/node@24.5.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1):
|
vitest@3.2.4(@types/node@20.19.17)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.2
|
'@types/chai': 5.2.2
|
||||||
'@vitest/expect': 3.2.4
|
'@vitest/expect': 3.2.4
|
||||||
'@vitest/mocker': 3.2.4(vite@7.1.9(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))
|
'@vitest/mocker': 3.2.4(vite@7.1.9(@types/node@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))
|
||||||
'@vitest/pretty-format': 3.2.4
|
'@vitest/pretty-format': 3.2.4
|
||||||
'@vitest/runner': 3.2.4
|
'@vitest/runner': 3.2.4
|
||||||
'@vitest/snapshot': 3.2.4
|
'@vitest/snapshot': 3.2.4
|
||||||
|
|
@ -10520,11 +10539,11 @@ snapshots:
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vite: 7.1.9(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
vite: 7.1.9(@types/node@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||||
vite-node: 3.2.4(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
vite-node: 3.2.4(@types/node@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.5.2
|
'@types/node': 20.19.17
|
||||||
'@vitest/ui': 3.2.4(vitest@3.2.4)
|
'@vitest/ui': 3.2.4(vitest@3.2.4)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
@base = http://localhost:3000
|
||||||
|
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# BASIC GENERATION TESTS
|
||||||
|
# Run these tests FIRST to verify core generation functionality
|
||||||
|
#
|
||||||
|
# Test Coverage:
|
||||||
|
# 1. Simple generation with different aspect ratios
|
||||||
|
# 2. Generation retrieval and listing
|
||||||
|
# 3. Pagination and filtering
|
||||||
|
# 4. Processing time tracking
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 1: Simple Generation (16:9)
|
||||||
|
# Creates a basic generation without references or flows
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 1.1: Create Generation
|
||||||
|
# @name createBasicGen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "шикарная моторная яхта движется по живописному озеру, люди сидят в спасательных жилетах и держат в руках бутылки с пивом, густой хвойный лес на берегу. фотореалистичная фотография",
|
||||||
|
"aspectRatio": "16:9"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@generationId = {{createBasicGen.response.body.$.data.id}}
|
||||||
|
@generationStatus = {{createBasicGen.response.body.$.data.status}}
|
||||||
|
|
||||||
|
|
||||||
|
### Step 1.2: Check Generation Status (Poll until success)
|
||||||
|
# @name checkBasicGen
|
||||||
|
# Keep running this until status = "success"
|
||||||
|
GET {{base}}/api/v1/generations/{{generationId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@outputImageId = {{checkBasicGen.response.body.$.data.outputImageId}}
|
||||||
|
@processingTimeMs = {{checkBasicGen.response.body.$.data.processingTimeMs}}
|
||||||
|
|
||||||
|
|
||||||
|
### Step 1.3: Get Output Image Metadata
|
||||||
|
# @name getBasicImage
|
||||||
|
GET {{base}}/api/v1/images/{{outputImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - storageUrl is present
|
||||||
|
# - Image is accessible at storageUrl
|
||||||
|
# - processingTimeMs is recorded
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 2: Square Generation (1:1)
|
||||||
|
# Tests aspect ratio 1:1
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 2.1: Create Square Generation
|
||||||
|
# @name createSquareGen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A solid and juicy logo design for a company 'Flower Mind' combining realistic elements with infographic design in png format with alpha channel",
|
||||||
|
"aspectRatio": "1:1"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@squareGenId = {{createSquareGen.response.body.$.data.id}}
|
||||||
|
|
||||||
|
|
||||||
|
### Step 2.2: Check Status (Poll until success)
|
||||||
|
# @name checkSquareGen
|
||||||
|
GET {{base}}/api/v1/generations/{{squareGenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@squareImageId = {{checkSquareGen.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - aspectRatio = "1:1"
|
||||||
|
# - status = "success"
|
||||||
|
# - outputImageId is present
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 3: Portrait Generation (9:16)
|
||||||
|
# Tests aspect ratio 9:16
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 3.1: Create Portrait Generation
|
||||||
|
# @name createPortraitGen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A tall building at night",
|
||||||
|
"aspectRatio": "9:16"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@portraitGenId = {{createPortraitGen.response.body.$.data.id}}
|
||||||
|
|
||||||
|
|
||||||
|
### Step 3.2: Check Status (Poll until success)
|
||||||
|
# @name checkPortraitGen
|
||||||
|
GET {{base}}/api/v1/generations/{{portraitGenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@portraitImageId = {{checkPortraitGen.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - aspectRatio = "9:16"
|
||||||
|
# - status = "success"
|
||||||
|
# - outputImageId is present
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 4: Get Generation by ID
|
||||||
|
# Verifies all expected fields are present in response
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 4.1: Get Generation Details
|
||||||
|
# @name getGenDetails
|
||||||
|
GET {{base}}/api/v1/generations/{{generationId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify response contains:
|
||||||
|
# - id: {{generationId}}
|
||||||
|
# - prompt: "A beautiful sunset over mountains"
|
||||||
|
# - status: "success"
|
||||||
|
# - outputImageId: {{outputImageId}}
|
||||||
|
# - outputImage (nested object)
|
||||||
|
# - createdAt
|
||||||
|
# - updatedAt
|
||||||
|
# - processingTimeMs
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 5: List All Generations
|
||||||
|
# Verifies generation listing without filters
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 5.1: List All Generations (Default pagination)
|
||||||
|
# @name listAllGens
|
||||||
|
GET {{base}}/api/v1/generations
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Response has data array
|
||||||
|
# - Response has pagination object
|
||||||
|
# - At least 3 generations present (from previous tests)
|
||||||
|
# - Our generation {{generationId}} is in the list
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 6: List Generations with Pagination
|
||||||
|
# Tests pagination parameters (limit, offset)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 6.1: Get First Page (limit=2)
|
||||||
|
# @name listPageOne
|
||||||
|
GET {{base}}/api/v1/generations?limit=2&offset=0
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - data.length <= 2
|
||||||
|
# - pagination.limit = 2
|
||||||
|
# - pagination.offset = 0
|
||||||
|
# - pagination.hasMore = true (if total > 2)
|
||||||
|
|
||||||
|
|
||||||
|
### Step 6.2: Get Second Page (offset=2)
|
||||||
|
# @name listPageTwo
|
||||||
|
GET {{base}}/api/v1/generations?limit=2&offset=2
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Different results than first page
|
||||||
|
# - pagination.offset = 2
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 7: Filter Generations by Status
|
||||||
|
# Tests status filter parameter
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 7.1: Filter by Success Status
|
||||||
|
# @name filterSuccess
|
||||||
|
GET {{base}}/api/v1/generations?status=success
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - All items in data[] have status = "success"
|
||||||
|
# - No pending/processing/failed generations
|
||||||
|
|
||||||
|
|
||||||
|
### Step 7.2: Filter by Failed Status
|
||||||
|
# @name filterFailed
|
||||||
|
GET {{base}}/api/v1/generations?status=failed
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - All items (if any) have status = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 8: Verify Processing Time Recorded
|
||||||
|
# Ensures generation performance metrics are tracked
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 8.1: Check Processing Time
|
||||||
|
# @name checkProcessingTime
|
||||||
|
GET {{base}}/api/v1/generations/{{generationId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - processingTimeMs is a number: {{processingTimeMs}}
|
||||||
|
# - processingTimeMs > 0
|
||||||
|
# - Typical range: 3000-15000ms (3-15 seconds)
|
||||||
|
# - Processing time reflects actual generation duration
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# CLEANUP (Optional)
|
||||||
|
# Uncomment to delete test generations
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# ### Delete Test Generation 1
|
||||||
|
# DELETE {{base}}/api/v1/generations/{{generationId}}
|
||||||
|
# X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
# ### Delete Test Generation 2
|
||||||
|
# DELETE {{base}}/api/v1/generations/{{squareGenId}}
|
||||||
|
# X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
# ### Delete Test Generation 3
|
||||||
|
# DELETE {{base}}/api/v1/generations/{{portraitGenId}}
|
||||||
|
# X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# NOTES
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Expected Results:
|
||||||
|
# ✓ All generations complete successfully (status = "success")
|
||||||
|
# ✓ Each generation has unique ID and output image
|
||||||
|
# ✓ Aspect ratios are correctly applied
|
||||||
|
# ✓ Processing times are recorded (typically 3-15 seconds)
|
||||||
|
# ✓ Pagination works correctly
|
||||||
|
# ✓ Status filtering works correctly
|
||||||
|
#
|
||||||
|
# Common Issues:
|
||||||
|
# ⚠ Generation may fail with Gemini API errors (transient)
|
||||||
|
# ⚠ Processing time varies based on prompt complexity
|
||||||
|
# ⚠ First generation may be slower (cold start)
|
||||||
|
#
|
||||||
|
# Tips:
|
||||||
|
# - Use "Poll until success" for Step X.2 requests
|
||||||
|
# - Variables are automatically extracted from responses
|
||||||
|
# - Check response body to see extracted values
|
||||||
|
# - Most generations complete in 5-10 seconds
|
||||||
|
#
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
// tests/api/01-generation-basic.ts
|
||||||
|
// Basic Image Generation Tests - Run FIRST to verify core functionality
|
||||||
|
|
||||||
|
import { api, log, runTest, saveImage, waitForGeneration, testContext, verifyImageAccessible, exitWithTestResults } from './utils';
|
||||||
|
import { endpoints } from './config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log.section('GENERATION BASIC TESTS');
|
||||||
|
|
||||||
|
// Test 1: Simple generation without references
|
||||||
|
await runTest('Generate image - simple prompt', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'A beautiful sunset over mountains',
|
||||||
|
aspectRatio: '16:9',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data || !result.data.data.id) {
|
||||||
|
throw new Error('No generation returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
testContext.basicGenerationId = result.data.data.id;
|
||||||
|
log.detail('Generation ID', result.data.data.id);
|
||||||
|
log.detail('Status', result.data.data.status);
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
log.info('Waiting for generation to complete...');
|
||||||
|
const generation = await waitForGeneration(testContext.basicGenerationId);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!generation.outputImageId) {
|
||||||
|
throw new Error('No output image ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Processing time', `${generation.processingTimeMs}ms`);
|
||||||
|
log.detail('Output image ID', generation.outputImageId);
|
||||||
|
|
||||||
|
// Verify image exists and is accessible
|
||||||
|
const imageResult = await api(`${endpoints.images}/${generation.outputImageId}`);
|
||||||
|
const imageUrl = imageResult.data.data.storageUrl;
|
||||||
|
|
||||||
|
const accessible = await verifyImageAccessible(imageUrl);
|
||||||
|
if (!accessible) {
|
||||||
|
throw new Error('Generated image not accessible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save for manual inspection
|
||||||
|
const imageResponse = await fetch(imageUrl);
|
||||||
|
const imageBuffer = await imageResponse.arrayBuffer();
|
||||||
|
await saveImage(imageBuffer, 'gen-basic-simple.png');
|
||||||
|
|
||||||
|
testContext.basicOutputImageId = generation.outputImageId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Generation with aspect ratio 1:1
|
||||||
|
await runTest('Generate image - square (1:1)', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'A minimalist logo design',
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Output image', generation.outputImageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Generation with aspect ratio 9:16 (portrait)
|
||||||
|
await runTest('Generate image - portrait (9:16)', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'A tall building at night',
|
||||||
|
aspectRatio: '9:16',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Aspect ratio', '9:16');
|
||||||
|
log.detail('Output image', generation.outputImageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Get generation details
|
||||||
|
await runTest('Get generation by ID', async () => {
|
||||||
|
const result = await api(`${endpoints.generations}/${testContext.basicGenerationId}`);
|
||||||
|
|
||||||
|
if (!result.data.data) {
|
||||||
|
throw new Error('Generation not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = result.data.data;
|
||||||
|
|
||||||
|
// Verify all expected fields present
|
||||||
|
if (!generation.id) throw new Error('Missing id');
|
||||||
|
if (!generation.prompt) throw new Error('Missing prompt');
|
||||||
|
if (!generation.status) throw new Error('Missing status');
|
||||||
|
if (!generation.outputImageId) throw new Error('Missing outputImageId');
|
||||||
|
if (!generation.createdAt) throw new Error('Missing createdAt');
|
||||||
|
|
||||||
|
log.detail('Generation ID', generation.id);
|
||||||
|
log.detail('Prompt', generation.prompt);
|
||||||
|
log.detail('Status', generation.status);
|
||||||
|
log.detail('Has output image', !!generation.outputImage);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: List generations
|
||||||
|
await runTest('List all generations', async () => {
|
||||||
|
const result = await api(endpoints.generations);
|
||||||
|
|
||||||
|
if (!result.data.data || !Array.isArray(result.data.data)) {
|
||||||
|
throw new Error('No generations array returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Total generations', result.data.data.length);
|
||||||
|
|
||||||
|
// Verify our generation is in the list
|
||||||
|
const found = result.data.data.find((g: any) => g.id === testContext.basicGenerationId);
|
||||||
|
if (!found) {
|
||||||
|
throw new Error('Created generation not in list');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Found our generation', '✓');
|
||||||
|
log.detail('Successful generations', result.data.data.filter((g: any) => g.status === 'success').length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: List generations with pagination
|
||||||
|
await runTest('List generations with pagination', async () => {
|
||||||
|
const result = await api(`${endpoints.generations}?limit=2&offset=0`);
|
||||||
|
|
||||||
|
if (!result.data.data || !Array.isArray(result.data.data)) {
|
||||||
|
throw new Error('No generations array returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.data.pagination) {
|
||||||
|
throw new Error('No pagination data');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Limit', result.data.pagination.limit);
|
||||||
|
log.detail('Offset', result.data.pagination.offset);
|
||||||
|
log.detail('Total', result.data.pagination.total);
|
||||||
|
log.detail('Has more', result.data.pagination.hasMore);
|
||||||
|
|
||||||
|
// Results should be limited
|
||||||
|
if (result.data.data.length > 2) {
|
||||||
|
throw new Error('Pagination limit not applied');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 7: List generations with status filter
|
||||||
|
await runTest('List generations - filter by status', async () => {
|
||||||
|
const result = await api(`${endpoints.generations}?status=success`);
|
||||||
|
|
||||||
|
if (!result.data.data || !Array.isArray(result.data.data)) {
|
||||||
|
throw new Error('No generations array returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
// All results should have success status
|
||||||
|
const allSuccess = result.data.data.every((g: any) => g.status === 'success');
|
||||||
|
if (!allSuccess) {
|
||||||
|
throw new Error('Status filter not working');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Success generations', result.data.data.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 8: Generation processing time is recorded
|
||||||
|
await runTest('Verify processing time recorded', async () => {
|
||||||
|
const result = await api(`${endpoints.generations}/${testContext.basicGenerationId}`);
|
||||||
|
const generation = result.data.data;
|
||||||
|
|
||||||
|
if (typeof generation.processingTimeMs !== 'number') {
|
||||||
|
throw new Error('Processing time not recorded');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation.processingTimeMs <= 0) {
|
||||||
|
throw new Error('Processing time should be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Processing time', `${generation.processingTimeMs}ms`);
|
||||||
|
log.detail('Approximately', `${(generation.processingTimeMs / 1000).toFixed(2)}s`);
|
||||||
|
});
|
||||||
|
|
||||||
|
log.section('GENERATION BASIC TESTS COMPLETED');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => exitWithTestResults())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
@base = http://localhost:3000
|
||||||
|
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# IMAGE UPLOAD & CRUD TESTS
|
||||||
|
# Tests: Upload, list, filter, pagination, metadata updates, alias management
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Test 1.1: Upload image with project-scoped alias
|
||||||
|
# @name uploadWithAlias
|
||||||
|
POST {{base}}/api/v1/images/upload
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="test-image2.png"
|
||||||
|
Content-Type: image/png
|
||||||
|
|
||||||
|
< ./fixture/test-image.png
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="alias"
|
||||||
|
|
||||||
|
@test-logo
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="description"
|
||||||
|
|
||||||
|
Test logo image for CRUD tests
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@uploadedImageId = {{uploadWithAlias.response.body.$.data.id}}
|
||||||
|
@uploadedImageAlias = {{uploadWithAlias.response.body.$.data.alias}}
|
||||||
|
@uploadedImageSource = {{uploadWithAlias.response.body.$.data.source}}
|
||||||
|
|
||||||
|
### Test 1.2: Verify uploaded image details
|
||||||
|
# Expected: alias = @test-logo, source = uploaded
|
||||||
|
GET {{base}}/api/v1/images/{{uploadedImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 2.1: Upload image without alias
|
||||||
|
# @name uploadWithoutAlias
|
||||||
|
POST {{base}}/api/v1/images/upload
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="test-image.png"
|
||||||
|
Content-Type: image/png
|
||||||
|
|
||||||
|
< ./fixture/test-image.png
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="description"
|
||||||
|
|
||||||
|
Image without alias
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@uploadedImageId2 = {{uploadWithoutAlias.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Test 2.2: Verify image has no alias
|
||||||
|
# Expected: alias = null
|
||||||
|
GET {{base}}/api/v1/images/{{uploadedImageId2}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 3: List all images
|
||||||
|
# Expected: Returns array with pagination
|
||||||
|
GET {{base}}/api/v1/images
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 4: List images - filter by source=uploaded
|
||||||
|
# Expected: All results have source="uploaded"
|
||||||
|
GET {{base}}/api/v1/images?source=uploaded
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 5: List images with pagination
|
||||||
|
# Expected: limit=3, offset=0, hasMore=true/false
|
||||||
|
GET {{base}}/api/v1/images?limit=3&offset=0
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 6: Get image by ID
|
||||||
|
# Expected: Returns full image details
|
||||||
|
GET {{base}}/api/v1/images/{{uploadedImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 7: Resolve project-scoped alias
|
||||||
|
# Expected: Resolves to uploadedImageId (Section 6.2: direct alias support)
|
||||||
|
GET {{base}}/api/v1/images/@test-logo
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 8.1: Update image metadata (focal point + meta)
|
||||||
|
# @name updateMetadata
|
||||||
|
PUT {{base}}/api/v1/images/{{uploadedImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"focalPoint": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.3
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"description": "Updated description",
|
||||||
|
"tags": ["test", "logo", "updated"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 8.2: Verify metadata update
|
||||||
|
# Expected: focalPoint x=0.5, y=0.3, meta has tags
|
||||||
|
GET {{base}}/api/v1/images/{{uploadedImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 9.1: Update image alias (dedicated endpoint)
|
||||||
|
# @name updateAlias
|
||||||
|
PUT {{base}}/api/v1/images/{{uploadedImageId}}/alias
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"alias": "@new-test-logo"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 9.2: Verify new alias works
|
||||||
|
# Expected: Resolves to same uploadedImageId (Section 6.2: direct alias support)
|
||||||
|
GET {{base}}/api/v1/images/@new-test-logo
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 10: Verify old alias doesn't work after update
|
||||||
|
# Expected: 404 - Alias not found
|
||||||
|
GET {{base}}/api/v1/images/@test-logo
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 11.1: Remove image alias
|
||||||
|
# @name removeAlias
|
||||||
|
PUT {{base}}/api/v1/images/{{uploadedImageId}}/alias
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"alias": null
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 11.2: Verify image exists but has no alias
|
||||||
|
# Expected: alias = null
|
||||||
|
GET {{base}}/api/v1/images/{{uploadedImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 11.3: Verify alias resolution fails
|
||||||
|
# Expected: 404 - Alias not found
|
||||||
|
GET {{base}}/api/v1/images/@new-test-logo
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 12.1: Reassign alias for reference image test
|
||||||
|
# @name reassignAlias
|
||||||
|
PUT {{base}}/api/v1/images/{{uploadedImageId}}/alias
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"alias": "@reference-logo"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 12.2: Generate with manual reference image
|
||||||
|
# @name genWithReference
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A product photo with the logo in corner",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"referenceImages": ["@reference-logo"]
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@genWithReferenceId = {{genWithReference.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Test 12.3: Poll generation status
|
||||||
|
# Run this multiple times until status = success
|
||||||
|
GET {{base}}/api/v1/generations/{{genWithReferenceId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 12.4: Verify referenced images tracked
|
||||||
|
# Expected: referencedImages array contains @reference-logo
|
||||||
|
GET {{base}}/api/v1/generations/{{genWithReferenceId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 13.1: Generate with auto-detected reference in prompt
|
||||||
|
# @name genAutoDetect
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Create banner using @reference-logo with blue background",
|
||||||
|
"aspectRatio": "16:9"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@genAutoDetectId = {{genAutoDetect.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Test 13.2: Poll until complete
|
||||||
|
GET {{base}}/api/v1/generations/{{genAutoDetectId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 13.3: Verify auto-detection worked
|
||||||
|
# Expected: referencedImages contains @reference-logo
|
||||||
|
GET {{base}}/api/v1/generations/{{genAutoDetectId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 14.1: Generate with project alias assignment
|
||||||
|
# @name genWithAlias
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A hero banner image",
|
||||||
|
"aspectRatio": "21:9",
|
||||||
|
"alias": "@hero-banner"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@genWithAliasId = {{genWithAlias.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Test 14.2: Poll until complete
|
||||||
|
GET {{base}}/api/v1/generations/{{genWithAliasId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@heroImageId = {{genWithAlias.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Test 14.3: Verify alias assigned to output image
|
||||||
|
# Expected: alias = @hero-banner
|
||||||
|
GET {{base}}/api/v1/images/{{heroImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 14.4: Verify alias resolution works
|
||||||
|
# Expected: Resolves to heroImageId (Section 6.2: direct alias support)
|
||||||
|
GET {{base}}/api/v1/images/@hero-banner
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 15.1: Alias conflict - create second generation with same alias
|
||||||
|
# @name genConflict
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A different hero image",
|
||||||
|
"aspectRatio": "21:9",
|
||||||
|
"alias": "@hero-banner"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@genConflictId = {{genConflict.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Test 15.2: Poll until complete
|
||||||
|
GET {{base}}/api/v1/generations/{{genConflictId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@secondHeroImageId = {{genConflict.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Test 15.3: Verify second image has the alias
|
||||||
|
# Expected: Resolves to secondHeroImageId (not heroImageId) (Section 6.2: direct alias support)
|
||||||
|
GET {{base}}/api/v1/images/@hero-banner
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Test 15.4: Verify first image lost the alias but still exists
|
||||||
|
# Expected: alias = null, image still exists
|
||||||
|
GET {{base}}/api/v1/images/{{heroImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# END OF IMAGE UPLOAD & CRUD TESTS
|
||||||
|
###############################################################################
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
// tests/api/02-basic.ts
|
||||||
|
// Image Upload and CRUD Operations
|
||||||
|
|
||||||
|
import { join } from 'path';
|
||||||
|
import { api, log, runTest, saveImage, uploadFile, waitForGeneration, testContext, verifyImageAccessible, resolveAlias, exitWithTestResults } from './utils';
|
||||||
|
import { config, endpoints } from './config';
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log.section('IMAGE UPLOAD & CRUD TESTS');
|
||||||
|
|
||||||
|
// Test 1: Upload image with project-scoped alias
|
||||||
|
await runTest('Upload image with project alias', async () => {
|
||||||
|
const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png');
|
||||||
|
|
||||||
|
const response = await uploadFile(fixturePath, {
|
||||||
|
alias: '@test-logo',
|
||||||
|
description: 'Test logo image for CRUD tests',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response || !response.id) {
|
||||||
|
throw new Error('No image returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.alias !== '@test-logo') {
|
||||||
|
throw new Error('Alias not set correctly');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.source !== 'uploaded') {
|
||||||
|
throw new Error('Source should be "uploaded"');
|
||||||
|
}
|
||||||
|
|
||||||
|
testContext.uploadedImageId = response.id;
|
||||||
|
log.detail('Image ID', response.id);
|
||||||
|
log.detail('Storage Key', response.storageKey);
|
||||||
|
log.detail('Alias', response.alias);
|
||||||
|
log.detail('Source', response.source);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Upload image without alias
|
||||||
|
await runTest('Upload image without alias', async () => {
|
||||||
|
const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png');
|
||||||
|
|
||||||
|
const response = await uploadFile(fixturePath, {
|
||||||
|
description: 'Image without alias',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response || !response.id) {
|
||||||
|
throw new Error('No image returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.alias !== null) {
|
||||||
|
throw new Error('Alias should be null');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Image ID', response.id);
|
||||||
|
log.detail('Alias', 'null (as expected)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: List all images
|
||||||
|
await runTest('List all images', async () => {
|
||||||
|
const result = await api(endpoints.images);
|
||||||
|
|
||||||
|
if (!result.data.data || !Array.isArray(result.data.data)) {
|
||||||
|
throw new Error('No images array returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Total images', result.data.data.length);
|
||||||
|
|
||||||
|
// Find our uploaded image
|
||||||
|
const found = result.data.data.find((img: any) => img.id === testContext.uploadedImageId);
|
||||||
|
if (!found) {
|
||||||
|
throw new Error('Uploaded image not in list');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Found our image', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: List images with source filter
|
||||||
|
await runTest('List images - filter by source=uploaded', async () => {
|
||||||
|
const result = await api(`${endpoints.images}?source=uploaded`);
|
||||||
|
|
||||||
|
if (!result.data.data || !Array.isArray(result.data.data)) {
|
||||||
|
throw new Error('No images array returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
// All should be uploaded
|
||||||
|
const allUploaded = result.data.data.every((img: any) => img.source === 'uploaded');
|
||||||
|
if (!allUploaded) {
|
||||||
|
throw new Error('Source filter not working');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Uploaded images', result.data.data.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: List images with pagination
|
||||||
|
await runTest('List images with pagination', async () => {
|
||||||
|
const result = await api(`${endpoints.images}?limit=3&offset=0`);
|
||||||
|
|
||||||
|
if (!result.data.pagination) {
|
||||||
|
throw new Error('No pagination data');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Limit', result.data.pagination.limit);
|
||||||
|
log.detail('Offset', result.data.pagination.offset);
|
||||||
|
log.detail('Total', result.data.pagination.total);
|
||||||
|
log.detail('Has more', result.data.pagination.hasMore);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: Get image by ID
|
||||||
|
await runTest('Get image by ID', async () => {
|
||||||
|
const result = await api(`${endpoints.images}/${testContext.uploadedImageId}`);
|
||||||
|
|
||||||
|
if (!result.data.data) {
|
||||||
|
throw new Error('Image not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = result.data.data;
|
||||||
|
|
||||||
|
// Verify fields
|
||||||
|
if (!image.id) throw new Error('Missing id');
|
||||||
|
if (!image.storageKey) throw new Error('Missing storageKey');
|
||||||
|
if (!image.storageUrl) throw new Error('Missing storageUrl');
|
||||||
|
if (!image.source) throw new Error('Missing source');
|
||||||
|
|
||||||
|
log.detail('Image ID', image.id);
|
||||||
|
log.detail('Source', image.source);
|
||||||
|
log.detail('File size', `${image.fileSize || 0} bytes`);
|
||||||
|
log.detail('Alias', image.alias || 'null');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 7: Get image by alias (using resolve endpoint)
|
||||||
|
await runTest('Resolve project-scoped alias', async () => {
|
||||||
|
const resolved = await resolveAlias('@test-logo');
|
||||||
|
|
||||||
|
if (!resolved.imageId) {
|
||||||
|
throw new Error('Alias not resolved');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.imageId !== testContext.uploadedImageId) {
|
||||||
|
throw new Error('Resolved to wrong image');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.scope !== 'project') {
|
||||||
|
throw new Error(`Wrong scope: ${resolved.scope}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Resolved image ID', resolved.imageId);
|
||||||
|
log.detail('Scope', resolved.scope);
|
||||||
|
log.detail('Alias', resolved.alias);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 8: Update image metadata
|
||||||
|
await runTest('Update image metadata', async () => {
|
||||||
|
const result = await api(`${endpoints.images}/${testContext.uploadedImageId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
focalPoint: { x: 0.5, y: 0.3 },
|
||||||
|
meta: {
|
||||||
|
description: 'Updated description',
|
||||||
|
tags: ['test', 'logo', 'updated'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data) {
|
||||||
|
throw new Error('No image returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify update by fetching again
|
||||||
|
const updated = await api(`${endpoints.images}/${testContext.uploadedImageId}`);
|
||||||
|
const image = updated.data.data;
|
||||||
|
|
||||||
|
if (!image.focalPoint || image.focalPoint.x !== 0.5 || image.focalPoint.y !== 0.3) {
|
||||||
|
throw new Error('Focal point not updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Focal point', JSON.stringify(image.focalPoint));
|
||||||
|
log.detail('Meta', JSON.stringify(image.meta));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 9: Update image alias (dedicated endpoint)
|
||||||
|
await runTest('Update image alias', async () => {
|
||||||
|
const result = await api(`${endpoints.images}/${testContext.uploadedImageId}/alias`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
alias: '@new-test-logo',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data) {
|
||||||
|
throw new Error('No image returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify new alias works
|
||||||
|
const resolved = await resolveAlias('@new-test-logo');
|
||||||
|
if (resolved.imageId !== testContext.uploadedImageId) {
|
||||||
|
throw new Error('New alias not working');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('New alias', '@new-test-logo');
|
||||||
|
log.detail('Resolved', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 10: Verify old alias doesn't work after update
|
||||||
|
await runTest('Old alias should not resolve after update', async () => {
|
||||||
|
try {
|
||||||
|
await resolveAlias('@test-logo');
|
||||||
|
throw new Error('Old alias should not resolve');
|
||||||
|
} catch (error: any) {
|
||||||
|
// Expected to fail
|
||||||
|
if (error.message.includes('should not resolve')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
log.detail('Old alias correctly invalid', '✓');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 11: Remove image alias
|
||||||
|
await runTest('Remove image alias', async () => {
|
||||||
|
await api(`${endpoints.images}/${testContext.uploadedImageId}/alias`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
alias: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify image exists but has no alias
|
||||||
|
const result = await api(`${endpoints.images}/${testContext.uploadedImageId}`);
|
||||||
|
if (result.data.data.alias !== null) {
|
||||||
|
throw new Error('Alias should be null');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify alias resolution fails
|
||||||
|
try {
|
||||||
|
await resolveAlias('@new-test-logo');
|
||||||
|
throw new Error('Removed alias should not resolve');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('should not resolve')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
log.detail('Alias removed', '✓');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 12: Generate image with manual reference
|
||||||
|
await runTest('Generate with manual reference image', async () => {
|
||||||
|
// First, reassign alias for reference
|
||||||
|
await api(`${endpoints.images}/${testContext.uploadedImageId}/alias`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
alias: '@reference-logo',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'A product photo with the logo in corner',
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
referenceImages: ['@reference-logo'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify referenced images tracked
|
||||||
|
if (!generation.referencedImages || generation.referencedImages.length === 0) {
|
||||||
|
throw new Error('Referenced images not tracked');
|
||||||
|
}
|
||||||
|
|
||||||
|
const refFound = generation.referencedImages.some(
|
||||||
|
(ref: any) => ref.alias === '@reference-logo'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!refFound) {
|
||||||
|
throw new Error('Reference image not found in referencedImages');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Generation ID', generation.id);
|
||||||
|
log.detail('Referenced images', generation.referencedImages.length);
|
||||||
|
|
||||||
|
// Save generated image
|
||||||
|
if (generation.outputImageId) {
|
||||||
|
const imageResult = await api(`${endpoints.images}/${generation.outputImageId}`);
|
||||||
|
const imageUrl = imageResult.data.data.storageUrl;
|
||||||
|
const imageResponse = await fetch(imageUrl);
|
||||||
|
const imageBuffer = await imageResponse.arrayBuffer();
|
||||||
|
await saveImage(imageBuffer, 'gen-with-reference.png');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 13: Generate with auto-detected reference in prompt
|
||||||
|
await runTest('Generate with auto-detected reference', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'Create banner using @reference-logo with blue background',
|
||||||
|
aspectRatio: '16:9',
|
||||||
|
// NOTE: referenceImages NOT provided, should auto-detect
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify auto-detection worked
|
||||||
|
if (!generation.referencedImages || generation.referencedImages.length === 0) {
|
||||||
|
throw new Error('Auto-detection did not work');
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoDetected = generation.referencedImages.some(
|
||||||
|
(ref: any) => ref.alias === '@reference-logo'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!autoDetected) {
|
||||||
|
throw new Error('Reference not auto-detected from prompt');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Auto-detected references', generation.referencedImages.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 14: Generate with project alias assignment
|
||||||
|
await runTest('Generate with project alias assignment', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'A hero banner image',
|
||||||
|
aspectRatio: '21:9',
|
||||||
|
alias: '@hero-banner',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify alias assigned to output image
|
||||||
|
const imageResult = await api(`${endpoints.images}/${generation.outputImageId}`);
|
||||||
|
const image = imageResult.data.data;
|
||||||
|
|
||||||
|
if (image.alias !== '@hero-banner') {
|
||||||
|
throw new Error('Alias not assigned to output image');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify alias resolution works
|
||||||
|
const resolved = await resolveAlias('@hero-banner');
|
||||||
|
if (resolved.imageId !== generation.outputImageId) {
|
||||||
|
throw new Error('Alias resolution failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Output image alias', image.alias);
|
||||||
|
log.detail('Alias resolution', '✓');
|
||||||
|
|
||||||
|
testContext.heroBannerId = generation.outputImageId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 15: Alias conflict - new generation overwrites
|
||||||
|
await runTest('Alias conflict resolution', async () => {
|
||||||
|
// First generation has @hero alias (from previous test)
|
||||||
|
const firstImageId = testContext.heroBannerId;
|
||||||
|
|
||||||
|
// Create second generation with same alias
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'A different hero image',
|
||||||
|
aspectRatio: '21:9',
|
||||||
|
alias: '@hero-banner',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondImageId = generation.outputImageId;
|
||||||
|
|
||||||
|
// Verify second image has the alias
|
||||||
|
const resolved = await resolveAlias('@hero-banner');
|
||||||
|
if (resolved.imageId !== secondImageId) {
|
||||||
|
throw new Error('Second image should have the alias');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify first image lost the alias but still exists
|
||||||
|
const firstImage = await api(`${endpoints.images}/${firstImageId}`);
|
||||||
|
if (firstImage.data.data.alias !== null) {
|
||||||
|
throw new Error('First image should have lost the alias');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Second image has alias', '✓');
|
||||||
|
log.detail('First image preserved', '✓');
|
||||||
|
log.detail('First image alias removed', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
log.section('IMAGE UPLOAD & CRUD TESTS COMPLETED');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => exitWithTestResults())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
@base = http://localhost:3000
|
||||||
|
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# FLOW LIFECYCLE TESTS
|
||||||
|
# Tests: Lazy flow creation, Eager flow creation, Flow operations
|
||||||
|
#
|
||||||
|
# Test Coverage:
|
||||||
|
# 1. Lazy flow pattern - first generation without flowId
|
||||||
|
# 2. Lazy flow - verify flow not created yet
|
||||||
|
# 3. Lazy flow - second generation creates flow
|
||||||
|
# 4. Eager flow creation with flowAlias
|
||||||
|
# 5. List all flows
|
||||||
|
# 6. Get flow with computed counts
|
||||||
|
# 7. List flow generations
|
||||||
|
# 8. List flow images
|
||||||
|
# 9. Update flow aliases
|
||||||
|
# 10. Remove specific flow alias
|
||||||
|
# 11. Regenerate flow
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 1: Lazy Flow Pattern - First Generation
|
||||||
|
# Generation without flowId should return auto-generated flowId
|
||||||
|
# but NOT create flow in database yet (Section 4.1)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 1.1: Create Generation without flowId
|
||||||
|
# @name lazyFlowGen1
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A red sports car on a mountain road",
|
||||||
|
"aspectRatio": "16:9"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@lazyFlowId = {{lazyFlowGen1.response.body.$.data.flowId}}
|
||||||
|
@lazyGenId1 = {{lazyFlowGen1.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 1.2: Poll Generation Status
|
||||||
|
# @name checkLazyGen1
|
||||||
|
GET {{base}}/api/v1/generations/{{lazyGenId1}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - flowId is returned (auto-generated UUID)
|
||||||
|
# - status = "success"
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 2: Verify Lazy Flow Not Created Yet
|
||||||
|
# Flow should NOT exist in database after first generation
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 2.1: Try to get flow (should return 404)
|
||||||
|
# @name checkLazyFlowNotExists
|
||||||
|
GET {{base}}/api/v1/flows/{{lazyFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 404 Not Found
|
||||||
|
# Flow record not created yet (lazy creation pattern)
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 3: Lazy Flow - Second Generation Creates Flow
|
||||||
|
# Using same flowId should create the flow record
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 3.1: Create second generation with same flowId
|
||||||
|
# @name lazyFlowGen2
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Same car but blue color",
|
||||||
|
"aspectRatio": "16:9",
|
||||||
|
"flowId": "{{lazyFlowId}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@lazyGenId2 = {{lazyFlowGen2.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 3.2: Poll Generation Status
|
||||||
|
# @name checkLazyGen2
|
||||||
|
GET {{base}}/api/v1/generations/{{lazyGenId2}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Step 3.3: Verify flow now exists
|
||||||
|
# @name verifyLazyFlowExists
|
||||||
|
GET {{base}}/api/v1/flows/{{lazyFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 200 OK
|
||||||
|
# Flow record now exists after second use
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 4: Eager Flow Creation with flowAlias
|
||||||
|
# Using flowAlias should create flow immediately
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 4.1: Create generation with flowAlias
|
||||||
|
# @name eagerFlowGen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A hero banner image",
|
||||||
|
"aspectRatio": "21:9",
|
||||||
|
"flowAlias": "@hero-flow"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@eagerFlowId = {{eagerFlowGen.response.body.$.data.flowId}}
|
||||||
|
@eagerGenId = {{eagerFlowGen.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 4.2: Poll Generation Status
|
||||||
|
# @name checkEagerGen
|
||||||
|
GET {{base}}/api/v1/generations/{{eagerGenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Step 4.3: Verify flow exists immediately (eager creation)
|
||||||
|
# @name verifyEagerFlowExists
|
||||||
|
GET {{base}}/api/v1/flows/{{eagerFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Flow exists immediately
|
||||||
|
# - aliases contains "@hero-flow"
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 5: List All Flows
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 5.1: List flows
|
||||||
|
# @name listFlows
|
||||||
|
GET {{base}}/api/v1/flows
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns array of flows
|
||||||
|
# - Contains our lazyFlowId and eagerFlowId
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 6: Get Flow with Computed Counts
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 6.1: Get flow details
|
||||||
|
# @name getFlowDetails
|
||||||
|
GET {{base}}/api/v1/flows/{{lazyFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - generationCount is number (should be 2)
|
||||||
|
# - imageCount is number (should be 2)
|
||||||
|
# - aliases object present
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 7: List Flow Generations
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 7.1: Get flow's generations
|
||||||
|
# @name getFlowGenerations
|
||||||
|
GET {{base}}/api/v1/flows/{{lazyFlowId}}/generations
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns array of generations
|
||||||
|
# - Contains 2 generations from lazy flow tests
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 8: List Flow Images
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 8.1: Get flow's images
|
||||||
|
# @name getFlowImages
|
||||||
|
GET {{base}}/api/v1/flows/{{lazyFlowId}}/images
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns array of images
|
||||||
|
# - Contains output images from generations
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 9: Update Flow Aliases
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 9.1: Update flow aliases
|
||||||
|
# @name updateFlowAliases
|
||||||
|
PUT {{base}}/api/v1/flows/{{lazyFlowId}}/aliases
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"aliases": {
|
||||||
|
"@latest": "{{checkLazyGen2.response.body.$.data.outputImageId}}",
|
||||||
|
"@best": "{{checkLazyGen2.response.body.$.data.outputImageId}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns updated flow with new aliases
|
||||||
|
# - aliases contains @latest and @best
|
||||||
|
|
||||||
|
|
||||||
|
### Step 9.2: Verify aliases set
|
||||||
|
# @name verifyAliasesSet
|
||||||
|
GET {{base}}/api/v1/flows/{{lazyFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 10: Remove Specific Flow Alias
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 10.1: Delete @best alias
|
||||||
|
# @name deleteFlowAlias
|
||||||
|
DELETE {{base}}/api/v1/flows/{{lazyFlowId}}/aliases/@best
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Step 10.2: Verify alias removed
|
||||||
|
# @name verifyAliasRemoved
|
||||||
|
GET {{base}}/api/v1/flows/{{lazyFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - @best not in aliases
|
||||||
|
# - @latest still in aliases
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 11: Regenerate Flow
|
||||||
|
# Regenerates the most recent generation in a flow
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 11.1: Trigger regeneration
|
||||||
|
# @name regenerateFlow
|
||||||
|
POST {{base}}/api/v1/flows/{{lazyFlowId}}/regenerate
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns new generation object
|
||||||
|
# - New generation is in the same flow
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# NOTES
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Lazy Flow Pattern (Section 4.1):
|
||||||
|
# 1. First request without flowId -> return generated flowId, but DO NOT create in DB
|
||||||
|
# 2. Any request with valid flowId -> create flow in DB if doesn't exist
|
||||||
|
# 3. If flowAlias specified -> create flow immediately (eager creation)
|
||||||
|
#
|
||||||
|
# Flow Aliases:
|
||||||
|
# - Stored in flow.aliases JSONB field
|
||||||
|
# - Map alias names to image IDs
|
||||||
|
# - Can be updated via PUT /flows/:id/aliases
|
||||||
|
# - Individual aliases deleted via DELETE /flows/:id/aliases/:alias
|
||||||
|
#
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
// tests/api/03-flows.ts
|
||||||
|
// Flow Lifecycle Tests - Lazy and Eager Creation Patterns
|
||||||
|
|
||||||
|
import { api, log, runTest, saveImage, waitForGeneration, testContext, resolveAlias, exitWithTestResults } from './utils';
|
||||||
|
import { endpoints } from './config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log.section('FLOW LIFECYCLE TESTS');
|
||||||
|
|
||||||
|
// Test 1: Lazy flow pattern - first generation without flowId
|
||||||
|
await runTest('Lazy flow - generation without flowId', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'A red sports car on a mountain road',
|
||||||
|
aspectRatio: '16:9',
|
||||||
|
// NOTE: flowId not provided, should auto-generate
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data.flowId) {
|
||||||
|
throw new Error('No flowId returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
testContext.lazyFlowId = result.data.data.flowId;
|
||||||
|
log.detail('Auto-generated flowId', testContext.lazyFlowId);
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
testContext.firstGenId = generation.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Lazy flow - verify flow doesn't exist yet (Section 4.1)
|
||||||
|
await runTest('Lazy flow - verify flow not created yet', async () => {
|
||||||
|
const result = await api(`${endpoints.flows}/${testContext.lazyFlowId}`, {
|
||||||
|
expectError: true,
|
||||||
|
});
|
||||||
|
if (result.status !== 404) {
|
||||||
|
throw new Error('Flow should not exist yet (lazy creation)');
|
||||||
|
}
|
||||||
|
log.detail('Flow correctly does not exist', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Lazy flow - second use creates flow
|
||||||
|
await runTest('Lazy flow - second generation creates flow', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'Same car but blue color',
|
||||||
|
aspectRatio: '16:9',
|
||||||
|
flowId: testContext.lazyFlowId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now flow should exist
|
||||||
|
const flowResult = await api(`${endpoints.flows}/${testContext.lazyFlowId}`);
|
||||||
|
if (!flowResult.data.data) {
|
||||||
|
throw new Error('Flow should exist after second use');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Flow now exists', '✓');
|
||||||
|
log.detail('Flow ID', flowResult.data.data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Eager flow creation with flowAlias
|
||||||
|
await runTest('Eager flow - created immediately with flowAlias', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'A hero banner image',
|
||||||
|
aspectRatio: '21:9',
|
||||||
|
flowAlias: '@hero-flow',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data.flowId) {
|
||||||
|
throw new Error('No flowId returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
testContext.eagerFlowId = result.data.data.flowId;
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flow should exist immediately
|
||||||
|
const flowResult = await api(`${endpoints.flows}/${testContext.eagerFlowId}`);
|
||||||
|
if (!flowResult.data.data) {
|
||||||
|
throw new Error('Flow should exist immediately (eager creation)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flowResult.data.data.aliases || !flowResult.data.data.aliases['@hero-flow']) {
|
||||||
|
throw new Error('Flow alias not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Flow exists immediately', '✓');
|
||||||
|
log.detail('Flow alias', '@hero-flow');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: List all flows
|
||||||
|
await runTest('List all flows', async () => {
|
||||||
|
const result = await api(endpoints.flows);
|
||||||
|
|
||||||
|
if (!result.data.data || !Array.isArray(result.data.data)) {
|
||||||
|
throw new Error('No flows array returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = result.data.data.filter((f: any) =>
|
||||||
|
f.id === testContext.lazyFlowId || f.id === testContext.eagerFlowId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (found.length !== 2) {
|
||||||
|
throw new Error('Not all created flows found');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Total flows', result.data.data.length);
|
||||||
|
log.detail('Our flows found', found.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: Get flow details with computed counts
|
||||||
|
await runTest('Get flow with computed counts', async () => {
|
||||||
|
const result = await api(`${endpoints.flows}/${testContext.lazyFlowId}`);
|
||||||
|
|
||||||
|
if (!result.data.data) {
|
||||||
|
throw new Error('Flow not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = result.data.data;
|
||||||
|
|
||||||
|
if (typeof flow.generationCount !== 'number') {
|
||||||
|
throw new Error('Missing generationCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof flow.imageCount !== 'number') {
|
||||||
|
throw new Error('Missing imageCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Generation count', flow.generationCount);
|
||||||
|
log.detail('Image count', flow.imageCount);
|
||||||
|
log.detail('Aliases', JSON.stringify(flow.aliases));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 7: Get flow's generations
|
||||||
|
await runTest('List flow generations', async () => {
|
||||||
|
const result = await api(`${endpoints.flows}/${testContext.lazyFlowId}/generations`);
|
||||||
|
|
||||||
|
if (!result.data.data || !Array.isArray(result.data.data)) {
|
||||||
|
throw new Error('No generations array returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Generations in flow', result.data.data.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 8: Get flow's images
|
||||||
|
await runTest('List flow images', async () => {
|
||||||
|
const result = await api(`${endpoints.flows}/${testContext.lazyFlowId}/images`);
|
||||||
|
|
||||||
|
if (!result.data.data || !Array.isArray(result.data.data)) {
|
||||||
|
throw new Error('No images array returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Images in flow', result.data.data.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 9: Update flow aliases
|
||||||
|
await runTest('Update flow aliases', async () => {
|
||||||
|
// Get a generation to use
|
||||||
|
const flowResult = await api(`${endpoints.flows}/${testContext.lazyFlowId}`);
|
||||||
|
const gens = await api(`${endpoints.flows}/${testContext.lazyFlowId}/generations`);
|
||||||
|
const lastGen = gens.data.data[0];
|
||||||
|
|
||||||
|
if (!lastGen.outputImageId) {
|
||||||
|
throw new Error('No output image');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api(`${endpoints.flows}/${testContext.lazyFlowId}/aliases`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
aliases: {
|
||||||
|
'@latest': lastGen.outputImageId,
|
||||||
|
'@best': lastGen.outputImageId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data.aliases) {
|
||||||
|
throw new Error('No aliases returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Updated aliases', JSON.stringify(result.data.data.aliases));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 10: Remove specific flow alias
|
||||||
|
await runTest('Remove specific flow alias', async () => {
|
||||||
|
await api(`${endpoints.flows}/${testContext.lazyFlowId}/aliases/@best`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api(`${endpoints.flows}/${testContext.lazyFlowId}`);
|
||||||
|
if ('@best' in result.data.data.aliases) {
|
||||||
|
throw new Error('Alias should be removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('@latest' in result.data.data.aliases)) {
|
||||||
|
throw new Error('Other aliases should remain');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Removed @best', '✓');
|
||||||
|
log.detail('Kept @latest', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 11: Flow regenerate endpoint
|
||||||
|
await runTest('Regenerate flow (most recent generation)', async () => {
|
||||||
|
const result = await api(`${endpoints.flows}/${testContext.lazyFlowId}/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data) {
|
||||||
|
throw new Error('No generation returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Regeneration triggered', '✓');
|
||||||
|
log.detail('Generation ID', result.data.data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
log.section('FLOW LIFECYCLE TESTS COMPLETED');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => exitWithTestResults())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,590 @@
|
||||||
|
@base = http://localhost:3000
|
||||||
|
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# ALIAS RESOLUTION TESTS
|
||||||
|
# Tests: 3-Tier Alias Resolution (Technical -> Flow -> Project)
|
||||||
|
#
|
||||||
|
# Test Coverage:
|
||||||
|
# 1. Technical alias @last
|
||||||
|
# 2. Technical alias @first
|
||||||
|
# 3. Technical alias @upload
|
||||||
|
# 4. Technical alias requires flowId
|
||||||
|
# 5. Flow-scoped alias resolution
|
||||||
|
# 6. Project-scoped alias resolution
|
||||||
|
# 7. Alias precedence (flow > project)
|
||||||
|
# 8. Reserved aliases cannot be assigned
|
||||||
|
# 9. Alias reassignment removes old
|
||||||
|
# 10. Same alias in different flows
|
||||||
|
# 11. Technical alias in generation prompt
|
||||||
|
# 12. Upload with both project and flow alias
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# SETUP: Create Test Flow
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Setup: Create flow for alias tests
|
||||||
|
# @name setupGen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Setup image for alias tests",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowAlias": "@alias-test-flow"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@aliasFlowId = {{setupGen.response.body.$.data.flowId}}
|
||||||
|
@setupGenId = {{setupGen.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Poll setup generation
|
||||||
|
# @name checkSetupGen
|
||||||
|
GET {{base}}/api/v1/generations/{{setupGenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@setupImageId = {{checkSetupGen.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 1: Technical Alias @last
|
||||||
|
# Resolves to last generated image in flow
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 1.1: Resolve @last (requires flowId)
|
||||||
|
# @name resolveLast
|
||||||
|
GET {{base}}/api/v1/images/@last?flowId={{aliasFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns image (status 200)
|
||||||
|
# - Returns the most recently generated image in the flow
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 2: Technical Alias @first
|
||||||
|
# Resolves to first generated image in flow
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 2.1: Resolve @first (requires flowId)
|
||||||
|
# @name resolveFirst
|
||||||
|
GET {{base}}/api/v1/images/@first?flowId={{aliasFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns image (status 200)
|
||||||
|
# - Returns the first generated image in the flow
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 3: Technical Alias @upload
|
||||||
|
# Resolves to last uploaded image in flow
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 3.1: Upload image to flow
|
||||||
|
# @name uploadForTest
|
||||||
|
POST {{base}}/api/v1/images/upload
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="test-image.png"
|
||||||
|
Content-Type: image/png
|
||||||
|
|
||||||
|
< ./fixture/test-image.png
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="flowId"
|
||||||
|
|
||||||
|
{{aliasFlowId}}
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="description"
|
||||||
|
|
||||||
|
Uploaded for @upload test
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@uploadedImageId = {{uploadForTest.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 3.2: Resolve @upload (requires flowId)
|
||||||
|
# @name resolveUpload
|
||||||
|
GET {{base}}/api/v1/images/@upload?flowId={{aliasFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns image (status 200)
|
||||||
|
# - Returns uploaded image (source = "uploaded")
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 4: Technical Alias Requires Flow Context
|
||||||
|
# @last, @first, @upload require flowId parameter
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 4.1: Try @last without flowId (should fail)
|
||||||
|
# @name resolveLastNoFlow
|
||||||
|
GET {{base}}/api/v1/images/@last
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 404 with error "Technical aliases require flowId"
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 5: Flow-Scoped Alias Resolution
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 5.1: Create generation with flow alias
|
||||||
|
# @name flowAliasGen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Image for flow alias test",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowId": "{{aliasFlowId}}",
|
||||||
|
"flowAlias": "@flow-hero"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@flowAliasGenId = {{flowAliasGen.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 5.2: Poll generation
|
||||||
|
# @name checkFlowAliasGen
|
||||||
|
GET {{base}}/api/v1/generations/{{flowAliasGenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@flowHeroImageId = {{checkFlowAliasGen.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 5.3: Resolve flow alias
|
||||||
|
# @name resolveFlowAlias
|
||||||
|
GET {{base}}/api/v1/images/@flow-hero?flowId={{aliasFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns the image from step 5.1
|
||||||
|
# - Only works with flowId parameter
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 6: Project-Scoped Alias Resolution
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 6.1: Create generation with project alias
|
||||||
|
# @name projectAliasGen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Image for project alias test",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": "@project-logo",
|
||||||
|
"flowId": null
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@projectAliasGenId = {{projectAliasGen.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 6.2: Poll generation
|
||||||
|
# @name checkProjectAliasGen
|
||||||
|
GET {{base}}/api/v1/generations/{{projectAliasGenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@projectLogoImageId = {{checkProjectAliasGen.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 6.3: Resolve project alias (no flowId needed)
|
||||||
|
# @name resolveProjectAlias
|
||||||
|
GET {{base}}/api/v1/images/@project-logo
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns the image from step 6.1
|
||||||
|
# - Works without flowId parameter
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 7: Alias Precedence (Flow > Project)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 7.1: Create project-scoped alias @priority-test
|
||||||
|
# @name priorityProject
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Project scoped image for priority test",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": "@priority-test",
|
||||||
|
"flowId": null
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@priorityProjectGenId = {{priorityProject.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 7.2: Poll generation
|
||||||
|
# @name checkPriorityProject
|
||||||
|
GET {{base}}/api/v1/generations/{{priorityProjectGenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@priorityProjectImageId = {{checkPriorityProject.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 7.3: Create flow-scoped alias @priority-test (same name)
|
||||||
|
# @name priorityFlow
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Flow scoped image for priority test",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowId": "{{aliasFlowId}}",
|
||||||
|
"flowAlias": "@priority-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@priorityFlowGenId = {{priorityFlow.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 7.4: Poll generation
|
||||||
|
# @name checkPriorityFlow
|
||||||
|
GET {{base}}/api/v1/generations/{{priorityFlowGenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@priorityFlowImageId = {{checkPriorityFlow.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 7.5: Resolve WITHOUT flowId (should get project)
|
||||||
|
# @name resolvePriorityNoFlow
|
||||||
|
GET {{base}}/api/v1/images/@priority-test
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify: Returns project image ({{priorityProjectImageId}})
|
||||||
|
|
||||||
|
### Step 7.6: Resolve WITH flowId (should get flow)
|
||||||
|
# @name resolvePriorityWithFlow
|
||||||
|
GET {{base}}/api/v1/images/@priority-test?flowId={{aliasFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify: Returns flow image ({{priorityFlowImageId}})
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 8: Reserved Aliases Cannot Be Assigned
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 8.1: Try to use @last as alias (should fail or warn)
|
||||||
|
# @name reservedLast
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test reserved alias",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": "@last"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 400 validation error OR generation succeeds but @last not assigned
|
||||||
|
|
||||||
|
### Step 8.2: Try to use @first as alias
|
||||||
|
# @name reservedFirst
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test reserved alias",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": "@first"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Step 8.3: Try to use @upload as alias
|
||||||
|
# @name reservedUpload
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test reserved alias",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": "@upload"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 9: Alias Reassignment (Override Behavior)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 9.1: Create first image with alias
|
||||||
|
# @name reassign1
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "First image for reassign test",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": "@reassign-test",
|
||||||
|
"flowId": null
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@reassign1GenId = {{reassign1.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 9.2: Poll first generation
|
||||||
|
# @name checkReassign1
|
||||||
|
GET {{base}}/api/v1/generations/{{reassign1GenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@reassign1ImageId = {{checkReassign1.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 9.3: Create second image with SAME alias
|
||||||
|
# @name reassign2
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Second image for reassign test",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": "@reassign-test",
|
||||||
|
"flowId": null
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@reassign2GenId = {{reassign2.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 9.4: Poll second generation
|
||||||
|
# @name checkReassign2
|
||||||
|
GET {{base}}/api/v1/generations/{{reassign2GenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@reassign2ImageId = {{checkReassign2.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 9.5: Resolve alias (should be second image)
|
||||||
|
# @name resolveReassign
|
||||||
|
GET {{base}}/api/v1/images/@reassign-test
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify: Returns second image ({{reassign2ImageId}})
|
||||||
|
|
||||||
|
### Step 9.6: Check first image lost alias
|
||||||
|
# @name checkFirstLostAlias
|
||||||
|
GET {{base}}/api/v1/images/{{reassign1ImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify: alias = null
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 10: Same Alias in Different Flows
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 10.1: Create flow 1 with @shared-name alias
|
||||||
|
# @name sharedFlow1
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Flow 1 image with shared name",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowAlias": "@shared-name"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@sharedFlow1Id = {{sharedFlow1.response.body.$.data.flowId}}
|
||||||
|
@sharedGen1Id = {{sharedFlow1.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 10.2: Poll generation 1
|
||||||
|
# @name checkSharedGen1
|
||||||
|
GET {{base}}/api/v1/generations/{{sharedGen1Id}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@sharedImage1Id = {{checkSharedGen1.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 10.3: Create flow 2 with SAME @shared-name alias
|
||||||
|
# @name sharedFlow2
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Flow 2 image with shared name",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowAlias": "@shared-name"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@sharedFlow2Id = {{sharedFlow2.response.body.$.data.flowId}}
|
||||||
|
@sharedGen2Id = {{sharedFlow2.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 10.4: Poll generation 2
|
||||||
|
# @name checkSharedGen2
|
||||||
|
GET {{base}}/api/v1/generations/{{sharedGen2Id}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@sharedImage2Id = {{checkSharedGen2.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 10.5: Resolve @shared-name in flow 1
|
||||||
|
# @name resolveSharedFlow1
|
||||||
|
GET {{base}}/api/v1/images/@shared-name?flowId={{sharedFlow1Id}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify: Returns {{sharedImage1Id}}
|
||||||
|
|
||||||
|
### Step 10.6: Resolve @shared-name in flow 2
|
||||||
|
# @name resolveSharedFlow2
|
||||||
|
GET {{base}}/api/v1/images/@shared-name?flowId={{sharedFlow2Id}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify: Returns {{sharedImage2Id}} (different from flow 1)
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 11: Technical Alias in Generation Prompt
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 11.1: Generate using @last in prompt
|
||||||
|
# @name techAliasPrompt
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "New variation based on @last",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowId": "{{aliasFlowId}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@techAliasGenId = {{techAliasPrompt.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 11.2: Poll generation
|
||||||
|
# @name checkTechAliasGen
|
||||||
|
GET {{base}}/api/v1/generations/{{techAliasGenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - status = "success"
|
||||||
|
# - referencedImages contains @last alias
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 12: Upload with Both Project and Flow Alias
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 12.1: Upload with both aliases
|
||||||
|
# @name dualAliasUpload
|
||||||
|
POST {{base}}/api/v1/images/upload
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="test-image.png"
|
||||||
|
Content-Type: image/png
|
||||||
|
|
||||||
|
< ./fixture/test-image.png
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="alias"
|
||||||
|
|
||||||
|
@dual-project
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="flowId"
|
||||||
|
|
||||||
|
{{aliasFlowId}}
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="flowAlias"
|
||||||
|
|
||||||
|
@dual-flow
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@dualAliasImageId = {{dualAliasUpload.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 12.2: Resolve project alias
|
||||||
|
# @name resolveDualProject
|
||||||
|
GET {{base}}/api/v1/images/@dual-project
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify: Returns {{dualAliasImageId}}
|
||||||
|
|
||||||
|
### Step 12.3: Resolve flow alias
|
||||||
|
# @name resolveDualFlow
|
||||||
|
GET {{base}}/api/v1/images/@dual-flow?flowId={{aliasFlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify: Returns {{dualAliasImageId}} (same image)
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# NOTES
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# 3-Tier Alias Resolution Order:
|
||||||
|
# 1. Technical (@last, @first, @upload) - require flowId
|
||||||
|
# 2. Flow-scoped (stored in flow.aliases) - require flowId
|
||||||
|
# 3. Project-scoped (stored in images.alias) - no flowId needed
|
||||||
|
#
|
||||||
|
# Alias Format:
|
||||||
|
# - Must start with @
|
||||||
|
# - Alphanumeric + hyphens only
|
||||||
|
# - Reserved: @last, @first, @upload
|
||||||
|
#
|
||||||
|
# Override Behavior (Section 5.2):
|
||||||
|
# - New alias assignment takes priority
|
||||||
|
# - Previous image loses its alias
|
||||||
|
# - Previous image is NOT deleted
|
||||||
|
#
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
// tests/api/04-aliases.ts
|
||||||
|
// 3-Tier Alias Resolution System Tests
|
||||||
|
|
||||||
|
import { join } from 'path';
|
||||||
|
import { api, log, runTest, uploadFile, waitForGeneration, testContext, resolveAlias, createTestImage, exitWithTestResults } from './utils';
|
||||||
|
import { config, endpoints } from './config';
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log.section('ALIAS RESOLUTION TESTS');
|
||||||
|
|
||||||
|
// Setup: Create a flow for testing
|
||||||
|
const setupGen = await createTestImage('Setup image for alias tests', {
|
||||||
|
flowAlias: '@alias-test-flow',
|
||||||
|
});
|
||||||
|
testContext.aliasFlowId = setupGen.flowId;
|
||||||
|
log.info(`Test flow created: ${testContext.aliasFlowId}`);
|
||||||
|
|
||||||
|
// Test 1: Technical alias @last
|
||||||
|
await runTest('Technical alias - @last', async () => {
|
||||||
|
const resolved = await resolveAlias('@last', testContext.aliasFlowId);
|
||||||
|
|
||||||
|
if (resolved.scope !== 'technical') {
|
||||||
|
throw new Error(`Wrong scope: ${resolved.scope}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved.imageId) {
|
||||||
|
throw new Error('No image resolved');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Scope', resolved.scope);
|
||||||
|
log.detail('Alias', '@last');
|
||||||
|
log.detail('Image ID', resolved.imageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Technical alias @first
|
||||||
|
await runTest('Technical alias - @first', async () => {
|
||||||
|
const resolved = await resolveAlias('@first', testContext.aliasFlowId);
|
||||||
|
|
||||||
|
if (resolved.scope !== 'technical') {
|
||||||
|
throw new Error(`Wrong scope: ${resolved.scope}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Scope', resolved.scope);
|
||||||
|
log.detail('Alias', '@first');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Technical alias @upload
|
||||||
|
await runTest('Technical alias - @upload', async () => {
|
||||||
|
// First upload an image to the flow
|
||||||
|
const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png');
|
||||||
|
await uploadFile(fixturePath, {
|
||||||
|
flowId: testContext.aliasFlowId,
|
||||||
|
description: 'Uploaded for @upload test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveAlias('@upload', testContext.aliasFlowId);
|
||||||
|
|
||||||
|
if (resolved.scope !== 'technical') {
|
||||||
|
throw new Error(`Wrong scope: ${resolved.scope}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Scope', resolved.scope);
|
||||||
|
log.detail('Alias', '@upload');
|
||||||
|
log.detail('Image source', 'uploaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Technical alias requires flowId
|
||||||
|
await runTest('Technical alias requires flow context', async () => {
|
||||||
|
try {
|
||||||
|
await resolveAlias('@last'); // No flowId
|
||||||
|
throw new Error('Should require flowId');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('Should require')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
log.detail('Correctly requires flowId', '✓');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: Flow-scoped alias
|
||||||
|
await runTest('Flow-scoped alias resolution', async () => {
|
||||||
|
const gen = await createTestImage('Image for flow alias', {
|
||||||
|
flowId: testContext.aliasFlowId,
|
||||||
|
flowAlias: '@flow-hero',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveAlias('@flow-hero', testContext.aliasFlowId);
|
||||||
|
|
||||||
|
if (resolved.scope !== 'flow') {
|
||||||
|
throw new Error(`Wrong scope: ${resolved.scope}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Scope', resolved.scope);
|
||||||
|
log.detail('Alias', '@flow-hero');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: Project-scoped alias
|
||||||
|
await runTest('Project-scoped alias resolution', async () => {
|
||||||
|
const gen = await createTestImage('Image for project alias', {
|
||||||
|
alias: '@project-logo',
|
||||||
|
flowId: null, // Explicitly no flow
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveAlias('@project-logo');
|
||||||
|
|
||||||
|
if (resolved.scope !== 'project') {
|
||||||
|
throw new Error(`Wrong scope: ${resolved.scope}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Scope', resolved.scope);
|
||||||
|
log.detail('Alias', '@project-logo');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 7: Alias priority - flow overrides project
|
||||||
|
await runTest('Alias precedence - flow > project', async () => {
|
||||||
|
// Create project alias
|
||||||
|
const projectGen = await createTestImage('Project scoped image', {
|
||||||
|
alias: '@priority-test',
|
||||||
|
flowId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create flow alias with same name
|
||||||
|
const flowGen = await createTestImage('Flow scoped image', {
|
||||||
|
flowId: testContext.aliasFlowId,
|
||||||
|
flowAlias: '@priority-test',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without flow context - should get project
|
||||||
|
const projectResolved = await resolveAlias('@priority-test');
|
||||||
|
if (projectResolved.imageId !== projectGen.outputImageId) {
|
||||||
|
throw new Error('Should resolve to project alias');
|
||||||
|
}
|
||||||
|
log.detail('Without flow context', 'resolved to project ✓');
|
||||||
|
|
||||||
|
// With flow context - should get flow
|
||||||
|
const flowResolved = await resolveAlias('@priority-test', testContext.aliasFlowId);
|
||||||
|
if (flowResolved.imageId !== flowGen.outputImageId) {
|
||||||
|
throw new Error('Should resolve to flow alias');
|
||||||
|
}
|
||||||
|
log.detail('With flow context', 'resolved to flow ✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 8: Reserved alias validation
|
||||||
|
await runTest('Reserved aliases cannot be assigned', async () => {
|
||||||
|
const reservedAliases = ['@last', '@first', '@upload'];
|
||||||
|
|
||||||
|
for (const reserved of reservedAliases) {
|
||||||
|
try {
|
||||||
|
const gen = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'Test',
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
alias: reserved,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we get here, it didn't throw - that's bad
|
||||||
|
log.warning(`Reserved alias ${reserved} was allowed!`);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Expected to fail
|
||||||
|
log.detail(`${reserved} correctly blocked`, '✓');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 9: Alias reassignment
|
||||||
|
await runTest('Alias reassignment removes old', async () => {
|
||||||
|
const gen1 = await createTestImage('First image', {
|
||||||
|
alias: '@reassign-test',
|
||||||
|
flowId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gen2 = await createTestImage('Second image', {
|
||||||
|
alias: '@reassign-test',
|
||||||
|
flowId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that gen2 has the alias
|
||||||
|
const resolved = await resolveAlias('@reassign-test');
|
||||||
|
if (resolved.imageId !== gen2.outputImageId) {
|
||||||
|
throw new Error('Alias should be on second image');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that gen1 lost the alias
|
||||||
|
const img1 = await api(`${endpoints.images}/${gen1.outputImageId}`);
|
||||||
|
if (img1.data.data.alias !== null) {
|
||||||
|
throw new Error('First image should have lost alias');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Second image has alias', '✓');
|
||||||
|
log.detail('First image lost alias', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 10: Same alias in different flows
|
||||||
|
await runTest('Same alias in different flows', async () => {
|
||||||
|
// Create two flows with same alias
|
||||||
|
const gen1 = await createTestImage('Flow 1 image', {
|
||||||
|
flowAlias: '@shared-name',
|
||||||
|
});
|
||||||
|
|
||||||
|
const gen2 = await createTestImage('Flow 2 image', {
|
||||||
|
flowAlias: '@shared-name',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve in each flow context
|
||||||
|
const resolved1 = await resolveAlias('@shared-name', gen1.flowId);
|
||||||
|
const resolved2 = await resolveAlias('@shared-name', gen2.flowId);
|
||||||
|
|
||||||
|
if (resolved1.imageId === resolved2.imageId) {
|
||||||
|
throw new Error('Should resolve to different images');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Flow 1 image', resolved1.imageId.slice(0, 8));
|
||||||
|
log.detail('Flow 2 image', resolved2.imageId.slice(0, 8));
|
||||||
|
log.detail('Isolation confirmed', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 11: Technical alias in generation prompt
|
||||||
|
await runTest('Use technical alias in prompt', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'New variation based on @last',
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
flowId: testContext.aliasFlowId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error('Generation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that @last was resolved
|
||||||
|
const hasLast = generation.referencedImages?.some((ref: any) => ref.alias === '@last');
|
||||||
|
if (!hasLast) {
|
||||||
|
throw new Error('Technical alias not resolved in prompt');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Technical alias resolved', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 12: Upload with dual aliases
|
||||||
|
await runTest('Upload with both project and flow alias', async () => {
|
||||||
|
const fixturePath = join(__dirname, config.fixturesDir, 'test-image.png');
|
||||||
|
|
||||||
|
const response = await uploadFile(fixturePath, {
|
||||||
|
alias: '@dual-project',
|
||||||
|
flowId: testContext.aliasFlowId,
|
||||||
|
flowAlias: '@dual-flow',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify both aliases work
|
||||||
|
const projectResolved = await resolveAlias('@dual-project');
|
||||||
|
const flowResolved = await resolveAlias('@dual-flow', testContext.aliasFlowId);
|
||||||
|
|
||||||
|
if (projectResolved.imageId !== response.id || flowResolved.imageId !== response.id) {
|
||||||
|
throw new Error('Both aliases should resolve to same image');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Project alias', '@dual-project ✓');
|
||||||
|
log.detail('Flow alias', '@dual-flow ✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
log.section('ALIAS RESOLUTION TESTS COMPLETED');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => exitWithTestResults())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
@base = http://localhost:3000
|
||||||
|
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# LIVE URL & SCOPE MANAGEMENT TESTS
|
||||||
|
# Tests: Live generation with caching, Scope management
|
||||||
|
#
|
||||||
|
# Test Coverage:
|
||||||
|
# 1. Create live scope
|
||||||
|
# 2. List all scopes
|
||||||
|
# 3. Get scope details
|
||||||
|
# 4. Update scope settings
|
||||||
|
# 5. Live URL - basic generation
|
||||||
|
# 6. Regenerate scope images
|
||||||
|
# 7. Delete scope
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 1: Create Live Scope
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 1.1: Create scope
|
||||||
|
# @name createScope
|
||||||
|
POST {{base}}/api/v1/live/scopes
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"slug": "test-scope",
|
||||||
|
"allowNewGenerations": true,
|
||||||
|
"newGenerationsLimit": 50
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns scope object
|
||||||
|
# - slug = "test-scope"
|
||||||
|
# - allowNewGenerations = true
|
||||||
|
# - newGenerationsLimit = 50
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 2: List All Scopes
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 2.1: List scopes
|
||||||
|
# @name listScopes
|
||||||
|
GET {{base}}/api/v1/live/scopes
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns array of scopes
|
||||||
|
# - Contains "test-scope"
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 3: Get Scope Details
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 3.1: Get scope by slug
|
||||||
|
# @name getScope
|
||||||
|
GET {{base}}/api/v1/live/scopes/test-scope
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns scope object
|
||||||
|
# - slug = "test-scope"
|
||||||
|
# - currentGenerations is number
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 4: Update Scope Settings
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 4.1: Disable new generations
|
||||||
|
# @name updateScopeDisable
|
||||||
|
PUT {{base}}/api/v1/live/scopes/test-scope
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"allowNewGenerations": false,
|
||||||
|
"newGenerationsLimit": 100
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - allowNewGenerations = false
|
||||||
|
# - newGenerationsLimit = 100
|
||||||
|
|
||||||
|
|
||||||
|
### Step 4.2: Re-enable for testing
|
||||||
|
# @name updateScopeEnable
|
||||||
|
PUT {{base}}/api/v1/live/scopes/test-scope
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"allowNewGenerations": true
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 5: Live URL - Basic Generation
|
||||||
|
# GET /api/v1/live?prompt=...
|
||||||
|
# Returns image bytes directly with cache headers
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 5.1: Generate via live URL
|
||||||
|
# @name liveGenerate
|
||||||
|
GET {{base}}/api/v1/live?prompt=A%20simple%20blue%20square%20on%20white%20background
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns 200
|
||||||
|
# - Response is image bytes (Content-Type: image/*)
|
||||||
|
# - X-Cache-Status header (HIT or MISS)
|
||||||
|
|
||||||
|
|
||||||
|
### Step 5.2: Same prompt again (should be cached)
|
||||||
|
# @name liveGenerateCached
|
||||||
|
GET {{base}}/api/v1/live?prompt=A%20simple%20blue%20square%20on%20white%20background
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - X-Cache-Status: HIT
|
||||||
|
# - Faster response time
|
||||||
|
|
||||||
|
|
||||||
|
### Step 5.3: Different prompt
|
||||||
|
# @name liveGenerateNew
|
||||||
|
GET {{base}}/api/v1/live?prompt=A%20red%20circle%20on%20black%20background
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - X-Cache-Status: MISS (new prompt)
|
||||||
|
|
||||||
|
|
||||||
|
### Step 5.4: With aspect ratio
|
||||||
|
# @name liveGenerateWithAspect
|
||||||
|
GET {{base}}/api/v1/live?prompt=A%20landscape%20scene&aspectRatio=16:9
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 6: Regenerate Scope Images
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 6.1: Trigger regeneration
|
||||||
|
# @name regenerateScope
|
||||||
|
POST {{base}}/api/v1/live/scopes/test-scope/regenerate
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns 200
|
||||||
|
# - Regeneration triggered
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 7: Delete Scope
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 7.1: Delete scope
|
||||||
|
# @name deleteScope
|
||||||
|
DELETE {{base}}/api/v1/live/scopes/test-scope
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns 200
|
||||||
|
|
||||||
|
|
||||||
|
### Step 7.2: Verify deleted (should 404)
|
||||||
|
# @name verifyScopeDeleted
|
||||||
|
GET {{base}}/api/v1/live/scopes/test-scope
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 404 Not Found
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# NOTES
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Live URL Endpoint:
|
||||||
|
# - GET /api/v1/live?prompt=...
|
||||||
|
# - Returns image bytes directly (not JSON)
|
||||||
|
# - Supports prompt caching via SHA-256 hash
|
||||||
|
#
|
||||||
|
# Response Headers:
|
||||||
|
# - Content-Type: image/jpeg (or image/png, etc.)
|
||||||
|
# - X-Cache-Status: HIT | MISS
|
||||||
|
# - X-Cache-Hit-Count: number (on HIT)
|
||||||
|
# - X-Generation-Id: UUID (on MISS)
|
||||||
|
# - X-Image-Id: UUID
|
||||||
|
# - Cache-Control: public, max-age=31536000
|
||||||
|
#
|
||||||
|
# Scope Management:
|
||||||
|
# - Scopes group generations for management
|
||||||
|
# - allowNewGenerations controls if new prompts generate
|
||||||
|
# - newGenerationsLimit caps generations per scope
|
||||||
|
#
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
// tests/api/05-live.ts
|
||||||
|
// Live URLs and Scope Management Tests
|
||||||
|
|
||||||
|
import { api, log, runTest, testContext, exitWithTestResults } from './utils';
|
||||||
|
import { endpoints } from './config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log.section('LIVE URL & SCOPE TESTS');
|
||||||
|
|
||||||
|
// Test 1: Create scope manually
|
||||||
|
await runTest('Create live scope', async () => {
|
||||||
|
const result = await api(`${endpoints.live}/scopes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: 'test-scope',
|
||||||
|
allowNewGenerations: true,
|
||||||
|
newGenerationsLimit: 50,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data) {
|
||||||
|
throw new Error('No scope returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Scope slug', result.data.data.slug);
|
||||||
|
log.detail('Allow new generations', result.data.data.allowNewGenerations);
|
||||||
|
log.detail('Limit', result.data.data.newGenerationsLimit);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: List scopes
|
||||||
|
await runTest('List all scopes', async () => {
|
||||||
|
const result = await api(`${endpoints.live}/scopes`);
|
||||||
|
|
||||||
|
if (!result.data.data || !Array.isArray(result.data.data)) {
|
||||||
|
throw new Error('No scopes array returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Total scopes', result.data.data.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Get scope details
|
||||||
|
await runTest('Get scope details', async () => {
|
||||||
|
const result = await api(`${endpoints.live}/scopes/test-scope`);
|
||||||
|
|
||||||
|
if (!result.data.data) {
|
||||||
|
throw new Error('Scope not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = result.data.data;
|
||||||
|
if (typeof scope.currentGenerations !== 'number') {
|
||||||
|
throw new Error('Missing currentGenerations count');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Slug', scope.slug);
|
||||||
|
log.detail('Current generations', scope.currentGenerations);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Update scope settings
|
||||||
|
await runTest('Update scope settings', async () => {
|
||||||
|
const result = await api(`${endpoints.live}/scopes/test-scope`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
allowNewGenerations: false,
|
||||||
|
newGenerationsLimit: 100,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data) {
|
||||||
|
throw new Error('No scope returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = result.data.data;
|
||||||
|
if (scope.allowNewGenerations !== false) {
|
||||||
|
throw new Error('Setting not updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Allow new generations', scope.allowNewGenerations);
|
||||||
|
log.detail('New limit', scope.newGenerationsLimit);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: Live URL - basic generation
|
||||||
|
await runTest('Live URL - basic generation', async () => {
|
||||||
|
// Re-enable generation for testing
|
||||||
|
await api(`${endpoints.live}/scopes/test-scope`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
allowNewGenerations: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live endpoint requires prompt query parameter
|
||||||
|
const testPrompt = encodeURIComponent('A simple blue square on white background');
|
||||||
|
const result = await api(`${endpoints.live}?prompt=${testPrompt}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response should be image bytes or generation info
|
||||||
|
log.detail('Response received', '✓');
|
||||||
|
log.detail('Status', result.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: Scope regenerate
|
||||||
|
await runTest('Regenerate scope images', async () => {
|
||||||
|
const result = await api(`${endpoints.live}/scopes/test-scope/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
log.detail('Regenerate triggered', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 7: Delete scope
|
||||||
|
await runTest('Delete scope', async () => {
|
||||||
|
await api(`${endpoints.live}/scopes/test-scope`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify deleted - check for 404 status
|
||||||
|
const result = await api(`${endpoints.live}/scopes/test-scope`, {
|
||||||
|
expectError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 404) {
|
||||||
|
throw new Error('Scope should be deleted');
|
||||||
|
}
|
||||||
|
log.detail('Scope deleted', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
log.section('LIVE URL & SCOPE TESTS COMPLETED');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => exitWithTestResults())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,315 @@
|
||||||
|
@base = http://localhost:3000
|
||||||
|
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# EDGE CASES & VALIDATION TESTS
|
||||||
|
# Tests: Input validation, Error handling, Edge cases
|
||||||
|
#
|
||||||
|
# Test Coverage:
|
||||||
|
# 1. Invalid alias format
|
||||||
|
# 2. Invalid aspect ratio
|
||||||
|
# 3. Missing required fields
|
||||||
|
# 4. 404 for non-existent resources
|
||||||
|
# 5. Regenerate generation
|
||||||
|
# 6. CDN endpoints
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 1: Invalid Alias Format
|
||||||
|
# Aliases must start with @ and contain only alphanumeric + hyphens
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 1.1: Alias without @ symbol (should fail)
|
||||||
|
# @name invalidNoAt
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test invalid alias",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": "no-at-symbol"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 400 validation error OR 500 with alias error
|
||||||
|
|
||||||
|
### Step 1.2: Alias with spaces (should fail)
|
||||||
|
# @name invalidWithSpaces
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test invalid alias",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": "@has spaces"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 400 validation error
|
||||||
|
|
||||||
|
### Step 1.3: Alias with special characters (should fail)
|
||||||
|
# @name invalidSpecialChars
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test invalid alias",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": "@special!chars"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 400 validation error
|
||||||
|
|
||||||
|
### Step 1.4: Empty alias (should fail or be ignored)
|
||||||
|
# @name invalidEmpty
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test invalid alias",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"alias": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 2: Invalid Aspect Ratio
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 2.1: Invalid aspect ratio string
|
||||||
|
# @name invalidAspectRatio
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test invalid aspect ratio",
|
||||||
|
"aspectRatio": "invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 400 validation error
|
||||||
|
|
||||||
|
### Step 2.2: Unsupported aspect ratio
|
||||||
|
# @name unsupportedAspectRatio
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test unsupported aspect ratio",
|
||||||
|
"aspectRatio": "5:7"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 400 validation error (only 1:1, 16:9, 9:16, 4:3, 3:4, 21:9 supported)
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 3: Missing Required Fields
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 3.1: Missing prompt
|
||||||
|
# @name missingPrompt
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"aspectRatio": "1:1"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 400 - "Prompt is required"
|
||||||
|
|
||||||
|
### Step 3.2: Empty body
|
||||||
|
# @name emptyBody
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 400 - "Prompt is required"
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 4: 404 for Non-Existent Resources
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 4.1: Non-existent image
|
||||||
|
# @name notFoundImage
|
||||||
|
GET {{base}}/api/v1/images/00000000-0000-0000-0000-000000000000
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 404 Not Found
|
||||||
|
|
||||||
|
### Step 4.2: Non-existent generation
|
||||||
|
# @name notFoundGeneration
|
||||||
|
GET {{base}}/api/v1/generations/00000000-0000-0000-0000-000000000000
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 404 Not Found
|
||||||
|
|
||||||
|
### Step 4.3: Non-existent flow
|
||||||
|
# @name notFoundFlow
|
||||||
|
GET {{base}}/api/v1/flows/00000000-0000-0000-0000-000000000000
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 404 Not Found
|
||||||
|
|
||||||
|
### Step 4.4: Non-existent alias
|
||||||
|
# @name notFoundAlias
|
||||||
|
GET {{base}}/api/v1/images/@non-existent-alias
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 404 - "Alias '@non-existent-alias' not found"
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 5: Regenerate Generation
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 5.1: Create generation for regenerate test
|
||||||
|
# @name createForRegen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test image for regenerate",
|
||||||
|
"aspectRatio": "1:1"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@regenSourceId = {{createForRegen.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 5.2: Poll until success
|
||||||
|
# @name checkForRegen
|
||||||
|
GET {{base}}/api/v1/generations/{{regenSourceId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Step 5.3: Regenerate
|
||||||
|
# @name regenerateGen
|
||||||
|
POST {{base}}/api/v1/generations/{{regenSourceId}}/regenerate
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Returns new generation
|
||||||
|
# - New generation has same prompt
|
||||||
|
|
||||||
|
### Step 5.4: Regenerate non-existent generation (should 404)
|
||||||
|
# @name regenerateNotFound
|
||||||
|
POST {{base}}/api/v1/generations/00000000-0000-0000-0000-000000000000/regenerate
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 404 Not Found
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 6: CDN Endpoints
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 6.1: CDN image by path (if implemented)
|
||||||
|
# @name cdnImage
|
||||||
|
GET {{base}}/api/v1/cdn/default/test-project/generated/2024-01/test.jpg
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Note: Endpoint structure check only - actual paths depend on storage
|
||||||
|
|
||||||
|
### Step 6.2: Health check
|
||||||
|
# @name healthCheck
|
||||||
|
GET {{base}}/health
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 200 with status info
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 7: Authentication Errors
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 7.1: Missing API key
|
||||||
|
# @name noApiKey
|
||||||
|
GET {{base}}/api/v1/generations
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 401 Unauthorized
|
||||||
|
|
||||||
|
### Step 7.2: Invalid API key
|
||||||
|
# @name invalidApiKey
|
||||||
|
GET {{base}}/api/v1/generations
|
||||||
|
X-API-Key: bnt_invalid_key_12345
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 401 Unauthorized
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 8: Malformed Requests
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 8.1: Invalid JSON
|
||||||
|
# @name invalidJson
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{invalid json}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 400 Bad Request
|
||||||
|
|
||||||
|
### Step 8.2: Wrong content type
|
||||||
|
# @name wrongContentType
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: text/plain
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
prompt=test&aspectRatio=1:1
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# NOTES
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Validation Rules:
|
||||||
|
# - Prompt: required, non-empty string
|
||||||
|
# - Aspect ratio: must be supported (1:1, 16:9, 9:16, 4:3, 3:4, 21:9)
|
||||||
|
# - Alias: must start with @, alphanumeric + hyphens only
|
||||||
|
# - UUID: must be valid UUID format
|
||||||
|
#
|
||||||
|
# Error Responses:
|
||||||
|
# - 400: Validation error (missing/invalid fields)
|
||||||
|
# - 401: Authentication error (missing/invalid API key)
|
||||||
|
# - 404: Resource not found
|
||||||
|
# - 429: Rate limit exceeded
|
||||||
|
# - 500: Internal server error
|
||||||
|
#
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
// tests/api/06-edge-cases.ts
|
||||||
|
// Validation and Error Handling Tests
|
||||||
|
|
||||||
|
import { join } from 'path';
|
||||||
|
import { api, log, runTest, testContext, uploadFile, exitWithTestResults } from './utils';
|
||||||
|
import { config, endpoints } from './config';
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log.section('EDGE CASES & VALIDATION TESTS');
|
||||||
|
|
||||||
|
// Test 1: Invalid alias format
|
||||||
|
await runTest('Invalid alias format', async () => {
|
||||||
|
const invalidAliases = ['no-at-symbol', '@has spaces', '@special!chars', ''];
|
||||||
|
|
||||||
|
for (const invalid of invalidAliases) {
|
||||||
|
try {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'Test',
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
alias: invalid,
|
||||||
|
}),
|
||||||
|
expectError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status >= 400) {
|
||||||
|
log.detail(`"${invalid}" correctly rejected`, '✓');
|
||||||
|
} else {
|
||||||
|
log.warning(`"${invalid}" was accepted!`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.detail(`"${invalid}" correctly rejected`, '✓');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Invalid aspect ratio
|
||||||
|
await runTest('Invalid aspect ratio', async () => {
|
||||||
|
try {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'Test',
|
||||||
|
aspectRatio: 'invalid',
|
||||||
|
}),
|
||||||
|
expectError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status >= 400) {
|
||||||
|
log.detail('Invalid aspect ratio rejected', '✓');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.detail('Invalid aspect ratio rejected', '✓');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Missing required fields
|
||||||
|
await runTest('Missing required fields', async () => {
|
||||||
|
try {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
// Missing prompt
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
}),
|
||||||
|
expectError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status >= 400) {
|
||||||
|
log.detail('Missing prompt rejected', '✓');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.detail('Missing prompt rejected', '✓');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Non-existent resources
|
||||||
|
await runTest('404 for non-existent resources', async () => {
|
||||||
|
const fakeUuid = '00000000-0000-0000-0000-000000000000';
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
{ url: `${endpoints.images}/${fakeUuid}`, name: 'image' },
|
||||||
|
{ url: `${endpoints.generations}/${fakeUuid}`, name: 'generation' },
|
||||||
|
{ url: `${endpoints.flows}/${fakeUuid}`, name: 'flow' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
try {
|
||||||
|
const result = await api(test.url, { expectError: true });
|
||||||
|
if (result.status === 404) {
|
||||||
|
log.detail(`${test.name} 404`, '✓');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.detail(`${test.name} 404`, '✓');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: Regenerate successful generation
|
||||||
|
await runTest('Regenerate successful generation', async () => {
|
||||||
|
// Create a generation first
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'Test for regenerate',
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait briefly (not full completion)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// Regenerate
|
||||||
|
const regen = await api(`${endpoints.generations}/${result.data.data.id}/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!regen.data.data) {
|
||||||
|
throw new Error('No regeneration returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Regenerate triggered', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: CDN image by filename (if implemented)
|
||||||
|
await runTest('CDN endpoints exist', async () => {
|
||||||
|
// Just verify the endpoint structure exists
|
||||||
|
log.detail('CDN endpoints', 'not fully tested (no org/project context)');
|
||||||
|
});
|
||||||
|
|
||||||
|
log.section('EDGE CASES & VALIDATION TESTS COMPLETED');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => exitWithTestResults())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
@base = http://localhost:3000
|
||||||
|
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# KNOWN ISSUES TESTS
|
||||||
|
# These tests document known bugs and implementation gaps
|
||||||
|
#
|
||||||
|
# ⚠️ EXPECTED TO FAIL until issues are fixed
|
||||||
|
#
|
||||||
|
# Test Coverage:
|
||||||
|
# 1. Project alias on flow image
|
||||||
|
# 2. Flow delete cascades non-aliased images
|
||||||
|
# 3. Flow delete preserves aliased images
|
||||||
|
# 4. Flow delete cascades generations
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# ISSUE 1: Project Alias on Flow Image
|
||||||
|
# An image in a flow should be able to have a project-scoped alias
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 1.1: Create image with both flow and project alias
|
||||||
|
# @name issue1Gen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Image in flow with project alias",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowAlias": "@flow-test",
|
||||||
|
"alias": "@project-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@issue1FlowId = {{issue1Gen.response.body.$.data.flowId}}
|
||||||
|
@issue1GenId = {{issue1Gen.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 1.2: Poll generation
|
||||||
|
# @name checkIssue1Gen
|
||||||
|
GET {{base}}/api/v1/generations/{{issue1GenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@issue1ImageId = {{checkIssue1Gen.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 1.3: Resolve project alias (via deprecated /resolve endpoint)
|
||||||
|
# @name resolveProjectOnFlow
|
||||||
|
GET {{base}}/api/v1/images/resolve/@project-test
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# BUG: Project alias on flow image should be resolvable
|
||||||
|
# Expected: Returns image with id = {{issue1ImageId}}
|
||||||
|
|
||||||
|
### Step 1.4: Resolve project alias (via direct path - Section 6.2)
|
||||||
|
# @name resolveProjectOnFlowDirect
|
||||||
|
GET {{base}}/api/v1/images/@project-test
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# This should work after Section 6.2 implementation
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# ISSUE 2: Flow Delete Cascades Non-Aliased Images
|
||||||
|
# When deleting a flow, images without project alias should be deleted
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 2.1: Create flow with non-aliased image
|
||||||
|
# @name issue2Gen1
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "No alias image",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowAlias": "@issue-flow"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@issue2FlowId = {{issue2Gen1.response.body.$.data.flowId}}
|
||||||
|
@issue2Gen1Id = {{issue2Gen1.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 2.2: Poll generation
|
||||||
|
# @name checkIssue2Gen1
|
||||||
|
GET {{base}}/api/v1/generations/{{issue2Gen1Id}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@issue2Image1Id = {{checkIssue2Gen1.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 2.3: Add aliased image to same flow
|
||||||
|
# @name issue2Gen2
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "With alias image",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowId": "{{issue2FlowId}}",
|
||||||
|
"alias": "@protected-image"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@issue2Gen2Id = {{issue2Gen2.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 2.4: Poll generation
|
||||||
|
# @name checkIssue2Gen2
|
||||||
|
GET {{base}}/api/v1/generations/{{issue2Gen2Id}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@issue2Image2Id = {{checkIssue2Gen2.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 2.5: Delete flow
|
||||||
|
# @name deleteIssue2Flow
|
||||||
|
DELETE {{base}}/api/v1/flows/{{issue2FlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Step 2.6: Check non-aliased image (should be 404)
|
||||||
|
# @name checkIssue2Image1Deleted
|
||||||
|
GET {{base}}/api/v1/images/{{issue2Image1Id}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 404 - Non-aliased image should be deleted with flow
|
||||||
|
|
||||||
|
### Step 2.7: Check aliased image (should still exist)
|
||||||
|
# @name checkIssue2Image2Exists
|
||||||
|
GET {{base}}/api/v1/images/{{issue2Image2Id}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 200 - Aliased image should be preserved
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# ISSUE 3: Flow Delete Preserves Aliased Images
|
||||||
|
# Aliased images should have flowId set to null after flow deletion
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 3.1: Create flow with aliased image
|
||||||
|
# @name issue3Gen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Protected image",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowAlias": "@test-flow-2",
|
||||||
|
"alias": "@keep-this"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@issue3FlowId = {{issue3Gen.response.body.$.data.flowId}}
|
||||||
|
@issue3GenId = {{issue3Gen.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 3.2: Poll generation
|
||||||
|
# @name checkIssue3Gen
|
||||||
|
GET {{base}}/api/v1/generations/{{issue3GenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@issue3ImageId = {{checkIssue3Gen.response.body.$.data.outputImageId}}
|
||||||
|
|
||||||
|
### Step 3.3: Delete flow
|
||||||
|
# @name deleteIssue3Flow
|
||||||
|
DELETE {{base}}/api/v1/flows/{{issue3FlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Step 3.4: Check aliased image (should exist with flowId=null)
|
||||||
|
# @name checkIssue3ImagePreserved
|
||||||
|
GET {{base}}/api/v1/images/{{issue3ImageId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 200 with flowId = null
|
||||||
|
# BUG: flowId might not be set to null
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# ISSUE 4: Flow Delete Cascades Generations
|
||||||
|
# Generations should be deleted when flow is deleted
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 4.1: Create flow with generation
|
||||||
|
# @name issue4Gen
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Test generation",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"flowAlias": "@gen-flow"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@issue4FlowId = {{issue4Gen.response.body.$.data.flowId}}
|
||||||
|
@issue4GenId = {{issue4Gen.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 4.2: Poll generation
|
||||||
|
# @name checkIssue4Gen
|
||||||
|
GET {{base}}/api/v1/generations/{{issue4GenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Step 4.3: Delete flow
|
||||||
|
# @name deleteIssue4Flow
|
||||||
|
DELETE {{base}}/api/v1/flows/{{issue4FlowId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### Step 4.4: Check generation (should be 404)
|
||||||
|
# @name checkIssue4GenDeleted
|
||||||
|
GET {{base}}/api/v1/generations/{{issue4GenId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected: 404 - Generation should be deleted with flow
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# NOTES
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Flow Deletion Cascade (per api-refactoring-final.md):
|
||||||
|
# - Flow record → DELETE
|
||||||
|
# - All generations → DELETE
|
||||||
|
# - Images without alias → DELETE (with MinIO cleanup)
|
||||||
|
# - Images with project alias → KEEP (unlink: flowId = NULL)
|
||||||
|
#
|
||||||
|
# Known Issues:
|
||||||
|
# 1. Project alias on flow images may not resolve properly
|
||||||
|
# 2. Flow deletion may not properly cascade deletions
|
||||||
|
# 3. Aliased images may not have flowId set to null
|
||||||
|
#
|
||||||
|
# These tests document expected behavior that may not be implemented yet.
|
||||||
|
#
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
// tests/api/07-known-issues.ts
|
||||||
|
// Tests for Known Implementation Issues (EXPECTED TO FAIL)
|
||||||
|
|
||||||
|
import { api, log, runTest, createTestImage, testContext, exitWithTestResults } from './utils';
|
||||||
|
import { endpoints } from './config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log.section('KNOWN ISSUES TESTS (Expected to Fail)');
|
||||||
|
log.warning('These tests document known bugs and missing features');
|
||||||
|
|
||||||
|
// Issue #1: Project aliases on flow images
|
||||||
|
await runTest('ISSUE: Project alias on flow image', async () => {
|
||||||
|
const gen = await createTestImage('Image in flow with project alias', {
|
||||||
|
flowAlias: '@flow-test',
|
||||||
|
alias: '@project-test', // Project alias on flow image
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to resolve the project alias
|
||||||
|
const result = await api(`${endpoints.images}/resolve/@project-test`);
|
||||||
|
|
||||||
|
if (!result.data.data || result.data.data.imageId !== gen.outputImageId) {
|
||||||
|
throw new Error('Project alias on flow image should work but does not');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Project alias resolved', '✓');
|
||||||
|
log.detail('Image ID', gen.outputImageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue #2: Flow cascade delete - non-aliased images
|
||||||
|
await runTest('ISSUE: Flow delete cascades non-aliased images', async () => {
|
||||||
|
// Create flow with mixed images
|
||||||
|
const genWithoutAlias = await createTestImage('No alias', {
|
||||||
|
flowAlias: '@issue-flow',
|
||||||
|
});
|
||||||
|
const flowId = genWithoutAlias.flowId;
|
||||||
|
|
||||||
|
// Add another image with project alias
|
||||||
|
const genWithAlias = await createTestImage('With alias', {
|
||||||
|
flowId: flowId,
|
||||||
|
alias: '@protected-image',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete flow
|
||||||
|
await api(`${endpoints.flows}/${flowId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if non-aliased image was deleted
|
||||||
|
try {
|
||||||
|
await api(`${endpoints.images}/${genWithoutAlias.outputImageId}`, {
|
||||||
|
expectError: true,
|
||||||
|
});
|
||||||
|
log.detail('Non-aliased image deleted', '✓');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('expectError')) {
|
||||||
|
throw new Error('Non-aliased image should be deleted but still exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue #3: Flow cascade delete - aliased images protected
|
||||||
|
await runTest('ISSUE: Flow delete preserves aliased images', async () => {
|
||||||
|
// Create flow
|
||||||
|
const gen = await createTestImage('Protected image', {
|
||||||
|
flowAlias: '@test-flow-2',
|
||||||
|
alias: '@keep-this',
|
||||||
|
});
|
||||||
|
const flowId = gen.flowId;
|
||||||
|
|
||||||
|
// Delete flow
|
||||||
|
await api(`${endpoints.flows}/${flowId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aliased image should exist but flowId should be null
|
||||||
|
const image = await api(`${endpoints.images}/${gen.outputImageId}`);
|
||||||
|
|
||||||
|
if (image.data.data.flowId !== null) {
|
||||||
|
throw new Error('Aliased image should have flowId=null after flow deletion');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Aliased image preserved', '✓');
|
||||||
|
log.detail('flowId set to null', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue #4: Flow cascade delete - generations
|
||||||
|
await runTest('ISSUE: Flow delete cascades generations', async () => {
|
||||||
|
// Create flow with generation
|
||||||
|
const gen = await createTestImage('Test gen', {
|
||||||
|
flowAlias: '@gen-flow',
|
||||||
|
});
|
||||||
|
const flowId = gen.flowId;
|
||||||
|
const genId = gen.id;
|
||||||
|
|
||||||
|
// Delete flow
|
||||||
|
await api(`${endpoints.flows}/${flowId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generation should be deleted
|
||||||
|
try {
|
||||||
|
await api(`${endpoints.generations}/${genId}`, {
|
||||||
|
expectError: true,
|
||||||
|
});
|
||||||
|
log.detail('Generation deleted', '✓');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('expectError')) {
|
||||||
|
throw new Error('Generation should be deleted but still exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.section('KNOWN ISSUES TESTS COMPLETED');
|
||||||
|
log.warning('Failures above are EXPECTED and document bugs to fix');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => exitWithTestResults())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
@base = http://localhost:3000
|
||||||
|
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# AUTO-ENHANCE TESTS
|
||||||
|
# Tests: Prompt auto-enhancement feature
|
||||||
|
#
|
||||||
|
# Test Coverage:
|
||||||
|
# 1. Generate without autoEnhance param (defaults to true)
|
||||||
|
# 2. Generate with autoEnhance: false
|
||||||
|
# 3. Generate with autoEnhance: true
|
||||||
|
# 4. Verify enhancement quality
|
||||||
|
# 5. List generations with autoEnhance field
|
||||||
|
# 6. Verify response structure
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 1: Generate Without autoEnhance Parameter
|
||||||
|
# Should default to true (enhancement enabled)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 1.1: Create generation without autoEnhance param
|
||||||
|
# @name genDefaultEnhance
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "a simple test image",
|
||||||
|
"aspectRatio": "1:1"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@genDefaultId = {{genDefaultEnhance.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 1.2: Poll generation
|
||||||
|
# @name checkGenDefault
|
||||||
|
GET {{base}}/api/v1/generations/{{genDefaultId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - autoEnhance = true
|
||||||
|
# - originalPrompt = "a simple test image"
|
||||||
|
# - prompt != originalPrompt (was enhanced)
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 2: Generate with autoEnhance: false
|
||||||
|
# Should NOT enhance the prompt
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 2.1: Create generation with autoEnhance: false
|
||||||
|
# @name genNoEnhance
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "another test image",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"autoEnhance": false
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@genNoEnhanceId = {{genNoEnhance.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 2.2: Poll generation
|
||||||
|
# @name checkGenNoEnhance
|
||||||
|
GET {{base}}/api/v1/generations/{{genNoEnhanceId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - autoEnhance = false
|
||||||
|
# - originalPrompt = "another test image"
|
||||||
|
# - prompt = "another test image" (same, NOT enhanced)
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 3: Generate with autoEnhance: true
|
||||||
|
# Should enhance the prompt
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 3.1: Create generation with explicit autoEnhance: true
|
||||||
|
# @name genExplicitEnhance
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "third test image",
|
||||||
|
"aspectRatio": "1:1",
|
||||||
|
"autoEnhance": true
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@genExplicitId = {{genExplicitEnhance.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Step 3.2: Poll generation
|
||||||
|
# @name checkGenExplicit
|
||||||
|
GET {{base}}/api/v1/generations/{{genExplicitId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - autoEnhance = true
|
||||||
|
# - originalPrompt = "third test image"
|
||||||
|
# - prompt != originalPrompt (was enhanced)
|
||||||
|
# - prompt is longer and more descriptive
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 4: Verify Enhancement Quality
|
||||||
|
# Enhanced prompt should be longer and more descriptive
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 4.1: Get enhanced generation
|
||||||
|
# @name getEnhancedGen
|
||||||
|
GET {{base}}/api/v1/generations/{{genDefaultId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Enhanced prompt is longer than original
|
||||||
|
# - Enhanced prompt may contain: "photorealistic", "detailed", "scene", etc.
|
||||||
|
# - Compare: prompt.length > originalPrompt.length
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 5: List Generations with autoEnhance Field
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 5.1: List all generations
|
||||||
|
# @name listGens
|
||||||
|
GET {{base}}/api/v1/generations
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify:
|
||||||
|
# - Each generation has autoEnhance field (boolean)
|
||||||
|
# - Some generations have autoEnhance = true
|
||||||
|
# - Some generations have autoEnhance = false
|
||||||
|
|
||||||
|
|
||||||
|
### Step 5.2: Filter by status to see recent ones
|
||||||
|
# @name listSuccessGens
|
||||||
|
GET {{base}}/api/v1/generations?status=success&limit=10
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# TEST 6: Verify Response Structure
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Step 6.1: Get generation and check fields
|
||||||
|
# @name verifyStructure
|
||||||
|
GET {{base}}/api/v1/generations/{{genDefaultId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Expected fields:
|
||||||
|
# - prompt: string (final prompt, possibly enhanced)
|
||||||
|
# - originalPrompt: string (original input prompt)
|
||||||
|
# - autoEnhance: boolean (whether enhancement was applied)
|
||||||
|
# - status: string
|
||||||
|
# - outputImageId: string (on success)
|
||||||
|
# - processingTimeMs: number (on completion)
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# ADDITIONAL TEST CASES
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### Complex prompt that might be enhanced differently
|
||||||
|
# @name complexPrompt
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "a cat sitting on a windowsill",
|
||||||
|
"aspectRatio": "16:9"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@complexId = {{complexPrompt.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Check complex prompt enhancement
|
||||||
|
# @name checkComplexPrompt
|
||||||
|
GET {{base}}/api/v1/generations/{{complexId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify: Enhanced prompt should add details like lighting, perspective, style, etc.
|
||||||
|
|
||||||
|
|
||||||
|
### Short prompt enhancement
|
||||||
|
# @name shortPrompt
|
||||||
|
POST {{base}}/api/v1/generations
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "sunset",
|
||||||
|
"aspectRatio": "21:9"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
@shortId = {{shortPrompt.response.body.$.data.id}}
|
||||||
|
|
||||||
|
### Check short prompt enhancement
|
||||||
|
# @name checkShortPrompt
|
||||||
|
GET {{base}}/api/v1/generations/{{shortId}}
|
||||||
|
X-API-Key: {{apiKey}}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Verify: Very short prompts should be significantly enhanced
|
||||||
|
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# NOTES
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Auto-Enhance Feature:
|
||||||
|
# - Default: autoEnhance = true (prompts are enhanced by AI)
|
||||||
|
# - Set autoEnhance: false to disable enhancement
|
||||||
|
# - Enhanced prompts are more detailed and descriptive
|
||||||
|
#
|
||||||
|
# Response Fields:
|
||||||
|
# - prompt: The final prompt (enhanced if autoEnhance was true)
|
||||||
|
# - originalPrompt: The user's original input
|
||||||
|
# - autoEnhance: Boolean flag indicating if enhancement was applied
|
||||||
|
#
|
||||||
|
# Enhancement adds:
|
||||||
|
# - Descriptive adjectives
|
||||||
|
# - Lighting and atmosphere details
|
||||||
|
# - Perspective and composition hints
|
||||||
|
# - Style and rendering suggestions
|
||||||
|
#
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
// tests/api/08-auto-enhance.ts
|
||||||
|
// Auto-Enhance Feature Tests
|
||||||
|
|
||||||
|
import { api, log, runTest, waitForGeneration, testContext, exitWithTestResults } from './utils';
|
||||||
|
import { endpoints } from './config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log.section('AUTO-ENHANCE TESTS');
|
||||||
|
|
||||||
|
// Test 1: Generation without autoEnhance parameter (should default to true)
|
||||||
|
await runTest('Generate without autoEnhance param → should enhance', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'a simple test image',
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
// No autoEnhance parameter - should default to true
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data || !result.data.data.id) {
|
||||||
|
throw new Error('No generation returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify enhancement happened
|
||||||
|
if (!generation.originalPrompt) {
|
||||||
|
throw new Error('originalPrompt should be populated when enhanced');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!generation.autoEnhance) {
|
||||||
|
throw new Error('autoEnhance should be true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation.prompt === generation.originalPrompt) {
|
||||||
|
throw new Error('prompt and originalPrompt should be different (enhancement happened)');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Original prompt', generation.originalPrompt);
|
||||||
|
log.detail('Enhanced prompt', generation.prompt);
|
||||||
|
log.detail('autoEnhance', generation.autoEnhance);
|
||||||
|
log.detail('Enhancement confirmed', '✓');
|
||||||
|
|
||||||
|
testContext.enhancedGenId = generation.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Generation with autoEnhance: false
|
||||||
|
await runTest('Generate with autoEnhance: false → should NOT enhance', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'another test image',
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
autoEnhance: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data || !result.data.data.id) {
|
||||||
|
throw new Error('No generation returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify NO enhancement happened
|
||||||
|
if (!generation.originalPrompt) {
|
||||||
|
throw new Error('originalPrompt should be populated with original input');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation.autoEnhance) {
|
||||||
|
throw new Error('autoEnhance should be false');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation.prompt !== generation.originalPrompt) {
|
||||||
|
throw new Error('prompt and originalPrompt should be the SAME when NOT enhanced');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation.prompt !== 'another test image') {
|
||||||
|
throw new Error('both prompts should match original input (no enhancement)');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Prompt', generation.prompt);
|
||||||
|
log.detail('originalPrompt', generation.originalPrompt);
|
||||||
|
log.detail('autoEnhance', generation.autoEnhance);
|
||||||
|
log.detail('Prompts match (no enhancement)', '✓');
|
||||||
|
|
||||||
|
testContext.notEnhancedGenId = generation.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Generation with explicit autoEnhance: true
|
||||||
|
await runTest('Generate with autoEnhance: true → should enhance', async () => {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: 'third test image',
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
autoEnhance: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data || !result.data.data.id) {
|
||||||
|
throw new Error('No generation returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify enhancement happened
|
||||||
|
if (!generation.originalPrompt) {
|
||||||
|
throw new Error('originalPrompt should be populated');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!generation.autoEnhance) {
|
||||||
|
throw new Error('autoEnhance should be true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation.originalPrompt !== 'third test image') {
|
||||||
|
throw new Error('originalPrompt should match input');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation.prompt === generation.originalPrompt) {
|
||||||
|
throw new Error('prompt should be enhanced (different from original)');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Original prompt', generation.originalPrompt);
|
||||||
|
log.detail('Enhanced prompt', generation.prompt);
|
||||||
|
log.detail('autoEnhance', generation.autoEnhance);
|
||||||
|
log.detail('Enhancement confirmed', '✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Verify enhanced prompt is actually different and longer
|
||||||
|
await runTest('Verify enhancement quality', async () => {
|
||||||
|
const result = await api(`${endpoints.generations}/${testContext.enhancedGenId}`);
|
||||||
|
const generation = result.data.data;
|
||||||
|
|
||||||
|
const originalLength = generation.originalPrompt?.length || 0;
|
||||||
|
const enhancedLength = generation.prompt?.length || 0;
|
||||||
|
|
||||||
|
if (enhancedLength <= originalLength) {
|
||||||
|
log.warning('Enhanced prompt not longer than original (might not be truly enhanced)');
|
||||||
|
} else {
|
||||||
|
log.detail('Original length', originalLength);
|
||||||
|
log.detail('Enhanced length', enhancedLength);
|
||||||
|
log.detail('Increase', `+${enhancedLength - originalLength} chars`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the enhanced prompt contains more descriptive language
|
||||||
|
const hasPhotorealistic = generation.prompt.toLowerCase().includes('photorealistic') ||
|
||||||
|
generation.prompt.toLowerCase().includes('realistic') ||
|
||||||
|
generation.prompt.toLowerCase().includes('detailed');
|
||||||
|
|
||||||
|
if (hasPhotorealistic) {
|
||||||
|
log.detail('Enhancement adds descriptive terms', '✓');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: Verify both enhanced and non-enhanced are in listings
|
||||||
|
await runTest('List generations - verify autoEnhance field', async () => {
|
||||||
|
const result = await api(endpoints.generations);
|
||||||
|
|
||||||
|
if (!result.data.data || !Array.isArray(result.data.data)) {
|
||||||
|
throw new Error('No generations array returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const enhancedGens = result.data.data.filter((g: any) => g.autoEnhance === true);
|
||||||
|
const notEnhancedGens = result.data.data.filter((g: any) => g.autoEnhance === false);
|
||||||
|
|
||||||
|
log.detail('Total generations', result.data.data.length);
|
||||||
|
log.detail('Enhanced', enhancedGens.length);
|
||||||
|
log.detail('Not enhanced', notEnhancedGens.length);
|
||||||
|
|
||||||
|
if (enhancedGens.length === 0) {
|
||||||
|
throw new Error('Should have at least one enhanced generation');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notEnhancedGens.length === 0) {
|
||||||
|
throw new Error('Should have at least one non-enhanced generation');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: Verify response structure
|
||||||
|
await runTest('Verify response includes all enhancement fields', async () => {
|
||||||
|
const result = await api(`${endpoints.generations}/${testContext.enhancedGenId}`);
|
||||||
|
const generation = result.data.data;
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
if (typeof generation.prompt !== 'string') {
|
||||||
|
throw new Error('prompt should be string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof generation.autoEnhance !== 'boolean') {
|
||||||
|
throw new Error('autoEnhance should be boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
// originalPrompt can be null or string
|
||||||
|
if (generation.originalPrompt !== null && typeof generation.originalPrompt !== 'string') {
|
||||||
|
throw new Error('originalPrompt should be null or string');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.detail('Response structure', 'valid ✓');
|
||||||
|
log.detail('prompt type', typeof generation.prompt);
|
||||||
|
log.detail('originalPrompt type', typeof generation.originalPrompt || 'null');
|
||||||
|
log.detail('autoEnhance type', typeof generation.autoEnhance);
|
||||||
|
});
|
||||||
|
|
||||||
|
log.section('AUTO-ENHANCE TESTS COMPLETED');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => exitWithTestResults())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
# 📦 Installation Instructions
|
||||||
|
|
||||||
|
## Шаги установки тестовых скриптов
|
||||||
|
|
||||||
|
### 1. Создайте структуру директорий
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /projects/my-projects/banatie-service
|
||||||
|
mkdir -p tests/api/fixtures
|
||||||
|
mkdir -p results
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Скопируйте файлы
|
||||||
|
|
||||||
|
Скопируйте все файлы из `/tmp/` в соответствующие директории:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Core files
|
||||||
|
cp /tmp/test-config.ts tests/api/config.ts
|
||||||
|
cp /tmp/test-utils.ts tests/api/utils.ts
|
||||||
|
cp /tmp/test-run-all.ts tests/api/run-all.ts
|
||||||
|
cp /tmp/test-README.md tests/api/README.md
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
cp /tmp/test-01-basic.ts tests/api/01-basic.ts
|
||||||
|
cp /tmp/test-02-flows.ts tests/api/02-flows.ts
|
||||||
|
cp /tmp/test-03-aliases.ts tests/api/03-aliases.ts
|
||||||
|
cp /tmp/test-04-live.ts tests/api/04-live.ts
|
||||||
|
cp /tmp/test-05-edge-cases.ts tests/api/05-edge-cases.ts
|
||||||
|
|
||||||
|
# Test fixture
|
||||||
|
cp /tmp/test-image.png tests/api/fixtures/test-image.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Обновите package.json
|
||||||
|
|
||||||
|
Добавьте скрипты в root `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test:api": "tsx tests/api/run-all.ts",
|
||||||
|
"test:api:basic": "tsx tests/api/01-basic.ts",
|
||||||
|
"test:api:flows": "tsx tests/api/02-flows.ts",
|
||||||
|
"test:api:aliases": "tsx tests/api/03-aliases.ts",
|
||||||
|
"test:api:live": "tsx tests/api/04-live.ts",
|
||||||
|
"test:api:edge": "tsx tests/api/05-edge-cases.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Установите зависимости (если еще нет):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add -D tsx @types/node
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Настройте environment
|
||||||
|
|
||||||
|
Создайте `.env` в корне проекта (если еще нет):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_KEY=bnt_your_test_api_key_here
|
||||||
|
API_BASE_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Обновите .gitignore
|
||||||
|
|
||||||
|
Добавьте в `.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Test results
|
||||||
|
results/
|
||||||
|
|
||||||
|
# Test environment
|
||||||
|
tests/api/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Проверка установки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте структуру
|
||||||
|
tree tests/api
|
||||||
|
|
||||||
|
# Должно выглядеть так:
|
||||||
|
# tests/api/
|
||||||
|
# ├── config.ts
|
||||||
|
# ├── utils.ts
|
||||||
|
# ├── fixtures/
|
||||||
|
# │ └── test-image.png
|
||||||
|
# ├── 01-basic.ts
|
||||||
|
# ├── 02-flows.ts
|
||||||
|
# ├── 03-aliases.ts
|
||||||
|
# ├── 04-live.ts
|
||||||
|
# ├── 05-edge-cases.ts
|
||||||
|
# ├── run-all.ts
|
||||||
|
# └── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Первый запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запустите API сервер
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# В другом терминале запустите тесты
|
||||||
|
pnpm test:api:basic
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Checklist
|
||||||
|
|
||||||
|
- [ ] Директории созданы
|
||||||
|
- [ ] Все файлы скопированы
|
||||||
|
- [ ] package.json обновлен
|
||||||
|
- [ ] .env настроен с API key
|
||||||
|
- [ ] .gitignore обновлен
|
||||||
|
- [ ] Зависимости установлены
|
||||||
|
- [ ] API сервер запущен
|
||||||
|
- [ ] Первый тест прошел успешно
|
||||||
|
|
||||||
|
## 🎯 Готово!
|
||||||
|
|
||||||
|
Теперь можно запускать:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Все тесты
|
||||||
|
pnpm test:api
|
||||||
|
|
||||||
|
# Отдельные наборы
|
||||||
|
pnpm test:api:basic
|
||||||
|
pnpm test:api:flows
|
||||||
|
pnpm test:api:aliases
|
||||||
|
pnpm test:api:live
|
||||||
|
pnpm test:api:edge
|
||||||
|
```
|
||||||
|
|
||||||
|
Результаты будут в `results/` директории.
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// tests/api/config.ts
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// API Configuration
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
apiKey: 'bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d',
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
resultsDir: '../../results',
|
||||||
|
fixturesDir: './fixture',
|
||||||
|
|
||||||
|
// Timeouts
|
||||||
|
requestTimeout: 30000,
|
||||||
|
generationTimeout: 60000,
|
||||||
|
|
||||||
|
// Test settings
|
||||||
|
verbose: true,
|
||||||
|
saveImages: true,
|
||||||
|
cleanupOnSuccess: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const endpoints = {
|
||||||
|
generations: '/api/v1/generations',
|
||||||
|
images: '/api/v1/images',
|
||||||
|
flows: '/api/v1/flows',
|
||||||
|
live: '/api/v1/live',
|
||||||
|
analytics: '/api/v1/analytics',
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
|
|
@ -0,0 +1,98 @@
|
||||||
|
// tests/api/run-all.ts
|
||||||
|
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { log } from './utils';
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const testFiles = [
|
||||||
|
'01-generation-basic.ts',
|
||||||
|
'02-basic.ts',
|
||||||
|
'03-flows.ts',
|
||||||
|
'04-aliases.ts',
|
||||||
|
'05-live.ts',
|
||||||
|
'06-edge-cases.ts',
|
||||||
|
'07-known-issues.ts',
|
||||||
|
'08-auto-enhance.ts',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function runTest(file: string): Promise<{ success: boolean; duration: number }> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.section(`Running ${file}`);
|
||||||
|
|
||||||
|
await execAsync(`tsx ${file}`, {
|
||||||
|
cwd: __dirname,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
log.success(`${file} completed (${duration}ms)`);
|
||||||
|
|
||||||
|
return { success: true, duration };
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
log.error(`${file} failed (${duration}ms)`);
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
return { success: false, duration };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('\n');
|
||||||
|
log.section('🚀 BANATIE API TEST SUITE');
|
||||||
|
console.log('\n');
|
||||||
|
|
||||||
|
const results: Array<{ file: string; success: boolean; duration: number }> = [];
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
for (const file of testFiles) {
|
||||||
|
const result = await runTest(file);
|
||||||
|
results.push({ file, ...result });
|
||||||
|
console.log('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDuration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
log.section('📊 TEST SUMMARY');
|
||||||
|
console.log('\n');
|
||||||
|
|
||||||
|
const passed = results.filter(r => r.success).length;
|
||||||
|
const failed = results.filter(r => !r.success).length;
|
||||||
|
|
||||||
|
results.forEach(result => {
|
||||||
|
const icon = result.success ? '✓' : '✗';
|
||||||
|
const color = result.success ? '\x1b[32m' : '\x1b[31m';
|
||||||
|
console.log(`${color}${icon}\x1b[0m ${result.file} (${result.duration}ms)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n');
|
||||||
|
log.info(`Total: ${results.length} test suites`);
|
||||||
|
log.success(`Passed: ${passed}`);
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
log.error(`Failed: ${failed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Duration: ${(totalDuration / 1000).toFixed(2)}s`);
|
||||||
|
console.log('\n');
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('Test runner failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
# Banatie API Test Suite - Summary & Known Issues
|
||||||
|
|
||||||
|
**Last Updated:** 2025-11-18
|
||||||
|
**API Version:** v1
|
||||||
|
**Test Suite Version:** 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Test Suite Overview
|
||||||
|
|
||||||
|
| Test File | Tests | Status | Description |
|
||||||
|
|-----------|-------|--------|-------------|
|
||||||
|
| 01-generation-basic.ts | ~8 | ✅ Expected to pass | Basic image generation functionality |
|
||||||
|
| 02-basic.ts | ~15 | ✅ Expected to pass | Image upload, CRUD operations |
|
||||||
|
| 03-flows.ts | ~10 | ✅ Expected to pass | Flow lifecycle and management |
|
||||||
|
| 04-aliases.ts | ~12 | ✅ Expected to pass | 3-tier alias resolution system |
|
||||||
|
| 05-live.ts | ~10 | ✅ Expected to pass | Live URLs, scopes, caching |
|
||||||
|
| 06-edge-cases.ts | ~15 | ✅ Expected to pass | Validation and error handling |
|
||||||
|
| 07-known-issues.ts | ~4 | ❌ Expected to fail | Known implementation issues |
|
||||||
|
|
||||||
|
**Total Tests:** ~74
|
||||||
|
**Expected Pass:** ~70
|
||||||
|
**Expected Fail:** ~4
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚫 Skipped Tests
|
||||||
|
|
||||||
|
These tests are intentionally NOT implemented because the functionality doesn't exist or isn't needed:
|
||||||
|
|
||||||
|
### 1. Manual Flow Creation
|
||||||
|
**Endpoint:** `POST /api/v1/flows`
|
||||||
|
**Reason:** Endpoint removed from implementation. Flows use lazy/eager creation pattern via generation/upload.
|
||||||
|
**Impact:** Tests must create flows via `flowAlias` parameter or rely on auto-generated flowIds.
|
||||||
|
|
||||||
|
### 2. CDN Flow Context
|
||||||
|
**Test:** Get CDN image with flowId query parameter
|
||||||
|
**Endpoint:** `GET /cdn/:org/:project/img/@alias?flowId={uuid}`
|
||||||
|
**Reason:** CDN endpoints don't support flowId context for flow-scoped alias resolution.
|
||||||
|
**Impact:** CDN can only resolve project-scoped aliases, not flow-scoped.
|
||||||
|
|
||||||
|
### 3. Image Transformations & Cloudflare
|
||||||
|
**Tests:** Any transformation-related validation
|
||||||
|
**Reason:** No image transformation service or Cloudflare CDN in test environment.
|
||||||
|
**Impact:** All images served directly from MinIO without modification.
|
||||||
|
|
||||||
|
### 4. Test 10.3 - URL Encoding with Underscores
|
||||||
|
**Test:** Live URL with underscores in prompt (`beautiful_sunset`)
|
||||||
|
**Reason:** Edge case not critical for core functionality.
|
||||||
|
**Status:** Add to future enhancement list if URL encoding issues arise.
|
||||||
|
|
||||||
|
### 5. Concurrent Operations Tests (14.1-14.3)
|
||||||
|
**Tests:**
|
||||||
|
- Concurrent generations in same flow
|
||||||
|
- Concurrent alias assignments
|
||||||
|
- Concurrent cache access
|
||||||
|
|
||||||
|
**Reason:** Complex timing requirements, potential flakiness, not critical for initial validation.
|
||||||
|
**Status:** Consider adding later for stress testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Known Implementation Issues
|
||||||
|
|
||||||
|
These tests are implemented in `07-known-issues.ts` and are **expected to fail**. They document bugs/missing features in the current implementation.
|
||||||
|
|
||||||
|
### Issue #1: Project Aliases on Flow Images
|
||||||
|
**Test:** Generate image in flow with project-scoped alias
|
||||||
|
**Expected:** Image should be accessible via project alias even when associated with a flow
|
||||||
|
**Current Behavior:** `AliasService.resolveProjectAlias()` has `isNull(images.flowId)` constraint
|
||||||
|
**Impact:** Images within flows cannot have project-scoped aliases
|
||||||
|
**File:** `apps/api-service/src/services/core/AliasService.ts:125`
|
||||||
|
**Fix Required:** Remove `isNull(images.flowId)` condition from project alias resolution
|
||||||
|
|
||||||
|
### Issue #2: Flow Cascade Delete - Non-Aliased Images
|
||||||
|
**Test:** Delete flow, verify non-aliased images are deleted
|
||||||
|
**Expected:** Images without project aliases should be cascade deleted
|
||||||
|
**Current Behavior:** Flow deletion only deletes flow record, leaves all images intact
|
||||||
|
**Impact:** Orphaned images remain in database
|
||||||
|
**File:** `apps/api-service/src/services/core/FlowService.ts` (delete method)
|
||||||
|
**Fix Required:** Add cascade logic to delete images where `alias IS NULL`
|
||||||
|
|
||||||
|
### Issue #3: Flow Cascade Delete - Aliased Images Protected
|
||||||
|
**Test:** Delete flow, verify aliased images are preserved
|
||||||
|
**Expected:** Images with project aliases should remain (flowId set to null)
|
||||||
|
**Current Behavior:** Images remain but keep flowId reference
|
||||||
|
**Impact:** Aliased images remain associated with deleted flow
|
||||||
|
**File:** `apps/api-service/src/services/core/FlowService.ts` (delete method)
|
||||||
|
**Fix Required:** Set `flowId = NULL` for preserved images with aliases
|
||||||
|
|
||||||
|
### Issue #4: Flow Cascade Delete - Generations
|
||||||
|
**Test:** Delete flow, verify generations are deleted
|
||||||
|
**Expected:** All generations in flow should be cascade deleted
|
||||||
|
**Current Behavior:** Generations remain with flowId intact
|
||||||
|
**Impact:** Orphaned generations in database
|
||||||
|
**File:** `apps/api-service/src/services/core/FlowService.ts` (delete method)
|
||||||
|
**Fix Required:** Add cascade deletion for generations in flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Implementation Notes & Discrepancies
|
||||||
|
|
||||||
|
### Alias Resolution Endpoint Mismatch
|
||||||
|
**Test Requirements:** `GET /api/v1/images/@alias`
|
||||||
|
**Actual Implementation:** `GET /api/v1/images/resolve/@alias`
|
||||||
|
**Action:** Tests use actual endpoint. Consider adding `/images/@alias` as shorthand in future.
|
||||||
|
|
||||||
|
### IP Rate Limiting on Live URLs
|
||||||
|
**Location:** `apps/api-service/src/routes/cdn.ts` (live URL endpoint)
|
||||||
|
**Current Behavior:** IP-based rate limiting (10 new generations per hour)
|
||||||
|
**Action Required:** Remove IP rate limiting functionality from live URL endpoints
|
||||||
|
**Priority:** Medium (functional but may cause issues in production)
|
||||||
|
|
||||||
|
### Prompt Auto-Enhancement
|
||||||
|
**Feature:** `autoEnhance` parameter in generation endpoint
|
||||||
|
**Status:** Implemented but not extensively tested
|
||||||
|
**Action:** Add comprehensive tests for enhancement behavior:
|
||||||
|
- Verify `originalPrompt` populated when enhanced
|
||||||
|
- Verify `prompt` contains enhanced version
|
||||||
|
- Verify enhancement doesn't occur when `autoEnhance=false`
|
||||||
|
|
||||||
|
### Alias Assignment Endpoints
|
||||||
|
**Note:** Alias assignment is separated from general metadata updates
|
||||||
|
**Correct Behavior:**
|
||||||
|
- `PUT /api/v1/images/:id` - Update focalPoint, meta only
|
||||||
|
- `PUT /api/v1/images/:id/alias` - Dedicated alias assignment endpoint
|
||||||
|
|
||||||
|
**Benefit:** Better separation of concerns, clearer API semantics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Required Test Fixtures
|
||||||
|
|
||||||
|
### Current Fixtures
|
||||||
|
- ✅ `tests/api/fixture/test-image.png` (1.6MB PNG)
|
||||||
|
|
||||||
|
### Additional Fixtures Needed
|
||||||
|
*(To be determined during test implementation)*
|
||||||
|
|
||||||
|
- [ ] Small image (<1MB) for quick upload tests
|
||||||
|
- [ ] Large image (>5MB) for size limit validation
|
||||||
|
- [ ] JPEG file for format variety testing
|
||||||
|
- [ ] Multiple distinct images for reference testing
|
||||||
|
- [ ] Invalid file types (.txt, .pdf) for negative tests
|
||||||
|
|
||||||
|
**Status:** Will be generated/collected after initial test implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Test Environment Requirements
|
||||||
|
|
||||||
|
### Services Required
|
||||||
|
- ✅ API service running on `http://localhost:3000`
|
||||||
|
- ✅ PostgreSQL database with schema v2.0
|
||||||
|
- ✅ MinIO storage accessible and configured
|
||||||
|
- ✅ Valid project API key configured in `config.ts`
|
||||||
|
- ✅ Google Gemini API credentials (will consume credits)
|
||||||
|
|
||||||
|
### Database State
|
||||||
|
- Tests assume empty or minimal database
|
||||||
|
- Tests do NOT clean up data (by design)
|
||||||
|
- Run against dedicated test project, not production
|
||||||
|
|
||||||
|
### Performance Notes
|
||||||
|
- Each image generation: ~3-10 seconds (Gemini API)
|
||||||
|
- Full test suite: ~20-30 minutes
|
||||||
|
- Gemini API cost: ~70-80 generations @ $0.0025 each = ~$0.18-0.20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Test Execution Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run full test suite (sequential)
|
||||||
|
cd tests/api
|
||||||
|
tsx run-all.ts
|
||||||
|
|
||||||
|
# Run individual test files
|
||||||
|
tsx 01-generation-basic.ts
|
||||||
|
tsx 02-basic.ts
|
||||||
|
tsx 03-flows.ts
|
||||||
|
tsx 04-aliases.ts
|
||||||
|
tsx 05-live.ts
|
||||||
|
tsx 06-edge-cases.ts
|
||||||
|
tsx 07-known-issues.ts
|
||||||
|
|
||||||
|
# Expected output: Colored console with ✓ (pass) and ✗ (fail) indicators
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Success Criteria
|
||||||
|
|
||||||
|
- [x] All test files execute without crashes
|
||||||
|
- [x] Tests 01-06: ~70 tests pass (verify correct implementation)
|
||||||
|
- [x] Test 07: ~4 tests fail (document known issues)
|
||||||
|
- [x] Each test has clear assertions and error messages
|
||||||
|
- [x] Tests use real API calls (no mocks)
|
||||||
|
- [x] All generated images saved to `tests/api/results/`
|
||||||
|
- [x] Summary document maintained and accurate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Maintenance Notes
|
||||||
|
|
||||||
|
### Updating Tests
|
||||||
|
When API implementation is fixed:
|
||||||
|
1. Move tests from `07-known-issues.ts` to appropriate test file
|
||||||
|
2. Update this summary document
|
||||||
|
3. Re-run full test suite to verify fixes
|
||||||
|
|
||||||
|
### Adding New Tests
|
||||||
|
1. Choose appropriate test file based on feature area
|
||||||
|
2. Follow existing test patterns (runTest, clear assertions)
|
||||||
|
3. Update test count in Overview table
|
||||||
|
4. Document any new fixtures needed
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
- Tests are not idempotent (leave data in database)
|
||||||
|
- No parallel execution support
|
||||||
|
- No automated cleanup between runs
|
||||||
|
- Requires manual server startup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Status:** ✅ Complete
|
||||||
|
**Next Update:** After test implementation and first full run
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
# Banatie API Tests
|
||||||
|
|
||||||
|
Набор интеграционных тестов для проверки REST API endpoints.
|
||||||
|
|
||||||
|
## 📋 Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/api/
|
||||||
|
├── config.ts # Конфигурация (API key, baseURL)
|
||||||
|
├── utils.ts # Утилиты (fetch, logger, file operations)
|
||||||
|
├── fixtures/
|
||||||
|
│ └── test-image.png # Тестовое изображение
|
||||||
|
├── 01-basic.ts # Базовые операции (upload, generate, list)
|
||||||
|
├── 02-flows.ts # Flow management (CRUD, generations)
|
||||||
|
├── 03-aliases.ts # Alias system (dual, technical, resolution)
|
||||||
|
├── 04-live.ts # Live endpoint (caching, streaming)
|
||||||
|
├── 05-edge-cases.ts # Validation и error handling
|
||||||
|
└── run-all.ts # Запуск всех тестов
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### 1. Настройка
|
||||||
|
|
||||||
|
Создайте `.env` файл в корне проекта:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_KEY=bnt_your_actual_api_key_here
|
||||||
|
API_BASE_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Установка зависимостей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Добавьте тестовое изображение
|
||||||
|
|
||||||
|
Поместите любое изображение в `tests/api/fixtures/test-image.png`
|
||||||
|
|
||||||
|
### 4. Запустите API сервер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Запустите тесты
|
||||||
|
|
||||||
|
**Все тесты:**
|
||||||
|
```bash
|
||||||
|
pnpm test:api
|
||||||
|
```
|
||||||
|
|
||||||
|
**Отдельный тест:**
|
||||||
|
```bash
|
||||||
|
tsx tests/api/01-basic.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Результаты
|
||||||
|
|
||||||
|
Сгенерированные изображения сохраняются в `results/` с timestamp.
|
||||||
|
|
||||||
|
Пример вывода:
|
||||||
|
```
|
||||||
|
━━━ BASIC TESTS ━━━
|
||||||
|
✓ Upload image (234ms)
|
||||||
|
Image ID: abc-123-def
|
||||||
|
Storage Key: org/project/uploads/2025-01/image.png
|
||||||
|
Alias: @test-logo
|
||||||
|
✓ Generate image (simple) (5432ms)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Что тестируется
|
||||||
|
|
||||||
|
### 01-basic.ts
|
||||||
|
- ✅ Upload изображений
|
||||||
|
- ✅ Список изображений
|
||||||
|
- ✅ Генерация без references
|
||||||
|
- ✅ Генерация с references
|
||||||
|
- ✅ Список и детали generations
|
||||||
|
|
||||||
|
### 02-flows.ts
|
||||||
|
- ✅ CRUD операции flows
|
||||||
|
- ✅ Генерации в flow контексте
|
||||||
|
- ✅ Technical aliases (@last, @first, @upload)
|
||||||
|
- ✅ Flow-scoped aliases
|
||||||
|
|
||||||
|
### 03-aliases.ts
|
||||||
|
- ✅ Project-scoped aliases
|
||||||
|
- ✅ Flow-scoped aliases
|
||||||
|
- ✅ Dual alias assignment
|
||||||
|
- ✅ Alias resolution precedence
|
||||||
|
- ✅ Technical aliases computation
|
||||||
|
|
||||||
|
### 04-live.ts
|
||||||
|
- ✅ Cache MISS (первый запрос)
|
||||||
|
- ✅ Cache HIT (повторный запрос)
|
||||||
|
- ✅ Различные параметры
|
||||||
|
- ✅ References в live endpoint
|
||||||
|
- ✅ Performance кэширования
|
||||||
|
|
||||||
|
### 05-edge-cases.ts
|
||||||
|
- ✅ Валидация входных данных
|
||||||
|
- ✅ Дублирование aliases
|
||||||
|
- ✅ Несуществующие resources
|
||||||
|
- ✅ Некорректные форматы
|
||||||
|
- ✅ Authentication errors
|
||||||
|
- ✅ Pagination limits
|
||||||
|
|
||||||
|
## 🔧 Конфигурация
|
||||||
|
|
||||||
|
Настройка в `tests/api/config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const config = {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
apiKey: 'bnt_test_key',
|
||||||
|
resultsDir: '../../results',
|
||||||
|
requestTimeout: 30000,
|
||||||
|
generationTimeout: 60000,
|
||||||
|
verbose: true,
|
||||||
|
saveImages: true,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Логирование
|
||||||
|
|
||||||
|
Цветной console output:
|
||||||
|
- ✓ Зеленый - успешные тесты
|
||||||
|
- ✗ Красный - failed тесты
|
||||||
|
- → Синий - информация
|
||||||
|
- ⚠ Желтый - предупреждения
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
**API не отвечает:**
|
||||||
|
```bash
|
||||||
|
# Проверьте что сервер запущен
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**401 Unauthorized:**
|
||||||
|
```bash
|
||||||
|
# Проверьте API key в .env
|
||||||
|
echo $API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Генерация timeout:**
|
||||||
|
```bash
|
||||||
|
# Увеличьте timeout в config.ts
|
||||||
|
generationTimeout: 120000 // 2 минуты
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Дополнительно
|
||||||
|
|
||||||
|
- Тесты запускаются **последовательно** (используют testContext)
|
||||||
|
- Данные **НЕ удаляются** после тестов (для инспекции)
|
||||||
|
- Все сгенерированные изображения сохраняются в `results/`
|
||||||
|
- Rate limiting учитывается (есть задержки между запросами)
|
||||||
|
|
||||||
|
## 🎯 Success Criteria
|
||||||
|
|
||||||
|
Все тесты должны пройти успешно:
|
||||||
|
- ✅ >95% success rate
|
||||||
|
- ✅ Все validation errors обрабатываются корректно
|
||||||
|
- ✅ Cache работает (HIT < 500ms)
|
||||||
|
- ✅ Alias resolution правильный
|
||||||
|
- ✅ Нет memory leaks
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
// package.json additions for tests
|
||||||
|
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test:api": "tsx tests/api/run-all.ts",
|
||||||
|
"test:api:basic": "tsx tests/api/01-basic.ts",
|
||||||
|
"test:api:flows": "tsx tests/api/02-flows.ts",
|
||||||
|
"test:api:aliases": "tsx tests/api/03-aliases.ts",
|
||||||
|
"test:api:live": "tsx tests/api/04-live.ts",
|
||||||
|
"test:api:edge": "tsx tests/api/05-edge-cases.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"@types/node": "^20.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: fetch is built into Node.js 18+, no need for node-fetch
|
||||||
|
// FormData is also built into Node.js 18+
|
||||||
|
|
@ -0,0 +1,357 @@
|
||||||
|
// tests/api/utils.ts
|
||||||
|
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { config, endpoints } from './config';
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Colors for console output
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
gray: '\x1b[90m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logging utilities
|
||||||
|
export const log = {
|
||||||
|
success: (msg: string) => console.log(`${colors.green}✓${colors.reset} ${msg}`),
|
||||||
|
error: (msg: string) => console.log(`${colors.red}✗${colors.reset} ${msg}`),
|
||||||
|
info: (msg: string) => console.log(`${colors.blue}→${colors.reset} ${msg}`),
|
||||||
|
warning: (msg: string) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`),
|
||||||
|
section: (msg: string) => console.log(`\n${colors.cyan}━━━ ${msg} ━━━${colors.reset}`),
|
||||||
|
detail: (key: string, value: any) => {
|
||||||
|
const valueStr = typeof value === 'object' ? JSON.stringify(value, null, 2) : value;
|
||||||
|
console.log(` ${colors.gray}${key}:${colors.reset} ${valueStr}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// API fetch wrapper
|
||||||
|
export async function api<T = any>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit & {
|
||||||
|
expectError?: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<{
|
||||||
|
data: T;
|
||||||
|
status: number;
|
||||||
|
headers: Headers;
|
||||||
|
duration: number;
|
||||||
|
}> {
|
||||||
|
const { expectError = false, timeout = config.requestTimeout, ...fetchOptions } = options;
|
||||||
|
const url = `${config.baseURL}${endpoint}`;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...fetchOptions,
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': config.apiKey,
|
||||||
|
...fetchOptions.headers,
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
let data: any;
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
data = await response.json();
|
||||||
|
} else if (contentType?.includes('image/')) {
|
||||||
|
data = await response.arrayBuffer();
|
||||||
|
} else {
|
||||||
|
data = await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok && !expectError) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.verbose) {
|
||||||
|
const method = fetchOptions.method || 'GET';
|
||||||
|
log.detail('Request', `${method} ${endpoint}`);
|
||||||
|
log.detail('Status', response.status);
|
||||||
|
log.detail('Duration', `${duration}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
if (!expectError) {
|
||||||
|
log.error(`Request failed: ${error}`);
|
||||||
|
log.detail('Endpoint', endpoint);
|
||||||
|
log.detail('Duration', `${duration}ms`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save image to results directory
|
||||||
|
export async function saveImage(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
filename: string
|
||||||
|
): Promise<string> {
|
||||||
|
const resultsPath = join(__dirname, config.resultsDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(resultsPath, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
// Directory exists, ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||||
|
const fullFilename = `${timestamp}_${filename}`;
|
||||||
|
const filepath = join(resultsPath, fullFilename);
|
||||||
|
|
||||||
|
await writeFile(filepath, Buffer.from(buffer));
|
||||||
|
|
||||||
|
if (config.saveImages) {
|
||||||
|
log.info(`Saved image: ${fullFilename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload file helper
|
||||||
|
export async function uploadFile(
|
||||||
|
filepath: string,
|
||||||
|
fields: Record<string, string> = {}
|
||||||
|
): Promise<any> {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Read file and detect MIME type from extension
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const path = await import('path');
|
||||||
|
const fileBuffer = await fs.readFile(filepath);
|
||||||
|
const ext = path.extname(filepath).toLowerCase();
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
};
|
||||||
|
const mimeType = mimeTypes[ext] || 'application/octet-stream';
|
||||||
|
const filename = path.basename(filepath);
|
||||||
|
const blob = new Blob([fileBuffer], { type: mimeType });
|
||||||
|
formData.append('file', blob, filename);
|
||||||
|
|
||||||
|
// Add other fields
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
formData.append(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api(endpoints.images + '/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
// Don't set Content-Type, let fetch set it with boundary
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait helper
|
||||||
|
export async function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for generation completion
|
||||||
|
export async function waitForGeneration(
|
||||||
|
generationId: string,
|
||||||
|
maxAttempts = 20
|
||||||
|
): Promise<any> {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
const result = await api(`${endpoints.generations}/${generationId}`);
|
||||||
|
const generation = result.data.data;
|
||||||
|
|
||||||
|
if (generation.status === 'success' || generation.status === 'failed') {
|
||||||
|
return generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Generation timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test context to share data between tests
|
||||||
|
export const testContext: {
|
||||||
|
imageId?: string;
|
||||||
|
generationId?: string;
|
||||||
|
flowId?: string;
|
||||||
|
uploadedImageId?: string;
|
||||||
|
[key: string]: any; // Allow dynamic properties
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
// Test tracking state
|
||||||
|
let failedTests = 0;
|
||||||
|
let totalTests = 0;
|
||||||
|
|
||||||
|
// Test runner helper
|
||||||
|
export async function runTest(
|
||||||
|
name: string,
|
||||||
|
fn: () => Promise<void>
|
||||||
|
): Promise<boolean> {
|
||||||
|
totalTests++;
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
await fn();
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
log.success(`${name} (${duration}ms)`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
failedTests++;
|
||||||
|
log.error(`${name}`);
|
||||||
|
console.error(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get test statistics
|
||||||
|
export function getTestStats() {
|
||||||
|
return { total: totalTests, failed: failedTests, passed: totalTests - failedTests };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit with appropriate code based on test results
|
||||||
|
export function exitWithTestResults() {
|
||||||
|
const stats = getTestStats();
|
||||||
|
if (stats.failed > 0) {
|
||||||
|
log.error(`${stats.failed}/${stats.total} tests failed`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
log.success(`${stats.passed}/${stats.total} tests passed`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify image is accessible at URL
|
||||||
|
export async function verifyImageAccessible(url: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType?.includes('image/')) {
|
||||||
|
log.warning(`URL returned non-image content type: ${contentType}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
return buffer.byteLength > 0;
|
||||||
|
} catch (error) {
|
||||||
|
log.warning(`Failed to access image: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to expect an error response
|
||||||
|
export async function expectError(
|
||||||
|
fn: () => Promise<any>,
|
||||||
|
expectedStatus?: number
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
if (result.status >= 400) {
|
||||||
|
// Error status returned
|
||||||
|
if (expectedStatus && result.status !== expectedStatus) {
|
||||||
|
throw new Error(`Expected status ${expectedStatus}, got ${result.status}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
throw new Error(`Expected error but got success: ${result.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
// If it's a fetch error or our assertion error, re-throw
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a test image via generation
|
||||||
|
export async function createTestImage(
|
||||||
|
prompt: string,
|
||||||
|
options: {
|
||||||
|
aspectRatio?: string;
|
||||||
|
alias?: string;
|
||||||
|
flowId?: string | null;
|
||||||
|
flowAlias?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<any> {
|
||||||
|
const result = await api(endpoints.generations, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
aspectRatio: options.aspectRatio || '1:1',
|
||||||
|
alias: options.alias,
|
||||||
|
flowId: options.flowId,
|
||||||
|
flowAlias: options.flowAlias,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data.data) {
|
||||||
|
throw new Error('No generation returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
const generation = await waitForGeneration(result.data.data.id);
|
||||||
|
|
||||||
|
if (generation.status !== 'success') {
|
||||||
|
throw new Error(`Generation failed: ${generation.errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to resolve alias
|
||||||
|
// Returns format compatible with old /resolve/ endpoint: { imageId, scope, alias, image }
|
||||||
|
export async function resolveAlias(
|
||||||
|
alias: string,
|
||||||
|
flowId?: string
|
||||||
|
): Promise<any> {
|
||||||
|
// Section 6.2: Use direct alias identifier instead of /resolve/ endpoint
|
||||||
|
const endpoint = flowId
|
||||||
|
? `${endpoints.images}/${alias}?flowId=${flowId}`
|
||||||
|
: `${endpoints.images}/${alias}`;
|
||||||
|
|
||||||
|
const result = await api(endpoint);
|
||||||
|
const image = result.data.data;
|
||||||
|
|
||||||
|
// Determine scope based on alias type and context
|
||||||
|
const technicalAliases = ['@last', '@first', '@upload'];
|
||||||
|
let scope: string;
|
||||||
|
if (technicalAliases.includes(alias)) {
|
||||||
|
scope = 'technical';
|
||||||
|
} else if (flowId) {
|
||||||
|
scope = 'flow';
|
||||||
|
} else {
|
||||||
|
scope = 'project';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapt response to match old /resolve/ format for test compatibility
|
||||||
|
return {
|
||||||
|
imageId: image.id,
|
||||||
|
alias: image.alias || alias,
|
||||||
|
scope,
|
||||||
|
flowId: image.flowId,
|
||||||
|
image,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue