Compare commits

..

No commits in common. "main" and "feature/db-for-generation" have entirely different histories.

279 changed files with 2714 additions and 33569 deletions

View File

@ -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

4
.gitignore vendored
View File

@ -82,7 +82,3 @@ uploads/
# Temporary files # Temporary files
temp/ temp/
tmp/ tmp/
# Local Claude config (VPS-specific)
CLAUDE.local.md
.env.prod

View File

@ -42,9 +42,11 @@
"PERPLEXITY_TIMEOUT_MS": "600000" "PERPLEXITY_TIMEOUT_MS": "600000"
} }
}, },
"chrome-devtools": { "browsermcp": {
"type": "stdio",
"command": "npx", "command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"] "args": ["-y", "@browsermcp/mcp@latest"],
"env": {}
} }
} }
} }

View File

@ -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/`

View File

@ -300,52 +300,6 @@ curl -X POST http://localhost:3000/api/upload \
- **Rate Limits**: 100 requests per hour per key - **Rate Limits**: 100 requests per hour per key
- **Revocation**: Soft delete via `is_active` flag - **Revocation**: Soft delete via `is_active` flag
## Production Deployment
### VPS Infrastructure
Banatie is deployed as an isolated ecosystem on VPS at `/opt/banatie/`:
| Service | URL | Container |
|---------|-----|-----------|
| Landing | https://banatie.app | banatie-landing |
| API | https://api.banatie.app | banatie-api |
| MinIO Console | https://storage.banatie.app | banatie-minio |
| MinIO CDN | https://cdn.banatie.app | banatie-minio |
### Deploy Scripts
```bash
# From project root
./scripts/deploy-landing.sh # Deploy landing
./scripts/deploy-landing.sh --no-cache # Force rebuild (when deps change)
./scripts/deploy-api.sh # Deploy API
./scripts/deploy-api.sh --no-cache # Force rebuild
```
### Production Configuration Files
```
infrastructure/
├── docker-compose.production.yml # VPS docker-compose
├── .env.example # Environment variables template
└── secrets.env.example # Secrets template
```
### Key Production Learnings
1. **NEXT_PUBLIC_* variables** - Must be set at build time AND runtime for Next.js client-side code
2. **pnpm workspaces in Docker** - Symlinks break between stages; use single-stage install with `pnpm --filter`
3. **Docker User NS Remapping** - VPS uses UID offset 165536; container UID 1001 → host UID 166537
4. **DATABASE_URL encoding** - Special characters like `=` must be URL-encoded (`%3D`)
### Known Production Issues (Non-Critical)
1. **Healthcheck showing "unhealthy"** - Alpine images lack curl; services work correctly
2. **Next.js cache permission** - `.next/cache` may show EACCES; non-critical for functionality
See [docs/deployment.md](docs/deployment.md) for full deployment guide.
## Development Notes ## Development Notes
- Uses pnpm workspaces for monorepo management (required >= 8.0.0) - Uses pnpm workspaces for monorepo management (required >= 8.0.0)

View File

@ -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

View File

@ -1,42 +1,90 @@
# Simplified Dockerfile for API Service # Multi-stage Dockerfile for API Service
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm@10.11.0
# Copy workspace configuration
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
# Copy package.json files
COPY apps/api-service/package.json ./apps/api-service/
COPY packages/database/package.json ./packages/database/
# Install dependencies
RUN pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Install pnpm # Install pnpm
RUN npm install -g pnpm@10.11.0 RUN npm install -g pnpm@10.11.0
# Copy everything needed # Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api-service/node_modules ./apps/api-service/node_modules
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
# Copy workspace files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/api-service ./apps/api-service
# Copy database package
COPY packages/database ./packages/database COPY packages/database ./packages/database
# Install and build # Copy API service source (exclude .env - it's for local dev only)
RUN pnpm install --frozen-lockfile COPY apps/api-service/package.json ./apps/api-service/
RUN pnpm --filter @banatie/database build COPY apps/api-service/tsconfig.json ./apps/api-service/
RUN pnpm --filter @banatie/api-service build COPY apps/api-service/src ./apps/api-service/src
# Production runner # Set working directory to API service
WORKDIR /app/apps/api-service
# Build TypeScript
RUN pnpm build
# Stage 3: Production Runner
FROM node:20-alpine AS production FROM node:20-alpine AS production
WORKDIR /app WORKDIR /app
# Install pnpm
RUN npm install -g pnpm@10.11.0
ENV NODE_ENV=production ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 apiuser RUN adduser --system --uid 1001 apiuser
# Copy built app # Copy workspace configuration
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/packages/database ./packages/database COPY --from=builder /app/package.json ./
COPY --from=builder /app/apps/api-service/dist ./apps/api-service/dist COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/apps/api-service/package.json ./apps/api-service/
COPY --from=builder /app/apps/api-service/node_modules ./apps/api-service/node_modules
# Create directories # Copy database package
COPY --from=builder /app/packages/database ./packages/database
# Copy built API service
COPY --from=builder --chown=apiuser:nodejs /app/apps/api-service/dist ./apps/api-service/dist
COPY --from=builder /app/apps/api-service/package.json ./apps/api-service/
# Copy node_modules for runtime
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/api-service/node_modules ./apps/api-service/node_modules
COPY --from=builder /app/packages/database/node_modules ./packages/database/node_modules
# Create directories for logs and data
RUN mkdir -p /app/apps/api-service/logs /app/results /app/uploads/temp RUN mkdir -p /app/apps/api-service/logs /app/results /app/uploads/temp
RUN chown -R apiuser:nodejs /app/apps/api-service/logs /app/results /app/uploads RUN chown -R apiuser:nodejs /app/apps/api-service/logs /app/results /app/uploads
USER apiuser USER apiuser
EXPOSE 3000 EXPOSE 3000
WORKDIR /app/apps/api-service WORKDIR /app/apps/api-service
# Run production build
CMD ["node", "dist/server.js"] CMD ["node", "dist/server.js"]

View File

@ -9,7 +9,7 @@
"infra:logs": "docker compose logs -f", "infra:logs": "docker compose logs -f",
"dev": "npm run infra:up && echo 'Logs will be saved to api-dev.log' && tsx --watch src/server.ts 2>&1 | tee api-dev.log", "dev": "npm run infra:up && echo 'Logs will be saved to api-dev.log' && tsx --watch src/server.ts 2>&1 | tee api-dev.log",
"start": "node dist/server.js", "start": "node dist/server.js",
"build": "tsc && tsc-alias", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix", "lint:fix": "eslint src/**/*.ts --fix",
@ -43,12 +43,10 @@
"@google/genai": "^1.22.0", "@google/genai": "^1.22.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"drizzle-orm": "^0.36.4",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^7.4.1", "express-rate-limit": "^7.4.1",
"express-validator": "^7.2.0", "express-validator": "^7.2.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"image-size": "^2.0.2",
"mime": "3.0.0", "mime": "3.0.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"multer": "^2.0.2", "multer": "^2.0.2",
@ -72,7 +70,6 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"tsc-alias": "^1.8.10",
"tsx": "^4.20.5", "tsx": "^4.20.5",
"typescript": "^5.9.2" "typescript": "^5.9.2"
} }

View File

@ -1,15 +1,12 @@
import express, { Application } from 'express'; import express, { Application } from 'express';
import cors from 'cors'; import cors from 'cors';
import { config } from 'dotenv'; import { config } from 'dotenv';
import { randomUUID } from 'crypto';
import { Config } from './types/api'; import { Config } from './types/api';
import { textToImageRouter } from './routes/textToImage'; import { textToImageRouter } from './routes/textToImage';
import { imagesRouter } from './routes/images'; import { imagesRouter } from './routes/images';
import { uploadRouter } from './routes/upload'; import { uploadRouter } from './routes/upload';
import bootstrapRoutes from './routes/bootstrap'; import bootstrapRoutes from './routes/bootstrap';
import adminKeysRoutes from './routes/admin/keys'; import adminKeysRoutes from './routes/admin/keys';
import { v1Router } from './routes/v1';
import { cdnRouter } from './routes/cdn';
import { errorHandler, notFoundHandler } from './middleware/errorHandler'; import { errorHandler, notFoundHandler } from './middleware/errorHandler';
// Load environment variables // Load environment variables
@ -45,7 +42,7 @@ export const createApp = (): Application => {
// Request ID middleware for logging // Request ID middleware for logging
app.use((req, res, next) => { app.use((req, res, next) => {
req.requestId = randomUUID(); req.requestId = Math.random().toString(36).substr(2, 9);
res.setHeader('X-Request-ID', req.requestId); res.setHeader('X-Request-ID', req.requestId);
next(); next();
}); });
@ -113,19 +110,13 @@ export const createApp = (): Application => {
}); });
// Public routes (no authentication) // Public routes (no authentication)
// CDN routes for serving images and live URLs (public, no auth)
app.use('/cdn', cdnRouter);
// Bootstrap route (no auth, but works only once) // Bootstrap route (no auth, but works only once)
app.use('/api/bootstrap', bootstrapRoutes); app.use('/api/bootstrap', bootstrapRoutes);
// Admin routes (require master key) // Admin routes (require master key)
app.use('/api/admin/keys', adminKeysRoutes); app.use('/api/admin/keys', adminKeysRoutes);
// API v1 routes (versioned, require valid API key) // Protected API routes (require valid API key)
app.use('/api/v1', v1Router);
// Protected API routes (require valid API key) - Legacy
app.use('/api', textToImageRouter); app.use('/api', textToImageRouter);
app.use('/api', imagesRouter); app.use('/api', imagesRouter);
app.use('/api', uploadRouter); app.use('/api', uploadRouter);

View File

@ -1,4 +1,4 @@
import { createDbClient, type DbClient } from '@banatie/database'; import { createDbClient } from '@banatie/database';
import { config } from 'dotenv'; import { config } from 'dotenv';
import path from 'path'; import path from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
@ -20,7 +20,7 @@ const DATABASE_URL =
process.env['DATABASE_URL'] || process.env['DATABASE_URL'] ||
'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db'; 'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db';
export const db: DbClient = createDbClient(DATABASE_URL); export const db = createDbClient(DATABASE_URL);
console.log( console.log(
`[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`, `[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`,

View File

@ -6,11 +6,10 @@ import { Request, Response, NextFunction } from 'express';
*/ */
export function requireMasterKey(req: Request, res: Response, next: NextFunction): void { export function requireMasterKey(req: Request, res: Response, next: NextFunction): void {
if (!req.apiKey) { if (!req.apiKey) {
res.status(401).json({ return res.status(401).json({
error: 'Authentication required', error: 'Authentication required',
message: 'This endpoint requires authentication', message: 'This endpoint requires authentication',
}); });
return;
} }
if (req.apiKey.keyType !== 'master') { if (req.apiKey.keyType !== 'master') {
@ -18,11 +17,10 @@ export function requireMasterKey(req: Request, res: Response, next: NextFunction
`[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`, `[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`,
); );
res.status(403).json({ return res.status(403).json({
error: 'Master key required', error: 'Master key required',
message: 'This endpoint requires a master API key', message: 'This endpoint requires a master API key',
}); });
return;
} }
next(); next();

View File

@ -7,30 +7,27 @@ import { Request, Response, NextFunction } from 'express';
export function requireProjectKey(req: Request, res: Response, next: NextFunction): void { export function requireProjectKey(req: Request, res: Response, next: NextFunction): void {
// This middleware assumes validateApiKey has already run and attached req.apiKey // This middleware assumes validateApiKey has already run and attached req.apiKey
if (!req.apiKey) { if (!req.apiKey) {
res.status(401).json({ return res.status(401).json({
error: 'Authentication required', error: 'Authentication required',
message: 'API key validation must be performed first', message: 'API key validation must be performed first',
}); });
return;
} }
// Block master keys from generation endpoints // Block master keys from generation endpoints
if (req.apiKey.keyType === 'master') { if (req.apiKey.keyType === 'master') {
res.status(403).json({ return res.status(403).json({
error: 'Forbidden', error: 'Forbidden',
message: message:
'Master keys cannot be used for image generation. Please use a project-specific API key.', 'Master keys cannot be used for image generation. Please use a project-specific API key.',
}); });
return;
} }
// Ensure project key has required IDs // Ensure project key has required IDs
if (!req.apiKey.projectId) { if (!req.apiKey.projectId) {
res.status(400).json({ return res.status(400).json({
error: 'Invalid API key', error: 'Invalid API key',
message: 'Project key must be associated with a project', message: 'Project key must be associated with a project',
}); });
return;
} }
console.log( console.log(

View File

@ -23,22 +23,20 @@ export async function validateApiKey(
const providedKey = req.headers['x-api-key'] as string; const providedKey = req.headers['x-api-key'] as string;
if (!providedKey) { if (!providedKey) {
res.status(401).json({ return res.status(401).json({
error: 'Missing API key', error: 'Missing API key',
message: 'Provide your API key via X-API-Key header', message: 'Provide your API key via X-API-Key header',
}); });
return;
} }
try { try {
const apiKey = await apiKeyService.validateKey(providedKey); const apiKey = await apiKeyService.validateKey(providedKey);
if (!apiKey) { if (!apiKey) {
res.status(401).json({ return res.status(401).json({
error: 'Invalid API key', error: 'Invalid API key',
message: 'The provided API key is invalid, expired, or revoked', message: 'The provided API key is invalid, expired, or revoked',
}); });
return;
} }
// Attach to request for use in routes // Attach to request for use in routes

View File

@ -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
};

View File

@ -81,6 +81,8 @@ export const autoEnhancePrompt = async (
}), }),
enhancements: result.metadata?.enhancements || [], enhancements: result.metadata?.enhancements || [],
}; };
req.body.prompt = result.enhancedPrompt;
} else { } else {
console.warn(`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`); console.warn(`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`);
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`); console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);

View File

@ -1,9 +1,9 @@
import express, { Router } from 'express'; import express from 'express';
import { ApiKeyService } from '../../services/ApiKeyService'; import { ApiKeyService } from '../../services/ApiKeyService';
import { validateApiKey } from '../../middleware/auth/validateApiKey'; import { validateApiKey } from '../../middleware/auth/validateApiKey';
import { requireMasterKey } from '../../middleware/auth/requireMasterKey'; import { requireMasterKey } from '../../middleware/auth/requireMasterKey';
const router: Router = express.Router(); const router = express.Router();
const apiKeyService = new ApiKeyService(); const apiKeyService = new ApiKeyService();
// All admin routes require master key // All admin routes require master key
@ -14,12 +14,12 @@ router.use(requireMasterKey);
* Create a new API key * Create a new API key
* POST /api/admin/keys * POST /api/admin/keys
*/ */
router.post('/', async (req, res): Promise<void> => { router.post('/', async (req, res) => {
try { try {
const { const {
type, type,
projectId: _projectId, projectId,
organizationId: _organizationId, organizationId,
organizationSlug, organizationSlug,
projectSlug, projectSlug,
organizationName, organizationName,
@ -30,27 +30,24 @@ router.post('/', async (req, res): Promise<void> => {
// Validation // Validation
if (!type || !['master', 'project'].includes(type)) { if (!type || !['master', 'project'].includes(type)) {
res.status(400).json({ return res.status(400).json({
error: 'Invalid type', error: 'Invalid type',
message: 'Type must be either "master" or "project"', message: 'Type must be either "master" or "project"',
}); });
return;
} }
if (type === 'project' && !projectSlug) { if (type === 'project' && !projectSlug) {
res.status(400).json({ return res.status(400).json({
error: 'Missing projectSlug', error: 'Missing projectSlug',
message: 'Project keys require a projectSlug', message: 'Project keys require a projectSlug',
}); });
return;
} }
if (type === 'project' && !organizationSlug) { if (type === 'project' && !organizationSlug) {
res.status(400).json({ return res.status(400).json({
error: 'Missing organizationSlug', error: 'Missing organizationSlug',
message: 'Project keys require an organizationSlug', message: 'Project keys require an organizationSlug',
}); });
return;
} }
// Create key // Create key
@ -151,18 +148,17 @@ router.get('/', async (req, res) => {
* Revoke an API key * Revoke an API key
* DELETE /api/admin/keys/:keyId * DELETE /api/admin/keys/:keyId
*/ */
router.delete('/:keyId', async (req, res): Promise<void> => { router.delete('/:keyId', async (req, res) => {
try { try {
const { keyId } = req.params; const { keyId } = req.params;
const success = await apiKeyService.revokeKey(keyId); const success = await apiKeyService.revokeKey(keyId);
if (!success) { if (!success) {
res.status(404).json({ return res.status(404).json({
error: 'Key not found', error: 'Key not found',
message: 'The specified API key does not exist', message: 'The specified API key does not exist',
}); });
return;
} }
console.log(`[${new Date().toISOString()}] API key revoked: ${keyId} - by: ${req.apiKey!.id}`); console.log(`[${new Date().toISOString()}] API key revoked: ${keyId} - by: ${req.apiKey!.id}`);

View File

@ -1,7 +1,7 @@
import express, { Router } from 'express'; import express from 'express';
import { ApiKeyService } from '../services/ApiKeyService'; import { ApiKeyService } from '../services/ApiKeyService';
const router: Router = express.Router(); const router = express.Router();
const apiKeyService = new ApiKeyService(); const apiKeyService = new ApiKeyService();
/** /**
@ -10,18 +10,17 @@ const apiKeyService = new ApiKeyService();
* *
* POST /api/bootstrap/initial-key * POST /api/bootstrap/initial-key
*/ */
router.post('/initial-key', async (_req, res): Promise<void> => { router.post('/initial-key', async (req, res) => {
try { try {
// Check if any keys already exist // Check if any keys already exist
const hasKeys = await apiKeyService.hasAnyKeys(); const hasKeys = await apiKeyService.hasAnyKeys();
if (hasKeys) { if (hasKeys) {
console.warn(`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`); console.warn(`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`);
res.status(403).json({ return res.status(403).json({
error: 'Bootstrap not allowed', error: 'Bootstrap not allowed',
message: 'API keys already exist. Use /api/admin/keys to create new keys.', message: 'API keys already exist. Use /api/admin/keys to create new keys.',
}); });
return;
} }
// Create first master key // Create first master key

View File

@ -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',
},
});
}
}),
);

View File

@ -1,60 +1,77 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import type { Router as RouterType } from 'express';
import { StorageFactory } from '../services/StorageFactory'; import { StorageFactory } from '../services/StorageFactory';
import { asyncHandler } from '../middleware/errorHandler'; import { asyncHandler } from '../middleware/errorHandler';
import { validateApiKey } from '../middleware/auth/validateApiKey'; import { validateApiKey } from '../middleware/auth/validateApiKey';
import { requireProjectKey } from '../middleware/auth/requireProjectKey'; import { requireProjectKey } from '../middleware/auth/requireProjectKey';
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter'; import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
export const imagesRouter: RouterType = Router(); export const imagesRouter = Router();
/** /**
* GET /api/images/:orgSlug/:projectSlug/img/:imageId * GET /api/images/:orgId/:projectId/:category/:filename
* Serves images directly (streaming approach) * Serves images via presigned URLs (redirect approach)
* New format: {orgSlug}/{projectSlug}/img/{imageId}
*/ */
imagesRouter.get( imagesRouter.get(
'/images/:orgSlug/:projectSlug/img/:imageId', '/images/:orgId/:projectId/:category/:filename',
asyncHandler(async (req: Request, res: Response): Promise<void> => { asyncHandler(async (req: Request, res: Response) => {
const { orgSlug, projectSlug, imageId } = req.params; const { orgId, projectId, category, filename } = req.params;
// Validate required params (these are guaranteed by route pattern) // Validate category
if (!orgSlug || !projectSlug || !imageId) { if (!['uploads', 'generated', 'references'].includes(category)) {
res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: 'Missing required parameters', message: 'Invalid category',
}); });
return;
} }
const storageService = await StorageFactory.getInstance(); const storageService = await StorageFactory.getInstance();
try { try {
// Check if file exists first (fast check) // Check if file exists first (fast check)
const exists = await storageService.fileExists(orgSlug, projectSlug, imageId); const exists = await storageService.fileExists(
orgId,
projectId,
category as 'uploads' | 'generated' | 'references',
filename,
);
if (!exists) { if (!exists) {
res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'File not found', message: 'File not found',
}); });
return;
} }
// Determine content type from filename
const ext = filename.toLowerCase().split('.').pop();
const contentType =
{
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
}[ext || ''] || 'application/octet-stream';
// Set headers for optimal caching and performance // Set headers for optimal caching and performance
// Note: Content-Type will be set from MinIO metadata res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1 year + immutable res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); // 24 hours + immutable
res.setHeader('ETag', `"${imageId}"`); // UUID as ETag res.setHeader('ETag', `"${orgId}-${projectId}-${filename}"`); // Simple ETag
// Handle conditional requests (304 Not Modified) // Handle conditional requests (304 Not Modified)
const ifNoneMatch = req.headers['if-none-match']; const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === `"${imageId}"`) { if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) {
res.status(304).end(); // Not Modified return res.status(304).end(); // Not Modified
return;
} }
// Stream the file directly through our API (memory efficient) // Stream the file directly through our API (memory efficient)
const fileStream = await storageService.streamFile(orgSlug, projectSlug, imageId); const fileStream = await storageService.streamFile(
orgId,
projectId,
category as 'uploads' | 'generated' | 'references',
filename,
);
// Handle stream errors // Handle stream errors
fileStream.on('error', (streamError) => { fileStream.on('error', (streamError) => {
@ -71,7 +88,7 @@ imagesRouter.get(
fileStream.pipe(res); fileStream.pipe(res);
} catch (error) { } catch (error) {
console.error('Failed to stream file:', error); console.error('Failed to stream file:', error);
res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'File not found', message: 'File not found',
}); });
@ -80,42 +97,41 @@ imagesRouter.get(
); );
/** /**
* GET /api/images/url/:orgSlug/:projectSlug/img/:imageId * GET /api/images/url/:orgId/:projectId/:category/:filename
* Returns a presigned URL instead of redirecting * Returns a presigned URL instead of redirecting
*/ */
imagesRouter.get( imagesRouter.get(
'/images/url/:orgSlug/:projectSlug/img/:imageId', '/images/url/:orgId/:projectId/:category/:filename',
asyncHandler(async (req: Request, res: Response): Promise<void> => { asyncHandler(async (req: Request, res: Response) => {
const { orgSlug, projectSlug, imageId } = req.params; const { orgId, projectId, category, filename } = req.params;
const { expiry = '3600' } = req.query; // Default 1 hour const { expiry = '3600' } = req.query; // Default 1 hour
// Validate required params (these are guaranteed by route pattern) if (!['uploads', 'generated', 'references'].includes(category)) {
if (!orgSlug || !projectSlug || !imageId) { return res.status(400).json({
res.status(400).json({
success: false, success: false,
message: 'Missing required parameters', message: 'Invalid category',
}); });
return;
} }
const storageService = await StorageFactory.getInstance(); const storageService = await StorageFactory.getInstance();
try { try {
const presignedUrl = await storageService.getPresignedDownloadUrl( const presignedUrl = await storageService.getPresignedDownloadUrl(
orgSlug, orgId,
projectSlug, projectId,
imageId, category as 'uploads' | 'generated' | 'references',
filename,
parseInt(expiry as string, 10), parseInt(expiry as string, 10),
); );
res.json({ return res.json({
success: true, success: true,
url: presignedUrl, url: presignedUrl,
expiresIn: parseInt(expiry as string, 10), expiresIn: parseInt(expiry as string, 10),
}); });
} catch (error) { } catch (error) {
console.error('Failed to generate presigned URL:', error); console.error('Failed to generate presigned URL:', error);
res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'File not found or access denied', message: 'File not found or access denied',
}); });
@ -143,28 +159,27 @@ imagesRouter.get(
// Validate query parameters // Validate query parameters
if (isNaN(limit) || isNaN(offset)) { if (isNaN(limit) || isNaN(offset)) {
res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: 'Invalid query parameters', message: 'Invalid query parameters',
error: 'limit and offset must be valid numbers', error: 'limit and offset must be valid numbers',
}); });
return;
} }
// Extract org/project from validated API key // Extract org/project from validated API key
const orgSlug = req.apiKey?.organizationSlug || 'default'; const orgId = req.apiKey?.organizationSlug || 'default';
const projectSlug = req.apiKey?.projectSlug!; const projectId = req.apiKey?.projectSlug!;
console.log( console.log(
`[${timestamp}] [${requestId}] Listing images for org:${orgSlug}, project:${projectSlug}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`, `[${timestamp}] [${requestId}] Listing generated images for org:${orgId}, project:${projectId}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`,
); );
try { try {
// Get storage service instance // Get storage service instance
const storageService = await StorageFactory.getInstance(); const storageService = await StorageFactory.getInstance();
// List files in img folder // List files in generated category
const allFiles = await storageService.listFiles(orgSlug, projectSlug, prefix); const allFiles = await storageService.listFiles(orgId, projectId, 'generated', prefix);
// Sort by lastModified descending (newest first) // Sort by lastModified descending (newest first)
allFiles.sort((a, b) => { allFiles.sort((a, b) => {
@ -179,8 +194,8 @@ imagesRouter.get(
// Map to response format with public URLs // Map to response format with public URLs
const images = paginatedFiles.map((file) => ({ const images = paginatedFiles.map((file) => ({
imageId: file.filename, filename: file.filename,
url: storageService.getPublicUrl(orgSlug, projectSlug, file.filename), url: storageService.getPublicUrl(orgId, projectId, 'generated', file.filename),
size: file.size, size: file.size,
contentType: file.contentType, contentType: file.contentType,
lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(), lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(),
@ -189,7 +204,7 @@ imagesRouter.get(
const hasMore = offset + limit < total; const hasMore = offset + limit < total;
console.log( console.log(
`[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} images`, `[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} generated images`,
); );
return res.status(200).json({ return res.status(200).json({
@ -203,11 +218,11 @@ imagesRouter.get(
}, },
}); });
} catch (error) { } catch (error) {
console.error(`[${timestamp}] [${requestId}] Failed to list images:`, error); console.error(`[${timestamp}] [${requestId}] Failed to list generated images:`, error);
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: 'Failed to list images', message: 'Failed to list generated images',
error: error instanceof Error ? error.message : 'Unknown error occurred', error: error instanceof Error ? error.message : 'Unknown error occurred',
}); });
} }

View File

@ -1,6 +1,5 @@
import { Response, Router } from 'express'; import { Response, Router } from 'express';
import type { Router as RouterType } from 'express'; import type { Router as RouterType } from 'express';
import { randomUUID } from 'crypto';
import { ImageGenService } from '../services/ImageGenService'; import { ImageGenService } from '../services/ImageGenService';
import { validateTextToImageRequest, logTextToImageRequest } from '../middleware/jsonValidation'; import { validateTextToImageRequest, logTextToImageRequest } from '../middleware/jsonValidation';
import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement'; import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement';
@ -49,17 +48,14 @@ textToImageRouter.post(
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const requestId = req.requestId; const requestId = req.requestId;
const { prompt, aspectRatio, meta } = req.body; const { prompt, filename, aspectRatio, meta } = req.body;
// Extract org/project slugs from validated API key // Extract org/project slugs from validated API key
const orgSlug = req.apiKey?.organizationSlug || undefined; const orgId = req.apiKey?.organizationSlug || undefined;
const projectSlug = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
// Generate imageId (UUID) - this will be the filename in storage
const imageId = randomUUID();
console.log( console.log(
`[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgSlug}, project:${projectSlug}`, `[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgId}, project:${projectId}`,
); );
try { try {
@ -70,10 +66,10 @@ textToImageRouter.post(
const result = await imageGenService.generateImage({ const result = await imageGenService.generateImage({
prompt, prompt,
imageId, filename,
...(aspectRatio && { aspectRatio }), ...(aspectRatio && { aspectRatio }),
orgSlug, orgId,
projectSlug, projectId,
...(meta && { meta }), ...(meta && { meta }),
}); });
@ -81,7 +77,7 @@ textToImageRouter.post(
console.log(`[${timestamp}] [${requestId}] Text-to-image generation completed:`, { console.log(`[${timestamp}] [${requestId}] Text-to-image generation completed:`, {
success: result.success, success: result.success,
model: result.model, model: result.model,
imageId: result.imageId, filename: result.filename,
hasError: !!result.error, hasError: !!result.error,
}); });
@ -91,7 +87,7 @@ textToImageRouter.post(
success: true, success: true,
message: 'Image generated successfully', message: 'Image generated successfully',
data: { data: {
filename: result.imageId!, filename: result.filename!,
filepath: result.filepath!, filepath: result.filepath!,
...(result.url && { url: result.url }), ...(result.url && { url: result.url }),
...(result.description && { description: result.description }), ...(result.description && { description: result.description }),

View File

@ -1,6 +1,5 @@
import { Response, Router } from 'express'; import { Response, Router } from 'express';
import type { Router as RouterType } from 'express'; import type { Router as RouterType } from 'express';
import { randomUUID } from 'crypto';
import { StorageFactory } from '../services/StorageFactory'; import { StorageFactory } from '../services/StorageFactory';
import { asyncHandler } from '../middleware/errorHandler'; import { asyncHandler } from '../middleware/errorHandler';
import { validateApiKey } from '../middleware/auth/validateApiKey'; import { validateApiKey } from '../middleware/auth/validateApiKey';
@ -41,11 +40,11 @@ uploadRouter.post(
} }
// Extract org/project slugs from validated API key // Extract org/project slugs from validated API key
const orgSlug = req.apiKey?.organizationSlug || process.env['DEFAULT_ORG_SLUG'] || 'default'; const orgId = req.apiKey?.organizationSlug || 'default';
const projectSlug = req.apiKey?.projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main'; // Guaranteed by requireProjectKey middleware const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
console.log( console.log(
`[${timestamp}] [${requestId}] Starting file upload for org:${orgSlug}, project:${projectSlug}`, `[${timestamp}] [${requestId}] Starting file upload for org:${orgId}, project:${projectId}`,
); );
const file = req.file; const file = req.file;
@ -54,22 +53,18 @@ uploadRouter.post(
// Initialize storage service // Initialize storage service
const storageService = await StorageFactory.getInstance(); const storageService = await StorageFactory.getInstance();
// Generate imageId (UUID) - this will be the filename in storage // Upload file to MinIO in 'uploads' category
const imageId = randomUUID();
// Upload file to MinIO
// Path format: {orgSlug}/{projectSlug}/img/{imageId}
console.log( console.log(
`[${timestamp}] [${requestId}] Uploading file: ${file.originalname} as ${imageId} (${file.size} bytes)`, `[${timestamp}] [${requestId}] Uploading file: ${file.originalname} (${file.size} bytes)`,
); );
const uploadResult = await storageService.uploadFile( const uploadResult = await storageService.uploadFile(
orgSlug, orgId,
projectSlug, projectId,
imageId, 'uploads',
file.originalname,
file.buffer, file.buffer,
file.mimetype, file.mimetype,
file.originalname,
); );
if (!uploadResult.success) { if (!uploadResult.success) {

View File

@ -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 },
});
})
);

View File

@ -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 },
});
})
);

View File

@ -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 },
});
})
);

View File

@ -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);

View File

@ -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;
}
})
);

View File

@ -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 },
});
}),
);

View File

@ -1,6 +1,6 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { db } from '../db'; import { db } from '../db';
import { apiKeys, organizations, projects, type ApiKey } from '@banatie/database'; import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from '@banatie/database';
import { eq, and, desc } from 'drizzle-orm'; import { eq, and, desc } from 'drizzle-orm';
// Extended API key type with slugs for storage paths // Extended API key type with slugs for storage paths

View File

@ -1,7 +1,6 @@
import { GoogleGenAI } from '@google/genai'; import { GoogleGenAI } from '@google/genai';
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const mime = require('mime') as any; const mime = require('mime') as any;
import sizeOf from 'image-size';
import { import {
ImageGenerationOptions, ImageGenerationOptions,
ImageGenerationResult, ImageGenerationResult,
@ -12,13 +11,10 @@ import {
import { StorageFactory } from './StorageFactory'; import { StorageFactory } from './StorageFactory';
import { TTILogger, TTILogEntry } from './TTILogger'; import { TTILogger, TTILogEntry } from './TTILogger';
import { NetworkErrorDetector } from '../utils/NetworkErrorDetector'; import { NetworkErrorDetector } from '../utils/NetworkErrorDetector';
import { GeminiErrorDetector } from '../utils/GeminiErrorDetector';
import { ERROR_MESSAGES } from '../utils/constants/errors';
export class ImageGenService { export class ImageGenService {
private ai: GoogleGenAI; private ai: GoogleGenAI;
private primaryModel = 'gemini-2.5-flash-image'; private primaryModel = 'gemini-2.5-flash-image';
private static GEMINI_TIMEOUT_MS = 90_000; // 90 seconds
constructor(apiKey: string) { constructor(apiKey: string) {
if (!apiKey) { if (!apiKey) {
@ -32,12 +28,12 @@ export class ImageGenService {
* This method separates image generation from storage for clear error handling * This method separates image generation from storage for clear error handling
*/ */
async generateImage(options: ImageGenerationOptions): Promise<ImageGenerationResult> { async generateImage(options: ImageGenerationOptions): Promise<ImageGenerationResult> {
const { prompt, imageId, referenceImages, aspectRatio, orgSlug, projectSlug, meta } = options; const { prompt, filename, referenceImages, aspectRatio, orgId, projectId, meta } = options;
// Use default values if not provided // Use default values if not provided
const finalOrgSlug = orgSlug || process.env['DEFAULT_ORG_SLUG'] || 'default'; const finalOrgId = orgId || process.env['DEFAULT_ORG_ID'] || 'default';
const finalProjectSlug = projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main'; const finalProjectId = projectId || process.env['DEFAULT_PROJECT_ID'] || 'main';
const finalAspectRatio = aspectRatio || '16:9'; // Default to widescreen const finalAspectRatio = aspectRatio || '1:1'; // Default to square
// Step 1: Generate image from Gemini AI // Step 1: Generate image from Gemini AI
let generatedData: GeneratedImageData; let generatedData: GeneratedImageData;
@ -47,8 +43,8 @@ export class ImageGenService {
prompt, prompt,
referenceImages, referenceImages,
finalAspectRatio, finalAspectRatio,
finalOrgSlug, finalOrgId,
finalProjectSlug, finalProjectId,
meta, meta,
); );
generatedData = aiResult.generatedData; generatedData = aiResult.generatedData;
@ -64,31 +60,26 @@ export class ImageGenService {
} }
// Step 2: Save generated image to storage // Step 2: Save generated image to storage
// Path format: {orgSlug}/{projectSlug}/img/{imageId}
try { try {
const finalFilename = `${filename}.${generatedData.fileExtension}`;
const storageService = await StorageFactory.getInstance(); const storageService = await StorageFactory.getInstance();
// Original filename for metadata (e.g., "my-image.png")
const originalFilename = `generated-image.${generatedData.fileExtension}`;
const uploadResult = await storageService.uploadFile( const uploadResult = await storageService.uploadFile(
finalOrgSlug, finalOrgId,
finalProjectSlug, finalProjectId,
imageId, 'generated',
finalFilename,
generatedData.buffer, generatedData.buffer,
generatedData.mimeType, generatedData.mimeType,
originalFilename,
); );
if (uploadResult.success) { if (uploadResult.success) {
return { return {
success: true, success: true,
imageId: uploadResult.filename, filename: uploadResult.filename,
filepath: uploadResult.path, filepath: uploadResult.path,
url: uploadResult.url, url: uploadResult.url,
size: uploadResult.size,
model: this.primaryModel, model: this.primaryModel,
geminiParams, geminiParams,
generatedImageData: generatedData,
...(generatedData.description && { ...(generatedData.description && {
description: generatedData.description, description: generatedData.description,
}), }),
@ -131,8 +122,8 @@ export class ImageGenService {
prompt: string, prompt: string,
referenceImages: ReferenceImage[] | undefined, referenceImages: ReferenceImage[] | undefined,
aspectRatio: string, aspectRatio: string,
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
meta?: { tags?: string[] }, meta?: { tags?: string[] },
): Promise<{ ): Promise<{
generatedData: GeneratedImageData; generatedData: GeneratedImageData;
@ -188,8 +179,8 @@ export class ImageGenService {
const ttiLogger = TTILogger.getInstance(); const ttiLogger = TTILogger.getInstance();
const logEntry: TTILogEntry = { const logEntry: TTILogEntry = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
orgId: orgSlug, orgId,
projectId: projectSlug, projectId,
prompt, prompt,
model: this.primaryModel, model: this.primaryModel,
config, config,
@ -208,56 +199,18 @@ export class ImageGenService {
try { try {
// Use the EXACT same config and contents objects calculated above // Use the EXACT same config and contents objects calculated above
// Wrap with timeout to prevent hanging requests const response = await this.ai.models.generateContent({
const response = await this.withTimeout( model: this.primaryModel,
this.ai.models.generateContent({ config,
model: this.primaryModel, contents,
config, });
contents,
}),
ImageGenService.GEMINI_TIMEOUT_MS,
'Gemini image generation'
);
// Log response structure for debugging // Parse response
GeminiErrorDetector.logResponseStructure(response as any); if (!response.candidates || !response.candidates[0] || !response.candidates[0].content) {
throw new Error('No response received from Gemini AI');
// Check promptFeedback for blocked prompts FIRST
if ((response as any).promptFeedback?.blockReason) {
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
console.error(
`[ImageGenService] Prompt blocked:`,
GeminiErrorDetector.formatForLogging(errorResult!)
);
throw new Error(errorResult!.message);
} }
// Check if we have candidates const content = response.candidates[0].content;
if (!response.candidates || !response.candidates[0]) {
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
console.error(`[ImageGenService] No candidates in response`);
throw new Error(errorResult?.message || 'No response candidates from Gemini AI');
}
const candidate = response.candidates[0];
// Check finishReason for non-STOP completions
if (candidate.finishReason && candidate.finishReason !== 'STOP') {
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
console.error(
`[ImageGenService] Non-STOP finish reason:`,
GeminiErrorDetector.formatForLogging(errorResult!)
);
throw new Error(errorResult!.message);
}
// Check content exists
if (!candidate.content) {
console.error(`[ImageGenService] No content in candidate`);
throw new Error('No content in Gemini AI response');
}
const content = candidate.content;
let generatedDescription: string | undefined; let generatedDescription: string | undefined;
let imageData: { buffer: Buffer; mimeType: string } | null = null; let imageData: { buffer: Buffer; mimeType: string } | null = null;
@ -273,37 +226,15 @@ export class ImageGenService {
} }
if (!imageData) { if (!imageData) {
// Log what we got instead of image throw new Error('No image data received from Gemini AI');
const partTypes = (content.parts || []).map((p: any) =>
p.inlineData ? 'image' : p.text ? 'text' : 'other'
);
console.error(`[ImageGenService] No image data in response. Parts: [${partTypes.join(', ')}]`);
throw new Error(
`${ERROR_MESSAGES.GEMINI_NO_IMAGE}. Response contained: ${partTypes.join(', ') || 'nothing'}`
);
} }
const fileExtension = mime.getExtension(imageData.mimeType) || 'png'; const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
// Extract image dimensions from buffer
let width = 1024; // Default fallback
let height = 1024; // Default fallback
try {
const dimensions = sizeOf(imageData.buffer);
if (dimensions.width && dimensions.height) {
width = dimensions.width;
height = dimensions.height;
}
} catch (error) {
console.warn('Failed to extract image dimensions, using defaults:', error);
}
const generatedData: GeneratedImageData = { const generatedData: GeneratedImageData = {
buffer: imageData.buffer, buffer: imageData.buffer,
mimeType: imageData.mimeType, mimeType: imageData.mimeType,
fileExtension, fileExtension,
width,
height,
...(generatedDescription && { description: generatedDescription }), ...(generatedDescription && { description: generatedDescription }),
}; };
@ -312,38 +243,6 @@ export class ImageGenService {
geminiParams, geminiParams,
}; };
} catch (error) { } catch (error) {
// Check for rate limit (HTTP 429)
const err = error as { status?: number; message?: string };
if (err.status === 429) {
const geminiError = GeminiErrorDetector.classifyApiError(error);
console.error(
`[ImageGenService] Rate limit:`,
GeminiErrorDetector.formatForLogging(geminiError)
);
throw new Error(geminiError.message);
}
// Check for timeout
if (error instanceof Error && error.message.includes('timed out')) {
console.error(
`[ImageGenService] Timeout after ${ImageGenService.GEMINI_TIMEOUT_MS}ms:`,
error.message
);
throw new Error(
`${ERROR_MESSAGES.GEMINI_TIMEOUT} after ${ImageGenService.GEMINI_TIMEOUT_MS / 1000} seconds`
);
}
// Check for other API errors with status codes
if (err.status) {
const geminiError = GeminiErrorDetector.classifyApiError(error);
console.error(
`[ImageGenService] API error:`,
GeminiErrorDetector.formatForLogging(geminiError)
);
throw new Error(geminiError.message);
}
// Enhanced error detection with network diagnostics // Enhanced error detection with network diagnostics
if (error instanceof Error) { if (error instanceof Error) {
// Classify the error and check for network issues (only on failure) // Classify the error and check for network issues (only on failure)
@ -359,32 +258,6 @@ export class ImageGenService {
} }
} }
/**
* Wrap a promise with timeout
*/
private async withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
operationName: string
): Promise<T> {
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${operationName} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
try {
const result = await Promise.race([promise, timeoutPromise]);
clearTimeout(timeoutId!);
return result;
} catch (error) {
clearTimeout(timeoutId!);
throw error;
}
}
static validateReferenceImages(files: Express.Multer.File[]): { static validateReferenceImages(files: Express.Multer.File[]): {
valid: boolean; valid: boolean;
error?: string; error?: string;

View File

@ -4,7 +4,7 @@ import { StorageService, FileMetadata, UploadResult } from './StorageService';
export class MinioStorageService implements StorageService { export class MinioStorageService implements StorageService {
private client: MinioClient; private client: MinioClient;
private bucketName: string; private bucketName: string;
private cdnBaseUrl: string; private publicUrl: string;
constructor( constructor(
endpoint: string, endpoint: string,
@ -12,7 +12,7 @@ export class MinioStorageService implements StorageService {
secretKey: string, secretKey: string,
useSSL: boolean = false, useSSL: boolean = false,
bucketName: string = 'banatie', bucketName: string = 'banatie',
cdnBaseUrl?: string, publicUrl?: string,
) { ) {
// Parse endpoint to separate hostname and port // Parse endpoint to separate hostname and port
const cleanEndpoint = endpoint.replace(/^https?:\/\//, ''); const cleanEndpoint = endpoint.replace(/^https?:\/\//, '');
@ -31,59 +31,119 @@ export class MinioStorageService implements StorageService {
secretKey, secretKey,
}); });
this.bucketName = bucketName; this.bucketName = bucketName;
// CDN base URL without bucket name (e.g., https://cdn.banatie.app) this.publicUrl = publicUrl || `${useSSL ? 'https' : 'http'}://${endpoint}`;
this.cdnBaseUrl = cdnBaseUrl || process.env['CDN_BASE_URL'] || `${useSSL ? 'https' : 'http'}://${endpoint}/${bucketName}`;
} }
/** private getFilePath(
* Get file path in storage orgId: string,
* Format: {orgSlug}/{projectSlug}/img/{imageId} projectId: string,
*/ category: 'uploads' | 'generated' | 'references',
private getFilePath(orgSlug: string, projectSlug: string, imageId: string): string { filename: string,
return `${orgSlug}/${projectSlug}/img/${imageId}`; ): string {
// Simplified path without date folder for now
return `${orgId}/${projectId}/${category}/${filename}`;
} }
/** private generateUniqueFilename(originalFilename: string): string {
* Extract file extension from original filename // Sanitize filename first
*/ const sanitized = this.sanitizeFilename(originalFilename);
private extractExtension(filename: string): string | undefined {
if (!filename) return undefined; const timestamp = Date.now();
const lastDotIndex = filename.lastIndexOf('.'); const random = Math.random().toString(36).substring(2, 8);
if (lastDotIndex <= 0) return undefined; const ext = sanitized.includes('.') ? sanitized.substring(sanitized.lastIndexOf('.')) : '';
return filename.substring(lastDotIndex + 1).toLowerCase(); const name = sanitized.includes('.')
? sanitized.substring(0, sanitized.lastIndexOf('.'))
: sanitized;
return `${name}-${timestamp}-${random}${ext}`;
} }
/** private sanitizeFilename(filename: string): string {
* Validate storage path components // Remove path traversal attempts FIRST from entire filename
*/ let cleaned = filename.replace(/\.\./g, '').trim();
private validatePath(orgSlug: string, projectSlug: string, imageId: string): void {
// Validate orgSlug // Split filename and extension
if (!orgSlug || !/^[a-zA-Z0-9_-]+$/.test(orgSlug) || orgSlug.length > 50) { const lastDotIndex = cleaned.lastIndexOf('.');
let baseName = lastDotIndex > 0 ? cleaned.substring(0, lastDotIndex) : cleaned;
const extension = lastDotIndex > 0 ? cleaned.substring(lastDotIndex) : '';
// Remove dangerous characters from base name
baseName = baseName
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars
.trim();
// Replace non-ASCII characters with ASCII equivalents or remove them
// This prevents S3 signature mismatches with MinIO
baseName = baseName
.normalize('NFD') // Decompose combined characters (é -> e + ´)
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
.replace(/[^\x20-\x7E]/g, '_') // Replace any remaining non-ASCII with underscore
.replace(/[^\w\s\-_.]/g, '_') // Replace special chars (except word chars, space, dash, underscore, dot) with underscore
.replace(/\s+/g, '_') // Replace spaces with underscores
.replace(/_{2,}/g, '_') // Collapse multiple underscores
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
// Ensure we still have a valid base name
if (baseName.length === 0) {
baseName = 'file';
}
// Sanitize extension (remove only dangerous chars, keep the dot)
let sanitizedExt = extension
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
.replace(/[^\x20-\x7E]/g, '')
.toLowerCase();
// Ensure extension starts with a dot and is reasonable
if (sanitizedExt && !sanitizedExt.startsWith('.')) {
sanitizedExt = '.' + sanitizedExt;
}
if (sanitizedExt.length > 10) {
sanitizedExt = sanitizedExt.substring(0, 10);
}
const result = baseName + sanitizedExt;
return result.substring(0, 255); // Limit total length
}
private validateFilePath(
orgId: string,
projectId: string,
category: string,
filename: string,
): void {
// Validate orgId
if (!orgId || !/^[a-zA-Z0-9_-]+$/.test(orgId) || orgId.length > 50) {
throw new Error( throw new Error(
'Invalid organization slug: must be alphanumeric with dashes/underscores, max 50 chars', 'Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars',
); );
} }
// Validate projectSlug // Validate projectId
if (!projectSlug || !/^[a-zA-Z0-9_-]+$/.test(projectSlug) || projectSlug.length > 50) { if (!projectId || !/^[a-zA-Z0-9_-]+$/.test(projectId) || projectId.length > 50) {
throw new Error( throw new Error(
'Invalid project slug: must be alphanumeric with dashes/underscores, max 50 chars', 'Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars',
); );
} }
// Validate imageId (UUID format) // Validate category
if (!imageId || imageId.length === 0 || imageId.length > 50) { if (!['uploads', 'generated', 'references'].includes(category)) {
throw new Error('Invalid imageId: must be 1-50 characters'); throw new Error('Invalid category: must be uploads, generated, or references');
}
// Validate filename
if (!filename || filename.length === 0 || filename.length > 255) {
throw new Error('Invalid filename: must be 1-255 characters');
} }
// Check for path traversal and dangerous patterns // Check for path traversal and dangerous patterns
if (imageId.includes('..') || imageId.includes('/') || imageId.includes('\\')) { if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw new Error('Invalid characters in imageId: path traversal not allowed'); throw new Error('Invalid characters in filename: path traversal not allowed');
} }
// Prevent null bytes and control characters // Prevent null bytes and control characters
if (/[\x00-\x1f]/.test(imageId)) { if (/[\x00-\x1f]/.test(filename)) {
throw new Error('Invalid imageId: control characters not allowed'); throw new Error('Invalid filename: control characters not allowed');
} }
} }
@ -94,8 +154,8 @@ export class MinioStorageService implements StorageService {
console.log(`Created bucket: ${this.bucketName}`); console.log(`Created bucket: ${this.bucketName}`);
} }
// Bucket should be public for CDN access (configured via mc anonymous set download) // Note: With SNMD and presigned URLs, we don't need bucket policies
console.log(`Bucket ${this.bucketName} ready for CDN access`); console.log(`Bucket ${this.bucketName} ready for presigned URL access`);
} }
async bucketExists(): Promise<boolean> { async bucketExists(): Promise<boolean> {
@ -103,15 +163,15 @@ export class MinioStorageService implements StorageService {
} }
async uploadFile( async uploadFile(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
buffer: Buffer, buffer: Buffer,
contentType: string, contentType: string,
originalFilename?: string,
): Promise<UploadResult> { ): Promise<UploadResult> {
// Validate inputs first // Validate inputs first
this.validatePath(orgSlug, projectSlug, imageId); this.validateFilePath(orgId, projectId, category, filename);
if (!buffer || buffer.length === 0) { if (!buffer || buffer.length === 0) {
throw new Error('Buffer cannot be empty'); throw new Error('Buffer cannot be empty');
@ -124,36 +184,26 @@ export class MinioStorageService implements StorageService {
// Ensure bucket exists // Ensure bucket exists
await this.createBucket(); await this.createBucket();
// Get file path: {orgSlug}/{projectSlug}/img/{imageId} // Generate unique filename to avoid conflicts
const filePath = this.getFilePath(orgSlug, projectSlug, imageId); const uniqueFilename = this.generateUniqueFilename(filename);
const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename);
// Extract file extension from original filename
const fileExtension = originalFilename ? this.extractExtension(originalFilename) : undefined;
// Encode original filename to Base64 to safely store non-ASCII characters in metadata // Encode original filename to Base64 to safely store non-ASCII characters in metadata
const originalNameEncoded = originalFilename const originalNameEncoded = Buffer.from(filename, 'utf-8').toString('base64');
? Buffer.from(originalFilename, 'utf-8').toString('base64')
: undefined;
const metadata: Record<string, string> = { const metadata = {
'Content-Type': contentType, 'Content-Type': contentType,
'X-Amz-Meta-Project': projectSlug, 'X-Amz-Meta-Original-Name': originalNameEncoded,
'X-Amz-Meta-Organization': orgSlug, 'X-Amz-Meta-Original-Name-Encoding': 'base64',
'X-Amz-Meta-Category': category,
'X-Amz-Meta-Project': projectId,
'X-Amz-Meta-Organization': orgId,
'X-Amz-Meta-Upload-Time': new Date().toISOString(), 'X-Amz-Meta-Upload-Time': new Date().toISOString(),
}; };
if (originalNameEncoded) { console.log(`Uploading file to: ${this.bucketName}/${filePath}`);
metadata['X-Amz-Meta-Original-Name'] = originalNameEncoded;
metadata['X-Amz-Meta-Original-Name-Encoding'] = 'base64';
}
if (fileExtension) { const result = await this.client.putObject(
metadata['X-Amz-Meta-File-Extension'] = fileExtension;
}
console.log(`[MinIO] Uploading file to: ${this.bucketName}/${filePath}`);
await this.client.putObject(
this.bucketName, this.bucketName,
filePath, filePath,
buffer, buffer,
@ -161,29 +211,28 @@ export class MinioStorageService implements StorageService {
metadata, metadata,
); );
const url = this.getPublicUrl(orgSlug, projectSlug, imageId); const url = this.getPublicUrl(orgId, projectId, category, uniqueFilename);
console.log(`[MinIO] CDN URL: ${url}`); console.log(`Generated API URL: ${url}`);
return { return {
success: true, success: true,
filename: imageId, filename: uniqueFilename,
path: filePath, path: filePath,
url, url,
size: buffer.length, size: buffer.length,
contentType, contentType,
...(originalFilename && { originalFilename }),
...(fileExtension && { fileExtension }),
}; };
} }
async downloadFile( async downloadFile(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<Buffer> { ): Promise<Buffer> {
this.validatePath(orgSlug, projectSlug, imageId); this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgId, projectId, category, filename);
const stream = await this.client.getObject(this.bucketName, filePath); const stream = await this.client.getObject(this.bucketName, filePath);
@ -196,91 +245,184 @@ export class MinioStorageService implements StorageService {
} }
async streamFile( async streamFile(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<import('stream').Readable> { ): Promise<import('stream').Readable> {
this.validatePath(orgSlug, projectSlug, imageId); this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgId, projectId, category, filename);
// Return the stream directly without buffering - memory efficient! // Return the stream directly without buffering - memory efficient!
return await this.client.getObject(this.bucketName, filePath); return await this.client.getObject(this.bucketName, filePath);
} }
async deleteFile( async deleteFile(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<void> { ): Promise<void> {
this.validatePath(orgSlug, projectSlug, imageId); this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgId, projectId, category, filename);
await this.client.removeObject(this.bucketName, filePath); await this.client.removeObject(this.bucketName, filePath);
} }
/** getPublicUrl(
* Get public CDN URL for file access orgId: string,
* Returns: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId} projectId: string,
*/ category: 'uploads' | 'generated' | 'references',
getPublicUrl(orgSlug: string, projectSlug: string, imageId: string): string { filename: string,
this.validatePath(orgSlug, projectSlug, imageId); ): string {
const filePath = this.getFilePath(orgSlug, projectSlug, imageId); this.validateFilePath(orgId, projectId, category, filename);
return `${this.cdnBaseUrl}/${filePath}`; // Production-ready: Return API URL for presigned URL access
const apiBaseUrl = process.env['API_BASE_URL'] || 'http://localhost:3000';
return `${apiBaseUrl}/api/images/${orgId}/${projectId}/${category}/${filename}`;
} }
async getPresignedUploadUrl( async getPresignedUploadUrl(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
expirySeconds: number, expirySeconds: number,
contentType: string, contentType: string,
): Promise<string> { ): Promise<string> {
this.validatePath(orgSlug, projectSlug, imageId); this.validateFilePath(orgId, projectId, category, filename);
if (!contentType || contentType.trim().length === 0) { if (!contentType || contentType.trim().length === 0) {
throw new Error('Content type is required for presigned upload URL'); throw new Error('Content type is required for presigned upload URL');
} }
const filePath = this.getFilePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgId, projectId, category, filename);
return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds); return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds);
} }
async getPresignedDownloadUrl( async getPresignedDownloadUrl(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
expirySeconds: number = 86400, // 24 hours default expirySeconds: number = 86400, // 24 hours default
): Promise<string> { ): Promise<string> {
this.validatePath(orgSlug, projectSlug, imageId); this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgSlug, projectSlug, imageId); const filePath = this.getFilePath(orgId, projectId, category, filename);
const presignedUrl = await this.client.presignedGetObject( const presignedUrl = await this.client.presignedGetObject(
this.bucketName, this.bucketName,
filePath, filePath,
expirySeconds, expirySeconds,
); );
// Replace internal Docker hostname with CDN URL if configured // Replace internal Docker hostname with public URL if configured
if (this.cdnBaseUrl) { if (this.publicUrl) {
// Access protected properties via type assertion for URL replacement const clientEndpoint = this.client.host + (this.client.port ? `:${this.client.port}` : '');
const client = this.client as unknown as { host: string; port: number; protocol: string }; const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, '');
const clientEndpoint = client.host + (client.port ? `:${client.port}` : '');
return presignedUrl.replace(`${client.protocol}//${clientEndpoint}/${this.bucketName}`, this.cdnBaseUrl); return presignedUrl.replace(`${this.client.protocol}//${clientEndpoint}`, this.publicUrl);
} }
return presignedUrl; return presignedUrl;
} }
/** async listProjectFiles(
* List files in a project's img folder orgId: string,
*/ projectId: string,
category?: 'uploads' | 'generated' | 'references',
): Promise<FileMetadata[]> {
const prefix = category ? `${orgId}/${projectId}/${category}/` : `${orgId}/${projectId}/`;
const files: FileMetadata[] = [];
return new Promise((resolve, reject) => {
const stream = this.client.listObjects(this.bucketName, prefix, true);
stream.on('data', async (obj) => {
try {
if (!obj.name) return;
const metadata = await this.client.statObject(this.bucketName, obj.name);
const pathParts = obj.name.split('/');
const filename = pathParts[pathParts.length - 1];
const categoryFromPath = pathParts[2] as 'uploads' | 'generated' | 'references';
if (!filename || !categoryFromPath) {
return;
}
files.push({
key: `${this.bucketName}/${obj.name}`,
filename,
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
size: obj.size || 0,
url: this.getPublicUrl(orgId, projectId, categoryFromPath, filename),
createdAt: obj.lastModified || new Date(),
});
} catch (error) {}
});
stream.on('end', () => resolve(files));
stream.on('error', reject);
});
}
parseKey(key: string): {
orgId: string;
projectId: string;
category: 'uploads' | 'generated' | 'references';
filename: string;
} | null {
try {
const match = key.match(
/^banatie\/([^/]+)\/([^/]+)\/(uploads|generated|references)\/[^/]+\/(.+)$/,
);
if (!match) {
return null;
}
const [, orgId, projectId, category, filename] = match;
if (!orgId || !projectId || !category || !filename) {
return null;
}
return {
orgId,
projectId,
category: category as 'uploads' | 'generated' | 'references',
filename,
};
} catch {
return null;
}
}
async fileExists(
orgId: string,
projectId: string,
category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<boolean> {
try {
this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgId, projectId, category, filename);
await this.client.statObject(this.bucketName, filePath);
return true;
} catch (error) {
return false;
}
}
async listFiles( async listFiles(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
category: 'uploads' | 'generated' | 'references',
prefix?: string, prefix?: string,
): Promise<FileMetadata[]> { ): Promise<FileMetadata[]> {
this.validatePath(orgSlug, projectSlug, 'dummy'); this.validateFilePath(orgId, projectId, category, 'dummy.txt');
const basePath = `${orgSlug}/${projectSlug}/img/`; const basePath = `${orgId}/${projectId}/${category}/`;
const searchPrefix = prefix ? `${basePath}${prefix}` : basePath; const searchPrefix = prefix ? `${basePath}${prefix}` : basePath;
const files: FileMetadata[] = []; const files: FileMetadata[] = [];
@ -288,22 +430,31 @@ export class MinioStorageService implements StorageService {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const stream = this.client.listObjects(this.bucketName, searchPrefix, true); const stream = this.client.listObjects(this.bucketName, searchPrefix, true);
stream.on('data', async (obj) => { stream.on('data', (obj) => {
if (!obj.name || !obj.size) return; if (!obj.name || !obj.size) return;
try { try {
const pathParts = obj.name.split('/'); const pathParts = obj.name.split('/');
const imageId = pathParts[pathParts.length - 1]; const filename = pathParts[pathParts.length - 1];
if (!imageId) return; if (!filename) return;
// Get metadata to find content type (no extension in filename) // Infer content type from file extension (more efficient than statObject)
const metadata = await this.client.statObject(this.bucketName, obj.name); const ext = filename.toLowerCase().split('.').pop();
const contentType =
{
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
}[ext || ''] || 'application/octet-stream';
files.push({ files.push({
filename: imageId!, filename,
size: obj.size, size: obj.size,
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream', contentType,
lastModified: obj.lastModified || new Date(), lastModified: obj.lastModified || new Date(),
etag: obj.etag || '', etag: obj.etag || '',
path: obj.name, path: obj.name,
@ -323,52 +474,4 @@ export class MinioStorageService implements StorageService {
}); });
}); });
} }
/**
* Parse storage key to extract components
* Format: {orgSlug}/{projectSlug}/img/{imageId}
*/
parseKey(key: string): {
orgSlug: string;
projectSlug: string;
imageId: string;
} | null {
try {
// Match: orgSlug/projectSlug/img/imageId
const match = key.match(/^([^/]+)\/([^/]+)\/img\/([^/]+)$/);
if (!match) {
return null;
}
const [, orgSlug, projectSlug, imageId] = match;
if (!orgSlug || !projectSlug || !imageId) {
return null;
}
return {
orgSlug,
projectSlug,
imageId,
};
} catch {
return null;
}
}
async fileExists(
orgSlug: string,
projectSlug: string,
imageId: string,
): Promise<boolean> {
try {
this.validatePath(orgSlug, projectSlug, imageId);
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
await this.client.statObject(this.bucketName, filePath);
return true;
} catch (error) {
return false;
}
}
} }

View File

@ -11,13 +11,11 @@ export interface FileMetadata {
export interface UploadResult { export interface UploadResult {
success: boolean; success: boolean;
filename: string; // UUID (same as image.id) filename: string;
path: string; path: string;
url: string; // CDN URL for accessing the file url: string; // API URL for accessing the file
size: number; size: number;
contentType: string; contentType: string;
originalFilename?: string; // User's original filename
fileExtension?: string; // Original extension (png, jpg, etc.)
error?: string; error?: string;
} }
@ -34,125 +32,123 @@ export interface StorageService {
/** /**
* Upload a file to storage * Upload a file to storage
* Path format: {orgSlug}/{projectSlug}/img/{imageId} * @param orgId Organization ID
* * @param projectId Project ID
* @param orgSlug Organization slug * @param category File category (uploads, generated, references)
* @param projectSlug Project slug * @param filename Original filename
* @param imageId UUID for the file (same as image.id in DB)
* @param buffer File buffer * @param buffer File buffer
* @param contentType MIME type * @param contentType MIME type
* @param originalFilename Original filename from user (for metadata)
*/ */
uploadFile( uploadFile(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
buffer: Buffer, buffer: Buffer,
contentType: string, contentType: string,
originalFilename?: string,
): Promise<UploadResult>; ): Promise<UploadResult>;
/** /**
* Download a file from storage * Download a file from storage
* @param orgSlug Organization slug * @param orgId Organization ID
* @param projectSlug Project slug * @param projectId Project ID
* @param imageId UUID filename * @param category File category
* @param filename Filename to download
*/ */
downloadFile( downloadFile(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<Buffer>; ): Promise<Buffer>;
/** /**
* Stream a file from storage (memory efficient) * Stream a file from storage (memory efficient)
* @param orgSlug Organization slug * @param orgId Organization ID
* @param projectSlug Project slug * @param projectId Project ID
* @param imageId UUID filename * @param category File category
* @param filename Filename to stream
*/ */
streamFile( streamFile(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<Readable>; ): Promise<Readable>;
/** /**
* Generate a presigned URL for downloading a file * Generate a presigned URL for downloading a file
* @param orgSlug Organization slug * @param orgId Organization ID
* @param projectSlug Project slug * @param projectId Project ID
* @param imageId UUID filename * @param category File category
* @param filename Filename
* @param expirySeconds URL expiry time in seconds * @param expirySeconds URL expiry time in seconds
*/ */
getPresignedDownloadUrl( getPresignedDownloadUrl(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
expirySeconds: number, expirySeconds: number,
): Promise<string>; ): Promise<string>;
/** /**
* Generate a presigned URL for uploading a file * Generate a presigned URL for uploading a file
* @param orgSlug Organization slug * @param orgId Organization ID
* @param projectSlug Project slug * @param projectId Project ID
* @param imageId UUID filename * @param category File category
* @param filename Filename
* @param expirySeconds URL expiry time in seconds * @param expirySeconds URL expiry time in seconds
* @param contentType MIME type * @param contentType MIME type
*/ */
getPresignedUploadUrl( getPresignedUploadUrl(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
expirySeconds: number, expirySeconds: number,
contentType: string, contentType: string,
): Promise<string>; ): Promise<string>;
/** /**
* List files in a project's img folder * List files in a specific path
* @param orgSlug Organization slug * @param orgId Organization ID
* @param projectSlug Project slug * @param projectId Project ID
* @param category File category
* @param prefix Optional prefix to filter files * @param prefix Optional prefix to filter files
*/ */
listFiles( listFiles(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
category: 'uploads' | 'generated' | 'references',
prefix?: string, prefix?: string,
): Promise<FileMetadata[]>; ): Promise<FileMetadata[]>;
/** /**
* Delete a file from storage * Delete a file from storage
* @param orgSlug Organization slug * @param orgId Organization ID
* @param projectSlug Project slug * @param projectId Project ID
* @param imageId UUID filename to delete * @param category File category
* @param filename Filename to delete
*/ */
deleteFile( deleteFile(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<void>; ): Promise<void>;
/** /**
* Check if a file exists * Check if a file exists
* @param orgSlug Organization slug * @param orgId Organization ID
* @param projectSlug Project slug * @param projectId Project ID
* @param imageId UUID filename to check * @param category File category
* @param filename Filename to check
*/ */
fileExists( fileExists(
orgSlug: string, orgId: string,
projectSlug: string, projectId: string,
imageId: string, category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<boolean>; ): Promise<boolean>;
/**
* Get the public CDN URL for a file
* Returns: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId}
*
* @param orgSlug Organization slug
* @param projectSlug Project slug
* @param imageId UUID filename
*/
getPublicUrl(
orgSlug: string,
projectSlug: string,
imageId: string,
): string;
} }

View File

@ -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;
}
}

View File

@ -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),
};
}
}

View File

@ -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));
}
}

View File

@ -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)
)
);
}
}

View File

@ -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: {},
});
}
}

View File

@ -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;
}
}

View File

@ -1,6 +0,0 @@
export * from './AliasService';
export * from './ImageService';
export * from './GenerationService';
export * from './FlowService';
export * from './PromptCacheService';
export * from './LiveScopeService';

View File

@ -57,11 +57,11 @@ export interface GenerateImageRequestWithFiles extends Request {
// Image generation service types // Image generation service types
export interface ImageGenerationOptions { export interface ImageGenerationOptions {
prompt: string; prompt: string;
imageId: string; // UUID used as filename in storage (same as image.id in DB) filename: string;
referenceImages?: ReferenceImage[]; referenceImages?: ReferenceImage[];
aspectRatio?: string; aspectRatio?: string;
orgSlug?: string; orgId?: string;
projectSlug?: string; projectId?: string;
userId?: string; userId?: string;
meta?: { meta?: {
tags?: string[]; tags?: string[];
@ -91,15 +91,13 @@ export interface GeminiParams {
export interface ImageGenerationResult { export interface ImageGenerationResult {
success: boolean; success: boolean;
imageId?: string; // UUID filename (same as image.id in DB) filename?: string;
filepath?: string; filepath?: string;
url?: string; // CDN URL for accessing the image url?: string; // API URL for accessing the image
size?: number; // File size in bytes
description?: string; description?: string;
model: string; model: string;
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
error?: string; error?: string;
errorCode?: string; // Gemini-specific error code (GEMINI_RATE_LIMIT, GEMINI_TIMEOUT, etc.)
errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors
generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails
} }
@ -110,8 +108,6 @@ export interface GeneratedImageData {
mimeType: string; mimeType: string;
fileExtension: string; fileExtension: string;
description?: string; description?: string;
width: number;
height: number;
} }
// Logging types // Logging types

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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(),
});

View File

@ -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');
}
}

View File

@ -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;
};

View File

@ -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];

View File

@ -1,3 +0,0 @@
export * from './aliases';
export * from './limits';
export * from './errors';

View File

@ -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;

View File

@ -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');
};

View File

@ -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);
};

View File

@ -1,4 +0,0 @@
export * from './paginationBuilder';
export * from './hashHelper';
export * from './queryHelper';
export * from './cacheKeyHelper';

View File

@ -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),
};
};

View File

@ -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);
};

View File

@ -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));
};

View File

@ -1,3 +0,0 @@
export * from './aliasValidator';
export * from './paginationValidator';
export * from './queryValidator';

View File

@ -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,
},
};
};

View File

@ -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 };
};

View File

@ -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

View File

@ -32,10 +32,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# waitlist logs
/waitlist-logs/
# vercel # vercel
.vercel .vercel

View File

@ -1,39 +1,90 @@
# Simplified Dockerfile for Next.js Landing Page # Multi-stage Dockerfile for Next.js Landing Page
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm@10.11.0
# Copy workspace configuration
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
# Copy landing package.json
COPY apps/landing/package.json ./apps/landing/
# Copy database package (workspace dependency)
COPY packages/database/package.json ./packages/database/
# Install dependencies
RUN pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Install pnpm # Install pnpm
RUN npm install -g pnpm@10.11.0 RUN npm install -g pnpm@10.11.0
# Copy everything # Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/landing/node_modules ./apps/landing/node_modules
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
# Copy workspace files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/landing ./apps/landing
# Copy database package
COPY packages/database ./packages/database COPY packages/database ./packages/database
# Install and build # Copy landing app
RUN pnpm install --frozen-lockfile COPY apps/landing ./apps/landing
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @banatie/landing build
# Production runner # Set working directory to landing
WORKDIR /app/apps/landing
# Build Next.js application
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Stage 3: Production Runner
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
WORKDIR /app WORKDIR /app
# Install pnpm
RUN npm install -g pnpm@10.11.0
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
# Copy built app # Copy workspace configuration
COPY --from=builder /app/apps/landing/.next/standalone ./ COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/apps/landing/.next/static ./apps/landing/.next/static COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
# Copy database package
COPY --from=builder /app/packages/database ./packages/database
# Copy built Next.js application
COPY --from=builder --chown=nextjs:nodejs /app/apps/landing/.next ./apps/landing/.next
COPY --from=builder /app/apps/landing/package.json ./apps/landing/
COPY --from=builder /app/apps/landing/public ./apps/landing/public COPY --from=builder /app/apps/landing/public ./apps/landing/public
# Copy node_modules for runtime
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/landing/node_modules ./apps/landing/node_modules
COPY --from=builder /app/packages/database/node_modules ./packages/database/node_modules
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME=0.0.0.0
WORKDIR /app/apps/landing WORKDIR /app/apps/landing
CMD ["node", "server.js"]
CMD ["pnpm", "start"]

View File

@ -2,12 +2,8 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
trailingSlash: true,
images: { images: {
formats: ['image/avif', 'image/webp'], unoptimized: true,
},
eslint: {
ignoreDuringBuilds: true,
}, },
}; };

View File

@ -5,23 +5,22 @@
"scripts": { "scripts": {
"dev": "next dev -p 3010", "dev": "next dev -p 3010",
"build": "next build", "build": "next build",
"postbuild": "cp -r .next/static .next/standalone/apps/landing/.next/ && cp -r public .next/standalone/apps/landing/", "start": "next start",
"start": "node .next/standalone/apps/landing/server.js", "deploy": "cp -r out/* /var/www/banatie.app/",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@banatie/database": "workspace:*",
"lucide-react": "^0.400.0",
"next": "15.5.9",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0" "react-dom": "19.1.0",
"next": "15.5.4",
"@banatie/database": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "typescript": "^5",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"tailwindcss": "^4", "@tailwindcss/postcss": "^4",
"typescript": "^5" "tailwindcss": "^4"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 907 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 920 KiB

View File

@ -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"]
}

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>&lt;img&gt;</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> &quot;office&quot; becomes a detailed
modern office with proper lighting
</li>
<li>
<span className="text-gray-500"></span> &quot;headshot&quot; 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&apos;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&apos;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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>&lt;img src="..."&gt;</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 passno 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 intentno 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>
</>
);
}

View File

@ -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">&lt;img src="..."&gt;</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>
</>
);
}

View File

@ -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 />
</>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

View File

@ -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">&quot;Authorization: Bearer $API_KEY&quot;</span>{' '}
<span className="text-gray-300">\</span>
<br />
<span className="text-gray-300 ml-4">-d</span>{' '}
<span className="text-green-400">
&apos;{`{"prompt": "modern office interior, natural light"}`}&apos;
</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">&quot;url&quot;</span>
<span className="text-gray-300">:</span>{' '}
<span className="text-green-400">
&quot;https://cdn.banatie.app/img/a7x2k9.png&quot;
</span>
<span className="text-gray-300">,</span>
<br />
<span className="text-purple-400 ml-4">&quot;enhanced_prompt&quot;</span>
<span className="text-gray-300">:</span>{' '}
<span className="text-green-400">&quot;A photorealistic modern office...&quot;</span>
<span className="text-gray-300">,</span>
<br />
<span className="text-purple-400 ml-4">&quot;generation_time&quot;</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>
);
}

View File

@ -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>
);
}

View File

@ -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&apos;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>
);
}

View File

@ -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&apos;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>
);
}

Some files were not shown because too many files have changed in this diff Show More