Compare commits
No commits in common. "c148c53013eba9ced729e0dbd826fe3da2469c2c" and "a7dc96d1a5e5bfa686b2a58fa85c0f048bed3c3d" have entirely different histories.
c148c53013
...
a7dc96d1a5
|
|
@ -1,216 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,934 +0,0 @@
|
||||||
# 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,12 +43,10 @@
|
||||||
"@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,15 +1,12 @@
|
||||||
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
|
||||||
|
|
@ -45,7 +42,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 = randomUUID();
|
req.requestId = Math.random().toString(36).substr(2, 9);
|
||||||
res.setHeader('X-Request-ID', req.requestId);
|
res.setHeader('X-Request-ID', req.requestId);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
@ -113,19 +110,13 @@ 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);
|
||||||
|
|
||||||
// API v1 routes (versioned, require valid API key)
|
// Protected API routes (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);
|
||||||
|
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
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,6 +81,8 @@ 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`);
|
||||||
|
|
|
||||||
|
|
@ -1,481 +0,0 @@
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,630 +0,0 @@
|
||||||
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 },
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
@ -1,549 +0,0 @@
|
||||||
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 },
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
@ -1,946 +0,0 @@
|
||||||
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 },
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
@ -1,510 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
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,
|
||||||
|
|
@ -79,10 +78,8 @@ 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,
|
||||||
}),
|
}),
|
||||||
|
|
@ -234,25 +231,10 @@ 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 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,674 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,364 +0,0 @@
|
||||||
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)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
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: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export * from './AliasService';
|
|
||||||
export * from './ImageService';
|
|
||||||
export * from './GenerationService';
|
|
||||||
export * from './FlowService';
|
|
||||||
export * from './PromptCacheService';
|
|
||||||
export * from './LiveScopeService';
|
|
||||||
|
|
@ -94,7 +94,6 @@ 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
|
||||||
|
|
@ -109,8 +108,6 @@ export interface GeneratedImageData {
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
fileExtension: string;
|
fileExtension: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logging types
|
// Logging types
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
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(),
|
|
||||||
});
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
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];
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export * from './aliases';
|
|
||||||
export * from './limits';
|
|
||||||
export * from './errors';
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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');
|
|
||||||
};
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export * from './paginationBuilder';
|
|
||||||
export * from './hashHelper';
|
|
||||||
export * from './queryHelper';
|
|
||||||
export * from './cacheKeyHelper';
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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));
|
|
||||||
};
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export * from './aliasValidator';
|
|
||||||
export * from './paginationValidator';
|
|
||||||
export * from './queryValidator';
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
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 true.
|
* The <InlineCode>autoEnhance</InlineCode> parameter defaults to false.
|
||||||
*
|
*
|
||||||
* // 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.
|
||||||
|
|
|
||||||
|
|
@ -1,840 +0,0 @@
|
||||||
# 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
|
|
@ -1,607 +0,0 @@
|
||||||
# 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*
|
|
||||||
|
|
@ -0,0 +1,664 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
@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
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,449 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,343 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
@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}}
|
|
||||||
|
|
@ -1,374 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
@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,7 +17,6 @@
|
||||||
"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"
|
||||||
|
|
@ -41,8 +40,6 @@
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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,21 +2,11 @@ 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 }) => ({
|
||||||
|
|
@ -30,14 +20,9 @@ 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, many }) => ({
|
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
||||||
organization: one(organizations, {
|
organization: one(organizations, {
|
||||||
fields: [apiKeys.organizationId],
|
fields: [apiKeys.organizationId],
|
||||||
references: [organizations.id],
|
references: [organizations.id],
|
||||||
|
|
@ -46,77 +31,4 @@ export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({
|
||||||
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],
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
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, boolean, integer } from 'drizzle-orm/pg-core';
|
import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core';
|
||||||
import { organizations } from './organizations';
|
import { organizations } from './organizations';
|
||||||
|
|
||||||
export const projects = pgTable(
|
export const projects = pgTable(
|
||||||
|
|
@ -13,10 +13,6 @@ 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')
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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,9 +8,6 @@ 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)
|
||||||
|
|
@ -26,15 +23,12 @@ 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@20.19.17)(@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@24.5.2)(@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:
|
||||||
|
|
@ -102,9 +96,6 @@ 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
|
||||||
|
|
@ -117,9 +108,6 @@ 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
|
||||||
|
|
@ -3351,11 +3339,6 @@ 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'}
|
||||||
|
|
@ -6905,13 +6888,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@20.19.17)(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@24.5.2)(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@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
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/pretty-format@3.2.4':
|
'@vitest/pretty-format@3.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -6942,7 +6925,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@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: 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/utils@3.2.4':
|
'@vitest/utils@3.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -8450,8 +8433,6 @@ 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
|
||||||
|
|
@ -10480,13 +10461,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@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):
|
||||||
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@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
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)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- jiti
|
- jiti
|
||||||
|
|
@ -10501,7 +10482,7 @@ snapshots:
|
||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
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@7.1.9(@types/node@24.5.2)(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)
|
||||||
|
|
@ -10510,18 +10491,18 @@ snapshots:
|
||||||
rollup: 4.52.4
|
rollup: 4.52.4
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.19.17
|
'@types/node': 24.5.2
|
||||||
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@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@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):
|
||||||
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@20.19.17)(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@24.5.2)(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
|
||||||
|
|
@ -10539,11 +10520,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@20.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)
|
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-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)
|
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)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.19.17
|
'@types/node': 24.5.2
|
||||||
'@vitest/ui': 3.2.4(vitest@3.2.4)
|
'@vitest/ui': 3.2.4(vitest@3.2.4)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
|
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
@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
|
|
||||||
#
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
@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
|
|
||||||
###############################################################################
|
|
||||||
|
|
@ -1,428 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,296 +0,0 @@
|
||||||
@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
|
|
||||||
#
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,590 +0,0 @@
|
||||||
@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
|
|
||||||
#
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
@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
|
|
||||||
#
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
@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
|
|
||||||
#
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
@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.
|
|
||||||
#
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
@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
|
|
||||||
#
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
# 📦 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/` директории.
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// 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.
|
Before Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
|
|
@ -1,98 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
// 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+
|
|
||||||
|
|
@ -1,357 +0,0 @@
|
||||||
// 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