Compare commits
No commits in common. "main" and "feature/db-for-generation" have entirely different histories.
main
...
feature/db
|
|
@ -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
|
|
||||||
|
|
@ -82,7 +82,3 @@ uploads/
|
||||||
# Temporary files
|
# Temporary files
|
||||||
temp/
|
temp/
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
# Local Claude config (VPS-specific)
|
|
||||||
CLAUDE.local.md
|
|
||||||
.env.prod
|
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,11 @@
|
||||||
"PERPLEXITY_TIMEOUT_MS": "600000"
|
"PERPLEXITY_TIMEOUT_MS": "600000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chrome-devtools": {
|
"browsermcp": {
|
||||||
|
"type": "stdio",
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "chrome-devtools-mcp@latest"]
|
"args": ["-y", "@browsermcp/mcp@latest"],
|
||||||
|
"env": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
CLAUDE.local.md
|
|
@ -1,109 +0,0 @@
|
||||||
# Banatie Service - VPS Environment
|
|
||||||
|
|
||||||
## Environment Context
|
|
||||||
This Claude Code instance runs on the **VPS server** with direct access to production services.
|
|
||||||
|
|
||||||
Your main folder is `/home/usul/workspace/projects/banatie-service`. Use it for git operations, code review, and documentation.
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
| Path | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `/home/usul/workspace/projects/banatie-service` | Git repository (source code, docs) |
|
|
||||||
| `/opt/banatie/` | Production deployment (docker-compose, configs) |
|
|
||||||
| `/opt/banatie/data/` | Persistent data (minio, postgres) |
|
|
||||||
|
|
||||||
## Deployment Workflow
|
|
||||||
|
|
||||||
### Update from Git
|
|
||||||
```bash
|
|
||||||
cd /home/usul/workspace/projects/banatie-service
|
|
||||||
git pull origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deploy API
|
|
||||||
```bash
|
|
||||||
./scripts/deploy-api.sh
|
|
||||||
# or with rebuild:
|
|
||||||
./scripts/deploy-api.sh --no-cache
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deploy Landing
|
|
||||||
```bash
|
|
||||||
./scripts/deploy-landing.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Docker Operations
|
|
||||||
```bash
|
|
||||||
cd /opt/banatie
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs -f api
|
|
||||||
docker compose logs -f landing
|
|
||||||
docker compose restart api
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Operations
|
|
||||||
|
|
||||||
### Check Service Status
|
|
||||||
```bash
|
|
||||||
docker compose -f /opt/banatie/docker-compose.yml ps
|
|
||||||
curl -s http://localhost:3000/health | jq
|
|
||||||
```
|
|
||||||
|
|
||||||
### View Logs
|
|
||||||
```bash
|
|
||||||
docker compose -f /opt/banatie/docker-compose.yml logs -f api --tail=100
|
|
||||||
docker compose -f /opt/banatie/docker-compose.yml logs -f landing --tail=100
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Access
|
|
||||||
```bash
|
|
||||||
docker exec -it banatie-postgres psql -U banatie_user -d banatie_db
|
|
||||||
```
|
|
||||||
|
|
||||||
### MinIO Access
|
|
||||||
```bash
|
|
||||||
docker exec -it banatie-minio mc ls storage/banatie
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reset Database (DESTRUCTIVE)
|
|
||||||
```bash
|
|
||||||
cd /opt/banatie
|
|
||||||
docker compose down
|
|
||||||
sudo rm -rf data/postgres/*
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production URLs
|
|
||||||
|
|
||||||
| Service | URL |
|
|
||||||
|---------|-----|
|
|
||||||
| Landing | https://banatie.app |
|
|
||||||
| API | https://api.banatie.app |
|
|
||||||
| CDN | https://cdn.banatie.app |
|
|
||||||
| MinIO Console | https://storage.banatie.app |
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| Location | Purpose |
|
|
||||||
|----------|---------|
|
|
||||||
| `/opt/banatie/docker-compose.yml` | Production compose (copy from `infrastructure/docker-compose.vps.yml`) |
|
|
||||||
| `/opt/banatie/.env` | Environment variables |
|
|
||||||
| `/opt/banatie/secrets.env` | Secrets (GEMINI_API_KEY, etc.) |
|
|
||||||
| `infrastructure/docker-compose.vps.yml` | Source template for production compose |
|
|
||||||
| `docs/url-fix-vps-site.md` | CDN deployment instructions |
|
|
||||||
|
|
||||||
## Operational Responsibilities
|
|
||||||
|
|
||||||
- Execute deployment procedures using scripts in `scripts/`
|
|
||||||
- Monitor service health via Docker logs
|
|
||||||
- Update configurations and restart services as needed
|
|
||||||
- Document encountered issues and their resolutions
|
|
||||||
- Commit operational changes and lessons learned to the repository
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
- Always `git pull` before deploying
|
|
||||||
- Check logs after deployment for errors
|
|
||||||
- Secrets are in `/opt/banatie/secrets.env` (not in git)
|
|
||||||
- Database and MinIO data persist in `/opt/banatie/data/`
|
|
||||||
46
CLAUDE.md
|
|
@ -300,52 +300,6 @@ curl -X POST http://localhost:3000/api/upload \
|
||||||
- **Rate Limits**: 100 requests per hour per key
|
- **Rate Limits**: 100 requests per hour per key
|
||||||
- **Revocation**: Soft delete via `is_active` flag
|
- **Revocation**: Soft delete via `is_active` flag
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
### VPS Infrastructure
|
|
||||||
|
|
||||||
Banatie is deployed as an isolated ecosystem on VPS at `/opt/banatie/`:
|
|
||||||
|
|
||||||
| Service | URL | Container |
|
|
||||||
|---------|-----|-----------|
|
|
||||||
| Landing | https://banatie.app | banatie-landing |
|
|
||||||
| API | https://api.banatie.app | banatie-api |
|
|
||||||
| MinIO Console | https://storage.banatie.app | banatie-minio |
|
|
||||||
| MinIO CDN | https://cdn.banatie.app | banatie-minio |
|
|
||||||
|
|
||||||
### Deploy Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From project root
|
|
||||||
./scripts/deploy-landing.sh # Deploy landing
|
|
||||||
./scripts/deploy-landing.sh --no-cache # Force rebuild (when deps change)
|
|
||||||
./scripts/deploy-api.sh # Deploy API
|
|
||||||
./scripts/deploy-api.sh --no-cache # Force rebuild
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Configuration Files
|
|
||||||
|
|
||||||
```
|
|
||||||
infrastructure/
|
|
||||||
├── docker-compose.production.yml # VPS docker-compose
|
|
||||||
├── .env.example # Environment variables template
|
|
||||||
└── secrets.env.example # Secrets template
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Production Learnings
|
|
||||||
|
|
||||||
1. **NEXT_PUBLIC_* variables** - Must be set at build time AND runtime for Next.js client-side code
|
|
||||||
2. **pnpm workspaces in Docker** - Symlinks break between stages; use single-stage install with `pnpm --filter`
|
|
||||||
3. **Docker User NS Remapping** - VPS uses UID offset 165536; container UID 1001 → host UID 166537
|
|
||||||
4. **DATABASE_URL encoding** - Special characters like `=` must be URL-encoded (`%3D`)
|
|
||||||
|
|
||||||
### Known Production Issues (Non-Critical)
|
|
||||||
|
|
||||||
1. **Healthcheck showing "unhealthy"** - Alpine images lack curl; services work correctly
|
|
||||||
2. **Next.js cache permission** - `.next/cache` may show EACCES; non-critical for functionality
|
|
||||||
|
|
||||||
See [docs/deployment.md](docs/deployment.md) for full deployment guide.
|
|
||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|
||||||
- Uses pnpm workspaces for monorepo management (required >= 8.0.0)
|
- Uses pnpm workspaces for monorepo management (required >= 8.0.0)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,42 +1,90 @@
|
||||||
# Simplified Dockerfile for API Service
|
# Multi-stage Dockerfile for API Service
|
||||||
|
|
||||||
|
# Stage 1: Dependencies
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
|
# Copy workspace configuration
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Copy package.json files
|
||||||
|
COPY apps/api-service/package.json ./apps/api-service/
|
||||||
|
COPY packages/database/package.json ./packages/database/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Stage 2: Builder
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install pnpm
|
# Install pnpm
|
||||||
RUN npm install -g pnpm@10.11.0
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
# Copy everything needed
|
# Copy dependencies from deps stage
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/apps/api-service/node_modules ./apps/api-service/node_modules
|
||||||
|
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||||
|
|
||||||
|
# Copy workspace files
|
||||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
COPY apps/api-service ./apps/api-service
|
|
||||||
|
# Copy database package
|
||||||
COPY packages/database ./packages/database
|
COPY packages/database ./packages/database
|
||||||
|
|
||||||
# Install and build
|
# Copy API service source (exclude .env - it's for local dev only)
|
||||||
RUN pnpm install --frozen-lockfile
|
COPY apps/api-service/package.json ./apps/api-service/
|
||||||
RUN pnpm --filter @banatie/database build
|
COPY apps/api-service/tsconfig.json ./apps/api-service/
|
||||||
RUN pnpm --filter @banatie/api-service build
|
COPY apps/api-service/src ./apps/api-service/src
|
||||||
|
|
||||||
# Production runner
|
# Set working directory to API service
|
||||||
|
WORKDIR /app/apps/api-service
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 3: Production Runner
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 apiuser
|
RUN adduser --system --uid 1001 apiuser
|
||||||
|
|
||||||
# Copy built app
|
# Copy workspace configuration
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||||
COPY --from=builder /app/packages/database ./packages/database
|
COPY --from=builder /app/package.json ./
|
||||||
COPY --from=builder /app/apps/api-service/dist ./apps/api-service/dist
|
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||||
COPY --from=builder /app/apps/api-service/package.json ./apps/api-service/
|
|
||||||
COPY --from=builder /app/apps/api-service/node_modules ./apps/api-service/node_modules
|
|
||||||
|
|
||||||
# Create directories
|
# Copy database package
|
||||||
|
COPY --from=builder /app/packages/database ./packages/database
|
||||||
|
|
||||||
|
# Copy built API service
|
||||||
|
COPY --from=builder --chown=apiuser:nodejs /app/apps/api-service/dist ./apps/api-service/dist
|
||||||
|
COPY --from=builder /app/apps/api-service/package.json ./apps/api-service/
|
||||||
|
|
||||||
|
# Copy node_modules for runtime
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/apps/api-service/node_modules ./apps/api-service/node_modules
|
||||||
|
COPY --from=builder /app/packages/database/node_modules ./packages/database/node_modules
|
||||||
|
|
||||||
|
# Create directories for logs and data
|
||||||
RUN mkdir -p /app/apps/api-service/logs /app/results /app/uploads/temp
|
RUN mkdir -p /app/apps/api-service/logs /app/results /app/uploads/temp
|
||||||
RUN chown -R apiuser:nodejs /app/apps/api-service/logs /app/results /app/uploads
|
RUN chown -R apiuser:nodejs /app/apps/api-service/logs /app/results /app/uploads
|
||||||
|
|
||||||
USER apiuser
|
USER apiuser
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
WORKDIR /app/apps/api-service
|
WORKDIR /app/apps/api-service
|
||||||
|
|
||||||
|
# Run production build
|
||||||
CMD ["node", "dist/server.js"]
|
CMD ["node", "dist/server.js"]
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"infra:logs": "docker compose logs -f",
|
"infra:logs": "docker compose logs -f",
|
||||||
"dev": "npm run infra:up && echo 'Logs will be saved to api-dev.log' && tsx --watch src/server.ts 2>&1 | tee api-dev.log",
|
"dev": "npm run infra:up && echo 'Logs will be saved to api-dev.log' && tsx --watch src/server.ts 2>&1 | tee api-dev.log",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"build": "tsc && tsc-alias",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint src/**/*.ts",
|
"lint": "eslint src/**/*.ts",
|
||||||
"lint:fix": "eslint src/**/*.ts --fix",
|
"lint:fix": "eslint src/**/*.ts --fix",
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -72,7 +70,6 @@
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"tsc-alias": "^1.8.10",
|
|
||||||
"tsx": "^4.20.5",
|
"tsx": "^4.20.5",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.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,4 +1,4 @@
|
||||||
import { createDbClient, type DbClient } from '@banatie/database';
|
import { createDbClient } from '@banatie/database';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
|
|
@ -20,7 +20,7 @@ const DATABASE_URL =
|
||||||
process.env['DATABASE_URL'] ||
|
process.env['DATABASE_URL'] ||
|
||||||
'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db';
|
'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db';
|
||||||
|
|
||||||
export const db: DbClient = createDbClient(DATABASE_URL);
|
export const db = createDbClient(DATABASE_URL);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`,
|
`[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`,
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@ import { Request, Response, NextFunction } from 'express';
|
||||||
*/
|
*/
|
||||||
export function requireMasterKey(req: Request, res: Response, next: NextFunction): void {
|
export function requireMasterKey(req: Request, res: Response, next: NextFunction): void {
|
||||||
if (!req.apiKey) {
|
if (!req.apiKey) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Authentication required',
|
error: 'Authentication required',
|
||||||
message: 'This endpoint requires authentication',
|
message: 'This endpoint requires authentication',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.apiKey.keyType !== 'master') {
|
if (req.apiKey.keyType !== 'master') {
|
||||||
|
|
@ -18,11 +17,10 @@ export function requireMasterKey(req: Request, res: Response, next: NextFunction
|
||||||
`[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`,
|
`[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Master key required',
|
error: 'Master key required',
|
||||||
message: 'This endpoint requires a master API key',
|
message: 'This endpoint requires a master API key',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|
|
||||||
|
|
@ -7,30 +7,27 @@ import { Request, Response, NextFunction } from 'express';
|
||||||
export function requireProjectKey(req: Request, res: Response, next: NextFunction): void {
|
export function requireProjectKey(req: Request, res: Response, next: NextFunction): void {
|
||||||
// This middleware assumes validateApiKey has already run and attached req.apiKey
|
// This middleware assumes validateApiKey has already run and attached req.apiKey
|
||||||
if (!req.apiKey) {
|
if (!req.apiKey) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Authentication required',
|
error: 'Authentication required',
|
||||||
message: 'API key validation must be performed first',
|
message: 'API key validation must be performed first',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block master keys from generation endpoints
|
// Block master keys from generation endpoints
|
||||||
if (req.apiKey.keyType === 'master') {
|
if (req.apiKey.keyType === 'master') {
|
||||||
res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Forbidden',
|
error: 'Forbidden',
|
||||||
message:
|
message:
|
||||||
'Master keys cannot be used for image generation. Please use a project-specific API key.',
|
'Master keys cannot be used for image generation. Please use a project-specific API key.',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure project key has required IDs
|
// Ensure project key has required IDs
|
||||||
if (!req.apiKey.projectId) {
|
if (!req.apiKey.projectId) {
|
||||||
res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid API key',
|
error: 'Invalid API key',
|
||||||
message: 'Project key must be associated with a project',
|
message: 'Project key must be associated with a project',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|
|
||||||
|
|
@ -23,22 +23,20 @@ export async function validateApiKey(
|
||||||
const providedKey = req.headers['x-api-key'] as string;
|
const providedKey = req.headers['x-api-key'] as string;
|
||||||
|
|
||||||
if (!providedKey) {
|
if (!providedKey) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Missing API key',
|
error: 'Missing API key',
|
||||||
message: 'Provide your API key via X-API-Key header',
|
message: 'Provide your API key via X-API-Key header',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiKey = await apiKeyService.validateKey(providedKey);
|
const apiKey = await apiKeyService.validateKey(providedKey);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Invalid API key',
|
error: 'Invalid API key',
|
||||||
message: 'The provided API key is invalid, expired, or revoked',
|
message: 'The provided API key is invalid, expired, or revoked',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach to request for use in routes
|
// Attach to request for use in routes
|
||||||
|
|
|
||||||
|
|
@ -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,9 +1,9 @@
|
||||||
import express, { Router } from 'express';
|
import express from 'express';
|
||||||
import { ApiKeyService } from '../../services/ApiKeyService';
|
import { ApiKeyService } from '../../services/ApiKeyService';
|
||||||
import { validateApiKey } from '../../middleware/auth/validateApiKey';
|
import { validateApiKey } from '../../middleware/auth/validateApiKey';
|
||||||
import { requireMasterKey } from '../../middleware/auth/requireMasterKey';
|
import { requireMasterKey } from '../../middleware/auth/requireMasterKey';
|
||||||
|
|
||||||
const router: Router = express.Router();
|
const router = express.Router();
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
|
|
||||||
// All admin routes require master key
|
// All admin routes require master key
|
||||||
|
|
@ -14,12 +14,12 @@ router.use(requireMasterKey);
|
||||||
* Create a new API key
|
* Create a new API key
|
||||||
* POST /api/admin/keys
|
* POST /api/admin/keys
|
||||||
*/
|
*/
|
||||||
router.post('/', async (req, res): Promise<void> => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
type,
|
type,
|
||||||
projectId: _projectId,
|
projectId,
|
||||||
organizationId: _organizationId,
|
organizationId,
|
||||||
organizationSlug,
|
organizationSlug,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
organizationName,
|
organizationName,
|
||||||
|
|
@ -30,27 +30,24 @@ router.post('/', async (req, res): Promise<void> => {
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!type || !['master', 'project'].includes(type)) {
|
if (!type || !['master', 'project'].includes(type)) {
|
||||||
res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid type',
|
error: 'Invalid type',
|
||||||
message: 'Type must be either "master" or "project"',
|
message: 'Type must be either "master" or "project"',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'project' && !projectSlug) {
|
if (type === 'project' && !projectSlug) {
|
||||||
res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Missing projectSlug',
|
error: 'Missing projectSlug',
|
||||||
message: 'Project keys require a projectSlug',
|
message: 'Project keys require a projectSlug',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'project' && !organizationSlug) {
|
if (type === 'project' && !organizationSlug) {
|
||||||
res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Missing organizationSlug',
|
error: 'Missing organizationSlug',
|
||||||
message: 'Project keys require an organizationSlug',
|
message: 'Project keys require an organizationSlug',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create key
|
// Create key
|
||||||
|
|
@ -151,18 +148,17 @@ router.get('/', async (req, res) => {
|
||||||
* Revoke an API key
|
* Revoke an API key
|
||||||
* DELETE /api/admin/keys/:keyId
|
* DELETE /api/admin/keys/:keyId
|
||||||
*/
|
*/
|
||||||
router.delete('/:keyId', async (req, res): Promise<void> => {
|
router.delete('/:keyId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { keyId } = req.params;
|
const { keyId } = req.params;
|
||||||
|
|
||||||
const success = await apiKeyService.revokeKey(keyId);
|
const success = await apiKeyService.revokeKey(keyId);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
res.status(404).json({
|
return res.status(404).json({
|
||||||
error: 'Key not found',
|
error: 'Key not found',
|
||||||
message: 'The specified API key does not exist',
|
message: 'The specified API key does not exist',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[${new Date().toISOString()}] API key revoked: ${keyId} - by: ${req.apiKey!.id}`);
|
console.log(`[${new Date().toISOString()}] API key revoked: ${keyId} - by: ${req.apiKey!.id}`);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import express, { Router } from 'express';
|
import express from 'express';
|
||||||
import { ApiKeyService } from '../services/ApiKeyService';
|
import { ApiKeyService } from '../services/ApiKeyService';
|
||||||
|
|
||||||
const router: Router = express.Router();
|
const router = express.Router();
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -10,18 +10,17 @@ const apiKeyService = new ApiKeyService();
|
||||||
*
|
*
|
||||||
* POST /api/bootstrap/initial-key
|
* POST /api/bootstrap/initial-key
|
||||||
*/
|
*/
|
||||||
router.post('/initial-key', async (_req, res): Promise<void> => {
|
router.post('/initial-key', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Check if any keys already exist
|
// Check if any keys already exist
|
||||||
const hasKeys = await apiKeyService.hasAnyKeys();
|
const hasKeys = await apiKeyService.hasAnyKeys();
|
||||||
|
|
||||||
if (hasKeys) {
|
if (hasKeys) {
|
||||||
console.warn(`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`);
|
console.warn(`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`);
|
||||||
res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Bootstrap not allowed',
|
error: 'Bootstrap not allowed',
|
||||||
message: 'API keys already exist. Use /api/admin/keys to create new keys.',
|
message: 'API keys already exist. Use /api/admin/keys to create new keys.',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create first master key
|
// Create first master key
|
||||||
|
|
|
||||||
|
|
@ -1,483 +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
|
|
||||||
// Storage key format: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
const storageService = await StorageFactory.getInstance();
|
|
||||||
const keyParts = image.storageKey.split('/');
|
|
||||||
|
|
||||||
if (keyParts.length < 4 || keyParts[2] !== 'img') {
|
|
||||||
throw new Error('Invalid storage key format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedOrgSlug = keyParts[0]!;
|
|
||||||
const storedProjectSlug = keyParts[1]!;
|
|
||||||
const imageId = keyParts[3]!;
|
|
||||||
|
|
||||||
const buffer = await storageService.downloadFile(storedOrgSlug, storedProjectSlug, imageId);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// Storage key format: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
const storageService = await StorageFactory.getInstance();
|
|
||||||
const keyParts = cachedImage.storageKey.split('/');
|
|
||||||
|
|
||||||
if (keyParts.length < 4 || keyParts[2] !== 'img') {
|
|
||||||
throw new Error('Invalid storage key format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedOrgSlug = keyParts[0]!;
|
|
||||||
const storedProjectSlug = keyParts[1]!;
|
|
||||||
const imageId = keyParts[3]!;
|
|
||||||
|
|
||||||
const buffer = await storageService.downloadFile(storedOrgSlug, storedProjectSlug, imageId);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
organizationSlug: orgSlug,
|
|
||||||
projectSlug: projectSlug,
|
|
||||||
prompt,
|
|
||||||
aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
|
||||||
autoEnhance: normalizedAutoEnhance,
|
|
||||||
requestId: req.requestId,
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
|
||||||
// Storage key format: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
const storageService = await StorageFactory.getInstance();
|
|
||||||
const keyParts = generation.outputImage.storageKey.split('/');
|
|
||||||
|
|
||||||
if (keyParts.length < 4 || keyParts[2] !== 'img') {
|
|
||||||
throw new Error('Invalid storage key format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedOrgSlug = keyParts[0]!;
|
|
||||||
const storedProjectSlug = keyParts[1]!;
|
|
||||||
const imageId = keyParts[3]!;
|
|
||||||
|
|
||||||
const buffer = await storageService.downloadFile(storedOrgSlug, storedProjectSlug, imageId);
|
|
||||||
|
|
||||||
// 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,60 +1,77 @@
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import type { Router as RouterType } from 'express';
|
|
||||||
import { StorageFactory } from '../services/StorageFactory';
|
import { StorageFactory } from '../services/StorageFactory';
|
||||||
import { asyncHandler } from '../middleware/errorHandler';
|
import { asyncHandler } from '../middleware/errorHandler';
|
||||||
import { validateApiKey } from '../middleware/auth/validateApiKey';
|
import { validateApiKey } from '../middleware/auth/validateApiKey';
|
||||||
import { requireProjectKey } from '../middleware/auth/requireProjectKey';
|
import { requireProjectKey } from '../middleware/auth/requireProjectKey';
|
||||||
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
|
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
|
||||||
|
|
||||||
export const imagesRouter: RouterType = Router();
|
export const imagesRouter = Router();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/images/:orgSlug/:projectSlug/img/:imageId
|
* GET /api/images/:orgId/:projectId/:category/:filename
|
||||||
* Serves images directly (streaming approach)
|
* Serves images via presigned URLs (redirect approach)
|
||||||
* New format: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
*/
|
*/
|
||||||
imagesRouter.get(
|
imagesRouter.get(
|
||||||
'/images/:orgSlug/:projectSlug/img/:imageId',
|
'/images/:orgId/:projectId/:category/:filename',
|
||||||
asyncHandler(async (req: Request, res: Response): Promise<void> => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const { orgSlug, projectSlug, imageId } = req.params;
|
const { orgId, projectId, category, filename } = req.params;
|
||||||
|
|
||||||
// Validate required params (these are guaranteed by route pattern)
|
// Validate category
|
||||||
if (!orgSlug || !projectSlug || !imageId) {
|
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||||
res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Missing required parameters',
|
message: 'Invalid category',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageService = await StorageFactory.getInstance();
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if file exists first (fast check)
|
// Check if file exists first (fast check)
|
||||||
const exists = await storageService.fileExists(orgSlug, projectSlug, imageId);
|
const exists = await storageService.fileExists(
|
||||||
|
orgId,
|
||||||
|
projectId,
|
||||||
|
category as 'uploads' | 'generated' | 'references',
|
||||||
|
filename,
|
||||||
|
);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'File not found',
|
message: 'File not found',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine content type from filename
|
||||||
|
const ext = filename.toLowerCase().split('.').pop();
|
||||||
|
const contentType =
|
||||||
|
{
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
}[ext || ''] || 'application/octet-stream';
|
||||||
|
|
||||||
// Set headers for optimal caching and performance
|
// Set headers for optimal caching and performance
|
||||||
// Note: Content-Type will be set from MinIO metadata
|
res.setHeader('Content-Type', contentType);
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1 year + immutable
|
res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); // 24 hours + immutable
|
||||||
res.setHeader('ETag', `"${imageId}"`); // UUID as ETag
|
res.setHeader('ETag', `"${orgId}-${projectId}-${filename}"`); // Simple ETag
|
||||||
|
|
||||||
// Handle conditional requests (304 Not Modified)
|
// Handle conditional requests (304 Not Modified)
|
||||||
const ifNoneMatch = req.headers['if-none-match'];
|
const ifNoneMatch = req.headers['if-none-match'];
|
||||||
if (ifNoneMatch === `"${imageId}"`) {
|
if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) {
|
||||||
res.status(304).end(); // Not Modified
|
return res.status(304).end(); // Not Modified
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream the file directly through our API (memory efficient)
|
// Stream the file directly through our API (memory efficient)
|
||||||
const fileStream = await storageService.streamFile(orgSlug, projectSlug, imageId);
|
const fileStream = await storageService.streamFile(
|
||||||
|
orgId,
|
||||||
|
projectId,
|
||||||
|
category as 'uploads' | 'generated' | 'references',
|
||||||
|
filename,
|
||||||
|
);
|
||||||
|
|
||||||
// Handle stream errors
|
// Handle stream errors
|
||||||
fileStream.on('error', (streamError) => {
|
fileStream.on('error', (streamError) => {
|
||||||
|
|
@ -71,7 +88,7 @@ imagesRouter.get(
|
||||||
fileStream.pipe(res);
|
fileStream.pipe(res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to stream file:', error);
|
console.error('Failed to stream file:', error);
|
||||||
res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'File not found',
|
message: 'File not found',
|
||||||
});
|
});
|
||||||
|
|
@ -80,42 +97,41 @@ imagesRouter.get(
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/images/url/:orgSlug/:projectSlug/img/:imageId
|
* GET /api/images/url/:orgId/:projectId/:category/:filename
|
||||||
* Returns a presigned URL instead of redirecting
|
* Returns a presigned URL instead of redirecting
|
||||||
*/
|
*/
|
||||||
imagesRouter.get(
|
imagesRouter.get(
|
||||||
'/images/url/:orgSlug/:projectSlug/img/:imageId',
|
'/images/url/:orgId/:projectId/:category/:filename',
|
||||||
asyncHandler(async (req: Request, res: Response): Promise<void> => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const { orgSlug, projectSlug, imageId } = req.params;
|
const { orgId, projectId, category, filename } = req.params;
|
||||||
const { expiry = '3600' } = req.query; // Default 1 hour
|
const { expiry = '3600' } = req.query; // Default 1 hour
|
||||||
|
|
||||||
// Validate required params (these are guaranteed by route pattern)
|
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||||
if (!orgSlug || !projectSlug || !imageId) {
|
return res.status(400).json({
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Missing required parameters',
|
message: 'Invalid category',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageService = await StorageFactory.getInstance();
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const presignedUrl = await storageService.getPresignedDownloadUrl(
|
const presignedUrl = await storageService.getPresignedDownloadUrl(
|
||||||
orgSlug,
|
orgId,
|
||||||
projectSlug,
|
projectId,
|
||||||
imageId,
|
category as 'uploads' | 'generated' | 'references',
|
||||||
|
filename,
|
||||||
parseInt(expiry as string, 10),
|
parseInt(expiry as string, 10),
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
url: presignedUrl,
|
url: presignedUrl,
|
||||||
expiresIn: parseInt(expiry as string, 10),
|
expiresIn: parseInt(expiry as string, 10),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate presigned URL:', error);
|
console.error('Failed to generate presigned URL:', error);
|
||||||
res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'File not found or access denied',
|
message: 'File not found or access denied',
|
||||||
});
|
});
|
||||||
|
|
@ -143,28 +159,27 @@ imagesRouter.get(
|
||||||
|
|
||||||
// Validate query parameters
|
// Validate query parameters
|
||||||
if (isNaN(limit) || isNaN(offset)) {
|
if (isNaN(limit) || isNaN(offset)) {
|
||||||
res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Invalid query parameters',
|
message: 'Invalid query parameters',
|
||||||
error: 'limit and offset must be valid numbers',
|
error: 'limit and offset must be valid numbers',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract org/project from validated API key
|
// Extract org/project from validated API key
|
||||||
const orgSlug = req.apiKey?.organizationSlug || 'default';
|
const orgId = req.apiKey?.organizationSlug || 'default';
|
||||||
const projectSlug = req.apiKey?.projectSlug!;
|
const projectId = req.apiKey?.projectSlug!;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[${timestamp}] [${requestId}] Listing images for org:${orgSlug}, project:${projectSlug}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`,
|
`[${timestamp}] [${requestId}] Listing generated images for org:${orgId}, project:${projectId}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get storage service instance
|
// Get storage service instance
|
||||||
const storageService = await StorageFactory.getInstance();
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
|
||||||
// List files in img folder
|
// List files in generated category
|
||||||
const allFiles = await storageService.listFiles(orgSlug, projectSlug, prefix);
|
const allFiles = await storageService.listFiles(orgId, projectId, 'generated', prefix);
|
||||||
|
|
||||||
// Sort by lastModified descending (newest first)
|
// Sort by lastModified descending (newest first)
|
||||||
allFiles.sort((a, b) => {
|
allFiles.sort((a, b) => {
|
||||||
|
|
@ -179,8 +194,8 @@ imagesRouter.get(
|
||||||
|
|
||||||
// Map to response format with public URLs
|
// Map to response format with public URLs
|
||||||
const images = paginatedFiles.map((file) => ({
|
const images = paginatedFiles.map((file) => ({
|
||||||
imageId: file.filename,
|
filename: file.filename,
|
||||||
url: storageService.getPublicUrl(orgSlug, projectSlug, file.filename),
|
url: storageService.getPublicUrl(orgId, projectId, 'generated', file.filename),
|
||||||
size: file.size,
|
size: file.size,
|
||||||
contentType: file.contentType,
|
contentType: file.contentType,
|
||||||
lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(),
|
lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(),
|
||||||
|
|
@ -189,7 +204,7 @@ imagesRouter.get(
|
||||||
const hasMore = offset + limit < total;
|
const hasMore = offset + limit < total;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} images`,
|
`[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} generated images`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
|
|
@ -203,11 +218,11 @@ imagesRouter.get(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[${timestamp}] [${requestId}] Failed to list images:`, error);
|
console.error(`[${timestamp}] [${requestId}] Failed to list generated images:`, error);
|
||||||
|
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to list images',
|
message: 'Failed to list generated images',
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Response, Router } from 'express';
|
import { Response, Router } from 'express';
|
||||||
import type { Router as RouterType } from 'express';
|
import type { Router as RouterType } from 'express';
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { ImageGenService } from '../services/ImageGenService';
|
import { ImageGenService } from '../services/ImageGenService';
|
||||||
import { validateTextToImageRequest, logTextToImageRequest } from '../middleware/jsonValidation';
|
import { validateTextToImageRequest, logTextToImageRequest } from '../middleware/jsonValidation';
|
||||||
import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement';
|
import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement';
|
||||||
|
|
@ -49,17 +48,14 @@ textToImageRouter.post(
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const requestId = req.requestId;
|
const requestId = req.requestId;
|
||||||
const { prompt, aspectRatio, meta } = req.body;
|
const { prompt, filename, aspectRatio, meta } = req.body;
|
||||||
|
|
||||||
// Extract org/project slugs from validated API key
|
// Extract org/project slugs from validated API key
|
||||||
const orgSlug = req.apiKey?.organizationSlug || undefined;
|
const orgId = req.apiKey?.organizationSlug || undefined;
|
||||||
const projectSlug = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
||||||
|
|
||||||
// Generate imageId (UUID) - this will be the filename in storage
|
|
||||||
const imageId = randomUUID();
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgSlug}, project:${projectSlug}`,
|
`[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgId}, project:${projectId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -70,10 +66,10 @@ textToImageRouter.post(
|
||||||
|
|
||||||
const result = await imageGenService.generateImage({
|
const result = await imageGenService.generateImage({
|
||||||
prompt,
|
prompt,
|
||||||
imageId,
|
filename,
|
||||||
...(aspectRatio && { aspectRatio }),
|
...(aspectRatio && { aspectRatio }),
|
||||||
orgSlug,
|
orgId,
|
||||||
projectSlug,
|
projectId,
|
||||||
...(meta && { meta }),
|
...(meta && { meta }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -81,7 +77,7 @@ textToImageRouter.post(
|
||||||
console.log(`[${timestamp}] [${requestId}] Text-to-image generation completed:`, {
|
console.log(`[${timestamp}] [${requestId}] Text-to-image generation completed:`, {
|
||||||
success: result.success,
|
success: result.success,
|
||||||
model: result.model,
|
model: result.model,
|
||||||
imageId: result.imageId,
|
filename: result.filename,
|
||||||
hasError: !!result.error,
|
hasError: !!result.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -91,7 +87,7 @@ textToImageRouter.post(
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Image generated successfully',
|
message: 'Image generated successfully',
|
||||||
data: {
|
data: {
|
||||||
filename: result.imageId!,
|
filename: result.filename!,
|
||||||
filepath: result.filepath!,
|
filepath: result.filepath!,
|
||||||
...(result.url && { url: result.url }),
|
...(result.url && { url: result.url }),
|
||||||
...(result.description && { description: result.description }),
|
...(result.description && { description: result.description }),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Response, Router } from 'express';
|
import { Response, Router } from 'express';
|
||||||
import type { Router as RouterType } from 'express';
|
import type { Router as RouterType } from 'express';
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { StorageFactory } from '../services/StorageFactory';
|
import { StorageFactory } from '../services/StorageFactory';
|
||||||
import { asyncHandler } from '../middleware/errorHandler';
|
import { asyncHandler } from '../middleware/errorHandler';
|
||||||
import { validateApiKey } from '../middleware/auth/validateApiKey';
|
import { validateApiKey } from '../middleware/auth/validateApiKey';
|
||||||
|
|
@ -41,11 +40,11 @@ uploadRouter.post(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract org/project slugs from validated API key
|
// Extract org/project slugs from validated API key
|
||||||
const orgSlug = req.apiKey?.organizationSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
const orgId = req.apiKey?.organizationSlug || 'default';
|
||||||
const projectSlug = req.apiKey?.projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main'; // Guaranteed by requireProjectKey middleware
|
const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[${timestamp}] [${requestId}] Starting file upload for org:${orgSlug}, project:${projectSlug}`,
|
`[${timestamp}] [${requestId}] Starting file upload for org:${orgId}, project:${projectId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
|
|
@ -54,22 +53,18 @@ uploadRouter.post(
|
||||||
// Initialize storage service
|
// Initialize storage service
|
||||||
const storageService = await StorageFactory.getInstance();
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
|
||||||
// Generate imageId (UUID) - this will be the filename in storage
|
// Upload file to MinIO in 'uploads' category
|
||||||
const imageId = randomUUID();
|
|
||||||
|
|
||||||
// Upload file to MinIO
|
|
||||||
// Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
console.log(
|
console.log(
|
||||||
`[${timestamp}] [${requestId}] Uploading file: ${file.originalname} as ${imageId} (${file.size} bytes)`,
|
`[${timestamp}] [${requestId}] Uploading file: ${file.originalname} (${file.size} bytes)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadResult = await storageService.uploadFile(
|
const uploadResult = await storageService.uploadFile(
|
||||||
orgSlug,
|
orgId,
|
||||||
projectSlug,
|
projectId,
|
||||||
imageId,
|
'uploads',
|
||||||
|
file.originalname,
|
||||||
file.buffer,
|
file.buffer,
|
||||||
file.mimetype,
|
file.mimetype,
|
||||||
file.originalname,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!uploadResult.success) {
|
if (!uploadResult.success) {
|
||||||
|
|
|
||||||
|
|
@ -1,629 +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 {
|
|
||||||
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,553 +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 organizationSlug = req.apiKey.organizationSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
|
||||||
const projectSlug = req.apiKey.projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main';
|
|
||||||
|
|
||||||
const generation = await service.create({
|
|
||||||
projectId,
|
|
||||||
apiKeyId,
|
|
||||||
organizationSlug,
|
|
||||||
projectSlug,
|
|
||||||
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,948 +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)
|
|
||||||
// Use flowId directly since TypeScript has narrowed it to string in this branch
|
|
||||||
const providedFlowId = flowId;
|
|
||||||
finalFlowId = providedFlowId;
|
|
||||||
pendingFlowId = null;
|
|
||||||
|
|
||||||
// Check if flow exists, create if not
|
|
||||||
const existingFlow = await db.query.flows.findFirst({
|
|
||||||
where: eq(flows.id, providedFlowId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingFlow) {
|
|
||||||
await db.insert(flows).values({
|
|
||||||
id: providedFlowId,
|
|
||||||
projectId,
|
|
||||||
aliases: {},
|
|
||||||
meta: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Link any pending images to this new flow
|
|
||||||
await service.linkPendingImagesToFlow(providedFlowId, 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;
|
|
||||||
const organizationSlug = req.apiKey.organizationSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
|
||||||
const projectSlug = req.apiKey.projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main';
|
|
||||||
|
|
||||||
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: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
const keyParts = image.storageKey.split('/');
|
|
||||||
if (keyParts.length < 4 || keyParts[2] !== 'img') {
|
|
||||||
throw new Error('Invalid storage key format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedOrgSlug = keyParts[0]!;
|
|
||||||
const storedProjectSlug = keyParts[1]!;
|
|
||||||
const imageId = keyParts[3]!;
|
|
||||||
|
|
||||||
// Download image from storage
|
|
||||||
const buffer = await storageService.downloadFile(
|
|
||||||
storedOrgSlug,
|
|
||||||
storedProjectSlug,
|
|
||||||
imageId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
organizationSlug,
|
|
||||||
projectSlug,
|
|
||||||
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: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
const keyParts = generation.outputImage.storageKey.split('/');
|
|
||||||
if (keyParts.length < 4 || keyParts[2] !== 'img') {
|
|
||||||
throw new Error('Invalid storage key format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedOrgSlug = keyParts[0]!;
|
|
||||||
const storedProjectSlug = keyParts[1]!;
|
|
||||||
const imageId = keyParts[3]!;
|
|
||||||
|
|
||||||
const buffer = await storageService.downloadFile(
|
|
||||||
storedOrgSlug,
|
|
||||||
storedProjectSlug,
|
|
||||||
imageId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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,6 +1,6 @@
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { apiKeys, organizations, projects, type ApiKey } from '@banatie/database';
|
import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from '@banatie/database';
|
||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
// Extended API key type with slugs for storage paths
|
// Extended API key type with slugs for storage paths
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -12,13 +11,10 @@ import {
|
||||||
import { StorageFactory } from './StorageFactory';
|
import { StorageFactory } from './StorageFactory';
|
||||||
import { TTILogger, TTILogEntry } from './TTILogger';
|
import { TTILogger, TTILogEntry } from './TTILogger';
|
||||||
import { NetworkErrorDetector } from '../utils/NetworkErrorDetector';
|
import { NetworkErrorDetector } from '../utils/NetworkErrorDetector';
|
||||||
import { GeminiErrorDetector } from '../utils/GeminiErrorDetector';
|
|
||||||
import { ERROR_MESSAGES } from '../utils/constants/errors';
|
|
||||||
|
|
||||||
export class ImageGenService {
|
export class ImageGenService {
|
||||||
private ai: GoogleGenAI;
|
private ai: GoogleGenAI;
|
||||||
private primaryModel = 'gemini-2.5-flash-image';
|
private primaryModel = 'gemini-2.5-flash-image';
|
||||||
private static GEMINI_TIMEOUT_MS = 90_000; // 90 seconds
|
|
||||||
|
|
||||||
constructor(apiKey: string) {
|
constructor(apiKey: string) {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
|
|
@ -32,12 +28,12 @@ export class ImageGenService {
|
||||||
* This method separates image generation from storage for clear error handling
|
* This method separates image generation from storage for clear error handling
|
||||||
*/
|
*/
|
||||||
async generateImage(options: ImageGenerationOptions): Promise<ImageGenerationResult> {
|
async generateImage(options: ImageGenerationOptions): Promise<ImageGenerationResult> {
|
||||||
const { prompt, imageId, referenceImages, aspectRatio, orgSlug, projectSlug, meta } = options;
|
const { prompt, filename, referenceImages, aspectRatio, orgId, projectId, meta } = options;
|
||||||
|
|
||||||
// Use default values if not provided
|
// Use default values if not provided
|
||||||
const finalOrgSlug = orgSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
const finalOrgId = orgId || process.env['DEFAULT_ORG_ID'] || 'default';
|
||||||
const finalProjectSlug = projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main';
|
const finalProjectId = projectId || process.env['DEFAULT_PROJECT_ID'] || 'main';
|
||||||
const finalAspectRatio = aspectRatio || '16:9'; // Default to widescreen
|
const finalAspectRatio = aspectRatio || '1:1'; // Default to square
|
||||||
|
|
||||||
// Step 1: Generate image from Gemini AI
|
// Step 1: Generate image from Gemini AI
|
||||||
let generatedData: GeneratedImageData;
|
let generatedData: GeneratedImageData;
|
||||||
|
|
@ -47,8 +43,8 @@ export class ImageGenService {
|
||||||
prompt,
|
prompt,
|
||||||
referenceImages,
|
referenceImages,
|
||||||
finalAspectRatio,
|
finalAspectRatio,
|
||||||
finalOrgSlug,
|
finalOrgId,
|
||||||
finalProjectSlug,
|
finalProjectId,
|
||||||
meta,
|
meta,
|
||||||
);
|
);
|
||||||
generatedData = aiResult.generatedData;
|
generatedData = aiResult.generatedData;
|
||||||
|
|
@ -64,31 +60,26 @@ export class ImageGenService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Save generated image to storage
|
// Step 2: Save generated image to storage
|
||||||
// Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
try {
|
try {
|
||||||
|
const finalFilename = `${filename}.${generatedData.fileExtension}`;
|
||||||
const storageService = await StorageFactory.getInstance();
|
const storageService = await StorageFactory.getInstance();
|
||||||
// Original filename for metadata (e.g., "my-image.png")
|
|
||||||
const originalFilename = `generated-image.${generatedData.fileExtension}`;
|
|
||||||
|
|
||||||
const uploadResult = await storageService.uploadFile(
|
const uploadResult = await storageService.uploadFile(
|
||||||
finalOrgSlug,
|
finalOrgId,
|
||||||
finalProjectSlug,
|
finalProjectId,
|
||||||
imageId,
|
'generated',
|
||||||
|
finalFilename,
|
||||||
generatedData.buffer,
|
generatedData.buffer,
|
||||||
generatedData.mimeType,
|
generatedData.mimeType,
|
||||||
originalFilename,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (uploadResult.success) {
|
if (uploadResult.success) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
imageId: 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,
|
||||||
}),
|
}),
|
||||||
|
|
@ -131,8 +122,8 @@ export class ImageGenService {
|
||||||
prompt: string,
|
prompt: string,
|
||||||
referenceImages: ReferenceImage[] | undefined,
|
referenceImages: ReferenceImage[] | undefined,
|
||||||
aspectRatio: string,
|
aspectRatio: string,
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
meta?: { tags?: string[] },
|
meta?: { tags?: string[] },
|
||||||
): Promise<{
|
): Promise<{
|
||||||
generatedData: GeneratedImageData;
|
generatedData: GeneratedImageData;
|
||||||
|
|
@ -188,8 +179,8 @@ export class ImageGenService {
|
||||||
const ttiLogger = TTILogger.getInstance();
|
const ttiLogger = TTILogger.getInstance();
|
||||||
const logEntry: TTILogEntry = {
|
const logEntry: TTILogEntry = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
orgId: orgSlug,
|
orgId,
|
||||||
projectId: projectSlug,
|
projectId,
|
||||||
prompt,
|
prompt,
|
||||||
model: this.primaryModel,
|
model: this.primaryModel,
|
||||||
config,
|
config,
|
||||||
|
|
@ -208,56 +199,18 @@ export class ImageGenService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the EXACT same config and contents objects calculated above
|
// Use the EXACT same config and contents objects calculated above
|
||||||
// Wrap with timeout to prevent hanging requests
|
const response = await this.ai.models.generateContent({
|
||||||
const response = await this.withTimeout(
|
model: this.primaryModel,
|
||||||
this.ai.models.generateContent({
|
config,
|
||||||
model: this.primaryModel,
|
contents,
|
||||||
config,
|
});
|
||||||
contents,
|
|
||||||
}),
|
|
||||||
ImageGenService.GEMINI_TIMEOUT_MS,
|
|
||||||
'Gemini image generation'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log response structure for debugging
|
// Parse response
|
||||||
GeminiErrorDetector.logResponseStructure(response as any);
|
if (!response.candidates || !response.candidates[0] || !response.candidates[0].content) {
|
||||||
|
throw new Error('No response received from Gemini AI');
|
||||||
// Check promptFeedback for blocked prompts FIRST
|
|
||||||
if ((response as any).promptFeedback?.blockReason) {
|
|
||||||
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
|
|
||||||
console.error(
|
|
||||||
`[ImageGenService] Prompt blocked:`,
|
|
||||||
GeminiErrorDetector.formatForLogging(errorResult!)
|
|
||||||
);
|
|
||||||
throw new Error(errorResult!.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have candidates
|
const content = response.candidates[0].content;
|
||||||
if (!response.candidates || !response.candidates[0]) {
|
|
||||||
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
|
|
||||||
console.error(`[ImageGenService] No candidates in response`);
|
|
||||||
throw new Error(errorResult?.message || 'No response candidates from Gemini AI');
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = response.candidates[0];
|
|
||||||
|
|
||||||
// Check finishReason for non-STOP completions
|
|
||||||
if (candidate.finishReason && candidate.finishReason !== 'STOP') {
|
|
||||||
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
|
|
||||||
console.error(
|
|
||||||
`[ImageGenService] Non-STOP finish reason:`,
|
|
||||||
GeminiErrorDetector.formatForLogging(errorResult!)
|
|
||||||
);
|
|
||||||
throw new Error(errorResult!.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check content exists
|
|
||||||
if (!candidate.content) {
|
|
||||||
console.error(`[ImageGenService] No content in candidate`);
|
|
||||||
throw new Error('No content in Gemini AI response');
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = candidate.content;
|
|
||||||
let generatedDescription: string | undefined;
|
let generatedDescription: string | undefined;
|
||||||
let imageData: { buffer: Buffer; mimeType: string } | null = null;
|
let imageData: { buffer: Buffer; mimeType: string } | null = null;
|
||||||
|
|
||||||
|
|
@ -273,37 +226,15 @@ export class ImageGenService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!imageData) {
|
if (!imageData) {
|
||||||
// Log what we got instead of image
|
throw new Error('No image data received from Gemini AI');
|
||||||
const partTypes = (content.parts || []).map((p: any) =>
|
|
||||||
p.inlineData ? 'image' : p.text ? 'text' : 'other'
|
|
||||||
);
|
|
||||||
console.error(`[ImageGenService] No image data in response. Parts: [${partTypes.join(', ')}]`);
|
|
||||||
throw new Error(
|
|
||||||
`${ERROR_MESSAGES.GEMINI_NO_IMAGE}. Response contained: ${partTypes.join(', ') || 'nothing'}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -312,38 +243,6 @@ export class ImageGenService {
|
||||||
geminiParams,
|
geminiParams,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Check for rate limit (HTTP 429)
|
|
||||||
const err = error as { status?: number; message?: string };
|
|
||||||
if (err.status === 429) {
|
|
||||||
const geminiError = GeminiErrorDetector.classifyApiError(error);
|
|
||||||
console.error(
|
|
||||||
`[ImageGenService] Rate limit:`,
|
|
||||||
GeminiErrorDetector.formatForLogging(geminiError)
|
|
||||||
);
|
|
||||||
throw new Error(geminiError.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for timeout
|
|
||||||
if (error instanceof Error && error.message.includes('timed out')) {
|
|
||||||
console.error(
|
|
||||||
`[ImageGenService] Timeout after ${ImageGenService.GEMINI_TIMEOUT_MS}ms:`,
|
|
||||||
error.message
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`${ERROR_MESSAGES.GEMINI_TIMEOUT} after ${ImageGenService.GEMINI_TIMEOUT_MS / 1000} seconds`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for other API errors with status codes
|
|
||||||
if (err.status) {
|
|
||||||
const geminiError = GeminiErrorDetector.classifyApiError(error);
|
|
||||||
console.error(
|
|
||||||
`[ImageGenService] API error:`,
|
|
||||||
GeminiErrorDetector.formatForLogging(geminiError)
|
|
||||||
);
|
|
||||||
throw new Error(geminiError.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced error detection with network diagnostics
|
// Enhanced error detection with network diagnostics
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
// Classify the error and check for network issues (only on failure)
|
// Classify the error and check for network issues (only on failure)
|
||||||
|
|
@ -359,32 +258,6 @@ export class ImageGenService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap a promise with timeout
|
|
||||||
*/
|
|
||||||
private async withTimeout<T>(
|
|
||||||
promise: Promise<T>,
|
|
||||||
timeoutMs: number,
|
|
||||||
operationName: string
|
|
||||||
): Promise<T> {
|
|
||||||
let timeoutId: NodeJS.Timeout;
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
reject(new Error(`${operationName} timed out after ${timeoutMs}ms`));
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await Promise.race([promise, timeoutPromise]);
|
|
||||||
clearTimeout(timeoutId!);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeoutId!);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static validateReferenceImages(files: Express.Multer.File[]): {
|
static validateReferenceImages(files: Express.Multer.File[]): {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { StorageService, FileMetadata, UploadResult } from './StorageService';
|
||||||
export class MinioStorageService implements StorageService {
|
export class MinioStorageService implements StorageService {
|
||||||
private client: MinioClient;
|
private client: MinioClient;
|
||||||
private bucketName: string;
|
private bucketName: string;
|
||||||
private cdnBaseUrl: string;
|
private publicUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
|
|
@ -12,7 +12,7 @@ export class MinioStorageService implements StorageService {
|
||||||
secretKey: string,
|
secretKey: string,
|
||||||
useSSL: boolean = false,
|
useSSL: boolean = false,
|
||||||
bucketName: string = 'banatie',
|
bucketName: string = 'banatie',
|
||||||
cdnBaseUrl?: string,
|
publicUrl?: string,
|
||||||
) {
|
) {
|
||||||
// Parse endpoint to separate hostname and port
|
// Parse endpoint to separate hostname and port
|
||||||
const cleanEndpoint = endpoint.replace(/^https?:\/\//, '');
|
const cleanEndpoint = endpoint.replace(/^https?:\/\//, '');
|
||||||
|
|
@ -31,59 +31,119 @@ export class MinioStorageService implements StorageService {
|
||||||
secretKey,
|
secretKey,
|
||||||
});
|
});
|
||||||
this.bucketName = bucketName;
|
this.bucketName = bucketName;
|
||||||
// CDN base URL without bucket name (e.g., https://cdn.banatie.app)
|
this.publicUrl = publicUrl || `${useSSL ? 'https' : 'http'}://${endpoint}`;
|
||||||
this.cdnBaseUrl = cdnBaseUrl || process.env['CDN_BASE_URL'] || `${useSSL ? 'https' : 'http'}://${endpoint}/${bucketName}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private getFilePath(
|
||||||
* Get file path in storage
|
orgId: string,
|
||||||
* Format: {orgSlug}/{projectSlug}/img/{imageId}
|
projectId: string,
|
||||||
*/
|
category: 'uploads' | 'generated' | 'references',
|
||||||
private getFilePath(orgSlug: string, projectSlug: string, imageId: string): string {
|
filename: string,
|
||||||
return `${orgSlug}/${projectSlug}/img/${imageId}`;
|
): string {
|
||||||
|
// Simplified path without date folder for now
|
||||||
|
return `${orgId}/${projectId}/${category}/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private generateUniqueFilename(originalFilename: string): string {
|
||||||
* Extract file extension from original filename
|
// Sanitize filename first
|
||||||
*/
|
const sanitized = this.sanitizeFilename(originalFilename);
|
||||||
private extractExtension(filename: string): string | undefined {
|
|
||||||
if (!filename) return undefined;
|
const timestamp = Date.now();
|
||||||
const lastDotIndex = filename.lastIndexOf('.');
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
if (lastDotIndex <= 0) return undefined;
|
const ext = sanitized.includes('.') ? sanitized.substring(sanitized.lastIndexOf('.')) : '';
|
||||||
return filename.substring(lastDotIndex + 1).toLowerCase();
|
const name = sanitized.includes('.')
|
||||||
|
? sanitized.substring(0, sanitized.lastIndexOf('.'))
|
||||||
|
: sanitized;
|
||||||
|
|
||||||
|
return `${name}-${timestamp}-${random}${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private sanitizeFilename(filename: string): string {
|
||||||
* Validate storage path components
|
// Remove path traversal attempts FIRST from entire filename
|
||||||
*/
|
let cleaned = filename.replace(/\.\./g, '').trim();
|
||||||
private validatePath(orgSlug: string, projectSlug: string, imageId: string): void {
|
|
||||||
// Validate orgSlug
|
// Split filename and extension
|
||||||
if (!orgSlug || !/^[a-zA-Z0-9_-]+$/.test(orgSlug) || orgSlug.length > 50) {
|
const lastDotIndex = cleaned.lastIndexOf('.');
|
||||||
|
let baseName = lastDotIndex > 0 ? cleaned.substring(0, lastDotIndex) : cleaned;
|
||||||
|
const extension = lastDotIndex > 0 ? cleaned.substring(lastDotIndex) : '';
|
||||||
|
|
||||||
|
// Remove dangerous characters from base name
|
||||||
|
baseName = baseName
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Replace non-ASCII characters with ASCII equivalents or remove them
|
||||||
|
// This prevents S3 signature mismatches with MinIO
|
||||||
|
baseName = baseName
|
||||||
|
.normalize('NFD') // Decompose combined characters (é -> e + ´)
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
|
||||||
|
.replace(/[^\x20-\x7E]/g, '_') // Replace any remaining non-ASCII with underscore
|
||||||
|
.replace(/[^\w\s\-_.]/g, '_') // Replace special chars (except word chars, space, dash, underscore, dot) with underscore
|
||||||
|
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||||
|
.replace(/_{2,}/g, '_') // Collapse multiple underscores
|
||||||
|
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
|
||||||
|
|
||||||
|
// Ensure we still have a valid base name
|
||||||
|
if (baseName.length === 0) {
|
||||||
|
baseName = 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize extension (remove only dangerous chars, keep the dot)
|
||||||
|
let sanitizedExt = extension
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
|
||||||
|
.replace(/[^\x20-\x7E]/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
// Ensure extension starts with a dot and is reasonable
|
||||||
|
if (sanitizedExt && !sanitizedExt.startsWith('.')) {
|
||||||
|
sanitizedExt = '.' + sanitizedExt;
|
||||||
|
}
|
||||||
|
if (sanitizedExt.length > 10) {
|
||||||
|
sanitizedExt = sanitizedExt.substring(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = baseName + sanitizedExt;
|
||||||
|
return result.substring(0, 255); // Limit total length
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateFilePath(
|
||||||
|
orgId: string,
|
||||||
|
projectId: string,
|
||||||
|
category: string,
|
||||||
|
filename: string,
|
||||||
|
): void {
|
||||||
|
// Validate orgId
|
||||||
|
if (!orgId || !/^[a-zA-Z0-9_-]+$/.test(orgId) || orgId.length > 50) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Invalid organization slug: must be alphanumeric with dashes/underscores, max 50 chars',
|
'Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate projectSlug
|
// Validate projectId
|
||||||
if (!projectSlug || !/^[a-zA-Z0-9_-]+$/.test(projectSlug) || projectSlug.length > 50) {
|
if (!projectId || !/^[a-zA-Z0-9_-]+$/.test(projectId) || projectId.length > 50) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Invalid project slug: must be alphanumeric with dashes/underscores, max 50 chars',
|
'Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate imageId (UUID format)
|
// Validate category
|
||||||
if (!imageId || imageId.length === 0 || imageId.length > 50) {
|
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||||
throw new Error('Invalid imageId: must be 1-50 characters');
|
throw new Error('Invalid category: must be uploads, generated, or references');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate filename
|
||||||
|
if (!filename || filename.length === 0 || filename.length > 255) {
|
||||||
|
throw new Error('Invalid filename: must be 1-255 characters');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for path traversal and dangerous patterns
|
// Check for path traversal and dangerous patterns
|
||||||
if (imageId.includes('..') || imageId.includes('/') || imageId.includes('\\')) {
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
throw new Error('Invalid characters in imageId: path traversal not allowed');
|
throw new Error('Invalid characters in filename: path traversal not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent null bytes and control characters
|
// Prevent null bytes and control characters
|
||||||
if (/[\x00-\x1f]/.test(imageId)) {
|
if (/[\x00-\x1f]/.test(filename)) {
|
||||||
throw new Error('Invalid imageId: control characters not allowed');
|
throw new Error('Invalid filename: control characters not allowed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,8 +154,8 @@ export class MinioStorageService implements StorageService {
|
||||||
console.log(`Created bucket: ${this.bucketName}`);
|
console.log(`Created bucket: ${this.bucketName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bucket should be public for CDN access (configured via mc anonymous set download)
|
// Note: With SNMD and presigned URLs, we don't need bucket policies
|
||||||
console.log(`Bucket ${this.bucketName} ready for CDN access`);
|
console.log(`Bucket ${this.bucketName} ready for presigned URL access`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bucketExists(): Promise<boolean> {
|
async bucketExists(): Promise<boolean> {
|
||||||
|
|
@ -103,15 +163,15 @@ export class MinioStorageService implements StorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(
|
async uploadFile(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
originalFilename?: string,
|
|
||||||
): Promise<UploadResult> {
|
): Promise<UploadResult> {
|
||||||
// Validate inputs first
|
// Validate inputs first
|
||||||
this.validatePath(orgSlug, projectSlug, imageId);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
|
|
||||||
if (!buffer || buffer.length === 0) {
|
if (!buffer || buffer.length === 0) {
|
||||||
throw new Error('Buffer cannot be empty');
|
throw new Error('Buffer cannot be empty');
|
||||||
|
|
@ -124,36 +184,26 @@ export class MinioStorageService implements StorageService {
|
||||||
// Ensure bucket exists
|
// Ensure bucket exists
|
||||||
await this.createBucket();
|
await this.createBucket();
|
||||||
|
|
||||||
// Get file path: {orgSlug}/{projectSlug}/img/{imageId}
|
// Generate unique filename to avoid conflicts
|
||||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
const uniqueFilename = this.generateUniqueFilename(filename);
|
||||||
|
const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename);
|
||||||
// Extract file extension from original filename
|
|
||||||
const fileExtension = originalFilename ? this.extractExtension(originalFilename) : undefined;
|
|
||||||
|
|
||||||
// Encode original filename to Base64 to safely store non-ASCII characters in metadata
|
// Encode original filename to Base64 to safely store non-ASCII characters in metadata
|
||||||
const originalNameEncoded = originalFilename
|
const originalNameEncoded = Buffer.from(filename, 'utf-8').toString('base64');
|
||||||
? Buffer.from(originalFilename, 'utf-8').toString('base64')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const metadata: Record<string, string> = {
|
const metadata = {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'X-Amz-Meta-Project': projectSlug,
|
'X-Amz-Meta-Original-Name': originalNameEncoded,
|
||||||
'X-Amz-Meta-Organization': orgSlug,
|
'X-Amz-Meta-Original-Name-Encoding': 'base64',
|
||||||
|
'X-Amz-Meta-Category': category,
|
||||||
|
'X-Amz-Meta-Project': projectId,
|
||||||
|
'X-Amz-Meta-Organization': orgId,
|
||||||
'X-Amz-Meta-Upload-Time': new Date().toISOString(),
|
'X-Amz-Meta-Upload-Time': new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (originalNameEncoded) {
|
console.log(`Uploading file to: ${this.bucketName}/${filePath}`);
|
||||||
metadata['X-Amz-Meta-Original-Name'] = originalNameEncoded;
|
|
||||||
metadata['X-Amz-Meta-Original-Name-Encoding'] = 'base64';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileExtension) {
|
const result = await this.client.putObject(
|
||||||
metadata['X-Amz-Meta-File-Extension'] = fileExtension;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[MinIO] Uploading file to: ${this.bucketName}/${filePath}`);
|
|
||||||
|
|
||||||
await this.client.putObject(
|
|
||||||
this.bucketName,
|
this.bucketName,
|
||||||
filePath,
|
filePath,
|
||||||
buffer,
|
buffer,
|
||||||
|
|
@ -161,29 +211,28 @@ export class MinioStorageService implements StorageService {
|
||||||
metadata,
|
metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = this.getPublicUrl(orgSlug, projectSlug, imageId);
|
const url = this.getPublicUrl(orgId, projectId, category, uniqueFilename);
|
||||||
|
|
||||||
console.log(`[MinIO] CDN URL: ${url}`);
|
console.log(`Generated API URL: ${url}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
filename: imageId,
|
filename: uniqueFilename,
|
||||||
path: filePath,
|
path: filePath,
|
||||||
url,
|
url,
|
||||||
size: buffer.length,
|
size: buffer.length,
|
||||||
contentType,
|
contentType,
|
||||||
...(originalFilename && { originalFilename }),
|
|
||||||
...(fileExtension && { fileExtension }),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadFile(
|
async downloadFile(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
this.validatePath(orgSlug, projectSlug, imageId);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||||
|
|
||||||
const stream = await this.client.getObject(this.bucketName, filePath);
|
const stream = await this.client.getObject(this.bucketName, filePath);
|
||||||
|
|
||||||
|
|
@ -196,91 +245,184 @@ export class MinioStorageService implements StorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async streamFile(
|
async streamFile(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
): Promise<import('stream').Readable> {
|
): Promise<import('stream').Readable> {
|
||||||
this.validatePath(orgSlug, projectSlug, imageId);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||||
|
|
||||||
// Return the stream directly without buffering - memory efficient!
|
// Return the stream directly without buffering - memory efficient!
|
||||||
return await this.client.getObject(this.bucketName, filePath);
|
return await this.client.getObject(this.bucketName, filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFile(
|
async deleteFile(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.validatePath(orgSlug, projectSlug, imageId);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||||
await this.client.removeObject(this.bucketName, filePath);
|
await this.client.removeObject(this.bucketName, filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getPublicUrl(
|
||||||
* Get public CDN URL for file access
|
orgId: string,
|
||||||
* Returns: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId}
|
projectId: string,
|
||||||
*/
|
category: 'uploads' | 'generated' | 'references',
|
||||||
getPublicUrl(orgSlug: string, projectSlug: string, imageId: string): string {
|
filename: string,
|
||||||
this.validatePath(orgSlug, projectSlug, imageId);
|
): string {
|
||||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
return `${this.cdnBaseUrl}/${filePath}`;
|
// Production-ready: Return API URL for presigned URL access
|
||||||
|
const apiBaseUrl = process.env['API_BASE_URL'] || 'http://localhost:3000';
|
||||||
|
return `${apiBaseUrl}/api/images/${orgId}/${projectId}/${category}/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPresignedUploadUrl(
|
async getPresignedUploadUrl(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
expirySeconds: number,
|
expirySeconds: number,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
this.validatePath(orgSlug, projectSlug, imageId);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
|
|
||||||
if (!contentType || contentType.trim().length === 0) {
|
if (!contentType || contentType.trim().length === 0) {
|
||||||
throw new Error('Content type is required for presigned upload URL');
|
throw new Error('Content type is required for presigned upload URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||||
return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds);
|
return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPresignedDownloadUrl(
|
async getPresignedDownloadUrl(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
expirySeconds: number = 86400, // 24 hours default
|
expirySeconds: number = 86400, // 24 hours default
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
this.validatePath(orgSlug, projectSlug, imageId);
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||||
const presignedUrl = await this.client.presignedGetObject(
|
const presignedUrl = await this.client.presignedGetObject(
|
||||||
this.bucketName,
|
this.bucketName,
|
||||||
filePath,
|
filePath,
|
||||||
expirySeconds,
|
expirySeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Replace internal Docker hostname with CDN URL if configured
|
// Replace internal Docker hostname with public URL if configured
|
||||||
if (this.cdnBaseUrl) {
|
if (this.publicUrl) {
|
||||||
// Access protected properties via type assertion for URL replacement
|
const clientEndpoint = this.client.host + (this.client.port ? `:${this.client.port}` : '');
|
||||||
const client = this.client as unknown as { host: string; port: number; protocol: string };
|
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, '');
|
||||||
const clientEndpoint = client.host + (client.port ? `:${client.port}` : '');
|
|
||||||
|
|
||||||
return presignedUrl.replace(`${client.protocol}//${clientEndpoint}/${this.bucketName}`, this.cdnBaseUrl);
|
return presignedUrl.replace(`${this.client.protocol}//${clientEndpoint}`, this.publicUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return presignedUrl;
|
return presignedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async listProjectFiles(
|
||||||
* List files in a project's img folder
|
orgId: string,
|
||||||
*/
|
projectId: string,
|
||||||
|
category?: 'uploads' | 'generated' | 'references',
|
||||||
|
): Promise<FileMetadata[]> {
|
||||||
|
const prefix = category ? `${orgId}/${projectId}/${category}/` : `${orgId}/${projectId}/`;
|
||||||
|
|
||||||
|
const files: FileMetadata[] = [];
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const stream = this.client.listObjects(this.bucketName, prefix, true);
|
||||||
|
|
||||||
|
stream.on('data', async (obj) => {
|
||||||
|
try {
|
||||||
|
if (!obj.name) return;
|
||||||
|
|
||||||
|
const metadata = await this.client.statObject(this.bucketName, obj.name);
|
||||||
|
|
||||||
|
const pathParts = obj.name.split('/');
|
||||||
|
const filename = pathParts[pathParts.length - 1];
|
||||||
|
const categoryFromPath = pathParts[2] as 'uploads' | 'generated' | 'references';
|
||||||
|
|
||||||
|
if (!filename || !categoryFromPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
key: `${this.bucketName}/${obj.name}`,
|
||||||
|
filename,
|
||||||
|
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
|
||||||
|
size: obj.size || 0,
|
||||||
|
url: this.getPublicUrl(orgId, projectId, categoryFromPath, filename),
|
||||||
|
createdAt: obj.lastModified || new Date(),
|
||||||
|
});
|
||||||
|
} catch (error) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => resolve(files));
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parseKey(key: string): {
|
||||||
|
orgId: string;
|
||||||
|
projectId: string;
|
||||||
|
category: 'uploads' | 'generated' | 'references';
|
||||||
|
filename: string;
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
const match = key.match(
|
||||||
|
/^banatie\/([^/]+)\/([^/]+)\/(uploads|generated|references)\/[^/]+\/(.+)$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, orgId, projectId, category, filename] = match;
|
||||||
|
|
||||||
|
if (!orgId || !projectId || !category || !filename) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orgId,
|
||||||
|
projectId,
|
||||||
|
category: category as 'uploads' | 'generated' | 'references',
|
||||||
|
filename,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fileExists(
|
||||||
|
orgId: string,
|
||||||
|
projectId: string,
|
||||||
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
this.validateFilePath(orgId, projectId, category, filename);
|
||||||
|
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||||
|
await this.client.statObject(this.bucketName, filePath);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async listFiles(
|
async listFiles(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
|
category: 'uploads' | 'generated' | 'references',
|
||||||
prefix?: string,
|
prefix?: string,
|
||||||
): Promise<FileMetadata[]> {
|
): Promise<FileMetadata[]> {
|
||||||
this.validatePath(orgSlug, projectSlug, 'dummy');
|
this.validateFilePath(orgId, projectId, category, 'dummy.txt');
|
||||||
|
|
||||||
const basePath = `${orgSlug}/${projectSlug}/img/`;
|
const basePath = `${orgId}/${projectId}/${category}/`;
|
||||||
const searchPrefix = prefix ? `${basePath}${prefix}` : basePath;
|
const searchPrefix = prefix ? `${basePath}${prefix}` : basePath;
|
||||||
|
|
||||||
const files: FileMetadata[] = [];
|
const files: FileMetadata[] = [];
|
||||||
|
|
@ -288,22 +430,31 @@ export class MinioStorageService implements StorageService {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const stream = this.client.listObjects(this.bucketName, searchPrefix, true);
|
const stream = this.client.listObjects(this.bucketName, searchPrefix, true);
|
||||||
|
|
||||||
stream.on('data', async (obj) => {
|
stream.on('data', (obj) => {
|
||||||
if (!obj.name || !obj.size) return;
|
if (!obj.name || !obj.size) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pathParts = obj.name.split('/');
|
const pathParts = obj.name.split('/');
|
||||||
const imageId = pathParts[pathParts.length - 1];
|
const filename = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
if (!imageId) return;
|
if (!filename) return;
|
||||||
|
|
||||||
// Get metadata to find content type (no extension in filename)
|
// Infer content type from file extension (more efficient than statObject)
|
||||||
const metadata = await this.client.statObject(this.bucketName, obj.name);
|
const ext = filename.toLowerCase().split('.').pop();
|
||||||
|
const contentType =
|
||||||
|
{
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
}[ext || ''] || 'application/octet-stream';
|
||||||
|
|
||||||
files.push({
|
files.push({
|
||||||
filename: imageId!,
|
filename,
|
||||||
size: obj.size,
|
size: obj.size,
|
||||||
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
|
contentType,
|
||||||
lastModified: obj.lastModified || new Date(),
|
lastModified: obj.lastModified || new Date(),
|
||||||
etag: obj.etag || '',
|
etag: obj.etag || '',
|
||||||
path: obj.name,
|
path: obj.name,
|
||||||
|
|
@ -323,52 +474,4 @@ export class MinioStorageService implements StorageService {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse storage key to extract components
|
|
||||||
* Format: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
*/
|
|
||||||
parseKey(key: string): {
|
|
||||||
orgSlug: string;
|
|
||||||
projectSlug: string;
|
|
||||||
imageId: string;
|
|
||||||
} | null {
|
|
||||||
try {
|
|
||||||
// Match: orgSlug/projectSlug/img/imageId
|
|
||||||
const match = key.match(/^([^/]+)\/([^/]+)\/img\/([^/]+)$/);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, orgSlug, projectSlug, imageId] = match;
|
|
||||||
|
|
||||||
if (!orgSlug || !projectSlug || !imageId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
orgSlug,
|
|
||||||
projectSlug,
|
|
||||||
imageId,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fileExists(
|
|
||||||
orgSlug: string,
|
|
||||||
projectSlug: string,
|
|
||||||
imageId: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
this.validatePath(orgSlug, projectSlug, imageId);
|
|
||||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
|
||||||
await this.client.statObject(this.bucketName, filePath);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,11 @@ export interface FileMetadata {
|
||||||
|
|
||||||
export interface UploadResult {
|
export interface UploadResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
filename: string; // UUID (same as image.id)
|
filename: string;
|
||||||
path: string;
|
path: string;
|
||||||
url: string; // CDN URL for accessing the file
|
url: string; // API URL for accessing the file
|
||||||
size: number;
|
size: number;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
originalFilename?: string; // User's original filename
|
|
||||||
fileExtension?: string; // Original extension (png, jpg, etc.)
|
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,125 +32,123 @@ export interface StorageService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a file to storage
|
* Upload a file to storage
|
||||||
* Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
* @param orgId Organization ID
|
||||||
*
|
* @param projectId Project ID
|
||||||
* @param orgSlug Organization slug
|
* @param category File category (uploads, generated, references)
|
||||||
* @param projectSlug Project slug
|
* @param filename Original filename
|
||||||
* @param imageId UUID for the file (same as image.id in DB)
|
|
||||||
* @param buffer File buffer
|
* @param buffer File buffer
|
||||||
* @param contentType MIME type
|
* @param contentType MIME type
|
||||||
* @param originalFilename Original filename from user (for metadata)
|
|
||||||
*/
|
*/
|
||||||
uploadFile(
|
uploadFile(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
originalFilename?: string,
|
|
||||||
): Promise<UploadResult>;
|
): Promise<UploadResult>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a file from storage
|
* Download a file from storage
|
||||||
* @param orgSlug Organization slug
|
* @param orgId Organization ID
|
||||||
* @param projectSlug Project slug
|
* @param projectId Project ID
|
||||||
* @param imageId UUID filename
|
* @param category File category
|
||||||
|
* @param filename Filename to download
|
||||||
*/
|
*/
|
||||||
downloadFile(
|
downloadFile(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
): Promise<Buffer>;
|
): Promise<Buffer>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream a file from storage (memory efficient)
|
* Stream a file from storage (memory efficient)
|
||||||
* @param orgSlug Organization slug
|
* @param orgId Organization ID
|
||||||
* @param projectSlug Project slug
|
* @param projectId Project ID
|
||||||
* @param imageId UUID filename
|
* @param category File category
|
||||||
|
* @param filename Filename to stream
|
||||||
*/
|
*/
|
||||||
streamFile(
|
streamFile(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
): Promise<Readable>;
|
): Promise<Readable>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a presigned URL for downloading a file
|
* Generate a presigned URL for downloading a file
|
||||||
* @param orgSlug Organization slug
|
* @param orgId Organization ID
|
||||||
* @param projectSlug Project slug
|
* @param projectId Project ID
|
||||||
* @param imageId UUID filename
|
* @param category File category
|
||||||
|
* @param filename Filename
|
||||||
* @param expirySeconds URL expiry time in seconds
|
* @param expirySeconds URL expiry time in seconds
|
||||||
*/
|
*/
|
||||||
getPresignedDownloadUrl(
|
getPresignedDownloadUrl(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
expirySeconds: number,
|
expirySeconds: number,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a presigned URL for uploading a file
|
* Generate a presigned URL for uploading a file
|
||||||
* @param orgSlug Organization slug
|
* @param orgId Organization ID
|
||||||
* @param projectSlug Project slug
|
* @param projectId Project ID
|
||||||
* @param imageId UUID filename
|
* @param category File category
|
||||||
|
* @param filename Filename
|
||||||
* @param expirySeconds URL expiry time in seconds
|
* @param expirySeconds URL expiry time in seconds
|
||||||
* @param contentType MIME type
|
* @param contentType MIME type
|
||||||
*/
|
*/
|
||||||
getPresignedUploadUrl(
|
getPresignedUploadUrl(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
expirySeconds: number,
|
expirySeconds: number,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List files in a project's img folder
|
* List files in a specific path
|
||||||
* @param orgSlug Organization slug
|
* @param orgId Organization ID
|
||||||
* @param projectSlug Project slug
|
* @param projectId Project ID
|
||||||
|
* @param category File category
|
||||||
* @param prefix Optional prefix to filter files
|
* @param prefix Optional prefix to filter files
|
||||||
*/
|
*/
|
||||||
listFiles(
|
listFiles(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
|
category: 'uploads' | 'generated' | 'references',
|
||||||
prefix?: string,
|
prefix?: string,
|
||||||
): Promise<FileMetadata[]>;
|
): Promise<FileMetadata[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a file from storage
|
* Delete a file from storage
|
||||||
* @param orgSlug Organization slug
|
* @param orgId Organization ID
|
||||||
* @param projectSlug Project slug
|
* @param projectId Project ID
|
||||||
* @param imageId UUID filename to delete
|
* @param category File category
|
||||||
|
* @param filename Filename to delete
|
||||||
*/
|
*/
|
||||||
deleteFile(
|
deleteFile(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a file exists
|
* Check if a file exists
|
||||||
* @param orgSlug Organization slug
|
* @param orgId Organization ID
|
||||||
* @param projectSlug Project slug
|
* @param projectId Project ID
|
||||||
* @param imageId UUID filename to check
|
* @param category File category
|
||||||
|
* @param filename Filename to check
|
||||||
*/
|
*/
|
||||||
fileExists(
|
fileExists(
|
||||||
orgSlug: string,
|
orgId: string,
|
||||||
projectSlug: string,
|
projectId: string,
|
||||||
imageId: string,
|
category: 'uploads' | 'generated' | 'references',
|
||||||
|
filename: string,
|
||||||
): Promise<boolean>;
|
): Promise<boolean>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the public CDN URL for a file
|
|
||||||
* Returns: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
*
|
|
||||||
* @param orgSlug Organization slug
|
|
||||||
* @param projectSlug Project slug
|
|
||||||
* @param imageId UUID filename
|
|
||||||
*/
|
|
||||||
getPublicUrl(
|
|
||||||
orgSlug: string,
|
|
||||||
projectSlug: string,
|
|
||||||
imageId: string,
|
|
||||||
): string;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,711 +0,0 @@
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { eq, desc, count, and, isNull, inArray } from 'drizzle-orm';
|
|
||||||
import { db } from '@/db';
|
|
||||||
import { generations, flows, images, projects } 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;
|
|
||||||
organizationSlug: string; // For storage paths (orgSlug/projectSlug/category/file)
|
|
||||||
projectSlug: string; // For storage paths
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate imageId (UUID) upfront - this will be the filename in storage
|
|
||||||
const imageId = randomUUID();
|
|
||||||
|
|
||||||
const genResult = await this.imageGenService.generateImage({
|
|
||||||
prompt: usedPrompt, // Use the prompt that was stored (enhanced or original)
|
|
||||||
imageId, // UUID used as filename: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
referenceImages: referenceImageBuffers,
|
|
||||||
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
|
||||||
orgSlug: params.organizationSlug,
|
|
||||||
projectSlug: params.projectSlug,
|
|
||||||
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({
|
|
||||||
id: imageId, // Use the same UUID for image record
|
|
||||||
projectId: params.projectId,
|
|
||||||
flowId: finalFlowId,
|
|
||||||
generationId: generation.id,
|
|
||||||
apiKeyId: params.apiKeyId,
|
|
||||||
storageKey,
|
|
||||||
storageUrl: genResult.url!,
|
|
||||||
mimeType: genResult.generatedImageData?.mimeType || 'image/png',
|
|
||||||
fileSize: genResult.size || 0,
|
|
||||||
fileHash,
|
|
||||||
source: 'generated',
|
|
||||||
alias: null,
|
|
||||||
meta: params.meta || {},
|
|
||||||
width: genResult.generatedImageData?.width ?? null,
|
|
||||||
height: genResult.generatedImageData?.height ?? null,
|
|
||||||
originalFilename: `generated-image.${genResult.generatedImageData?.fileExtension || 'png'}`,
|
|
||||||
fileExtension: genResult.generatedImageData?.fileExtension || 'png',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse storage key: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
const parts = resolution.image.storageKey.split('/');
|
|
||||||
if (parts.length < 4 || parts[2] !== 'img') {
|
|
||||||
throw new Error(`Invalid storage key format: ${resolution.image.storageKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const orgSlug = parts[0]!;
|
|
||||||
const projectSlug = parts[1]!;
|
|
||||||
const imageId = parts[3]!;
|
|
||||||
|
|
||||||
const buffer = await storageService.downloadFile(orgSlug, projectSlug, imageId);
|
|
||||||
|
|
||||||
buffers.push({
|
|
||||||
buffer,
|
|
||||||
mimetype: resolution.image.mimeType,
|
|
||||||
originalname: resolution.image.originalFilename || imageId,
|
|
||||||
});
|
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get organization and project slugs for storage paths
|
|
||||||
*/
|
|
||||||
private async getSlugs(projectId: string): Promise<{ orgSlug: string; projectSlug: string }> {
|
|
||||||
const project = await db.query.projects.findFirst({
|
|
||||||
where: eq(projects.id, projectId),
|
|
||||||
with: {
|
|
||||||
organization: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
throw new Error('Project not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
orgSlug: project.organization.slug,
|
|
||||||
projectSlug: project.slug,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
// Get slugs for storage paths
|
|
||||||
const { orgSlug, projectSlug } = await this.getSlugs(generation.projectId);
|
|
||||||
|
|
||||||
// Use the existing output image ID as the imageId for storage
|
|
||||||
// This ensures the file is overwritten at the same path
|
|
||||||
const imageId = generation.outputImageId;
|
|
||||||
|
|
||||||
// Use EXACT same parameters as original (no overrides)
|
|
||||||
const genResult = await this.imageGenService.generateImage({
|
|
||||||
prompt: generation.prompt,
|
|
||||||
imageId, // Same UUID to overwrite existing file
|
|
||||||
referenceImages: [], // TODO: Re-resolve referenced images if needed
|
|
||||||
aspectRatio: generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
|
||||||
orgSlug,
|
|
||||||
projectSlug,
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Get slugs for storage paths
|
|
||||||
const { orgSlug, projectSlug } = await this.getSlugs(generation.projectId);
|
|
||||||
|
|
||||||
// Use the existing output image ID as the imageId for storage
|
|
||||||
const imageId = generation.outputImageId!;
|
|
||||||
|
|
||||||
// Regenerate image
|
|
||||||
const genResult = await this.imageGenService.generateImage({
|
|
||||||
prompt: promptToUse,
|
|
||||||
imageId, // Same UUID to overwrite existing file
|
|
||||||
referenceImages: [],
|
|
||||||
aspectRatio: aspectRatioToUse,
|
|
||||||
orgSlug,
|
|
||||||
projectSlug,
|
|
||||||
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
|
|
||||||
// Storage key format: {orgSlug}/{projectSlug}/img/{imageId}
|
|
||||||
const storageService = await StorageFactory.getInstance();
|
|
||||||
const storageParts = image.storageKey.split('/');
|
|
||||||
|
|
||||||
if (storageParts.length >= 4 && storageParts[2] === 'img') {
|
|
||||||
const orgSlug = storageParts[0]!;
|
|
||||||
const projectSlug = storageParts[1]!;
|
|
||||||
const imageId = storageParts[3]!;
|
|
||||||
|
|
||||||
await storageService.deleteFile(orgSlug, projectSlug, imageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
|
|
@ -57,11 +57,11 @@ export interface GenerateImageRequestWithFiles extends Request {
|
||||||
// Image generation service types
|
// Image generation service types
|
||||||
export interface ImageGenerationOptions {
|
export interface ImageGenerationOptions {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
imageId: string; // UUID used as filename in storage (same as image.id in DB)
|
filename: string;
|
||||||
referenceImages?: ReferenceImage[];
|
referenceImages?: ReferenceImage[];
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
orgSlug?: string;
|
orgId?: string;
|
||||||
projectSlug?: string;
|
projectId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
meta?: {
|
meta?: {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|
@ -91,15 +91,13 @@ export interface GeminiParams {
|
||||||
|
|
||||||
export interface ImageGenerationResult {
|
export interface ImageGenerationResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
imageId?: string; // UUID filename (same as image.id in DB)
|
filename?: string;
|
||||||
filepath?: string;
|
filepath?: string;
|
||||||
url?: string; // CDN 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
|
||||||
error?: string;
|
error?: string;
|
||||||
errorCode?: string; // Gemini-specific error code (GEMINI_RATE_LIMIT, GEMINI_TIMEOUT, etc.)
|
|
||||||
errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors
|
errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors
|
||||||
generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails
|
generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails
|
||||||
}
|
}
|
||||||
|
|
@ -110,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,298 +0,0 @@
|
||||||
import { ERROR_CODES, ERROR_MESSAGES } from './constants/errors';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of Gemini error analysis
|
|
||||||
*/
|
|
||||||
export interface GeminiErrorResult {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
finishReason?: string | undefined;
|
|
||||||
blockReason?: string | undefined;
|
|
||||||
safetyCategories?: string[] | undefined;
|
|
||||||
retryAfter?: number | undefined;
|
|
||||||
httpStatus?: number | undefined;
|
|
||||||
technicalDetails?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safety rating from Gemini response
|
|
||||||
*/
|
|
||||||
interface SafetyRating {
|
|
||||||
category?: string;
|
|
||||||
probability?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gemini response structure (partial)
|
|
||||||
*/
|
|
||||||
interface GeminiResponse {
|
|
||||||
candidates?: Array<{
|
|
||||||
finishReason?: string;
|
|
||||||
finishMessage?: string;
|
|
||||||
content?: {
|
|
||||||
parts?: Array<{
|
|
||||||
text?: string;
|
|
||||||
inlineData?: { data?: string; mimeType?: string };
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
safetyRatings?: SafetyRating[];
|
|
||||||
}>;
|
|
||||||
promptFeedback?: {
|
|
||||||
blockReason?: string;
|
|
||||||
blockReasonMessage?: string;
|
|
||||||
safetyRatings?: SafetyRating[];
|
|
||||||
};
|
|
||||||
usageMetadata?: {
|
|
||||||
promptTokenCount?: number;
|
|
||||||
candidatesTokenCount?: number;
|
|
||||||
totalTokenCount?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detector for Gemini AI specific errors
|
|
||||||
* Provides detailed error classification for rate limits, safety blocks, timeouts, etc.
|
|
||||||
*/
|
|
||||||
export class GeminiErrorDetector {
|
|
||||||
/**
|
|
||||||
* Classify an API-level error (HTTP errors from Gemini)
|
|
||||||
*/
|
|
||||||
static classifyApiError(error: unknown): GeminiErrorResult {
|
|
||||||
const err = error as { status?: number; message?: string; details?: unknown };
|
|
||||||
|
|
||||||
// Check for rate limit (HTTP 429)
|
|
||||||
if (err.status === 429) {
|
|
||||||
const retryAfter = this.extractRetryAfter(error);
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_RATE_LIMIT,
|
|
||||||
message: retryAfter
|
|
||||||
? `${ERROR_MESSAGES.GEMINI_RATE_LIMIT}. Retry after ${retryAfter} seconds.`
|
|
||||||
: `${ERROR_MESSAGES.GEMINI_RATE_LIMIT}. Please wait before retrying.`,
|
|
||||||
httpStatus: 429,
|
|
||||||
retryAfter,
|
|
||||||
technicalDetails: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for authentication errors
|
|
||||||
if (err.status === 401 || err.status === 403) {
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: 'Gemini API authentication failed. Check API key.',
|
|
||||||
httpStatus: err.status,
|
|
||||||
technicalDetails: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for server errors
|
|
||||||
if (err.status === 500 || err.status === 503) {
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: 'Gemini API service temporarily unavailable.',
|
|
||||||
httpStatus: err.status,
|
|
||||||
technicalDetails: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for bad request
|
|
||||||
if (err.status === 400) {
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: `Gemini API invalid request: ${err.message || 'Unknown error'}`,
|
|
||||||
httpStatus: 400,
|
|
||||||
technicalDetails: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic API error
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: err.message || ERROR_MESSAGES.GEMINI_API_ERROR,
|
|
||||||
httpStatus: err.status,
|
|
||||||
technicalDetails: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze a Gemini response for errors (finishReason, blockReason)
|
|
||||||
* Returns null if no error detected
|
|
||||||
*/
|
|
||||||
static analyzeResponse(response: GeminiResponse): GeminiErrorResult | null {
|
|
||||||
// Check promptFeedback for blocked prompts
|
|
||||||
if (response.promptFeedback?.blockReason) {
|
|
||||||
const safetyCategories = this.extractSafetyCategories(
|
|
||||||
response.promptFeedback.safetyRatings
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
|
||||||
message:
|
|
||||||
response.promptFeedback.blockReasonMessage ||
|
|
||||||
`Prompt blocked: ${response.promptFeedback.blockReason}`,
|
|
||||||
blockReason: response.promptFeedback.blockReason,
|
|
||||||
safetyCategories,
|
|
||||||
technicalDetails: `blockReason: ${response.promptFeedback.blockReason}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check candidate finishReason
|
|
||||||
const candidate = response.candidates?.[0];
|
|
||||||
if (!candidate) {
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_NO_IMAGE,
|
|
||||||
message: 'No response candidates from Gemini AI.',
|
|
||||||
technicalDetails: 'response.candidates is empty or undefined',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const finishReason = candidate.finishReason;
|
|
||||||
|
|
||||||
// STOP is normal completion
|
|
||||||
if (!finishReason || finishReason === 'STOP') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different finishReasons
|
|
||||||
switch (finishReason) {
|
|
||||||
case 'SAFETY':
|
|
||||||
case 'IMAGE_SAFETY': {
|
|
||||||
const safetyCategories = this.extractSafetyCategories(candidate.safetyRatings);
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_SAFETY_BLOCK,
|
|
||||||
message: `Content blocked due to safety: ${safetyCategories.join(', ') || 'unspecified'}`,
|
|
||||||
finishReason,
|
|
||||||
safetyCategories,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}, safetyRatings: ${JSON.stringify(candidate.safetyRatings)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'NO_IMAGE':
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_NO_IMAGE,
|
|
||||||
message: 'Gemini AI could not generate an image for this prompt. Try rephrasing.',
|
|
||||||
finishReason,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'IMAGE_PROHIBITED_CONTENT':
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
|
||||||
message: 'Image generation blocked due to prohibited content in prompt.',
|
|
||||||
finishReason,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'MAX_TOKENS':
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: 'Response exceeded maximum token limit. Try a shorter prompt.',
|
|
||||||
finishReason,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'RECITATION':
|
|
||||||
case 'IMAGE_RECITATION':
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
|
||||||
message: 'Response blocked due to potential copyright concerns.',
|
|
||||||
finishReason,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: `Generation stopped unexpectedly: ${finishReason}`,
|
|
||||||
finishReason,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}, finishMessage: ${candidate.finishMessage}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if response has image data
|
|
||||||
*/
|
|
||||||
static hasImageData(response: GeminiResponse): boolean {
|
|
||||||
const parts = response.candidates?.[0]?.content?.parts;
|
|
||||||
if (!parts) return false;
|
|
||||||
return parts.some((part) => part.inlineData?.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format error result for logging
|
|
||||||
*/
|
|
||||||
static formatForLogging(result: GeminiErrorResult): string {
|
|
||||||
const parts = [`[${result.code}] ${result.message}`];
|
|
||||||
|
|
||||||
if (result.finishReason) {
|
|
||||||
parts.push(`finishReason=${result.finishReason}`);
|
|
||||||
}
|
|
||||||
if (result.blockReason) {
|
|
||||||
parts.push(`blockReason=${result.blockReason}`);
|
|
||||||
}
|
|
||||||
if (result.httpStatus) {
|
|
||||||
parts.push(`httpStatus=${result.httpStatus}`);
|
|
||||||
}
|
|
||||||
if (result.retryAfter) {
|
|
||||||
parts.push(`retryAfter=${result.retryAfter}s`);
|
|
||||||
}
|
|
||||||
if (result.safetyCategories?.length) {
|
|
||||||
parts.push(`safety=[${result.safetyCategories.join(', ')}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log Gemini response structure for debugging
|
|
||||||
*/
|
|
||||||
static logResponseStructure(response: GeminiResponse, prefix: string = ''): void {
|
|
||||||
const parts = response.candidates?.[0]?.content?.parts || [];
|
|
||||||
const partTypes = parts.map((p) => {
|
|
||||||
if (p.inlineData) return 'image';
|
|
||||||
if (p.text) return 'text';
|
|
||||||
return 'other';
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[ImageGenService]${prefix ? ` [${prefix}]` : ''} Gemini response:`, {
|
|
||||||
hasCandidates: !!response.candidates?.length,
|
|
||||||
candidateCount: response.candidates?.length || 0,
|
|
||||||
finishReason: response.candidates?.[0]?.finishReason || null,
|
|
||||||
blockReason: response.promptFeedback?.blockReason || null,
|
|
||||||
partsCount: parts.length,
|
|
||||||
partTypes,
|
|
||||||
usageMetadata: response.usageMetadata || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract retry-after value from error
|
|
||||||
*/
|
|
||||||
private static extractRetryAfter(error: unknown): number | undefined {
|
|
||||||
const err = error as { headers?: { get?: (key: string) => string | null } };
|
|
||||||
|
|
||||||
// Try to get from headers
|
|
||||||
if (err.headers?.get) {
|
|
||||||
const retryAfter = err.headers.get('retry-after');
|
|
||||||
if (retryAfter) {
|
|
||||||
const seconds = parseInt(retryAfter, 10);
|
|
||||||
if (!isNaN(seconds)) return seconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default retry after for rate limits
|
|
||||||
return 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract safety category names from ratings
|
|
||||||
*/
|
|
||||||
private static extractSafetyCategories(ratings?: SafetyRating[]): string[] {
|
|
||||||
if (!ratings || ratings.length === 0) return [];
|
|
||||||
|
|
||||||
// Filter for high/medium probability ratings and extract category names
|
|
||||||
return ratings
|
|
||||||
.filter((r) => r.probability === 'HIGH' || r.probability === 'MEDIUM')
|
|
||||||
.map((r) => r.category?.replace('HARM_CATEGORY_', '') || 'UNKNOWN')
|
|
||||||
.filter((c) => c !== 'UNKNOWN');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,131 +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',
|
|
||||||
|
|
||||||
// Gemini AI Errors
|
|
||||||
GEMINI_RATE_LIMIT: 'Gemini API rate limit exceeded',
|
|
||||||
GEMINI_CONTENT_BLOCKED: 'Content blocked by Gemini safety filters',
|
|
||||||
GEMINI_TIMEOUT: 'Gemini API request timed out',
|
|
||||||
GEMINI_NO_IMAGE: 'Gemini AI could not generate image',
|
|
||||||
GEMINI_SAFETY_BLOCK: 'Content blocked due to safety concerns',
|
|
||||||
GEMINI_API_ERROR: 'Gemini API returned an error',
|
|
||||||
} 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',
|
|
||||||
|
|
||||||
// Gemini AI Errors
|
|
||||||
GEMINI_RATE_LIMIT: 'GEMINI_RATE_LIMIT',
|
|
||||||
GEMINI_CONTENT_BLOCKED: 'GEMINI_CONTENT_BLOCKED',
|
|
||||||
GEMINI_TIMEOUT: 'GEMINI_TIMEOUT',
|
|
||||||
GEMINI_NO_IMAGE: 'GEMINI_NO_IMAGE',
|
|
||||||
GEMINI_SAFETY_BLOCK: 'GEMINI_SAFETY_BLOCK',
|
|
||||||
GEMINI_API_ERROR: 'GEMINI_API_ERROR',
|
|
||||||
} 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: '16:9',
|
|
||||||
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 };
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
# Landing App Environment Variables
|
|
||||||
|
|
||||||
# Waitlist logs path (absolute path required)
|
|
||||||
# Development: use local directory
|
|
||||||
# Production: use Docker volume path
|
|
||||||
WAITLIST_LOGS_PATH=/absolute/path/to/waitlist-logs
|
|
||||||
|
|
@ -32,10 +32,6 @@ yarn-error.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# waitlist logs
|
|
||||||
/waitlist-logs/
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,90 @@
|
||||||
# Simplified Dockerfile for Next.js Landing Page
|
# Multi-stage Dockerfile for Next.js Landing Page
|
||||||
|
|
||||||
|
# Stage 1: Dependencies
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
|
# Copy workspace configuration
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Copy landing package.json
|
||||||
|
COPY apps/landing/package.json ./apps/landing/
|
||||||
|
|
||||||
|
# Copy database package (workspace dependency)
|
||||||
|
COPY packages/database/package.json ./packages/database/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Stage 2: Builder
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install pnpm
|
# Install pnpm
|
||||||
RUN npm install -g pnpm@10.11.0
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
# Copy everything
|
# Copy dependencies from deps stage
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/apps/landing/node_modules ./apps/landing/node_modules
|
||||||
|
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||||
|
|
||||||
|
# Copy workspace files
|
||||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
COPY apps/landing ./apps/landing
|
|
||||||
|
# Copy database package
|
||||||
COPY packages/database ./packages/database
|
COPY packages/database ./packages/database
|
||||||
|
|
||||||
# Install and build
|
# Copy landing app
|
||||||
RUN pnpm install --frozen-lockfile
|
COPY apps/landing ./apps/landing
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
RUN pnpm --filter @banatie/landing build
|
|
||||||
|
|
||||||
# Production runner
|
# Set working directory to landing
|
||||||
|
WORKDIR /app/apps/landing
|
||||||
|
|
||||||
|
# Build Next.js application
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 3: Production Runner
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copy built app
|
# Copy workspace configuration
|
||||||
COPY --from=builder /app/apps/landing/.next/standalone ./
|
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||||
COPY --from=builder /app/apps/landing/.next/static ./apps/landing/.next/static
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Copy database package
|
||||||
|
COPY --from=builder /app/packages/database ./packages/database
|
||||||
|
|
||||||
|
# Copy built Next.js application
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/landing/.next ./apps/landing/.next
|
||||||
|
COPY --from=builder /app/apps/landing/package.json ./apps/landing/
|
||||||
COPY --from=builder /app/apps/landing/public ./apps/landing/public
|
COPY --from=builder /app/apps/landing/public ./apps/landing/public
|
||||||
|
|
||||||
|
# Copy node_modules for runtime
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/apps/landing/node_modules ./apps/landing/node_modules
|
||||||
|
COPY --from=builder /app/packages/database/node_modules ./packages/database/node_modules
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME=0.0.0.0
|
|
||||||
|
|
||||||
WORKDIR /app/apps/landing
|
WORKDIR /app/apps/landing
|
||||||
CMD ["node", "server.js"]
|
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,8 @@ import type { NextConfig } from 'next';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
trailingSlash: true,
|
|
||||||
images: {
|
images: {
|
||||||
formats: ['image/avif', 'image/webp'],
|
unoptimized: true,
|
||||||
},
|
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,22 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3010",
|
"dev": "next dev -p 3010",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"postbuild": "cp -r .next/static .next/standalone/apps/landing/.next/ && cp -r public .next/standalone/apps/landing/",
|
"start": "next start",
|
||||||
"start": "node .next/standalone/apps/landing/server.js",
|
"deploy": "cp -r out/* /var/www/banatie.app/",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@banatie/database": "workspace:*",
|
|
||||||
"lucide-react": "^0.400.0",
|
|
||||||
"next": "15.5.9",
|
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0",
|
||||||
|
"next": "15.5.4",
|
||||||
|
"@banatie/database": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"typescript": "^5",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"tailwindcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"typescript": "^5"
|
"tailwindcss": "^4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 907 B |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 920 KiB |
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Banatie - AI Image Generation API",
|
|
||||||
"short_name": "Banatie",
|
|
||||||
"description": "AI-powered image generation API with built-in CDN delivery",
|
|
||||||
"icons": [
|
|
||||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
|
||||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
|
||||||
],
|
|
||||||
"theme_color": "#a855f7",
|
|
||||||
"background_color": "#0f172a",
|
|
||||||
"display": "standalone",
|
|
||||||
"start_url": "/",
|
|
||||||
"scope": "/",
|
|
||||||
"orientation": "any",
|
|
||||||
"categories": ["productivity", "developer tools"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { Table } from '@/components/docs/shared/Table';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
|
||||||
import {
|
|
||||||
Hero,
|
|
||||||
SectionHeader,
|
|
||||||
InlineCode,
|
|
||||||
EndpointCard,
|
|
||||||
ResponseBlock,
|
|
||||||
} from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['api-flows'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'API Reference', path: '/docs/api/' },
|
|
||||||
{ name: 'Flows', path: '/docs/api/flows/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'overview', text: 'Overview', level: 2 },
|
|
||||||
{ id: 'list-flows', text: 'List Flows', level: 2 },
|
|
||||||
{ id: 'get-flow', text: 'Get Flow', level: 2 },
|
|
||||||
{ id: 'list-flow-generations', text: 'List Flow Generations', level: 2 },
|
|
||||||
{ id: 'list-flow-images', text: 'List Flow Images', level: 2 },
|
|
||||||
{ id: 'update-flow-aliases', text: 'Update Flow Aliases', level: 2 },
|
|
||||||
{ id: 'remove-flow-alias', text: 'Remove Flow Alias', level: 2 },
|
|
||||||
{ id: 'regenerate-flow', text: 'Regenerate Flow', level: 2 },
|
|
||||||
{ id: 'delete-flow', text: 'Delete Flow', level: 2 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function FlowsAPIPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'API Reference', href: '/docs/api/' },
|
|
||||||
{ label: 'Flows' },
|
|
||||||
]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/generation/',
|
|
||||||
title: 'Image Generation Guide',
|
|
||||||
description: 'Learn about chaining generations with flows.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/api/generations/',
|
|
||||||
title: 'Generations API',
|
|
||||||
description: 'Create generations within flows.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="Flows API"
|
|
||||||
subtitle="Manage generation chains and flow-scoped aliases."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section id="overview" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="overview">
|
|
||||||
Overview
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-4">
|
|
||||||
Flows group related generations together. They're created automatically when you chain generations using the same flowId.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-300 leading-relaxed">
|
|
||||||
Flows also support flow-scoped aliases — named references to images that are unique within a flow but don't conflict with project-level aliases.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="list-flows" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="list-flows">
|
|
||||||
List Flows
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Retrieve all flows for your project with computed counts.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="GET"
|
|
||||||
endpoint="/api/v1/flows"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Query Parameters</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">limit</InlineCode>, 'number', 'Results per page (default: 20, max: 100)'],
|
|
||||||
[<InlineCode key="p">offset</InlineCode>, 'number', 'Number of results to skip'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl "https://api.banatie.app/api/v1/flows?limit=10" \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
|
||||||
<ResponseBlock
|
|
||||||
status="success"
|
|
||||||
statusCode={200}
|
|
||||||
statusLabel="200 OK"
|
|
||||||
content={`{
|
|
||||||
"success": true,
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": "770e8400-e29b-41d4-a716-446655440002",
|
|
||||||
"aliases": {"@hero": "img-uuid-1", "@background": "img-uuid-2"},
|
|
||||||
"generationCount": 5,
|
|
||||||
"imageCount": 5,
|
|
||||||
"createdAt": "2025-01-15T10:00:00Z",
|
|
||||||
"updatedAt": "2025-01-15T10:30:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pagination": {
|
|
||||||
"total": 1,
|
|
||||||
"limit": 10,
|
|
||||||
"offset": 0,
|
|
||||||
"hasMore": false
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="get-flow" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="get-flow">
|
|
||||||
Get Flow
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Retrieve a single flow with detailed statistics.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="GET"
|
|
||||||
endpoint="/api/v1/flows/:id"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002 \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="list-flow-generations" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="list-flow-generations">
|
|
||||||
List Flow Generations
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Retrieve all generations in a specific flow.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="GET"
|
|
||||||
endpoint="/api/v1/flows/:id/generations"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl "https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002/generations?limit=20" \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="list-flow-images" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="list-flow-images">
|
|
||||||
List Flow Images
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Retrieve all images (generated and uploaded) in a flow.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="GET"
|
|
||||||
endpoint="/api/v1/flows/:id/images"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl "https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002/images" \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="update-flow-aliases" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="update-flow-aliases">
|
|
||||||
Update Flow Aliases
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Add or update flow-scoped aliases. Aliases are merged with existing ones.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="PUT"
|
|
||||||
endpoint="/api/v1/flows/:id/aliases"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">aliases</InlineCode>, 'object', 'Key-value pairs of aliases to add/update'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X PUT https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002/aliases \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{
|
|
||||||
"aliases": {
|
|
||||||
"@hero": "image-id-123",
|
|
||||||
"@background": "image-id-456"
|
|
||||||
}
|
|
||||||
}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="remove-flow-alias" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="remove-flow-alias">
|
|
||||||
Remove Flow Alias
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Remove a specific alias from a flow.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="DELETE"
|
|
||||||
endpoint="/api/v1/flows/:id/aliases/:alias"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X DELETE https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002/aliases/@hero \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="regenerate-flow" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="regenerate-flow">
|
|
||||||
Regenerate Flow
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Regenerate the most recent generation in the flow.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="POST"
|
|
||||||
endpoint="/api/v1/flows/:id/regenerate"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002/regenerate \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="delete-flow" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="delete-flow">
|
|
||||||
Delete Flow
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Delete a flow with cascade deletion. Images with project aliases are preserved.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="DELETE"
|
|
||||||
endpoint="/api/v1/flows/:id"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X DELETE https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002 \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="warning">
|
|
||||||
Deleting a flow removes all generations and images in it. Images with project aliases are preserved (unlinked from the flow).
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,345 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { Table } from '@/components/docs/shared/Table';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
|
||||||
import {
|
|
||||||
Hero,
|
|
||||||
SectionHeader,
|
|
||||||
InlineCode,
|
|
||||||
EndpointCard,
|
|
||||||
ResponseBlock,
|
|
||||||
} from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['api-generations'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'API Reference', path: '/docs/api/' },
|
|
||||||
{ name: 'Generations', path: '/docs/api/generations/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'create-generation', text: 'Create Generation', level: 2 },
|
|
||||||
{ id: 'list-generations', text: 'List Generations', level: 2 },
|
|
||||||
{ id: 'get-generation', text: 'Get Generation', level: 2 },
|
|
||||||
{ id: 'update-generation', text: 'Update Generation', level: 2 },
|
|
||||||
{ id: 'regenerate', text: 'Regenerate', level: 2 },
|
|
||||||
{ id: 'delete-generation', text: 'Delete Generation', level: 2 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function GenerationsAPIPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'API Reference', href: '/docs/api/' },
|
|
||||||
{ label: 'Generations' },
|
|
||||||
]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/generation/',
|
|
||||||
title: 'Image Generation Guide',
|
|
||||||
description: 'Concepts and examples for image generation.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/api/images/',
|
|
||||||
title: 'Images API',
|
|
||||||
description: 'Upload and manage images.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="Generations API"
|
|
||||||
subtitle="Create and manage AI image generations."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section id="create-generation" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="create-generation">
|
|
||||||
Create Generation
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Generate a new image from a text prompt.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="POST"
|
|
||||||
endpoint="/api/v1/generations"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Required', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[
|
|
||||||
<InlineCode key="p">prompt</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">string</span>,
|
|
||||||
<span key="r" className="text-green-400">Yes</span>,
|
|
||||||
'Text description of the image to generate',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">aspectRatio</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">string</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'1:1, 16:9, 9:16, 3:2, 21:9 (default: 1:1)',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">referenceImages</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">string[]</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Array of image IDs or @aliases to use as references',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">flowId</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">string</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Associate with existing flow',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">alias</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">string</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Project-scoped alias (@custom-name)',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">autoEnhance</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">boolean</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Enable prompt enhancement (default: true)',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">meta</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">object</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Custom metadata',
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{
|
|
||||||
"prompt": "a serene mountain landscape at sunset",
|
|
||||||
"aspectRatio": "16:9"
|
|
||||||
}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
|
||||||
<ResponseBlock
|
|
||||||
status="success"
|
|
||||||
statusCode={201}
|
|
||||||
statusLabel="201 Created"
|
|
||||||
content={`{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"status": "success",
|
|
||||||
"prompt": "a serene mountain landscape at sunset",
|
|
||||||
"aspectRatio": "16:9",
|
|
||||||
"outputImage": {
|
|
||||||
"id": "8a3b2c1d-4e5f-6789-abcd-ef0123456789",
|
|
||||||
"storageUrl": "https://cdn.banatie.app/my-org/my-project/img/8a3b2c1d-4e5f-6789-abcd-ef0123456789",
|
|
||||||
"width": 1792,
|
|
||||||
"height": 1008
|
|
||||||
},
|
|
||||||
"flowId": "770e8400-e29b-41d4-a716-446655440002",
|
|
||||||
"createdAt": "2025-01-15T10:30:00Z"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="list-generations" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="list-generations">
|
|
||||||
List Generations
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Retrieve all generations for your project with optional filtering.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="GET"
|
|
||||||
endpoint="/api/v1/generations"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Query Parameters</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">flowId</InlineCode>, 'string', 'Filter by flow ID'],
|
|
||||||
[<InlineCode key="p">status</InlineCode>, 'string', 'Filter by status: pending, processing, success, failed'],
|
|
||||||
[<InlineCode key="p">limit</InlineCode>, 'number', 'Results per page (default: 20, max: 100)'],
|
|
||||||
[<InlineCode key="p">offset</InlineCode>, 'number', 'Number of results to skip'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl "https://api.banatie.app/api/v1/generations?limit=10&status=success" \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="get-generation" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="get-generation">
|
|
||||||
Get Generation
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Retrieve a single generation by ID.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="GET"
|
|
||||||
endpoint="/api/v1/generations/:id"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl https://api.banatie.app/api/v1/generations/550e8400-e29b-41d4-a716-446655440000 \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="update-generation" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="update-generation">
|
|
||||||
Update Generation
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Update generation parameters. Changing prompt or aspectRatio triggers automatic regeneration.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="PUT"
|
|
||||||
endpoint="/api/v1/generations/:id"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">prompt</InlineCode>, 'string', 'New prompt (triggers regeneration)'],
|
|
||||||
[<InlineCode key="p">aspectRatio</InlineCode>, 'string', 'New aspect ratio (triggers regeneration)'],
|
|
||||||
[<InlineCode key="p">flowId</InlineCode>, 'string | null', 'Change flow association'],
|
|
||||||
[<InlineCode key="p">meta</InlineCode>, 'object', 'Update custom metadata'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="info">
|
|
||||||
Changing <InlineCode>prompt</InlineCode> or <InlineCode>aspectRatio</InlineCode> triggers a new generation. The image ID and URL remain the same — only the content changes.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="regenerate" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="regenerate">
|
|
||||||
Regenerate
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Create a new image using the exact same parameters. Useful for getting a different result or recovering from failures.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="POST"
|
|
||||||
endpoint="/api/v1/generations/:id/regenerate"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/generations/550e8400-e29b-41d4-a716-446655440000/regenerate \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="delete-generation" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="delete-generation">
|
|
||||||
Delete Generation
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Delete a generation and its output image. Images with project aliases are preserved.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="DELETE"
|
|
||||||
endpoint="/api/v1/generations/:id"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X DELETE https://api.banatie.app/api/v1/generations/550e8400-e29b-41d4-a716-446655440000 \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
|
||||||
<ResponseBlock
|
|
||||||
status="success"
|
|
||||||
statusCode={200}
|
|
||||||
statusLabel="200 OK"
|
|
||||||
content={`{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { Table } from '@/components/docs/shared/Table';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
|
||||||
import {
|
|
||||||
Hero,
|
|
||||||
SectionHeader,
|
|
||||||
InlineCode,
|
|
||||||
EndpointCard,
|
|
||||||
ResponseBlock,
|
|
||||||
} from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['api-images'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'API Reference', path: '/docs/api/' },
|
|
||||||
{ name: 'Images', path: '/docs/api/images/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'upload-image', text: 'Upload Image', level: 2 },
|
|
||||||
{ id: 'list-images', text: 'List Images', level: 2 },
|
|
||||||
{ id: 'get-image', text: 'Get Image', level: 2 },
|
|
||||||
{ id: 'update-image', text: 'Update Image', level: 2 },
|
|
||||||
{ id: 'assign-alias', text: 'Assign Alias', level: 2 },
|
|
||||||
{ id: 'delete-image', text: 'Delete Image', level: 2 },
|
|
||||||
{ id: 'cdn-endpoints', text: 'CDN Endpoints', level: 2 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ImagesAPIPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'API Reference', href: '/docs/api/' },
|
|
||||||
{ label: 'Images' },
|
|
||||||
]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/images/',
|
|
||||||
title: 'Working with Images Guide',
|
|
||||||
description: 'Concepts and examples for image management.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/api/generations/',
|
|
||||||
title: 'Generations API',
|
|
||||||
description: 'Create AI-generated images.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="Images API"
|
|
||||||
subtitle="Upload and manage images for your project."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section id="upload-image" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="upload-image">
|
|
||||||
Upload Image
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Upload an image file to your project storage.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="POST"
|
|
||||||
endpoint="/api/v1/images/upload"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Form Data</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Required', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[
|
|
||||||
<InlineCode key="p">file</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">file</span>,
|
|
||||||
<span key="r" className="text-green-400">Yes</span>,
|
|
||||||
'Image file (max 5MB, JPEG/PNG/WebP)',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">alias</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">string</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Project-scoped alias (@custom-name)',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">flowId</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">string</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Associate with flow',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">meta</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">string</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Custom metadata (JSON string)',
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/images/upload \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-F "file=@your-image.png" \\
|
|
||||||
-F "alias=@brand-logo"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
|
||||||
<ResponseBlock
|
|
||||||
status="success"
|
|
||||||
statusCode={201}
|
|
||||||
statusLabel="201 Created"
|
|
||||||
content={`{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"storageUrl": "https://cdn.banatie.app/my-org/my-project/img/550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"alias": "@brand-logo",
|
|
||||||
"source": "uploaded",
|
|
||||||
"width": 512,
|
|
||||||
"height": 512,
|
|
||||||
"mimeType": "image/png",
|
|
||||||
"fileSize": 24576,
|
|
||||||
"createdAt": "2025-01-15T10:30:00Z"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="list-images" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="list-images">
|
|
||||||
List Images
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Retrieve all images in your project with optional filtering.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="GET"
|
|
||||||
endpoint="/api/v1/images"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Query Parameters</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">flowId</InlineCode>, 'string', 'Filter by flow ID'],
|
|
||||||
[<InlineCode key="p">source</InlineCode>, 'string', 'Filter by source: generated, uploaded'],
|
|
||||||
[<InlineCode key="p">alias</InlineCode>, 'string', 'Filter by exact alias match'],
|
|
||||||
[<InlineCode key="p">limit</InlineCode>, 'number', 'Results per page (default: 20, max: 100)'],
|
|
||||||
[<InlineCode key="p">offset</InlineCode>, 'number', 'Number of results to skip'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl "https://api.banatie.app/api/v1/images?source=uploaded&limit=20" \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="get-image" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="get-image">
|
|
||||||
Get Image
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Retrieve a single image by ID or alias.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="GET"
|
|
||||||
endpoint="/api/v1/images/:id_or_alias"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Requests</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`# By UUID
|
|
||||||
curl https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000 \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"
|
|
||||||
|
|
||||||
# By alias
|
|
||||||
curl https://api.banatie.app/api/v1/images/@brand-logo \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="update-image" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="update-image">
|
|
||||||
Update Image
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Update image metadata (focal point and custom metadata).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="PUT"
|
|
||||||
endpoint="/api/v1/images/:id_or_alias"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">focalPoint</InlineCode>, 'object', 'Focal point for cropping {x: 0-1, y: 0-1}'],
|
|
||||||
[<InlineCode key="p">meta</InlineCode>, 'object', 'Custom metadata'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X PUT https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000 \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{
|
|
||||||
"focalPoint": {"x": 0.5, "y": 0.3},
|
|
||||||
"meta": {"category": "hero"}
|
|
||||||
}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="assign-alias" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="assign-alias">
|
|
||||||
Assign Alias
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Assign or remove a project-scoped alias from an image.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="PUT"
|
|
||||||
endpoint="/api/v1/images/:id_or_alias/alias"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">alias</InlineCode>, 'string | null', 'Alias to assign (@name) or null to remove'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Requests</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`# Assign alias
|
|
||||||
curl -X PUT https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{"alias": "@hero-background"}'
|
|
||||||
|
|
||||||
# Remove alias
|
|
||||||
curl -X PUT https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{"alias": null}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="delete-image" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="delete-image">
|
|
||||||
Delete Image
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Permanently delete an image from storage.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="DELETE"
|
|
||||||
endpoint="/api/v1/images/:id_or_alias"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X DELETE https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000 \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="warning">
|
|
||||||
Deletion is permanent. The image file and all references are removed from storage.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="cdn-endpoints" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="cdn-endpoints">
|
|
||||||
CDN Endpoints
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Access images directly via CDN (public, no authentication required):
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-3">By Image ID</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`GET https://cdn.banatie.app/{org}/{project}/img/{imageId}`}
|
|
||||||
language="text"
|
|
||||||
filename="CDN by ID"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-3">By Alias</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`GET https://cdn.banatie.app/{org}/{project}/img/@{alias}`}
|
|
||||||
language="text"
|
|
||||||
filename="CDN by Alias"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="info">
|
|
||||||
CDN URLs are public and don't require authentication. They return image bytes directly with appropriate caching headers.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,438 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { Table } from '@/components/docs/shared/Table';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
|
||||||
import {
|
|
||||||
Hero,
|
|
||||||
SectionHeader,
|
|
||||||
InlineCode,
|
|
||||||
EndpointCard,
|
|
||||||
ResponseBlock,
|
|
||||||
} from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['api-live-scopes'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'API Reference', path: '/docs/api/' },
|
|
||||||
{ name: 'Live Scopes', path: '/docs/api/live-scopes/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'overview', text: 'Overview', level: 2 },
|
|
||||||
{ id: 'create-scope', text: 'Create Scope', level: 2 },
|
|
||||||
{ id: 'list-scopes', text: 'List Scopes', level: 2 },
|
|
||||||
{ id: 'get-scope', text: 'Get Scope', level: 2 },
|
|
||||||
{ id: 'update-scope', text: 'Update Scope', level: 2 },
|
|
||||||
{ id: 'regenerate-scope', text: 'Regenerate Scope', level: 2 },
|
|
||||||
{ id: 'delete-scope', text: 'Delete Scope', level: 2 },
|
|
||||||
{ id: 'cdn-live-endpoint', text: 'CDN Live Endpoint', level: 2 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function LiveScopesAPIPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'API Reference', href: '/docs/api/' },
|
|
||||||
{ label: 'Live Scopes' },
|
|
||||||
]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/live-urls/',
|
|
||||||
title: 'Live URLs Guide',
|
|
||||||
description: 'Learn about live URL generation.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/api/generations/',
|
|
||||||
title: 'Generations API',
|
|
||||||
description: 'Full control via the generations API.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="Live Scopes API"
|
|
||||||
subtitle="Manage scopes for live URL generation."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section id="overview" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="overview">
|
|
||||||
Overview
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-4">
|
|
||||||
Live scopes organize live URL generations. Each scope has its own generation limit and can be configured independently.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-300 leading-relaxed">
|
|
||||||
Scopes are auto-created on first use, but you can pre-configure them via this API to set custom limits.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="create-scope" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="create-scope">
|
|
||||||
Create Scope
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Create a new live scope with custom settings.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="POST"
|
|
||||||
endpoint="/api/v1/live/scopes"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Required', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[
|
|
||||||
<InlineCode key="p">slug</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">string</span>,
|
|
||||||
<span key="r" className="text-green-400">Yes</span>,
|
|
||||||
'Unique scope identifier (alphanumeric + hyphens + underscores)',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">allowNewGenerations</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">boolean</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Allow new generations (default: true)',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">newGenerationsLimit</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">number</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Maximum generations allowed (default: 30)',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">meta</InlineCode>,
|
|
||||||
<span key="t" className="text-cyan-400">object</span>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Custom metadata',
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/live/scopes \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{
|
|
||||||
"slug": "hero-section",
|
|
||||||
"allowNewGenerations": true,
|
|
||||||
"newGenerationsLimit": 50,
|
|
||||||
"meta": {"description": "Hero section images"}
|
|
||||||
}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
|
||||||
<ResponseBlock
|
|
||||||
status="success"
|
|
||||||
statusCode={201}
|
|
||||||
statusLabel="201 Created"
|
|
||||||
content={`{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"id": "880e8400-e29b-41d4-a716-446655440003",
|
|
||||||
"slug": "hero-section",
|
|
||||||
"allowNewGenerations": true,
|
|
||||||
"newGenerationsLimit": 50,
|
|
||||||
"currentGenerations": 0,
|
|
||||||
"lastGeneratedAt": null,
|
|
||||||
"meta": {"description": "Hero section images"},
|
|
||||||
"createdAt": "2025-01-15T10:30:00Z"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="list-scopes" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="list-scopes">
|
|
||||||
List Scopes
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Retrieve all live scopes for your project.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="GET"
|
|
||||||
endpoint="/api/v1/live/scopes"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Query Parameters</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">slug</InlineCode>, 'string', 'Filter by exact slug match'],
|
|
||||||
[<InlineCode key="p">limit</InlineCode>, 'number', 'Results per page (default: 20, max: 100)'],
|
|
||||||
[<InlineCode key="p">offset</InlineCode>, 'number', 'Number of results to skip'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl "https://api.banatie.app/api/v1/live/scopes?limit=20" \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="get-scope" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="get-scope">
|
|
||||||
Get Scope
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Retrieve a single scope with statistics.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="GET"
|
|
||||||
endpoint="/api/v1/live/scopes/:slug"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl https://api.banatie.app/api/v1/live/scopes/hero-section \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="update-scope" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="update-scope">
|
|
||||||
Update Scope
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Update scope settings. Changes take effect immediately.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="PUT"
|
|
||||||
endpoint="/api/v1/live/scopes/:slug"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">allowNewGenerations</InlineCode>, 'boolean', 'Allow/disallow new generations'],
|
|
||||||
[<InlineCode key="p">newGenerationsLimit</InlineCode>, 'number', 'Update generation limit'],
|
|
||||||
[<InlineCode key="p">meta</InlineCode>, 'object', 'Update custom metadata'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X PUT https://api.banatie.app/api/v1/live/scopes/hero-section \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{
|
|
||||||
"allowNewGenerations": false,
|
|
||||||
"newGenerationsLimit": 100
|
|
||||||
}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="regenerate-scope" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="regenerate-scope">
|
|
||||||
Regenerate Scope
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Regenerate images in a scope. Can regenerate a specific image or all images.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="POST"
|
|
||||||
endpoint="/api/v1/live/scopes/:slug/regenerate"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Type', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">imageId</InlineCode>, 'string', 'Specific image to regenerate (omit for all)'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Requests</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`# Regenerate specific image
|
|
||||||
curl -X POST https://api.banatie.app/api/v1/live/scopes/hero-section/regenerate \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{"imageId": "550e8400-e29b-41d4-a716-446655440000"}'
|
|
||||||
|
|
||||||
# Regenerate all images in scope
|
|
||||||
curl -X POST https://api.banatie.app/api/v1/live/scopes/hero-section/regenerate \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
|
||||||
<ResponseBlock
|
|
||||||
status="success"
|
|
||||||
statusCode={200}
|
|
||||||
statusLabel="200 OK"
|
|
||||||
content={`{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"regenerated": 3,
|
|
||||||
"images": [...]
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="delete-scope" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="delete-scope">
|
|
||||||
Delete Scope
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Delete a scope and all its cached images.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EndpointCard
|
|
||||||
method="DELETE"
|
|
||||||
endpoint="/api/v1/live/scopes/:slug"
|
|
||||||
baseUrl="https://api.banatie.app"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X DELETE https://api.banatie.app/api/v1/live/scopes/hero-section \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Request"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="warning">
|
|
||||||
Deleting a scope permanently removes all cached images in it. This cannot be undone.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="cdn-live-endpoint" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="cdn-live-endpoint">
|
|
||||||
CDN Live Endpoint
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Public endpoint for live URL generation (no authentication required):
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`GET https://cdn.banatie.app/{org}/{project}/live/{scope}?prompt=...`}
|
|
||||||
language="text"
|
|
||||||
filename="Live URL Format"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Query Parameters</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Required', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[
|
|
||||||
<InlineCode key="p">prompt</InlineCode>,
|
|
||||||
<span key="r" className="text-green-400">Yes</span>,
|
|
||||||
'Text description of the image to generate',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">aspectRatio</InlineCode>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Image ratio (default: 16:9)',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">autoEnhance</InlineCode>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Enable prompt enhancement',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">template</InlineCode>,
|
|
||||||
<span key="r" className="text-gray-500">No</span>,
|
|
||||||
'Enhancement template to use',
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Example</h4>
|
|
||||||
<CodeBlock
|
|
||||||
code={`https://cdn.banatie.app/my-org/my-project/live/hero-section?prompt=mountain+landscape&aspectRatio=16:9`}
|
|
||||||
language="text"
|
|
||||||
filename="Live URL Example"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-white mb-4">Response Headers</h4>
|
|
||||||
<Table
|
|
||||||
headers={['Header', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="h">X-Cache-Status</InlineCode>, 'HIT (cached) or MISS (generated)'],
|
|
||||||
[<InlineCode key="h">X-Scope</InlineCode>, 'Scope identifier'],
|
|
||||||
[<InlineCode key="h">X-Image-Id</InlineCode>, 'Image UUID'],
|
|
||||||
[<InlineCode key="h">X-RateLimit-Remaining</InlineCode>, 'Remaining IP rate limit (on MISS)'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { Table } from '@/components/docs/shared/Table';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema, API_REFERENCE_SCHEMA } from '@/config/docs-schema';
|
|
||||||
import {
|
|
||||||
Hero,
|
|
||||||
SectionHeader,
|
|
||||||
InlineCode,
|
|
||||||
LinkCard,
|
|
||||||
LinkCardGrid,
|
|
||||||
} from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['api-overview'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'API Reference', path: '/docs/api/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'base-url', text: 'Base URL', level: 2 },
|
|
||||||
{ id: 'authentication', text: 'Authentication', level: 2 },
|
|
||||||
{ id: 'response-format', text: 'Response Format', level: 2 },
|
|
||||||
{ id: 'error-codes', text: 'Common Error Codes', level: 2 },
|
|
||||||
{ id: 'rate-limits', text: 'Rate Limits', level: 2 },
|
|
||||||
{ id: 'endpoints', text: 'Endpoints', level: 2 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function APIOverviewPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<JsonLd data={API_REFERENCE_SCHEMA} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'API Reference' },
|
|
||||||
]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/api/generations/',
|
|
||||||
title: 'Generations API',
|
|
||||||
description: 'Create and manage image generations.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/api/images/',
|
|
||||||
title: 'Images API',
|
|
||||||
description: 'Upload and organize images.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="API Reference"
|
|
||||||
subtitle="Complete REST API reference for Banatie. All endpoints, parameters, and response formats."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section id="base-url" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="base-url">
|
|
||||||
Base URL
|
|
||||||
</SectionHeader>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`https://api.banatie.app`}
|
|
||||||
language="text"
|
|
||||||
filename="Base URL"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="authentication" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="authentication">
|
|
||||||
Authentication
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
All endpoints require the <InlineCode>X-API-Key</InlineCode> header:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`X-API-Key: your_api_key_here`}
|
|
||||||
language="text"
|
|
||||||
filename="Header"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
See <a href="/docs/authentication/" className="text-purple-400 hover:underline">Authentication</a> for details on obtaining and using API keys.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="response-format" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="response-format">
|
|
||||||
Response Format
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
All responses follow a consistent JSON structure:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-300 mb-3">Success Response:</p>
|
|
||||||
<CodeBlock
|
|
||||||
code={`{
|
|
||||||
"success": true,
|
|
||||||
"data": { ... }
|
|
||||||
}`}
|
|
||||||
language="json"
|
|
||||||
filename="Success"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-300 mb-3">Error Response:</p>
|
|
||||||
<CodeBlock
|
|
||||||
code={`{
|
|
||||||
"success": false,
|
|
||||||
"error": {
|
|
||||||
"message": "Error description",
|
|
||||||
"code": "ERROR_CODE"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
language="json"
|
|
||||||
filename="Error"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-300 mb-3">Paginated Response:</p>
|
|
||||||
<CodeBlock
|
|
||||||
code={`{
|
|
||||||
"success": true,
|
|
||||||
"data": [ ... ],
|
|
||||||
"pagination": {
|
|
||||||
"total": 100,
|
|
||||||
"limit": 20,
|
|
||||||
"offset": 0,
|
|
||||||
"hasMore": true
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
language="json"
|
|
||||||
filename="Paginated"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="error-codes" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="error-codes">
|
|
||||||
Common Error Codes
|
|
||||||
</SectionHeader>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
headers={['Status', 'Code', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[
|
|
||||||
<InlineCode key="s" color="error">400</InlineCode>,
|
|
||||||
'VALIDATION_ERROR',
|
|
||||||
'Missing or invalid parameters',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="s" color="error">401</InlineCode>,
|
|
||||||
'UNAUTHORIZED',
|
|
||||||
'Missing or invalid API key',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="s" color="error">404</InlineCode>,
|
|
||||||
'*_NOT_FOUND',
|
|
||||||
'Requested resource not found',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="s" color="error">409</InlineCode>,
|
|
||||||
'ALIAS_CONFLICT',
|
|
||||||
'Alias already exists',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="s" color="error">429</InlineCode>,
|
|
||||||
'RATE_LIMIT_EXCEEDED',
|
|
||||||
'Too many requests',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="s" color="error">500</InlineCode>,
|
|
||||||
'INTERNAL_ERROR',
|
|
||||||
'Server error',
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="rate-limits" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="rate-limits">
|
|
||||||
Rate Limits
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
API requests are rate limited to 100 requests per hour per API key.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Rate limit headers are included in every response:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
headers={['Header', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="h">X-RateLimit-Limit</InlineCode>, 'Maximum requests per hour'],
|
|
||||||
[<InlineCode key="h">X-RateLimit-Remaining</InlineCode>, 'Requests remaining in current window'],
|
|
||||||
[<InlineCode key="h">X-RateLimit-Reset</InlineCode>, 'Unix timestamp when limit resets'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="endpoints" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="endpoints">
|
|
||||||
Endpoints
|
|
||||||
</SectionHeader>
|
|
||||||
|
|
||||||
<LinkCardGrid columns={2}>
|
|
||||||
<LinkCard
|
|
||||||
href="/docs/api/generations/"
|
|
||||||
title="Generations"
|
|
||||||
description="Create and manage AI image generations"
|
|
||||||
accent="primary"
|
|
||||||
/>
|
|
||||||
<LinkCard
|
|
||||||
href="/docs/api/images/"
|
|
||||||
title="Images"
|
|
||||||
description="Upload and organize images"
|
|
||||||
accent="secondary"
|
|
||||||
/>
|
|
||||||
<LinkCard
|
|
||||||
href="/docs/api/flows/"
|
|
||||||
title="Flows"
|
|
||||||
description="Manage generation chains"
|
|
||||||
accent="primary"
|
|
||||||
/>
|
|
||||||
<LinkCard
|
|
||||||
href="/docs/api/live-scopes/"
|
|
||||||
title="Live Scopes"
|
|
||||||
description="Control live URL generation"
|
|
||||||
accent="secondary"
|
|
||||||
/>
|
|
||||||
</LinkCardGrid>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { Table } from '@/components/docs/shared/Table';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
|
||||||
import {
|
|
||||||
Hero,
|
|
||||||
SectionHeader,
|
|
||||||
InlineCode,
|
|
||||||
} from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['authentication'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'Authentication', path: '/docs/authentication/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'early-access', text: 'Early Access', level: 2 },
|
|
||||||
{ id: 'using-your-api-key', text: 'Using Your API Key', level: 2 },
|
|
||||||
{ id: 'key-types', text: 'Key Types', level: 2 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AuthenticationPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'Authentication' },
|
|
||||||
]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/generation/',
|
|
||||||
title: 'Start Generating',
|
|
||||||
description: 'Create your first AI-generated image.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/api/',
|
|
||||||
title: 'API Reference',
|
|
||||||
description: 'Full endpoint documentation.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="Authentication"
|
|
||||||
subtitle="How to authenticate with Banatie API using API keys."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section id="early-access" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="early-access">
|
|
||||||
Early Access
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
We're currently in early access phase. API keys are issued personally via email.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
<strong className="text-white">To request access:</strong> Sign up at <a href="https://banatie.app" className="text-purple-400 hover:underline">banatie.app</a>. We'll send your API key within 24 hours.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="using-your-api-key" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="using-your-api-key">
|
|
||||||
Using Your API Key
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
All API requests require the <InlineCode>X-API-Key</InlineCode> header:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
|
||||||
-H "X-API-Key: your_key_here" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{"prompt": "a sunset over the ocean"}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Authenticated Request"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="prominent" type="warning">
|
|
||||||
<strong className="text-amber-300">Keep your API key secure.</strong> Don't commit it to version control or expose it in client-side code. Use environment variables in your applications.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="key-types" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="key-types">
|
|
||||||
Key Types
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Banatie uses two types of API keys:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
headers={['Type', 'Permissions', 'Expiration', 'Use Case']}
|
|
||||||
rows={[
|
|
||||||
[
|
|
||||||
<InlineCode key="t">Project Key</InlineCode>,
|
|
||||||
'Image generation, uploads, images',
|
|
||||||
<span key="e" className="text-amber-400">90 days</span>,
|
|
||||||
'Your application integration',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="t">Master Key</InlineCode>,
|
|
||||||
'Full admin access, key management',
|
|
||||||
<span key="e" className="text-green-400">Never expires</span>,
|
|
||||||
'Server-side admin operations',
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
You'll receive a Project Key for your application. Master Keys are for administrative operations — you probably don't need one.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="info">
|
|
||||||
API key management dashboard coming soon. For now, contact us if you need to rotate your key.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { Table } from '@/components/docs/shared/Table';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
|
||||||
import {
|
|
||||||
Hero,
|
|
||||||
SectionHeader,
|
|
||||||
InlineCode,
|
|
||||||
ResponseBlock,
|
|
||||||
} from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['generation'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'Image Generation', path: '/docs/generation/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'basic-generation', text: 'Basic Generation', level: 2 },
|
|
||||||
{ id: 'aspect-ratios', text: 'Aspect Ratios', level: 2 },
|
|
||||||
{ id: 'prompt-templates', text: 'Prompt Templates', level: 2 },
|
|
||||||
{ id: 'reference-images', text: 'Using Reference Images', level: 2 },
|
|
||||||
{ id: 'continuing-generation', text: 'Continuing Generation', level: 2 },
|
|
||||||
{ id: 'regeneration', text: 'Regeneration', level: 2 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function GenerationPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'Image Generation' },
|
|
||||||
]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/api/generations/',
|
|
||||||
title: 'API Reference: Generations',
|
|
||||||
description: 'Full endpoint documentation for generations.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/images/',
|
|
||||||
title: 'Working with Images',
|
|
||||||
description: 'Upload your own images and organize with aliases.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="Image Generation"
|
|
||||||
subtitle="Generate AI images from text prompts with support for references, templates, and chaining."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section id="basic-generation" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="basic-generation">
|
|
||||||
Basic Generation
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Generate an image by sending a text prompt to the generations endpoint:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-d '{
|
|
||||||
"prompt": "a serene mountain landscape at sunset",
|
|
||||||
"aspectRatio": "16:9"
|
|
||||||
}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Create Generation"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">
|
|
||||||
The response contains your generated image immediately:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ResponseBlock
|
|
||||||
status="success"
|
|
||||||
statusCode={201}
|
|
||||||
statusLabel="201 Created"
|
|
||||||
content={`{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"status": "success",
|
|
||||||
"prompt": "a serene mountain landscape at sunset",
|
|
||||||
"aspectRatio": "16:9",
|
|
||||||
"outputImage": {
|
|
||||||
"id": "8a3b2c1d-4e5f-6789-abcd-ef0123456789",
|
|
||||||
"storageUrl": "https://cdn.banatie.app/my-org/my-project/img/8a3b2c1d-4e5f-6789-abcd-ef0123456789",
|
|
||||||
"width": 1792,
|
|
||||||
"height": 1008
|
|
||||||
},
|
|
||||||
"flowId": "770e8400-e29b-41d4-a716-446655440002"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
One request, one result. The <InlineCode>storageUrl</InlineCode> is your production-ready image, served via CDN.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="aspect-ratios" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="aspect-ratios">
|
|
||||||
Aspect Ratios
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Choose the aspect ratio that fits your use case:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
headers={['Ratio', 'Dimensions', 'Best For']}
|
|
||||||
rows={[
|
|
||||||
[
|
|
||||||
<InlineCode key="ratio">1:1</InlineCode>,
|
|
||||||
'1024 x 1024',
|
|
||||||
'Social media posts, profile pictures, thumbnails',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="ratio">16:9</InlineCode>,
|
|
||||||
'1792 x 1008',
|
|
||||||
'Blog headers, presentations, video thumbnails',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="ratio">9:16</InlineCode>,
|
|
||||||
'1008 x 1792',
|
|
||||||
'Stories, mobile backgrounds, vertical banners',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="ratio">3:2</InlineCode>,
|
|
||||||
'1536 x 1024',
|
|
||||||
'Photography-style images, print layouts',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="ratio">21:9</InlineCode>,
|
|
||||||
'2016 x 864',
|
|
||||||
'Ultra-wide banners, cinematic headers',
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
Default is <InlineCode>16:9</InlineCode> if not specified.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="prompt-templates" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="prompt-templates">
|
|
||||||
Prompt Templates
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Templates improve your prompt for specific styles. Available templates:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
headers={['Template', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="t">general</InlineCode>, 'Balanced style for most use cases'],
|
|
||||||
[<InlineCode key="t">photorealistic</InlineCode>, 'Photo-like realism with natural lighting'],
|
|
||||||
[<InlineCode key="t">illustration</InlineCode>, 'Artistic illustration style'],
|
|
||||||
[<InlineCode key="t">minimalist</InlineCode>, 'Clean, simple compositions'],
|
|
||||||
[<InlineCode key="t">sticker</InlineCode>, 'Sticker-style with clear edges'],
|
|
||||||
[<InlineCode key="t">product</InlineCode>, 'E-commerce product photography'],
|
|
||||||
[<InlineCode key="t">comic</InlineCode>, 'Comic book visual style'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="info">
|
|
||||||
Template selection coming soon. Currently uses general style for all generations.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="reference-images" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="reference-images">
|
|
||||||
Using Reference Images
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Add reference images for style guidance or context. Pass image IDs or aliases in the <InlineCode>referenceImages</InlineCode> array:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-d '{
|
|
||||||
"prompt": "product photo in this style",
|
|
||||||
"referenceImages": ["@brand-style", "@product-template"],
|
|
||||||
"aspectRatio": "1:1"
|
|
||||||
}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="With References"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="info">
|
|
||||||
<strong>Pro Tip:</strong> Use aliases like <InlineCode>@logo</InlineCode> instead of UUIDs. See <a href="/docs/images/" className="text-purple-400 hover:underline">Working with Images</a> to learn about aliases.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
You can also mention aliases directly in your prompt text — they're auto-detected:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`{
|
|
||||||
"prompt": "create a banner using @brand-colors and @logo style"
|
|
||||||
}`}
|
|
||||||
language="json"
|
|
||||||
filename="Auto-detected aliases"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="continuing-generation" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="continuing-generation">
|
|
||||||
Continuing Generation
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Chain multiple generations together by passing the same <InlineCode>flowId</InlineCode>:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-d '{
|
|
||||||
"prompt": "same scene but at night",
|
|
||||||
"flowId": "770e8400-e29b-41d4-a716-446655440002"
|
|
||||||
}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Continue in Flow"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
Each response includes a <InlineCode>flowId</InlineCode> you can use to continue the sequence. Flows help organize related generations together.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="regeneration" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="regeneration">
|
|
||||||
Regeneration
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Want a different result with the same parameters? Regenerate an existing generation:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/generations/550e8400-e29b-41d4-a716-446655440000/regenerate \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Regenerate"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
Same prompt, new image. The generation ID and URL stay the same — the image content is replaced.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema } from '@/config/docs-schema';
|
|
||||||
import { Hero } from '@/components/docs/blocks';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['guides'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'Guides', path: '/docs/guides/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default function GuidesPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'Guides' },
|
|
||||||
]}
|
|
||||||
tocItems={[]}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/live-urls/',
|
|
||||||
title: 'Live URLs',
|
|
||||||
description: 'Generate images directly from URL parameters.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/generation/',
|
|
||||||
title: 'Image Generation',
|
|
||||||
description: 'Full control via the API.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="Guides"
|
|
||||||
subtitle="Step-by-step tutorials for common use cases."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="mb-12">
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<Link
|
|
||||||
href="/docs/guides/placeholder-images/"
|
|
||||||
className="block p-6 bg-slate-800/50 border border-slate-700 rounded-lg hover:border-purple-500/50 transition-colors"
|
|
||||||
>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">AI Placeholder Images</h3>
|
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
Generate contextual placeholder images for development.
|
|
||||||
Replace gray boxes with AI visuals that match your design.
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,721 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { Table } from '@/components/docs/shared/Table';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { LivePreview } from '@/components/docs/shared/LivePreview';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
|
||||||
import { Hero, SectionHeader, InlineCode } from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['guide-placeholder-images'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'Guides', path: '/docs/guides/' },
|
|
||||||
{ name: 'Placeholder Images', path: '/docs/guides/placeholder-images/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'what-this-guide-covers', text: 'What This Guide Covers', level: 2 },
|
|
||||||
{ id: 'how-to-create-placeholders', text: 'How to Create Placeholders', level: 2 },
|
|
||||||
{ id: 'quick-start', text: 'Quick Start', level: 2 },
|
|
||||||
{ id: 'organizing-placeholders', text: 'Organizing Placeholders', level: 2 },
|
|
||||||
{ id: 'prompt-tips', text: 'Prompt Tips', level: 2 },
|
|
||||||
{ id: 'light-mode-placeholders', text: 'Light Mode Placeholders', level: 2 },
|
|
||||||
{ id: 'dark-mode-placeholders', text: 'Dark Mode Placeholders', level: 2 },
|
|
||||||
{ id: 'placeholder-image-examples', text: 'Placeholder Image Examples', level: 2 },
|
|
||||||
{ id: 'file-based-workflow', text: 'File-based Workflow', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PlaceholderImagesGuidePage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'Guides', href: '/docs/guides/' },
|
|
||||||
{ label: 'Placeholder Images' },
|
|
||||||
]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/live-urls/',
|
|
||||||
title: 'Live URLs Reference',
|
|
||||||
description: 'Full parameter documentation for Live URLs.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/api/generations/',
|
|
||||||
title: 'Generations API',
|
|
||||||
description: 'Generate images programmatically.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="AI Placeholder Images"
|
|
||||||
subtitle="Generate contextual images for development. The new era of AI placeholders."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* What This Guide Covers */}
|
|
||||||
<section id="what-this-guide-covers" className="mb-12 scroll-mt-24">
|
|
||||||
<SectionHeader level={2} id="what-this-guide-covers">
|
|
||||||
What This Guide Covers
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-4">
|
|
||||||
Two ways to generate placeholder images with Banatie:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc list-inside text-gray-300 space-y-2 mb-6">
|
|
||||||
<li>
|
|
||||||
<a href="#quick-start" className="text-white font-semibold hover:text-purple-400">
|
|
||||||
Live URLs
|
|
||||||
</a>{' '}
|
|
||||||
— describe what you need right in <InlineCode><img></InlineCode> src URLs
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#file-based-workflow" className="text-white font-semibold hover:text-purple-400">
|
|
||||||
API generation
|
|
||||||
</a>{' '}
|
|
||||||
— full control, permanent URLs, downloadable files
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-300 leading-relaxed">
|
|
||||||
All examples on this page use real placeholder image URLs generated by Banatie.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* How to Create Placeholders */}
|
|
||||||
<section id="how-to-create-placeholders" className="mb-12 scroll-mt-24">
|
|
||||||
<SectionHeader level={2} id="how-to-create-placeholders">
|
|
||||||
How to Create Placeholders
|
|
||||||
</SectionHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-white font-semibold mb-2">Templates</h4>
|
|
||||||
<p className="text-gray-300 mb-2">
|
|
||||||
Choose a style, get quality results. Banatie enhances your simple prompt based on
|
|
||||||
the selected template:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc list-inside text-gray-300 space-y-1">
|
|
||||||
<li>
|
|
||||||
<InlineCode>photorealistic</InlineCode> — photo-quality images
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<InlineCode>digital-art</InlineCode> — stylized illustrations
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<InlineCode>3d-render</InlineCode> — 3D graphics
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-400 text-sm mt-3">
|
|
||||||
<a href="/docs/generation/#prompt-templates" className="text-purple-400 hover:underline">
|
|
||||||
View all templates →
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-white font-semibold mb-2">Simple Prompts</h4>
|
|
||||||
<p className="text-gray-300 mb-2">
|
|
||||||
Write minimal descriptions. Templates handle the rest:
|
|
||||||
</p>
|
|
||||||
<ul className="list-none text-gray-300 space-y-1">
|
|
||||||
<li>
|
|
||||||
<span className="text-gray-500">→</span> "office" becomes a detailed
|
|
||||||
modern office with proper lighting
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span className="text-gray-500">→</span> "headshot" becomes a
|
|
||||||
professional portrait with studio background
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-400 text-sm mt-3">
|
|
||||||
No need for complex prompts — this is for placeholders, not art.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Quick Start */}
|
|
||||||
<section id="quick-start" className="mb-12 scroll-mt-24">
|
|
||||||
<SectionHeader level={2} id="quick-start">
|
|
||||||
Quick Start
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Live URLs let you generate images by describing what you need right in the URL. No API
|
|
||||||
calls, no file management — just an HTML placeholder image tag with your prompt. Each
|
|
||||||
unique prompt is cached, so subsequent loads are instant via CDN.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-4">Basic URL format:</p>
|
|
||||||
<CodeBlock
|
|
||||||
code="https://cdn.banatie.app/{org}/{project}/live/{scope}?prompt={description}&aspectRatio={ratio}"
|
|
||||||
language="text"
|
|
||||||
filename="URL Format"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">Example:</p>
|
|
||||||
<CodeBlock
|
|
||||||
code={`<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/test?prompt=mountain+landscape"
|
|
||||||
alt="Mountain landscape"
|
|
||||||
/>`}
|
|
||||||
language="html"
|
|
||||||
filename="HTML Placeholder Image"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-4 mb-2">Result:</p>
|
|
||||||
<LivePreview showLabel={false}>
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/test?prompt=mountain+landscape&aspectRatio=16:9"
|
|
||||||
alt="Mountain landscape"
|
|
||||||
className="w-full max-w-md rounded-lg"
|
|
||||||
/>
|
|
||||||
</LivePreview>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">Parameters:</p>
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Required', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[<InlineCode key="p">prompt</InlineCode>, 'Yes', 'Image description (URL-encoded)'],
|
|
||||||
[
|
|
||||||
<InlineCode key="a">aspectRatio</InlineCode>,
|
|
||||||
'No',
|
|
||||||
'Ratio like 1:1, 16:9, 4:3 (default: 16:9)',
|
|
||||||
],
|
|
||||||
[<InlineCode key="t">template</InlineCode>, 'No', 'Style template name'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
For full parameter reference, see{' '}
|
|
||||||
<a href="/docs/live-urls/" className="text-purple-400 hover:underline">
|
|
||||||
Live URLs documentation
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Organizing Placeholders */}
|
|
||||||
<section id="organizing-placeholders" className="mb-12 scroll-mt-24">
|
|
||||||
<SectionHeader level={2} id="organizing-placeholders">
|
|
||||||
Organizing Placeholders
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-4">
|
|
||||||
Organize images by sections of your site using scopes:
|
|
||||||
</p>
|
|
||||||
<CodeBlock
|
|
||||||
code={`/live/avatars?prompt=... → user photos
|
|
||||||
/live/hero?prompt=... → hero backgrounds
|
|
||||||
/live/products?prompt=... → product catalog`}
|
|
||||||
language="text"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
Learn more about scopes in{' '}
|
|
||||||
<a href="/docs/live-urls/#scopes" className="text-purple-400 hover:underline">
|
|
||||||
Live URLs documentation
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Prompt Tips */}
|
|
||||||
<section id="prompt-tips" className="mb-12 scroll-mt-24">
|
|
||||||
<SectionHeader level={2} id="prompt-tips">
|
|
||||||
Prompt Tips
|
|
||||||
</SectionHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-white font-semibold mb-2">Write less, not more</h4>
|
|
||||||
<p className="text-gray-300 mb-2">
|
|
||||||
For placeholders, simple prompts are often enough. You can always add more details
|
|
||||||
later if needed. Templates handle the rest:
|
|
||||||
</p>
|
|
||||||
<ul className="list-none text-gray-300 space-y-1">
|
|
||||||
<li>
|
|
||||||
<span className="text-gray-500">→</span> Want an office? Write{' '}
|
|
||||||
<InlineCode>office</InlineCode>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span className="text-gray-500">→</span> Need a dark version? Add{' '}
|
|
||||||
<InlineCode>office dark background</InlineCode>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span className="text-gray-500">→</span> Templates handle lighting, composition,
|
|
||||||
style
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-white font-semibold mb-2">Colors and themes</h4>
|
|
||||||
<p className="text-gray-300 mb-2">Control the mood with color hints:</p>
|
|
||||||
<CodeBlock
|
|
||||||
code={`"dark background" → dark theme
|
|
||||||
"blue and orange accents" → specific palette
|
|
||||||
"warm lighting" → cozy feel`}
|
|
||||||
language="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="protip">
|
|
||||||
Templates automatically enhance your prompts. A simple description becomes a detailed
|
|
||||||
generation instruction.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Light Mode Placeholders */}
|
|
||||||
<section id="light-mode-placeholders" className="mb-12 scroll-mt-24">
|
|
||||||
<SectionHeader level={2} id="light-mode-placeholders">
|
|
||||||
Light Mode Placeholders
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-4">
|
|
||||||
Generated images work well with light interfaces by default. If you need more control,
|
|
||||||
specify background colors or accents to match your design system.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`"product on white background"
|
|
||||||
"office with soft natural light"
|
|
||||||
"portrait, bright studio, pastel tones"
|
|
||||||
"dashboard mockup, light gray background, blue accent"`}
|
|
||||||
language="text"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<LivePreview label="Light Background Example">
|
|
||||||
<div className="bg-white rounded-xl p-6 max-w-md">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=minimalist+desk+setup+white+background+clean+aesthetic&aspectRatio=16:9"
|
|
||||||
alt="Light theme placeholder"
|
|
||||||
className="w-full rounded-lg mb-4"
|
|
||||||
/>
|
|
||||||
<p className="text-gray-900 font-semibold">Clean Workspace</p>
|
|
||||||
<p className="text-gray-600 text-sm">White background, natural lighting</p>
|
|
||||||
</div>
|
|
||||||
</LivePreview>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Dark Mode Placeholders */}
|
|
||||||
<section id="dark-mode-placeholders" className="mb-12 scroll-mt-24">
|
|
||||||
<SectionHeader level={2} id="dark-mode-placeholders">
|
|
||||||
Dark Mode Placeholders
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-4">
|
|
||||||
For dark interfaces, add <InlineCode>dark background</InlineCode> or descriptive words
|
|
||||||
like night, moody, or twilight. You can also specify accent colors.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`"office interior, dark background"
|
|
||||||
"product photo, dark surface, moody lighting"
|
|
||||||
"night cityscape, neon accents"
|
|
||||||
"abstract gradient, dark purple and blue"`}
|
|
||||||
language="text"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<LivePreview label="Dark Background Example">
|
|
||||||
<div className="bg-slate-900 rounded-xl p-6 max-w-md border border-slate-700">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/hero?prompt=abstract+gradient+dark+background+purple+blue+moody&aspectRatio=16:9"
|
|
||||||
alt="Dark theme placeholder"
|
|
||||||
className="w-full rounded-lg mb-4"
|
|
||||||
/>
|
|
||||||
<p className="text-white font-semibold">Dark Gradient</p>
|
|
||||||
<p className="text-gray-400 text-sm">Dark background with purple accents</p>
|
|
||||||
</div>
|
|
||||||
</LivePreview>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Placeholder Image Examples */}
|
|
||||||
<section id="placeholder-image-examples" className="mb-12 scroll-mt-24">
|
|
||||||
<SectionHeader level={2} id="placeholder-image-examples">
|
|
||||||
Placeholder Image Examples
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-8">
|
|
||||||
Copy-paste examples for common placeholder image use cases.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Avatar */}
|
|
||||||
<div className="mb-10">
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Avatar</h3>
|
|
||||||
<p className="text-gray-400 text-sm mb-4">
|
|
||||||
<InlineCode>1:1</InlineCode> · User profiles, team sections, testimonials
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<LivePreview>
|
|
||||||
<div className="bg-slate-800/50 rounded-xl p-6 max-w-md">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/avatars?prompt=professional+studio+headshot+confident+woman+neutral+background&aspectRatio=1:1"
|
|
||||||
alt="Sarah Chen"
|
|
||||||
className="w-14 h-14 rounded-full object-cover flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<div className="border-l-2 border-purple-500/50 pl-4">
|
|
||||||
<p className="text-gray-300 italic">
|
|
||||||
Banatie cut our design mockup time in half. No more hunting for stock photos.
|
|
||||||
</p>
|
|
||||||
<p className="text-white font-semibold mt-3">Sarah Chen</p>
|
|
||||||
<p className="text-gray-500 text-sm">Product Designer at Acme</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LivePreview>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`<img
|
|
||||||
src="https://cdn.banatie.app/{org}/{project}/live/avatars?prompt=professional+headshot&aspectRatio=1:1"
|
|
||||||
alt="User avatar"
|
|
||||||
class="w-14 h-14 rounded-full object-cover"
|
|
||||||
/>`}
|
|
||||||
language="html"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<p className="text-gray-400 text-sm mb-3">Team grid example:</p>
|
|
||||||
<LivePreview showLabel={false}>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/avatars?prompt=professional+headshot+young+man+friendly+smile&aspectRatio=1:1"
|
|
||||||
alt="Alex Rivera"
|
|
||||||
className="w-12 h-12 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">Alex Rivera</p>
|
|
||||||
<p className="text-gray-400 text-sm">Engineering Lead</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/avatars?prompt=professional+headshot+woman+glasses+confident&aspectRatio=1:1"
|
|
||||||
alt="Maria Santos"
|
|
||||||
className="w-12 h-12 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">Maria Santos</p>
|
|
||||||
<p className="text-gray-400 text-sm">Design Director</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/avatars?prompt=professional+headshot+man+beard+casual&aspectRatio=1:1"
|
|
||||||
alt="James Wilson"
|
|
||||||
className="w-12 h-12 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-medium">James Wilson</p>
|
|
||||||
<p className="text-gray-400 text-sm">Product Manager</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LivePreview>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Product */}
|
|
||||||
<div className="mb-10">
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Product</h3>
|
|
||||||
<p className="text-gray-400 text-sm mb-4">
|
|
||||||
<InlineCode>1:1</InlineCode> or <InlineCode>4:5</InlineCode> · E-commerce, catalogs,
|
|
||||||
listings
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<LivePreview>
|
|
||||||
<div className="bg-white rounded-xl p-4 max-w-xs shadow-lg">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=minimalist+wireless+headphones+white+background+product+photo&aspectRatio=1:1"
|
|
||||||
alt="Wireless Pro Headphones"
|
|
||||||
className="w-full aspect-square object-cover rounded-lg mb-4"
|
|
||||||
/>
|
|
||||||
<p className="text-gray-900 font-semibold">Wireless Pro Headphones</p>
|
|
||||||
<p className="text-gray-600 text-lg font-bold mt-1">$299</p>
|
|
||||||
<button className="mt-3 w-full bg-gray-900 text-white py-2 px-4 rounded-lg text-sm font-medium">
|
|
||||||
Add to Cart
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</LivePreview>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`<img
|
|
||||||
src="https://cdn.banatie.app/{org}/{project}/live/products?prompt=product+photo+white+background&aspectRatio=1:1"
|
|
||||||
alt="Product"
|
|
||||||
class="w-full aspect-square object-cover rounded-lg"
|
|
||||||
/>`}
|
|
||||||
language="html"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<p className="text-gray-400 text-sm mb-3">Product grid example:</p>
|
|
||||||
<LivePreview showLabel={false}>
|
|
||||||
<div className="grid grid-cols-2 gap-4 max-w-lg">
|
|
||||||
<div className="bg-white rounded-lg p-3">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=modern+smart+watch+product+photo&aspectRatio=1:1"
|
|
||||||
alt="Smart Watch X1"
|
|
||||||
className="w-full aspect-square object-cover rounded-md mb-2"
|
|
||||||
/>
|
|
||||||
<p className="text-gray-900 font-medium text-sm">Smart Watch X1</p>
|
|
||||||
<p className="text-gray-600 font-bold">$199</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=wireless+earbuds+case+product+photo&aspectRatio=1:1"
|
|
||||||
alt="Wireless Earbuds"
|
|
||||||
className="w-full aspect-square object-cover rounded-md mb-2"
|
|
||||||
/>
|
|
||||||
<p className="text-gray-900 font-medium text-sm">Wireless Earbuds</p>
|
|
||||||
<p className="text-gray-600 font-bold">$249</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=portable+bluetooth+speaker+product+photo&aspectRatio=1:1"
|
|
||||||
alt="Portable Speaker"
|
|
||||||
className="w-full aspect-square object-cover rounded-md mb-2"
|
|
||||||
/>
|
|
||||||
<p className="text-gray-900 font-medium text-sm">Portable Speaker</p>
|
|
||||||
<p className="text-gray-600 font-bold">$79</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=laptop+stand+aluminum+product&aspectRatio=1:1"
|
|
||||||
alt="Laptop Stand"
|
|
||||||
className="w-full aspect-square object-cover rounded-md mb-2"
|
|
||||||
/>
|
|
||||||
<p className="text-gray-900 font-medium text-sm">Laptop Stand</p>
|
|
||||||
<p className="text-gray-600 font-bold">$129</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LivePreview>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hero */}
|
|
||||||
<div className="mb-10">
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Hero</h3>
|
|
||||||
<p className="text-gray-400 text-sm mb-4">
|
|
||||||
<InlineCode>16:9</InlineCode> · Landing pages, section backgrounds
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<LivePreview className="p-0">
|
|
||||||
<div className="relative w-full h-72 rounded-xl overflow-hidden">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/hero?prompt=aerial+view+modern+city+skyline+sunset+dramatic+lighting&aspectRatio=16:9"
|
|
||||||
alt="Hero background"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center text-center px-4">
|
|
||||||
<p className="text-3xl font-bold text-white mb-2">Build the Future</p>
|
|
||||||
<p className="text-gray-200">Start your next project with AI-powered tools</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LivePreview>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`<div class="relative w-full h-96 overflow-hidden">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/{org}/{project}/live/hero?prompt=abstract+tech+background&aspectRatio=16:9"
|
|
||||||
alt="Hero background"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center">
|
|
||||||
<h1 class="text-4xl font-bold text-white">Your Headline</h1>
|
|
||||||
</div>
|
|
||||||
</div>`}
|
|
||||||
language="html"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* OG Image */}
|
|
||||||
<div className="mb-10">
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">OG Image</h3>
|
|
||||||
<p className="text-gray-400 text-sm mb-4">
|
|
||||||
<InlineCode>1200:630</InlineCode> · Social sharing, Twitter/LinkedIn cards
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<LivePreview>
|
|
||||||
<div className="bg-slate-800 rounded-lg overflow-hidden max-w-md shadow-xl">
|
|
||||||
<div className="bg-slate-700 px-3 py-2 flex items-center gap-2">
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<span className="w-3 h-3 rounded-full bg-red-500"></span>
|
|
||||||
<span className="w-3 h-3 rounded-full bg-yellow-500"></span>
|
|
||||||
<span className="w-3 h-3 rounded-full bg-green-500"></span>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-400 text-xs ml-2">twitter.com</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/og?prompt=modern+tech+abstract+waves+purple+blue&aspectRatio=16:9"
|
|
||||||
alt="OG Image Preview"
|
|
||||||
className="w-full rounded-lg"
|
|
||||||
/>
|
|
||||||
<p className="text-gray-400 text-xs mt-2">banatie.app</p>
|
|
||||||
<p className="text-white font-medium mt-1">
|
|
||||||
AI Placeholder Images Guide | Banatie
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-400 text-sm mt-1">
|
|
||||||
Generate AI placeholder images for development...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LivePreview>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`<meta property="og:image" content="https://cdn.banatie.app/{org}/{project}/live/og?prompt=your+description&aspectRatio=1200:630" />
|
|
||||||
<meta property="og:image:width" content="1200" />
|
|
||||||
<meta property="og:image:height" content="630" />`}
|
|
||||||
language="html"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<TipBox variant="compact" type="info">
|
|
||||||
OG images are cached by social platforms. Change the prompt to regenerate.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<div className="mb-10">
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Features</h3>
|
|
||||||
<p className="text-gray-400 text-sm mb-4">
|
|
||||||
<InlineCode>1:1</InlineCode> · Feature grids, benefit sections, icons
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<LivePreview>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="group bg-slate-800/40 border border-slate-700/30 rounded-2xl overflow-hidden text-center transition-all duration-300 hover:border-slate-600/50 hover:shadow-lg hover:shadow-purple-500/10 hover:-translate-y-1">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/features?prompt=lightning+bolt+icon+single+line+art+illustration+minimal+background+hex+1e293b&aspectRatio=1:1"
|
|
||||||
alt="Lightning Fast"
|
|
||||||
className="w-full aspect-square object-cover"
|
|
||||||
/>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="font-semibold text-white">Lightning Fast</p>
|
|
||||||
<p className="mt-1 text-sm text-gray-400">
|
|
||||||
Deploy in seconds with our global CDN
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="group bg-slate-800/40 border border-slate-700/30 rounded-2xl overflow-hidden text-center transition-all duration-300 hover:border-slate-600/50 hover:shadow-lg hover:shadow-emerald-500/10 hover:-translate-y-1">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/features?prompt=shield+icon+single+line+art+illustration+minimal+background+hex+1e293b&aspectRatio=1:1"
|
|
||||||
alt="Secure by Default"
|
|
||||||
className="w-full aspect-square object-cover"
|
|
||||||
/>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="font-semibold text-white">Secure by Default</p>
|
|
||||||
<p className="mt-1 text-sm text-gray-400">Enterprise-grade security built in</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="group bg-slate-800/40 border border-slate-700/30 rounded-2xl overflow-hidden text-center transition-all duration-300 hover:border-slate-600/50 hover:shadow-lg hover:shadow-violet-500/10 hover:-translate-y-1">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/sys/demo/live/features?prompt=puzzle+piece+icon+single+line+art+illustration+minimal+background+hex+1e293b&aspectRatio=1:1"
|
|
||||||
alt="Easy Integration"
|
|
||||||
className="w-full aspect-square object-cover"
|
|
||||||
/>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="font-semibold text-white">Easy Integration</p>
|
|
||||||
<p className="mt-1 text-sm text-gray-400">Works with your existing stack</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LivePreview>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div class="bg-slate-800/40 border border-slate-700/30 rounded-2xl overflow-hidden text-center
|
|
||||||
transition-all duration-300 hover:border-slate-600/50 hover:shadow-lg hover:-translate-y-1">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/{org}/{project}/live/features?prompt=lightning+bolt+icon+single+line+art+illustration+minimal+background+hex+1e293b&aspectRatio=1:1"
|
|
||||||
alt="Lightning Fast"
|
|
||||||
class="w-full aspect-square object-cover"
|
|
||||||
/>
|
|
||||||
<div class="p-4">
|
|
||||||
<p class="font-semibold text-white">Lightning Fast</p>
|
|
||||||
<p class="mt-1 text-sm text-gray-400">Deploy in seconds with our global CDN</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Repeat for other features -->
|
|
||||||
</div>`}
|
|
||||||
language="html"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* File-based Workflow */}
|
|
||||||
<section id="file-based-workflow" className="mb-12 scroll-mt-24">
|
|
||||||
<SectionHeader level={2} id="file-based-workflow">
|
|
||||||
File-based Workflow
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Need files in your repo? Here's how to download generated images.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4 className="text-white font-semibold mb-3">When to Use Files</h4>
|
|
||||||
<ul className="list-disc list-inside text-gray-300 space-y-2 mb-6">
|
|
||||||
<li>Next.js/React projects with local image imports</li>
|
|
||||||
<li>Version-controlled placeholder assets</li>
|
|
||||||
<li>Offline or CI/CD environments</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4 className="text-white font-semibold mb-3">Generate via API</h4>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-4">Request:</p>
|
|
||||||
<CodeBlock
|
|
||||||
code={`POST https://api.banatie.app/v1/generations
|
|
||||||
Content-Type: application/json
|
|
||||||
X-API-Key: your_api_key
|
|
||||||
|
|
||||||
{
|
|
||||||
"prompt": "modern office interior"
|
|
||||||
}`}
|
|
||||||
language="http"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">Response:</p>
|
|
||||||
<CodeBlock
|
|
||||||
code={`{
|
|
||||||
"image": {
|
|
||||||
"id": "img_abc123",
|
|
||||||
"cdnUrl": "https://cdn.banatie.app/org/project/images/2025-01/abc123.png"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
Open <InlineCode>cdnUrl</InlineCode> in your browser, save the image, and add it to your
|
|
||||||
project's assets folder.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-4">
|
|
||||||
For full API reference, see{' '}
|
|
||||||
<a href="/docs/api/generations/" className="text-purple-400 hover:underline">
|
|
||||||
Generations API
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
|
||||||
import {
|
|
||||||
Hero,
|
|
||||||
SectionHeader,
|
|
||||||
InlineCode,
|
|
||||||
ResponseBlock,
|
|
||||||
} from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['images'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'Working with Images', path: '/docs/images/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'image-urls', text: 'Image URLs', level: 2 },
|
|
||||||
{ id: 'uploading-images', text: 'Uploading Images', level: 2 },
|
|
||||||
{ id: 'listing-images', text: 'Listing & Getting Images', level: 2 },
|
|
||||||
{ id: 'aliases', text: 'Aliases', level: 2 },
|
|
||||||
{ id: 'deleting-images', text: 'Deleting Images', level: 2 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ImagesPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'Working with Images' },
|
|
||||||
]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/api/images/',
|
|
||||||
title: 'API Reference: Images',
|
|
||||||
description: 'Full endpoint documentation for images.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/generation/',
|
|
||||||
title: 'Image Generation',
|
|
||||||
description: 'Use your images as references in generations.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="Working with Images"
|
|
||||||
subtitle="Upload, manage, and organize your images. CDN delivery, aliases, and image management."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section id="image-urls" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="image-urls">
|
|
||||||
Image URLs
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
All images are served via CDN with this URL structure:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`https://cdn.banatie.app/{org}/{project}/img/{imageId}`}
|
|
||||||
language="text"
|
|
||||||
filename="CDN URL Format"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
URLs are permanent, fast, and cached globally. Use them directly in your applications.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="uploading-images" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="uploading-images">
|
|
||||||
Uploading Images
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Upload your own images for use as brand assets, references, or logos:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/images/upload \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-F "file=@your-image.png" \\
|
|
||||||
-F "alias=@brand-logo"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Upload Image"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">
|
|
||||||
Response includes the CDN URL and image details:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ResponseBlock
|
|
||||||
status="success"
|
|
||||||
statusCode={201}
|
|
||||||
statusLabel="201 Created"
|
|
||||||
content={`{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"storageUrl": "https://cdn.banatie.app/my-org/my-project/img/550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"alias": "@brand-logo",
|
|
||||||
"source": "uploaded",
|
|
||||||
"width": 512,
|
|
||||||
"height": 512,
|
|
||||||
"mimeType": "image/png",
|
|
||||||
"fileSize": 24576
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="listing-images" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="listing-images">
|
|
||||||
Listing & Getting Images
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
List all images in your project:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl https://api.banatie.app/api/v1/images \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="List Images"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-6">
|
|
||||||
Get a specific image by ID or alias:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`# By UUID
|
|
||||||
curl https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000 \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"
|
|
||||||
|
|
||||||
# By alias
|
|
||||||
curl https://api.banatie.app/api/v1/images/@brand-logo \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Get Image"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="aliases" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="aliases">
|
|
||||||
Aliases
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Assign memorable names to images. Aliases start with <InlineCode>@</InlineCode> and make it easy to reference images without remembering UUIDs.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X PUT https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{"alias": "@hero-background"}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Assign Alias"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-6">
|
|
||||||
Access images via CDN using their alias:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`https://cdn.banatie.app/my-org/my-project/img/@hero-background`}
|
|
||||||
language="text"
|
|
||||||
filename="CDN Alias URL"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="info">
|
|
||||||
<strong>Pro Tip:</strong> Use aliases for brand assets like <InlineCode>@logo</InlineCode>, <InlineCode>@brand-colors</InlineCode>. Reference them in generations without remembering UUIDs.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">
|
|
||||||
Remove an alias by setting it to null:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X PUT https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{"alias": null}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Remove Alias"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="deleting-images" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="deleting-images">
|
|
||||||
Deleting Images
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Delete an image by ID or alias. This permanently removes the image from storage.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X DELETE https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000 \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY"`}
|
|
||||||
language="bash"
|
|
||||||
filename="Delete Image"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="warning">
|
|
||||||
Deletion is permanent. The image file and all references are removed.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { DocsSidebar } from '@/components/docs/layout/DocsSidebar';
|
|
||||||
import { ThreeColumnLayout } from '@/components/layout/ThreeColumnLayout';
|
|
||||||
import { ApiKeyWidget } from '@/components/shared/ApiKeyWidget/apikey-widget';
|
|
||||||
import { ApiKeyProvider } from '@/components/shared/ApiKeyWidget/apikey-context';
|
|
||||||
import { PageProvider } from '@/contexts/page-context';
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ label: 'API', href: '/docs/' },
|
|
||||||
{ label: 'SDK', href: '#', disabled: true },
|
|
||||||
{ label: 'MCP', href: '#', disabled: true },
|
|
||||||
{ label: 'CLI', href: '#', disabled: true },
|
|
||||||
{ label: 'Lab', href: '#', disabled: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DocsRootLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ApiKeyProvider>
|
|
||||||
<PageProvider
|
|
||||||
navItems={navItems}
|
|
||||||
currentPath={pathname}
|
|
||||||
rightSlot={<ApiKeyWidget />}
|
|
||||||
>
|
|
||||||
<ThreeColumnLayout
|
|
||||||
left={
|
|
||||||
<div className="border-r border-white/10 bg-slate-950/50 backdrop-blur-sm sticky top-12 h-[calc(100vh-3rem)] overflow-y-auto">
|
|
||||||
<DocsSidebar currentPath={pathname} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
center={children}
|
|
||||||
/>
|
|
||||||
</PageProvider>
|
|
||||||
</ApiKeyProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,335 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { Table } from '@/components/docs/shared/Table';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
|
||||||
import { Hero, SectionHeader, InlineCode } from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['live-urls'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
{ name: 'Live URLs', path: '/docs/live-urls/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'the-concept', text: 'The Concept', level: 2 },
|
|
||||||
{ id: 'url-format', text: 'URL Format', level: 2 },
|
|
||||||
{ id: 'try-it', text: 'Try It', level: 2 },
|
|
||||||
{ id: 'placeholder-images', text: 'Placeholder Images', level: 2 },
|
|
||||||
{ id: 'caching-behavior', text: 'Caching Behavior', level: 2 },
|
|
||||||
{ id: 'scopes', text: 'Scopes', level: 2 },
|
|
||||||
{ id: 'rate-limits', text: 'Rate Limits', level: 2 },
|
|
||||||
{ id: 'use-cases', text: 'Use Cases', level: 2 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function LiveUrlsPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[{ label: 'Documentation', href: '/docs/' }, { label: 'Live URLs' }]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/api/live-scopes/',
|
|
||||||
title: 'API Reference: Live Scopes',
|
|
||||||
description: 'Manage scopes and generation limits.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/generation/',
|
|
||||||
title: 'Image Generation',
|
|
||||||
description: 'Full control via the generations API.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="Live URLs"
|
|
||||||
subtitle="Generate images directly from URL parameters. No API calls needed — just use the URL in your HTML."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section id="the-concept" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="the-concept">
|
|
||||||
The Concept
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-4">
|
|
||||||
Put your prompt in a URL. Use it directly in an{' '}
|
|
||||||
<InlineCode><img src="..."></InlineCode> tag.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-300 leading-relaxed">
|
|
||||||
First request generates the image. All subsequent requests serve it from cache
|
|
||||||
instantly.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="url-format" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="url-format">
|
|
||||||
URL Format
|
|
||||||
</SectionHeader>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`https://cdn.banatie.app/{org}/{project}/live/{scope}?prompt=...&aspectRatio=...`}
|
|
||||||
language="text"
|
|
||||||
filename="Live URL Format"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">Query parameters:</p>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
headers={['Parameter', 'Required', 'Description']}
|
|
||||||
rows={[
|
|
||||||
[
|
|
||||||
<InlineCode key="p">prompt</InlineCode>,
|
|
||||||
<span key="r" className="text-green-400">
|
|
||||||
Yes
|
|
||||||
</span>,
|
|
||||||
'Text description of the image to generate',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">aspectRatio</InlineCode>,
|
|
||||||
<span key="r" className="text-gray-500">
|
|
||||||
No
|
|
||||||
</span>,
|
|
||||||
'Image ratio: 1:1, 16:9, 9:16, 3:2 (default: 16:9)',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">template</InlineCode>,
|
|
||||||
<span key="r" className="text-gray-500">
|
|
||||||
No
|
|
||||||
</span>,
|
|
||||||
'Enhancement template to use',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
<InlineCode key="p">autoEnhance</InlineCode>,
|
|
||||||
<span key="r" className="text-gray-500">
|
|
||||||
No
|
|
||||||
</span>,
|
|
||||||
'Enable prompt enhancement (default: true)',
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="try-it" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="try-it">
|
|
||||||
Try It
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">Open this URL in your browser:</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`https://cdn.banatie.app/my-org/my-project/live/demo?prompt=a+friendly+robot+waving+hello&aspectRatio=16:9`}
|
|
||||||
language="text"
|
|
||||||
filename="Example Live URL"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">Or use it directly in HTML:</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`<img
|
|
||||||
src="https://cdn.banatie.app/my-org/my-project/live/hero?prompt=mountain+landscape+at+sunset&aspectRatio=16:9"
|
|
||||||
alt="Mountain landscape"
|
|
||||||
/>`}
|
|
||||||
language="html"
|
|
||||||
filename="HTML Usage"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="caching-behavior" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="caching-behavior">
|
|
||||||
Caching Behavior
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
The response includes an <InlineCode>X-Cache-Status</InlineCode> header:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
headers={['Status', 'Meaning', 'Response Time']}
|
|
||||||
rows={[
|
|
||||||
[
|
|
||||||
<InlineCode key="s" color="success">
|
|
||||||
HIT
|
|
||||||
</InlineCode>,
|
|
||||||
'Image served from cache',
|
|
||||||
'Instant (milliseconds)',
|
|
||||||
],
|
|
||||||
[<InlineCode key="s">MISS</InlineCode>, 'New image generated', 'A few seconds'],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
Cache hits are unlimited and don't count toward rate limits. Only new generations (cache
|
|
||||||
MISS) are rate limited.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="scopes" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="scopes">
|
|
||||||
Scopes
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Scopes organize your live generations. Each scope has its own generation limit.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`# Different scopes for different purposes
|
|
||||||
https://cdn.banatie.app/my-org/my-project/live/hero-section?prompt=...
|
|
||||||
https://cdn.banatie.app/my-org/my-project/live/product-gallery?prompt=...
|
|
||||||
https://cdn.banatie.app/my-org/my-project/live/blog-images?prompt=...`}
|
|
||||||
language="text"
|
|
||||||
filename="Scope Examples"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
Scopes are auto-created on first use. You can also pre-configure them via the API to set
|
|
||||||
custom limits.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* <section id="rate-limits" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="rate-limits">
|
|
||||||
Rate Limits
|
|
||||||
</SectionHeader>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
headers={['Limit Type', 'Value', 'Notes']}
|
|
||||||
rows={[
|
|
||||||
[
|
|
||||||
'Per IP',
|
|
||||||
<span key="v" className="text-amber-400">
|
|
||||||
10 new generations/hour
|
|
||||||
</span>,
|
|
||||||
'Only applies to cache MISS',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Per Scope',
|
|
||||||
<span key="v" className="text-amber-400">
|
|
||||||
30 generations
|
|
||||||
</span>,
|
|
||||||
'Configurable via API',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Cache Hits',
|
|
||||||
<span key="v" className="text-green-400">
|
|
||||||
Unlimited
|
|
||||||
</span>,
|
|
||||||
'No limits on cached images',
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="info">
|
|
||||||
Rate limits protect the service from abuse. For high-volume needs, use the generations
|
|
||||||
API directly.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section> */}
|
|
||||||
|
|
||||||
<section id="use-cases" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="use-cases">
|
|
||||||
Use Cases
|
|
||||||
</SectionHeader>
|
|
||||||
|
|
||||||
<ul className="space-y-4 text-gray-300">
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-purple-400 mt-1">•</span>
|
|
||||||
<div>
|
|
||||||
<strong className="text-white">Static HTML & serverless sites</strong>
|
|
||||||
<p className="text-gray-400 text-sm mt-1">
|
|
||||||
Deploy HTML pages without configuring asset hosting or CDN infrastructure. Images
|
|
||||||
are served directly from Banatie's edge network.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-purple-400 mt-1">•</span>
|
|
||||||
<div>
|
|
||||||
<strong className="text-white">AI-assisted development</strong>
|
|
||||||
<p className="text-gray-400 text-sm mt-1">
|
|
||||||
Enable AI coding assistants to generate complete HTML or JSX with contextual
|
|
||||||
images in a single pass—no asset pipeline required.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-purple-400 mt-1">•</span>
|
|
||||||
<div>
|
|
||||||
<strong className="text-white">Rapid prototyping</strong>
|
|
||||||
<p className="text-gray-400 text-sm mt-1">
|
|
||||||
Test different visuals without writing backend code.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-purple-400 mt-1">•</span>
|
|
||||||
<div>
|
|
||||||
<strong className="text-white">AI placeholder images</strong>
|
|
||||||
<p className="text-gray-400 text-sm mt-1">
|
|
||||||
Replace gray boxes and random stock photos with contextual AI images. Perfect for
|
|
||||||
prototypes, client demos, and design mockups.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-purple-400 mt-1">•</span>
|
|
||||||
<div>
|
|
||||||
<strong className="text-white">Personalized content</strong>
|
|
||||||
<p className="text-gray-400 text-sm mt-1">
|
|
||||||
Generate unique images based on user data or preferences for dynamic,
|
|
||||||
individualized experiences.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
<section id="placeholder-images" className="mb-12">
|
|
||||||
<TipBox variant="protip">
|
|
||||||
Use Live URLs as intelligent placeholder images during development. Generate contextual
|
|
||||||
visuals that match your design intent—no more gray boxes or random stock photos.
|
|
||||||
</TipBox>
|
|
||||||
|
|
||||||
<p className="text-gray-400 text-sm mt-6 mb-4">Common placeholder configurations:</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`<!-- Avatar placeholder (200×200) -->
|
|
||||||
<img src="https://cdn.banatie.app/.../live/avatars?prompt=professional+headshot&aspectRatio=1:1" />
|
|
||||||
|
|
||||||
<!-- Thumbnail placeholder (300×200) -->
|
|
||||||
<img src="https://cdn.banatie.app/.../live/thumbs?prompt=product+photo&aspectRatio=3:2" />
|
|
||||||
|
|
||||||
<!-- Hero placeholder (1200×630) -->
|
|
||||||
<img src="https://cdn.banatie.app/.../live/hero?prompt=modern+office+interior&aspectRatio=1200:630" />
|
|
||||||
|
|
||||||
<!-- Card image placeholder (400×300) -->
|
|
||||||
<img src="https://cdn.banatie.app/.../live/cards?prompt=abstract+gradient+background&aspectRatio=4:3" />`}
|
|
||||||
language="html"
|
|
||||||
filename="Placeholder Examples"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<TipBox variant="compact" type="info">
|
|
||||||
For dark mode interfaces, include "dark theme" or "dark background" in your prompt.
|
|
||||||
</TipBox>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
|
||||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
|
||||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
|
||||||
import { JsonLd } from '@/components/seo/JsonLd';
|
|
||||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
|
||||||
import { createBreadcrumbSchema, createTechArticleSchema, HOW_TO_SCHEMA } from '@/config/docs-schema';
|
|
||||||
import {
|
|
||||||
Hero,
|
|
||||||
SectionHeader,
|
|
||||||
ResponseBlock,
|
|
||||||
LinkCard,
|
|
||||||
LinkCardGrid,
|
|
||||||
} from '@/components/docs/blocks';
|
|
||||||
|
|
||||||
const PAGE = DOCS_PAGES['getting-started'];
|
|
||||||
|
|
||||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
|
||||||
|
|
||||||
const breadcrumbSchema = createBreadcrumbSchema([
|
|
||||||
{ name: 'Home', path: '/' },
|
|
||||||
{ name: 'Documentation', path: '/docs/' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const articleSchema = createTechArticleSchema(PAGE);
|
|
||||||
|
|
||||||
const tocItems = [
|
|
||||||
{ id: 'what-is-banatie', text: 'What is Banatie?', level: 2 },
|
|
||||||
{ id: 'your-first-image', text: 'Your First Image', level: 2 },
|
|
||||||
{ id: 'production-ready', text: 'Production Ready', level: 2 },
|
|
||||||
{ id: 'live-urls', text: 'Live URLs', level: 2 },
|
|
||||||
{ id: 'get-your-api-key', text: 'Get Your API Key', level: 2 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function GettingStartedPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<JsonLd data={breadcrumbSchema} />
|
|
||||||
<JsonLd data={articleSchema} />
|
|
||||||
<JsonLd data={HOW_TO_SCHEMA} />
|
|
||||||
<DocPage
|
|
||||||
breadcrumbItems={[
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
|
||||||
{ label: 'Getting Started' },
|
|
||||||
]}
|
|
||||||
tocItems={tocItems}
|
|
||||||
nextSteps={{
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs/generation/',
|
|
||||||
title: 'Image Generation',
|
|
||||||
description: 'Aspect ratios, prompt templates, using references.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/images/',
|
|
||||||
title: 'Working with Images',
|
|
||||||
description: 'Upload your own, organize with aliases.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/live-urls/',
|
|
||||||
title: 'Live URLs',
|
|
||||||
description: 'Generate images directly from URL parameters.',
|
|
||||||
accent: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/docs/api/',
|
|
||||||
title: 'API Reference',
|
|
||||||
description: 'Full endpoint documentation.',
|
|
||||||
accent: 'secondary',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Hero
|
|
||||||
title="Get Started"
|
|
||||||
subtitle="Generate your first AI image in a few simple steps."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section id="what-is-banatie" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="what-is-banatie">
|
|
||||||
What is Banatie?
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-4">
|
|
||||||
Banatie is an image generation API for developers. Send a text prompt, get a production-ready image delivered via CDN.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-300 leading-relaxed">
|
|
||||||
Simple REST API. Optimized AI models that deliver consistent results. Images ready for production use immediately.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="your-first-image" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="your-first-image">
|
|
||||||
Your First Image
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Once you have your API key, generate an image with a single request:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-H "X-API-Key: YOUR_API_KEY" \\
|
|
||||||
-d '{"prompt": "a friendly robot waving hello"}'`}
|
|
||||||
language="bash"
|
|
||||||
filename="Generate Image"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">
|
|
||||||
That's it. The response contains your image:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ResponseBlock
|
|
||||||
status="success"
|
|
||||||
statusCode={200}
|
|
||||||
statusLabel="200 OK"
|
|
||||||
content={`{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"status": "success",
|
|
||||||
"outputImage": {
|
|
||||||
"storageUrl": "https://cdn.banatie.app/my-org/my-project/img/8a3b2c1d-4e5f-6789-abcd-ef0123456789",
|
|
||||||
"width": 1792,
|
|
||||||
"height": 1008
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
Open <code className="text-purple-400">storageUrl</code> in your browser — there's your robot.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="production-ready" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="production-ready">
|
|
||||||
Production Ready
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
The image URL is permanent and served via global CDN. What this means for you:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="space-y-3 text-gray-300">
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-purple-400 mt-1">•</span>
|
|
||||||
<span><strong className="text-white">Fast access</strong> — images load in milliseconds</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-purple-400 mt-1">•</span>
|
|
||||||
<span><strong className="text-white">Edge cached</strong> — served from locations closest to your users</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span className="text-purple-400 mt-1">•</span>
|
|
||||||
<span><strong className="text-white">Global distribution</strong> — works fast everywhere in the world</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
One request. Production-ready result. Drop the URL into your app and ship.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="live-urls" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="live-urls">
|
|
||||||
Live URLs
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
Want to skip the API call entirely? Generate images directly from a URL:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CodeBlock
|
|
||||||
code={`https://cdn.banatie.app/my-org/my-project/live/demo?prompt=a+friendly+robot+waving+hello`}
|
|
||||||
language="text"
|
|
||||||
filename="Live URL"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6 mb-6">
|
|
||||||
Put this in an <code className="text-purple-400"><img src="..."></code> tag. First request generates the image, all subsequent requests serve it from cache instantly.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<TipBox variant="compact" type="info">
|
|
||||||
Perfect for placeholders, dynamic content, and rapid prototyping.
|
|
||||||
</TipBox>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed mt-6">
|
|
||||||
<a href="/docs/live-urls/" className="text-purple-400 hover:underline">Learn more about Live URLs →</a>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="get-your-api-key" className="mb-12">
|
|
||||||
<SectionHeader level={2} id="get-your-api-key">
|
|
||||||
Get Your API Key
|
|
||||||
</SectionHeader>
|
|
||||||
<p className="text-gray-300 leading-relaxed mb-6">
|
|
||||||
We're currently in early access. API keys are issued personally.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-3 text-gray-300 mb-6">
|
|
||||||
<p><strong className="text-white">To request access:</strong></p>
|
|
||||||
<ol className="list-decimal list-inside space-y-2 pl-4">
|
|
||||||
<li>Go to <a href="https://banatie.app" className="text-purple-400 hover:underline">banatie.app</a></li>
|
|
||||||
<li>Enter your email in the signup form</li>
|
|
||||||
<li>We'll send your API key within 24 hours</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</DocPage>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import Image from 'next/image';
|
|
||||||
import { Footer } from '@/components/shared/Footer';
|
|
||||||
|
|
||||||
export default function AppsLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Scrollable Header (NOT sticky) */}
|
|
||||||
<header className="z-10 bg-slate-900/80 backdrop-blur-md border-b border-white/5">
|
|
||||||
<nav className="max-w-7xl mx-auto px-4 sm:px-6 py-2 sm:py-3 flex justify-between items-center h-12 sm:h-14 md:h-16">
|
|
||||||
<a href="/" className="h-full flex items-center">
|
|
||||||
<Image
|
|
||||||
src="/banatie-logo-horisontal.png"
|
|
||||||
alt="Banatie Logo"
|
|
||||||
width={150}
|
|
||||||
height={40}
|
|
||||||
priority
|
|
||||||
className="h-8 sm:h-10 md:h-full w-auto object-contain"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/#get-access"
|
|
||||||
className="text-xs sm:text-sm text-gray-300 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Get Access
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 377 KiB |
|
|
@ -1,62 +0,0 @@
|
||||||
import { Terminal } from 'lucide-react';
|
|
||||||
|
|
||||||
export function ApiExampleSection() {
|
|
||||||
return (
|
|
||||||
<section className="py-16 px-6">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="bg-gradient-to-b from-indigo-500/10 to-[rgba(30,27,75,0.4)] border border-indigo-500/20 backdrop-blur-[10px] rounded-2xl p-8">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-indigo-500/20 to-purple-500/20 flex items-center justify-center">
|
|
||||||
<Terminal className="w-5 h-5 text-indigo-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold">One request. Production-ready URL.</h2>
|
|
||||||
<p className="text-gray-400 text-sm">Simple REST API that handles everything</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-black/50 border border-indigo-500/20 rounded-lg p-4 font-mono text-sm overflow-x-auto mb-4">
|
|
||||||
<div className="text-gray-500 mb-2"># Generate an image</div>
|
|
||||||
<span className="text-cyan-400">curl</span>{' '}
|
|
||||||
<span className="text-gray-300">-X POST https://api.banatie.app/v1/generate \</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-gray-300 ml-4">-H</span>{' '}
|
|
||||||
<span className="text-green-400">"Authorization: Bearer $API_KEY"</span>{' '}
|
|
||||||
<span className="text-gray-300">\</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-gray-300 ml-4">-d</span>{' '}
|
|
||||||
<span className="text-green-400">
|
|
||||||
'{`{"prompt": "modern office interior, natural light"}`}'
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-black/50 border border-indigo-500/20 rounded-lg p-4 font-mono text-sm overflow-x-auto">
|
|
||||||
<div className="text-gray-500 mb-2"># Response</div>
|
|
||||||
<span className="text-gray-300">{'{'}</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-purple-400 ml-4">"url"</span>
|
|
||||||
<span className="text-gray-300">:</span>{' '}
|
|
||||||
<span className="text-green-400">
|
|
||||||
"https://cdn.banatie.app/img/a7x2k9.png"
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-300">,</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-purple-400 ml-4">"enhanced_prompt"</span>
|
|
||||||
<span className="text-gray-300">:</span>{' '}
|
|
||||||
<span className="text-green-400">"A photorealistic modern office..."</span>
|
|
||||||
<span className="text-gray-300">,</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-purple-400 ml-4">"generation_time"</span>
|
|
||||||
<span className="text-gray-300">:</span> <span className="text-yellow-400">12.4</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-gray-300">{'}'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm mt-4 text-center">
|
|
||||||
CDN-cached, optimized, ready to use. No download, no upload, no extra steps.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
const blobs = [
|
|
||||||
{
|
|
||||||
className: 'w-[600px] h-[600px] top-[-200px] right-[-100px]',
|
|
||||||
gradient: 'rgba(139, 92, 246, 0.3)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
className: 'w-[500px] h-[500px] top-[800px] left-[-150px]',
|
|
||||||
gradient: 'rgba(99, 102, 241, 0.25)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
className: 'w-[400px] h-[400px] top-[1600px] right-[-100px]',
|
|
||||||
gradient: 'rgba(236, 72, 153, 0.2)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
className: 'w-[550px] h-[550px] top-[2400px] left-[-200px]',
|
|
||||||
gradient: 'rgba(34, 211, 238, 0.15)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
className: 'w-[450px] h-[450px] top-[3200px] right-[-150px]',
|
|
||||||
gradient: 'rgba(139, 92, 246, 0.25)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
className: 'w-[500px] h-[500px] top-[4000px] left-[-100px]',
|
|
||||||
gradient: 'rgba(99, 102, 241, 0.2)',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function BackgroundBlobs() {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full absolute overflow-hidden">
|
|
||||||
{blobs.map((blob, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`absolute rounded-full blur-[80px] opacity-40 pointer-events-none ${blob.className}`}
|
|
||||||
style={{ background: `radial-gradient(circle, ${blob.gradient} 0%, transparent 70%)` }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { ArrowRight } from 'lucide-react';
|
|
||||||
|
|
||||||
export function FinalCtaSection() {
|
|
||||||
const scrollToTop = () => {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
setTimeout(() => {
|
|
||||||
const input = document.querySelector('input[type="email"]') as HTMLInputElement;
|
|
||||||
input?.focus();
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="join"
|
|
||||||
className="relative py-24 px-6 overflow-hidden"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(180deg, #1a2744 0%, #122035 50%, #0c1628 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 right-0 h-0.5 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(90deg, transparent 0%, rgba(34, 211, 238, 0.3) 25%, rgba(34, 211, 238, 0.6) 50%, rgba(34, 211, 238, 0.3) 75%, transparent 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 opacity-50 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage:
|
|
||||||
'radial-gradient(circle at 20% 50%, rgba(34, 211, 238, 0.15) 0%, transparent 40%), radial-gradient(circle at 80% 50%, rgba(34, 211, 238, 0.1) 0%, transparent 35%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-10 max-w-3xl mx-auto text-center">
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-6 text-white">
|
|
||||||
Ready to build?
|
|
||||||
</h2>
|
|
||||||
<p className="text-cyan-100/70 text-lg md:text-xl mb-10 max-w-2xl mx-auto">
|
|
||||||
Join developers waiting for early access. We'll notify you when your spot is ready.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={scrollToTop}
|
|
||||||
className="inline-flex items-center gap-3 px-10 py-4 bg-gradient-to-br from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 border-none rounded-xl text-white font-semibold text-lg cursor-pointer transition-all shadow-[0_8px_30px_rgba(99,102,241,0.35)] hover:shadow-[0_14px_40px_rgba(99,102,241,0.45)] hover:-translate-y-[3px]"
|
|
||||||
>
|
|
||||||
Get Early Access
|
|
||||||
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-[5px]" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="text-cyan-200/50 text-sm mt-8">
|
|
||||||
No credit card required • Free to start • Cancel anytime
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { Zap, Check, Crown, Type, Brain, Target, Image, Award } from 'lucide-react';
|
|
||||||
|
|
||||||
const flashFeatures = [
|
|
||||||
{ text: 'Sub-3 second', detail: 'generation time' },
|
|
||||||
{ text: 'Multi-turn editing', detail: '— refine through conversation' },
|
|
||||||
{ text: 'Up to 3 reference images', detail: 'for consistency' },
|
|
||||||
{ text: '1024px', detail: 'resolution output' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const proFeatures = [
|
|
||||||
{ text: 'Up to 4K', detail: 'resolution output' },
|
|
||||||
{ text: '14 reference images', detail: 'for brand consistency' },
|
|
||||||
{ text: 'Studio controls', detail: '— lighting, focus, color grading' },
|
|
||||||
{ text: 'Thinking mode', detail: '— advanced reasoning for complex prompts' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const capabilities = [
|
|
||||||
{
|
|
||||||
icon: Type,
|
|
||||||
title: 'Perfect Text Rendering',
|
|
||||||
description:
|
|
||||||
'Legible text in images — logos, diagrams, posters. What other models still struggle with.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Brain,
|
|
||||||
title: 'Native Multimodal',
|
|
||||||
description:
|
|
||||||
'Understands text AND images in one model. Not a text model + image model bolted together.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Target,
|
|
||||||
title: 'Precise Prompt Following',
|
|
||||||
description:
|
|
||||||
'What you ask is what you get. No artistic "interpretation" that ignores your instructions.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Image,
|
|
||||||
title: 'Professional Realism',
|
|
||||||
description:
|
|
||||||
'Photorealistic output that replaces stock photos. Not fantasy art — real, usable images.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function GeminiSection() {
|
|
||||||
return (
|
|
||||||
<section className="py-20 px-6">
|
|
||||||
<div className="max-w-5xl mx-auto">
|
|
||||||
<div className="bg-gradient-to-b from-[rgba(120,90,20,0.35)] via-[rgba(60,45,10,0.5)] to-[rgba(30,20,5,0.6)] border border-yellow-500/30 rounded-2xl p-8 md:p-12">
|
|
||||||
<div className="text-center mb-10">
|
|
||||||
<div className="flex items-center justify-center gap-3 mb-4">
|
|
||||||
<Zap className="w-8 h-8 text-yellow-400" />
|
|
||||||
<h2 className="text-2xl md:text-3xl font-bold">Powered by Google Gemini</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
|
||||||
We chose Gemini because it's the only model family that combines native
|
|
||||||
multimodal understanding with production-grade image generation. Two models, optimized
|
|
||||||
for different needs.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-6 mb-10">
|
|
||||||
<div className="bg-black/30 border border-cyan-500/30 rounded-xl p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center">
|
|
||||||
<Zap className="w-5 h-5 text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-lg">Gemini 2.5 Flash Image</h3>
|
|
||||||
<p className="text-cyan-400 text-sm">Nano Banana</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400 text-sm mb-4">
|
|
||||||
Optimized for speed and iteration. Perfect for rapid prototyping and high-volume
|
|
||||||
generation.
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
{flashFeatures.map((feature, i) => (
|
|
||||||
<li key={i} className="flex items-start gap-2">
|
|
||||||
<Check className="w-4 h-4 text-cyan-400 mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-gray-300">
|
|
||||||
<strong className="text-white">{feature.text}</strong> {feature.detail}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-black/30 border border-yellow-500/30 rounded-xl p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
|
||||||
<Crown className="w-5 h-5 text-yellow-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-lg">Gemini 3 Pro Image</h3>
|
|
||||||
<p className="text-yellow-400 text-sm">Nano Banana Pro</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400 text-sm mb-4">
|
|
||||||
Maximum quality and creative control. For production assets and professional
|
|
||||||
workflows.
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
{proFeatures.map((feature, i) => (
|
|
||||||
<li key={i} className="flex items-start gap-2">
|
|
||||||
<Check className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-gray-300">
|
|
||||||
<strong className="text-white">{feature.text}</strong> {feature.detail}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-yellow-500/20 pt-8">
|
|
||||||
<h4 className="text-center font-semibold mb-6 text-gray-300">
|
|
||||||
Why Gemini outperforms competitors
|
|
||||||
</h4>
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{capabilities.map((cap, i) => (
|
|
||||||
<div key={i} className="text-center p-4">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-yellow-500/10 flex items-center justify-center mx-auto mb-3">
|
|
||||||
<cap.icon className="w-6 h-6 text-yellow-400" />
|
|
||||||
</div>
|
|
||||||
<h5 className="font-medium text-sm mb-1">{cap.title}</h5>
|
|
||||||
<p className="text-gray-500 text-xs">{cap.description}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-gray-500 text-sm mt-8">
|
|
||||||
<Award className="w-4 h-4 inline mr-1 text-yellow-400" />
|
|
||||||
Gemini 2.5 Flash Image ranked #1 on LMArena for both text-to-image and image editing
|
|
||||||
(August 2025)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||