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
|
||||
|
|
@ -81,8 +81,4 @@ uploads/
|
|||
|
||||
# Temporary files
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# Local Claude config (VPS-specific)
|
||||
CLAUDE.local.md
|
||||
.env.prod
|
||||
tmp/
|
||||
|
|
@ -42,9 +42,11 @@
|
|||
"PERPLEXITY_TIMEOUT_MS": "600000"
|
||||
}
|
||||
},
|
||||
"chrome-devtools": {
|
||||
"browsermcp": {
|
||||
"type": "stdio",
|
||||
"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
|
||||
- **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
|
||||
|
||||
- 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
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
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 apps/api-service ./apps/api-service
|
||||
|
||||
# Copy database package
|
||||
COPY packages/database ./packages/database
|
||||
|
||||
# Install and build
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @banatie/database build
|
||||
RUN pnpm --filter @banatie/api-service build
|
||||
# Copy API service source (exclude .env - it's for local dev only)
|
||||
COPY apps/api-service/package.json ./apps/api-service/
|
||||
COPY apps/api-service/tsconfig.json ./apps/api-service/
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 apiuser
|
||||
|
||||
# Copy built app
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages/database ./packages/database
|
||||
COPY --from=builder /app/apps/api-service/dist ./apps/api-service/dist
|
||||
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
|
||||
# Copy workspace configuration
|
||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
|
||||
# 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 chown -R apiuser:nodejs /app/apps/api-service/logs /app/results /app/uploads
|
||||
|
||||
USER apiuser
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
WORKDIR /app/apps/api-service
|
||||
|
||||
# Run production build
|
||||
CMD ["node", "dist/server.js"]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"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",
|
||||
"start": "node dist/server.js",
|
||||
"build": "tsc && tsc-alias",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"lint:fix": "eslint src/**/*.ts --fix",
|
||||
|
|
@ -43,12 +43,10 @@
|
|||
"@google/genai": "^1.22.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.2",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"express-validator": "^7.2.0",
|
||||
"helmet": "^8.0.0",
|
||||
"image-size": "^2.0.2",
|
||||
"mime": "3.0.0",
|
||||
"minio": "^8.0.6",
|
||||
"multer": "^2.0.2",
|
||||
|
|
@ -72,7 +70,6 @@
|
|||
"prettier": "^3.4.2",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsc-alias": "^1.8.10",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
import express, { Application } from 'express';
|
||||
import cors from 'cors';
|
||||
import { config } from 'dotenv';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Config } from './types/api';
|
||||
import { textToImageRouter } from './routes/textToImage';
|
||||
import { imagesRouter } from './routes/images';
|
||||
import { uploadRouter } from './routes/upload';
|
||||
import bootstrapRoutes from './routes/bootstrap';
|
||||
import adminKeysRoutes from './routes/admin/keys';
|
||||
import { v1Router } from './routes/v1';
|
||||
import { cdnRouter } from './routes/cdn';
|
||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||
|
||||
// Load environment variables
|
||||
|
|
@ -45,7 +42,7 @@ export const createApp = (): Application => {
|
|||
|
||||
// Request ID middleware for logging
|
||||
app.use((req, res, next) => {
|
||||
req.requestId = randomUUID();
|
||||
req.requestId = Math.random().toString(36).substr(2, 9);
|
||||
res.setHeader('X-Request-ID', req.requestId);
|
||||
next();
|
||||
});
|
||||
|
|
@ -113,19 +110,13 @@ export const createApp = (): Application => {
|
|||
});
|
||||
|
||||
// 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)
|
||||
app.use('/api/bootstrap', bootstrapRoutes);
|
||||
|
||||
// Admin routes (require master key)
|
||||
app.use('/api/admin/keys', adminKeysRoutes);
|
||||
|
||||
// API v1 routes (versioned, require valid API key)
|
||||
app.use('/api/v1', v1Router);
|
||||
|
||||
// Protected API routes (require valid API key) - Legacy
|
||||
// Protected API routes (require valid API key)
|
||||
app.use('/api', textToImageRouter);
|
||||
app.use('/api', imagesRouter);
|
||||
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 path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
|
@ -20,7 +20,7 @@ const DATABASE_URL =
|
|||
process.env['DATABASE_URL'] ||
|
||||
'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(
|
||||
`[${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 {
|
||||
if (!req.apiKey) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'This endpoint requires authentication',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
|
||||
res.status(403).json({
|
||||
return res.status(403).json({
|
||||
error: 'Master key required',
|
||||
message: 'This endpoint requires a master API key',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
|
|
|
|||
|
|
@ -7,30 +7,27 @@ import { Request, Response, NextFunction } from 'express';
|
|||
export function requireProjectKey(req: Request, res: Response, next: NextFunction): void {
|
||||
// This middleware assumes validateApiKey has already run and attached req.apiKey
|
||||
if (!req.apiKey) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'API key validation must be performed first',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Block master keys from generation endpoints
|
||||
if (req.apiKey.keyType === 'master') {
|
||||
res.status(403).json({
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message:
|
||||
'Master keys cannot be used for image generation. Please use a project-specific API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure project key has required IDs
|
||||
if (!req.apiKey.projectId) {
|
||||
res.status(400).json({
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key',
|
||||
message: 'Project key must be associated with a project',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
|
|
|
|||
|
|
@ -23,22 +23,20 @@ export async function validateApiKey(
|
|||
const providedKey = req.headers['x-api-key'] as string;
|
||||
|
||||
if (!providedKey) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
error: 'Missing API key',
|
||||
message: 'Provide your API key via X-API-Key header',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = await apiKeyService.validateKey(providedKey);
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: 'The provided API key is invalid, expired, or revoked',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 || [],
|
||||
};
|
||||
|
||||
req.body.prompt = result.enhancedPrompt;
|
||||
} else {
|
||||
console.warn(`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`);
|
||||
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 { validateApiKey } from '../../middleware/auth/validateApiKey';
|
||||
import { requireMasterKey } from '../../middleware/auth/requireMasterKey';
|
||||
|
||||
const router: Router = express.Router();
|
||||
const router = express.Router();
|
||||
const apiKeyService = new ApiKeyService();
|
||||
|
||||
// All admin routes require master key
|
||||
|
|
@ -14,12 +14,12 @@ router.use(requireMasterKey);
|
|||
* Create a new API key
|
||||
* POST /api/admin/keys
|
||||
*/
|
||||
router.post('/', async (req, res): Promise<void> => {
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
type,
|
||||
projectId: _projectId,
|
||||
organizationId: _organizationId,
|
||||
projectId,
|
||||
organizationId,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
organizationName,
|
||||
|
|
@ -30,27 +30,24 @@ router.post('/', async (req, res): Promise<void> => {
|
|||
|
||||
// Validation
|
||||
if (!type || !['master', 'project'].includes(type)) {
|
||||
res.status(400).json({
|
||||
return res.status(400).json({
|
||||
error: 'Invalid type',
|
||||
message: 'Type must be either "master" or "project"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'project' && !projectSlug) {
|
||||
res.status(400).json({
|
||||
return res.status(400).json({
|
||||
error: 'Missing projectSlug',
|
||||
message: 'Project keys require a projectSlug',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'project' && !organizationSlug) {
|
||||
res.status(400).json({
|
||||
return res.status(400).json({
|
||||
error: 'Missing organizationSlug',
|
||||
message: 'Project keys require an organizationSlug',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create key
|
||||
|
|
@ -151,18 +148,17 @@ router.get('/', async (req, res) => {
|
|||
* Revoke an API key
|
||||
* DELETE /api/admin/keys/:keyId
|
||||
*/
|
||||
router.delete('/:keyId', async (req, res): Promise<void> => {
|
||||
router.delete('/:keyId', async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
|
||||
const success = await apiKeyService.revokeKey(keyId);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
return res.status(404).json({
|
||||
error: 'Key not found',
|
||||
message: 'The specified API key does not exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
const router: Router = express.Router();
|
||||
const router = express.Router();
|
||||
const apiKeyService = new ApiKeyService();
|
||||
|
||||
/**
|
||||
|
|
@ -10,18 +10,17 @@ const apiKeyService = new ApiKeyService();
|
|||
*
|
||||
* POST /api/bootstrap/initial-key
|
||||
*/
|
||||
router.post('/initial-key', async (_req, res): Promise<void> => {
|
||||
router.post('/initial-key', async (req, res) => {
|
||||
try {
|
||||
// Check if any keys already exist
|
||||
const hasKeys = await apiKeyService.hasAnyKeys();
|
||||
|
||||
if (hasKeys) {
|
||||
console.warn(`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`);
|
||||
res.status(403).json({
|
||||
return res.status(403).json({
|
||||
error: 'Bootstrap not allowed',
|
||||
message: 'API keys already exist. Use /api/admin/keys to create new keys.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 type { Router as RouterType } from 'express';
|
||||
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';
|
||||
|
||||
export const imagesRouter: RouterType = Router();
|
||||
export const imagesRouter = Router();
|
||||
|
||||
/**
|
||||
* GET /api/images/:orgSlug/:projectSlug/img/:imageId
|
||||
* Serves images directly (streaming approach)
|
||||
* New format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
* GET /api/images/:orgId/:projectId/:category/:filename
|
||||
* Serves images via presigned URLs (redirect approach)
|
||||
*/
|
||||
imagesRouter.get(
|
||||
'/images/:orgSlug/:projectSlug/img/:imageId',
|
||||
asyncHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const { orgSlug, projectSlug, imageId } = req.params;
|
||||
'/images/:orgId/:projectId/:category/:filename',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { orgId, projectId, category, filename } = req.params;
|
||||
|
||||
// Validate required params (these are guaranteed by route pattern)
|
||||
if (!orgSlug || !projectSlug || !imageId) {
|
||||
res.status(400).json({
|
||||
// Validate category
|
||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required parameters',
|
||||
message: 'Invalid category',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
try {
|
||||
// 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) {
|
||||
res.status(404).json({
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
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
|
||||
// Note: Content-Type will be set from MinIO metadata
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1 year + immutable
|
||||
res.setHeader('ETag', `"${imageId}"`); // UUID as ETag
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); // 24 hours + immutable
|
||||
res.setHeader('ETag', `"${orgId}-${projectId}-${filename}"`); // Simple ETag
|
||||
|
||||
// Handle conditional requests (304 Not Modified)
|
||||
const ifNoneMatch = req.headers['if-none-match'];
|
||||
if (ifNoneMatch === `"${imageId}"`) {
|
||||
res.status(304).end(); // Not Modified
|
||||
return;
|
||||
if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) {
|
||||
return res.status(304).end(); // Not Modified
|
||||
}
|
||||
|
||||
// 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
|
||||
fileStream.on('error', (streamError) => {
|
||||
|
|
@ -71,7 +88,7 @@ imagesRouter.get(
|
|||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Failed to stream file:', error);
|
||||
res.status(404).json({
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
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
|
||||
*/
|
||||
imagesRouter.get(
|
||||
'/images/url/:orgSlug/:projectSlug/img/:imageId',
|
||||
asyncHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const { orgSlug, projectSlug, imageId } = req.params;
|
||||
'/images/url/:orgId/:projectId/:category/:filename',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { orgId, projectId, category, filename } = req.params;
|
||||
const { expiry = '3600' } = req.query; // Default 1 hour
|
||||
|
||||
// Validate required params (these are guaranteed by route pattern)
|
||||
if (!orgSlug || !projectSlug || !imageId) {
|
||||
res.status(400).json({
|
||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required parameters',
|
||||
message: 'Invalid category',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
try {
|
||||
const presignedUrl = await storageService.getPresignedDownloadUrl(
|
||||
orgSlug,
|
||||
projectSlug,
|
||||
imageId,
|
||||
orgId,
|
||||
projectId,
|
||||
category as 'uploads' | 'generated' | 'references',
|
||||
filename,
|
||||
parseInt(expiry as string, 10),
|
||||
);
|
||||
|
||||
res.json({
|
||||
return res.json({
|
||||
success: true,
|
||||
url: presignedUrl,
|
||||
expiresIn: parseInt(expiry as string, 10),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate presigned URL:', error);
|
||||
res.status(404).json({
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'File not found or access denied',
|
||||
});
|
||||
|
|
@ -143,28 +159,27 @@ imagesRouter.get(
|
|||
|
||||
// Validate query parameters
|
||||
if (isNaN(limit) || isNaN(offset)) {
|
||||
res.status(400).json({
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid query parameters',
|
||||
error: 'limit and offset must be valid numbers',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract org/project from validated API key
|
||||
const orgSlug = req.apiKey?.organizationSlug || 'default';
|
||||
const projectSlug = req.apiKey?.projectSlug!;
|
||||
const orgId = req.apiKey?.organizationSlug || 'default';
|
||||
const projectId = req.apiKey?.projectSlug!;
|
||||
|
||||
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 {
|
||||
// Get storage service instance
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
// List files in img folder
|
||||
const allFiles = await storageService.listFiles(orgSlug, projectSlug, prefix);
|
||||
// List files in generated category
|
||||
const allFiles = await storageService.listFiles(orgId, projectId, 'generated', prefix);
|
||||
|
||||
// Sort by lastModified descending (newest first)
|
||||
allFiles.sort((a, b) => {
|
||||
|
|
@ -179,8 +194,8 @@ imagesRouter.get(
|
|||
|
||||
// Map to response format with public URLs
|
||||
const images = paginatedFiles.map((file) => ({
|
||||
imageId: file.filename,
|
||||
url: storageService.getPublicUrl(orgSlug, projectSlug, file.filename),
|
||||
filename: file.filename,
|
||||
url: storageService.getPublicUrl(orgId, projectId, 'generated', file.filename),
|
||||
size: file.size,
|
||||
contentType: file.contentType,
|
||||
lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(),
|
||||
|
|
@ -189,7 +204,7 @@ imagesRouter.get(
|
|||
const hasMore = offset + limit < total;
|
||||
|
||||
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({
|
||||
|
|
@ -203,11 +218,11 @@ imagesRouter.get(
|
|||
},
|
||||
});
|
||||
} 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({
|
||||
success: false,
|
||||
message: 'Failed to list images',
|
||||
message: 'Failed to list generated images',
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ImageGenService } from '../services/ImageGenService';
|
||||
import { validateTextToImageRequest, logTextToImageRequest } from '../middleware/jsonValidation';
|
||||
import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement';
|
||||
|
|
@ -49,17 +48,14 @@ textToImageRouter.post(
|
|||
|
||||
const timestamp = new Date().toISOString();
|
||||
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
|
||||
const orgSlug = req.apiKey?.organizationSlug || undefined;
|
||||
const projectSlug = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
||||
|
||||
// Generate imageId (UUID) - this will be the filename in storage
|
||||
const imageId = randomUUID();
|
||||
const orgId = req.apiKey?.organizationSlug || undefined;
|
||||
const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
||||
|
||||
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 {
|
||||
|
|
@ -70,10 +66,10 @@ textToImageRouter.post(
|
|||
|
||||
const result = await imageGenService.generateImage({
|
||||
prompt,
|
||||
imageId,
|
||||
filename,
|
||||
...(aspectRatio && { aspectRatio }),
|
||||
orgSlug,
|
||||
projectSlug,
|
||||
orgId,
|
||||
projectId,
|
||||
...(meta && { meta }),
|
||||
});
|
||||
|
||||
|
|
@ -81,7 +77,7 @@ textToImageRouter.post(
|
|||
console.log(`[${timestamp}] [${requestId}] Text-to-image generation completed:`, {
|
||||
success: result.success,
|
||||
model: result.model,
|
||||
imageId: result.imageId,
|
||||
filename: result.filename,
|
||||
hasError: !!result.error,
|
||||
});
|
||||
|
||||
|
|
@ -91,7 +87,7 @@ textToImageRouter.post(
|
|||
success: true,
|
||||
message: 'Image generated successfully',
|
||||
data: {
|
||||
filename: result.imageId!,
|
||||
filename: result.filename!,
|
||||
filepath: result.filepath!,
|
||||
...(result.url && { url: result.url }),
|
||||
...(result.description && { description: result.description }),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { StorageFactory } from '../services/StorageFactory';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import { validateApiKey } from '../middleware/auth/validateApiKey';
|
||||
|
|
@ -41,11 +40,11 @@ uploadRouter.post(
|
|||
}
|
||||
|
||||
// Extract org/project slugs from validated API key
|
||||
const orgSlug = req.apiKey?.organizationSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
||||
const projectSlug = req.apiKey?.projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main'; // Guaranteed by requireProjectKey middleware
|
||||
const orgId = req.apiKey?.organizationSlug || 'default';
|
||||
const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
||||
|
||||
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;
|
||||
|
|
@ -54,22 +53,18 @@ uploadRouter.post(
|
|||
// Initialize storage service
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
// Generate imageId (UUID) - this will be the filename in storage
|
||||
const imageId = randomUUID();
|
||||
|
||||
// Upload file to MinIO
|
||||
// Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
// Upload file to MinIO in 'uploads' category
|
||||
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(
|
||||
orgSlug,
|
||||
projectSlug,
|
||||
imageId,
|
||||
orgId,
|
||||
projectId,
|
||||
'uploads',
|
||||
file.originalname,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
file.originalname,
|
||||
);
|
||||
|
||||
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 { 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';
|
||||
|
||||
// Extended API key type with slugs for storage paths
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { GoogleGenAI } from '@google/genai';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mime = require('mime') as any;
|
||||
import sizeOf from 'image-size';
|
||||
import {
|
||||
ImageGenerationOptions,
|
||||
ImageGenerationResult,
|
||||
|
|
@ -12,13 +11,10 @@ import {
|
|||
import { StorageFactory } from './StorageFactory';
|
||||
import { TTILogger, TTILogEntry } from './TTILogger';
|
||||
import { NetworkErrorDetector } from '../utils/NetworkErrorDetector';
|
||||
import { GeminiErrorDetector } from '../utils/GeminiErrorDetector';
|
||||
import { ERROR_MESSAGES } from '../utils/constants/errors';
|
||||
|
||||
export class ImageGenService {
|
||||
private ai: GoogleGenAI;
|
||||
private primaryModel = 'gemini-2.5-flash-image';
|
||||
private static GEMINI_TIMEOUT_MS = 90_000; // 90 seconds
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
|
|
@ -32,12 +28,12 @@ export class ImageGenService {
|
|||
* This method separates image generation from storage for clear error handling
|
||||
*/
|
||||
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
|
||||
const finalOrgSlug = orgSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
||||
const finalProjectSlug = projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main';
|
||||
const finalAspectRatio = aspectRatio || '16:9'; // Default to widescreen
|
||||
const finalOrgId = orgId || process.env['DEFAULT_ORG_ID'] || 'default';
|
||||
const finalProjectId = projectId || process.env['DEFAULT_PROJECT_ID'] || 'main';
|
||||
const finalAspectRatio = aspectRatio || '1:1'; // Default to square
|
||||
|
||||
// Step 1: Generate image from Gemini AI
|
||||
let generatedData: GeneratedImageData;
|
||||
|
|
@ -47,8 +43,8 @@ export class ImageGenService {
|
|||
prompt,
|
||||
referenceImages,
|
||||
finalAspectRatio,
|
||||
finalOrgSlug,
|
||||
finalProjectSlug,
|
||||
finalOrgId,
|
||||
finalProjectId,
|
||||
meta,
|
||||
);
|
||||
generatedData = aiResult.generatedData;
|
||||
|
|
@ -64,31 +60,26 @@ export class ImageGenService {
|
|||
}
|
||||
|
||||
// Step 2: Save generated image to storage
|
||||
// Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
try {
|
||||
const finalFilename = `${filename}.${generatedData.fileExtension}`;
|
||||
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(
|
||||
finalOrgSlug,
|
||||
finalProjectSlug,
|
||||
imageId,
|
||||
finalOrgId,
|
||||
finalProjectId,
|
||||
'generated',
|
||||
finalFilename,
|
||||
generatedData.buffer,
|
||||
generatedData.mimeType,
|
||||
originalFilename,
|
||||
);
|
||||
|
||||
if (uploadResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
imageId: uploadResult.filename,
|
||||
filename: uploadResult.filename,
|
||||
filepath: uploadResult.path,
|
||||
url: uploadResult.url,
|
||||
size: uploadResult.size,
|
||||
model: this.primaryModel,
|
||||
geminiParams,
|
||||
generatedImageData: generatedData,
|
||||
...(generatedData.description && {
|
||||
description: generatedData.description,
|
||||
}),
|
||||
|
|
@ -131,8 +122,8 @@ export class ImageGenService {
|
|||
prompt: string,
|
||||
referenceImages: ReferenceImage[] | undefined,
|
||||
aspectRatio: string,
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
meta?: { tags?: string[] },
|
||||
): Promise<{
|
||||
generatedData: GeneratedImageData;
|
||||
|
|
@ -188,8 +179,8 @@ export class ImageGenService {
|
|||
const ttiLogger = TTILogger.getInstance();
|
||||
const logEntry: TTILogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
orgId: orgSlug,
|
||||
projectId: projectSlug,
|
||||
orgId,
|
||||
projectId,
|
||||
prompt,
|
||||
model: this.primaryModel,
|
||||
config,
|
||||
|
|
@ -208,56 +199,18 @@ export class ImageGenService {
|
|||
|
||||
try {
|
||||
// Use the EXACT same config and contents objects calculated above
|
||||
// Wrap with timeout to prevent hanging requests
|
||||
const response = await this.withTimeout(
|
||||
this.ai.models.generateContent({
|
||||
model: this.primaryModel,
|
||||
config,
|
||||
contents,
|
||||
}),
|
||||
ImageGenService.GEMINI_TIMEOUT_MS,
|
||||
'Gemini image generation'
|
||||
);
|
||||
const response = await this.ai.models.generateContent({
|
||||
model: this.primaryModel,
|
||||
config,
|
||||
contents,
|
||||
});
|
||||
|
||||
// Log response structure for debugging
|
||||
GeminiErrorDetector.logResponseStructure(response as any);
|
||||
|
||||
// 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);
|
||||
// Parse response
|
||||
if (!response.candidates || !response.candidates[0] || !response.candidates[0].content) {
|
||||
throw new Error('No response received from Gemini AI');
|
||||
}
|
||||
|
||||
// Check if we have candidates
|
||||
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;
|
||||
const content = response.candidates[0].content;
|
||||
let generatedDescription: string | undefined;
|
||||
let imageData: { buffer: Buffer; mimeType: string } | null = null;
|
||||
|
||||
|
|
@ -273,37 +226,15 @@ export class ImageGenService {
|
|||
}
|
||||
|
||||
if (!imageData) {
|
||||
// Log what we got instead of image
|
||||
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'}`
|
||||
);
|
||||
throw new Error('No image data received from Gemini AI');
|
||||
}
|
||||
|
||||
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 = {
|
||||
buffer: imageData.buffer,
|
||||
mimeType: imageData.mimeType,
|
||||
fileExtension,
|
||||
width,
|
||||
height,
|
||||
...(generatedDescription && { description: generatedDescription }),
|
||||
};
|
||||
|
||||
|
|
@ -312,38 +243,6 @@ export class ImageGenService {
|
|||
geminiParams,
|
||||
};
|
||||
} 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
|
||||
if (error instanceof Error) {
|
||||
// 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[]): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { StorageService, FileMetadata, UploadResult } from './StorageService';
|
|||
export class MinioStorageService implements StorageService {
|
||||
private client: MinioClient;
|
||||
private bucketName: string;
|
||||
private cdnBaseUrl: string;
|
||||
private publicUrl: string;
|
||||
|
||||
constructor(
|
||||
endpoint: string,
|
||||
|
|
@ -12,7 +12,7 @@ export class MinioStorageService implements StorageService {
|
|||
secretKey: string,
|
||||
useSSL: boolean = false,
|
||||
bucketName: string = 'banatie',
|
||||
cdnBaseUrl?: string,
|
||||
publicUrl?: string,
|
||||
) {
|
||||
// Parse endpoint to separate hostname and port
|
||||
const cleanEndpoint = endpoint.replace(/^https?:\/\//, '');
|
||||
|
|
@ -31,59 +31,119 @@ export class MinioStorageService implements StorageService {
|
|||
secretKey,
|
||||
});
|
||||
this.bucketName = bucketName;
|
||||
// CDN base URL without bucket name (e.g., https://cdn.banatie.app)
|
||||
this.cdnBaseUrl = cdnBaseUrl || process.env['CDN_BASE_URL'] || `${useSSL ? 'https' : 'http'}://${endpoint}/${bucketName}`;
|
||||
this.publicUrl = publicUrl || `${useSSL ? 'https' : 'http'}://${endpoint}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file path in storage
|
||||
* Format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
*/
|
||||
private getFilePath(orgSlug: string, projectSlug: string, imageId: string): string {
|
||||
return `${orgSlug}/${projectSlug}/img/${imageId}`;
|
||||
private getFilePath(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): string {
|
||||
// Simplified path without date folder for now
|
||||
return `${orgId}/${projectId}/${category}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file extension from original filename
|
||||
*/
|
||||
private extractExtension(filename: string): string | undefined {
|
||||
if (!filename) return undefined;
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex <= 0) return undefined;
|
||||
return filename.substring(lastDotIndex + 1).toLowerCase();
|
||||
private generateUniqueFilename(originalFilename: string): string {
|
||||
// Sanitize filename first
|
||||
const sanitized = this.sanitizeFilename(originalFilename);
|
||||
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const ext = sanitized.includes('.') ? sanitized.substring(sanitized.lastIndexOf('.')) : '';
|
||||
const name = sanitized.includes('.')
|
||||
? sanitized.substring(0, sanitized.lastIndexOf('.'))
|
||||
: sanitized;
|
||||
|
||||
return `${name}-${timestamp}-${random}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate storage path components
|
||||
*/
|
||||
private validatePath(orgSlug: string, projectSlug: string, imageId: string): void {
|
||||
// Validate orgSlug
|
||||
if (!orgSlug || !/^[a-zA-Z0-9_-]+$/.test(orgSlug) || orgSlug.length > 50) {
|
||||
private sanitizeFilename(filename: string): string {
|
||||
// Remove path traversal attempts FIRST from entire filename
|
||||
let cleaned = filename.replace(/\.\./g, '').trim();
|
||||
|
||||
// Split filename and extension
|
||||
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(
|
||||
'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
|
||||
if (!projectSlug || !/^[a-zA-Z0-9_-]+$/.test(projectSlug) || projectSlug.length > 50) {
|
||||
// Validate projectId
|
||||
if (!projectId || !/^[a-zA-Z0-9_-]+$/.test(projectId) || projectId.length > 50) {
|
||||
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)
|
||||
if (!imageId || imageId.length === 0 || imageId.length > 50) {
|
||||
throw new Error('Invalid imageId: must be 1-50 characters');
|
||||
// Validate category
|
||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||
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
|
||||
if (imageId.includes('..') || imageId.includes('/') || imageId.includes('\\')) {
|
||||
throw new Error('Invalid characters in imageId: path traversal not allowed');
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw new Error('Invalid characters in filename: path traversal not allowed');
|
||||
}
|
||||
|
||||
// Prevent null bytes and control characters
|
||||
if (/[\x00-\x1f]/.test(imageId)) {
|
||||
throw new Error('Invalid imageId: control characters not allowed');
|
||||
if (/[\x00-\x1f]/.test(filename)) {
|
||||
throw new Error('Invalid filename: control characters not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,8 +154,8 @@ export class MinioStorageService implements StorageService {
|
|||
console.log(`Created bucket: ${this.bucketName}`);
|
||||
}
|
||||
|
||||
// Bucket should be public for CDN access (configured via mc anonymous set download)
|
||||
console.log(`Bucket ${this.bucketName} ready for CDN access`);
|
||||
// Note: With SNMD and presigned URLs, we don't need bucket policies
|
||||
console.log(`Bucket ${this.bucketName} ready for presigned URL access`);
|
||||
}
|
||||
|
||||
async bucketExists(): Promise<boolean> {
|
||||
|
|
@ -103,15 +163,15 @@ export class MinioStorageService implements StorageService {
|
|||
}
|
||||
|
||||
async uploadFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
originalFilename?: string,
|
||||
): Promise<UploadResult> {
|
||||
// Validate inputs first
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error('Buffer cannot be empty');
|
||||
|
|
@ -124,36 +184,26 @@ export class MinioStorageService implements StorageService {
|
|||
// Ensure bucket exists
|
||||
await this.createBucket();
|
||||
|
||||
// Get file path: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
|
||||
// Extract file extension from original filename
|
||||
const fileExtension = originalFilename ? this.extractExtension(originalFilename) : undefined;
|
||||
// Generate unique filename to avoid conflicts
|
||||
const uniqueFilename = this.generateUniqueFilename(filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename);
|
||||
|
||||
// Encode original filename to Base64 to safely store non-ASCII characters in metadata
|
||||
const originalNameEncoded = originalFilename
|
||||
? Buffer.from(originalFilename, 'utf-8').toString('base64')
|
||||
: undefined;
|
||||
const originalNameEncoded = Buffer.from(filename, 'utf-8').toString('base64');
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
const metadata = {
|
||||
'Content-Type': contentType,
|
||||
'X-Amz-Meta-Project': projectSlug,
|
||||
'X-Amz-Meta-Organization': orgSlug,
|
||||
'X-Amz-Meta-Original-Name': originalNameEncoded,
|
||||
'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(),
|
||||
};
|
||||
|
||||
if (originalNameEncoded) {
|
||||
metadata['X-Amz-Meta-Original-Name'] = originalNameEncoded;
|
||||
metadata['X-Amz-Meta-Original-Name-Encoding'] = 'base64';
|
||||
}
|
||||
console.log(`Uploading file to: ${this.bucketName}/${filePath}`);
|
||||
|
||||
if (fileExtension) {
|
||||
metadata['X-Amz-Meta-File-Extension'] = fileExtension;
|
||||
}
|
||||
|
||||
console.log(`[MinIO] Uploading file to: ${this.bucketName}/${filePath}`);
|
||||
|
||||
await this.client.putObject(
|
||||
const result = await this.client.putObject(
|
||||
this.bucketName,
|
||||
filePath,
|
||||
buffer,
|
||||
|
|
@ -161,29 +211,28 @@ export class MinioStorageService implements StorageService {
|
|||
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 {
|
||||
success: true,
|
||||
filename: imageId,
|
||||
filename: uniqueFilename,
|
||||
path: filePath,
|
||||
url,
|
||||
size: buffer.length,
|
||||
contentType,
|
||||
...(originalFilename && { originalFilename }),
|
||||
...(fileExtension && { fileExtension }),
|
||||
};
|
||||
}
|
||||
|
||||
async downloadFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<Buffer> {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
|
||||
const stream = await this.client.getObject(this.bucketName, filePath);
|
||||
|
||||
|
|
@ -196,91 +245,184 @@ export class MinioStorageService implements StorageService {
|
|||
}
|
||||
|
||||
async streamFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<import('stream').Readable> {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
|
||||
// Return the stream directly without buffering - memory efficient!
|
||||
return await this.client.getObject(this.bucketName, filePath);
|
||||
}
|
||||
|
||||
async deleteFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
await this.client.removeObject(this.bucketName, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public CDN URL for file access
|
||||
* Returns: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId}
|
||||
*/
|
||||
getPublicUrl(orgSlug: string, projectSlug: string, imageId: string): string {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
return `${this.cdnBaseUrl}/${filePath}`;
|
||||
getPublicUrl(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): string {
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
// 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(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
expirySeconds: number,
|
||||
contentType: string,
|
||||
): Promise<string> {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
|
||||
if (!contentType || contentType.trim().length === 0) {
|
||||
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);
|
||||
}
|
||||
|
||||
async getPresignedDownloadUrl(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
expirySeconds: number = 86400, // 24 hours default
|
||||
): Promise<string> {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
const presignedUrl = await this.client.presignedGetObject(
|
||||
this.bucketName,
|
||||
filePath,
|
||||
expirySeconds,
|
||||
);
|
||||
|
||||
// Replace internal Docker hostname with CDN URL if configured
|
||||
if (this.cdnBaseUrl) {
|
||||
// Access protected properties via type assertion for URL replacement
|
||||
const client = this.client as unknown as { host: string; port: number; protocol: string };
|
||||
const clientEndpoint = client.host + (client.port ? `:${client.port}` : '');
|
||||
// Replace internal Docker hostname with public URL if configured
|
||||
if (this.publicUrl) {
|
||||
const clientEndpoint = this.client.host + (this.client.port ? `:${this.client.port}` : '');
|
||||
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, '');
|
||||
|
||||
return presignedUrl.replace(`${client.protocol}//${clientEndpoint}/${this.bucketName}`, this.cdnBaseUrl);
|
||||
return presignedUrl.replace(`${this.client.protocol}//${clientEndpoint}`, this.publicUrl);
|
||||
}
|
||||
|
||||
return presignedUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a project's img folder
|
||||
*/
|
||||
async listProjectFiles(
|
||||
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(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
prefix?: string,
|
||||
): 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 files: FileMetadata[] = [];
|
||||
|
|
@ -288,22 +430,31 @@ export class MinioStorageService implements StorageService {
|
|||
return new Promise((resolve, reject) => {
|
||||
const stream = this.client.listObjects(this.bucketName, searchPrefix, true);
|
||||
|
||||
stream.on('data', async (obj) => {
|
||||
stream.on('data', (obj) => {
|
||||
if (!obj.name || !obj.size) return;
|
||||
|
||||
try {
|
||||
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)
|
||||
const metadata = await this.client.statObject(this.bucketName, obj.name);
|
||||
// Infer content type from file extension (more efficient than statObject)
|
||||
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({
|
||||
filename: imageId!,
|
||||
filename,
|
||||
size: obj.size,
|
||||
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
|
||||
contentType,
|
||||
lastModified: obj.lastModified || new Date(),
|
||||
etag: obj.etag || '',
|
||||
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 {
|
||||
success: boolean;
|
||||
filename: string; // UUID (same as image.id)
|
||||
filename: string;
|
||||
path: string;
|
||||
url: string; // CDN URL for accessing the file
|
||||
url: string; // API URL for accessing the file
|
||||
size: number;
|
||||
contentType: string;
|
||||
originalFilename?: string; // User's original filename
|
||||
fileExtension?: string; // Original extension (png, jpg, etc.)
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
|
@ -34,125 +32,123 @@ export interface StorageService {
|
|||
|
||||
/**
|
||||
* Upload a file to storage
|
||||
* Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
*
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID for the file (same as image.id in DB)
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category (uploads, generated, references)
|
||||
* @param filename Original filename
|
||||
* @param buffer File buffer
|
||||
* @param contentType MIME type
|
||||
* @param originalFilename Original filename from user (for metadata)
|
||||
*/
|
||||
uploadFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
originalFilename?: string,
|
||||
): Promise<UploadResult>;
|
||||
|
||||
/**
|
||||
* Download a file from storage
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename to download
|
||||
*/
|
||||
downloadFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<Buffer>;
|
||||
|
||||
/**
|
||||
* Stream a file from storage (memory efficient)
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename to stream
|
||||
*/
|
||||
streamFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<Readable>;
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for downloading a file
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename
|
||||
* @param expirySeconds URL expiry time in seconds
|
||||
*/
|
||||
getPresignedDownloadUrl(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
expirySeconds: number,
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for uploading a file
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename
|
||||
* @param expirySeconds URL expiry time in seconds
|
||||
* @param contentType MIME type
|
||||
*/
|
||||
getPresignedUploadUrl(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
expirySeconds: number,
|
||||
contentType: string,
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* List files in a project's img folder
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* List files in a specific path
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param prefix Optional prefix to filter files
|
||||
*/
|
||||
listFiles(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
prefix?: string,
|
||||
): Promise<FileMetadata[]>;
|
||||
|
||||
/**
|
||||
* Delete a file from storage
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename to delete
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename to delete
|
||||
*/
|
||||
deleteFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename to check
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename to check
|
||||
*/
|
||||
fileExists(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): 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
|
||||
export interface ImageGenerationOptions {
|
||||
prompt: string;
|
||||
imageId: string; // UUID used as filename in storage (same as image.id in DB)
|
||||
filename: string;
|
||||
referenceImages?: ReferenceImage[];
|
||||
aspectRatio?: string;
|
||||
orgSlug?: string;
|
||||
projectSlug?: string;
|
||||
orgId?: string;
|
||||
projectId?: string;
|
||||
userId?: string;
|
||||
meta?: {
|
||||
tags?: string[];
|
||||
|
|
@ -91,15 +91,13 @@ export interface GeminiParams {
|
|||
|
||||
export interface ImageGenerationResult {
|
||||
success: boolean;
|
||||
imageId?: string; // UUID filename (same as image.id in DB)
|
||||
filename?: string;
|
||||
filepath?: string;
|
||||
url?: string; // CDN URL for accessing the image
|
||||
size?: number; // File size in bytes
|
||||
url?: string; // API URL for accessing the image
|
||||
description?: string;
|
||||
model: string;
|
||||
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
|
||||
error?: string;
|
||||
errorCode?: string; // Gemini-specific error code (GEMINI_RATE_LIMIT, GEMINI_TIMEOUT, etc.)
|
||||
errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors
|
||||
generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails
|
||||
}
|
||||
|
|
@ -110,8 +108,6 @@ export interface GeneratedImageData {
|
|||
mimeType: string;
|
||||
fileExtension: string;
|
||||
description?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// 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*
|
||||
!.env.example
|
||||
|
||||
# waitlist logs
|
||||
/waitlist-logs/
|
||||
|
||||
# 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
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
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 apps/landing ./apps/landing
|
||||
|
||||
# Copy database package
|
||||
COPY packages/database ./packages/database
|
||||
|
||||
# Install and build
|
||||
RUN pnpm install --frozen-lockfile
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm --filter @banatie/landing build
|
||||
# Copy landing app
|
||||
COPY apps/landing ./apps/landing
|
||||
|
||||
# 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
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built app
|
||||
COPY --from=builder /app/apps/landing/.next/standalone ./
|
||||
COPY --from=builder /app/apps/landing/.next/static ./apps/landing/.next/static
|
||||
# Copy workspace configuration
|
||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||
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 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
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
WORKDIR /app/apps/landing
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
|
|
|
|||
|
|
@ -2,12 +2,8 @@ import type { NextConfig } from 'next';
|
|||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,23 +5,22 @@
|
|||
"scripts": {
|
||||
"dev": "next dev -p 3010",
|
||||
"build": "next build",
|
||||
"postbuild": "cp -r .next/static .next/standalone/apps/landing/.next/ && cp -r public .next/standalone/apps/landing/",
|
||||
"start": "node .next/standalone/apps/landing/server.js",
|
||||
"start": "next start",
|
||||
"deploy": "cp -r out/* /var/www/banatie.app/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@banatie/database": "workspace:*",
|
||||
"lucide-react": "^0.400.0",
|
||||
"next": "15.5.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.5.4",
|
||||
"@banatie/database": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"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>
|
||||
);
|
||||
}
|
||||