chore: prettier

This commit is contained in:
Oleg Proskurin 2025-10-09 23:16:42 +07:00
parent 3462971e30
commit df6596d53c
98 changed files with 1244 additions and 1419 deletions

View File

@ -1,29 +1,34 @@
{
"mcpServers": {
"context7": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp",
"--api-key",
"ctx7sk-48cb1995-935a-4cc5-b9b0-535d600ea5e6"
],
"env": {}
},
"brave-search": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-brave-search"
"@upstash/context7-mcp",
"--api-key",
"ctx7sk-48cb1995-935a-4cc5-b9b0-535d600ea5e6"
],
"env": {}
},
"brave-search": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": {
"BRAVE_API_KEY": "BSAcRGGikEzY4B2j3NZ8Qy5NYh9l4HZ"
}
},
"postgres": {
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
"args": [
"run",
"-i",
"--rm",
"-e",
"DATABASE_URI",
"crystaldba/postgres-mcp",
"--access-mode=unrestricted"
],
"env": {
"DATABASE_URI": "postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db"
}

View File

@ -21,6 +21,7 @@ pnpm-debug.log*
# Data and storage
data/
data/postgres/**
*.sql
*.db
*.sqlite

View File

@ -115,6 +115,7 @@ Shared Drizzle ORM package used by API service and future apps:
- **Configuration**: `drizzle.config.ts` - Drizzle Kit configuration
Key table: `api_keys`
- Stores hashed API keys (SHA-256)
- Two types: `master` (admin, never expires) and `project` (90-day expiration)
- Soft delete via `is_active` flag
@ -127,6 +128,7 @@ Key table: `api_keys`
### Root `.env` (Docker Compose Infrastructure)
Used by Docker Compose services (MinIO, Postgres, API container). Key differences from local:
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@postgres:5432/banatie_db` (Docker network hostname)
- `MINIO_ENDPOINT=minio:9000` (Docker network hostname)
- `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` - MinIO admin credentials
@ -135,6 +137,7 @@ Used by Docker Compose services (MinIO, Postgres, API container). Key difference
### API Service `.env` (Local Development Only)
Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` locally:
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db` (port-forwarded)
- `MINIO_ENDPOINT=localhost:9000` (port-forwarded)
- **NOTE**: This file is excluded from Docker builds (see Dockerfile.mono)
@ -197,16 +200,19 @@ Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` local
## API Endpoints (API Service)
### Public Endpoints (No Authentication)
- `GET /health` - Health check with uptime and status
- `GET /api/info` - API information and limits
- `POST /api/bootstrap/initial-key` - Create first master key (one-time only)
### Admin Endpoints (Master Key Required)
- `POST /api/admin/keys` - Create new API keys (master or project)
- `GET /api/admin/keys` - List all API keys
- `DELETE /api/admin/keys/:keyId` - Revoke an API key
### Protected Endpoints (API Key Required)
- `POST /api/generate` - Generate images from text + optional reference images
- `POST /api/text-to-image` - Generate images from text only (JSON)
- `POST /api/enhance` - Enhance and optimize text prompts
@ -220,9 +226,11 @@ Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` local
1. **Start Services**: `docker compose up -d`
2. **Create Master Key**:
```bash
curl -X POST http://localhost:3000/api/bootstrap/initial-key
```
Save the returned key securely!
3. **Create Project Key** (for testing):

View File

@ -19,24 +19,28 @@ banatie-service/
## Applications
### 🚀 API Service (`apps/api-service`)
- **Port**: 3000
- **Tech**: Express.js, TypeScript, Gemini AI
- **Purpose**: Core REST API for AI image generation
- **Features**: Image generation, file upload, rate limiting, logging
### 🌐 Landing Page (`apps/landing`)
- **Port**: 3001
- **Tech**: Next.js 14, Tailwind CSS
- **Purpose**: Public landing page with demo
- **Features**: Service overview, image generation demo
### 🏢 Studio (`apps/studio`)
- **Port**: 3002
- **Tech**: Next.js 14, Supabase, Stripe
- **Purpose**: SaaS platform for paid users
- **Features**: Authentication, billing, subscriptions, usage tracking
### 🔧 Admin (`apps/admin`)
- **Port**: 3003
- **Tech**: Next.js 14, Dashboard components
- **Purpose**: Service administration and monitoring
@ -45,6 +49,7 @@ banatie-service/
## Quick Start
### Prerequisites
- Node.js >= 18.0.0
- pnpm >= 8.0.0
- Docker & Docker Compose
@ -124,19 +129,20 @@ See individual app README files for specific environment variables.
## Services & Ports
| Service | Port | Description |
|---------|------|-------------|
| API Service | 3000 | Core REST API |
| Landing Page | 3001 | Public website |
| Service | Port | Description |
| --------------- | ---- | ---------------- |
| API Service | 3000 | Core REST API |
| Landing Page | 3001 | Public website |
| Studio Platform | 3002 | SaaS application |
| Admin Dashboard | 3003 | Administration |
| PostgreSQL | 5434 | Database |
| MinIO API | 9000 | Object storage |
| MinIO Console | 9001 | Storage admin |
| Admin Dashboard | 3003 | Administration |
| PostgreSQL | 5434 | Database |
| MinIO API | 9000 | Object storage |
| MinIO Console | 9001 | Storage admin |
## API Usage
### Generate Image
```bash
# Basic text-to-image
curl -X POST http://localhost:3000/api/generate \

View File

@ -1,8 +1,5 @@
{
"extends": [
"next/core-web-vitals",
"prettier"
],
"extends": ["next/core-web-vitals", "prettier"],
"plugins": ["prettier"],
"rules": {
"prettier/prettier": "error"

View File

@ -11,6 +11,6 @@ const nextConfig = {
POSTGRES_URL: process.env.POSTGRES_URL,
MINIO_ENDPOINT: process.env.MINIO_ENDPOINT,
},
}
};
module.exports = nextConfig
module.exports = nextConfig;

View File

@ -1,22 +1,16 @@
import type { Metadata } from 'next'
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Banatie Admin - Service Administration',
description: 'Administration dashboard for managing the Banatie AI image generation service',
}
};
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-100">
<div className="min-h-screen">
{children}
</div>
<div className="min-h-screen">{children}</div>
</body>
</html>
)
);
}

View File

@ -6,9 +6,7 @@ export default function AdminDashboard() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<h1 className="text-2xl font-bold text-gray-900">
Banatie Admin
</h1>
<h1 className="text-2xl font-bold text-gray-900">Banatie Admin</h1>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-500">System Status: Online</span>
@ -21,7 +19,6 @@ export default function AdminDashboard() {
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{/* Stats Cards */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
@ -33,12 +30,8 @@ export default function AdminDashboard() {
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
API Requests
</dt>
<dd className="text-lg font-medium text-gray-900">
1,234
</dd>
<dt className="text-sm font-medium text-gray-500 truncate">API Requests</dt>
<dd className="text-lg font-medium text-gray-900">1,234</dd>
</dl>
</div>
</div>
@ -58,9 +51,7 @@ export default function AdminDashboard() {
<dt className="text-sm font-medium text-gray-500 truncate">
Images Generated
</dt>
<dd className="text-lg font-medium text-gray-900">
567
</dd>
<dd className="text-lg font-medium text-gray-900">567</dd>
</dl>
</div>
</div>
@ -77,12 +68,8 @@ export default function AdminDashboard() {
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Active Users
</dt>
<dd className="text-lg font-medium text-gray-900">
89
</dd>
<dt className="text-sm font-medium text-gray-500 truncate">Active Users</dt>
<dd className="text-lg font-medium text-gray-900">89</dd>
</dl>
</div>
</div>
@ -99,12 +86,8 @@ export default function AdminDashboard() {
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Errors (24h)
</dt>
<dd className="text-lg font-medium text-gray-900">
3
</dd>
<dt className="text-sm font-medium text-gray-500 truncate">Errors (24h)</dt>
<dd className="text-lg font-medium text-gray-900">3</dd>
</dl>
</div>
</div>
@ -116,9 +99,7 @@ export default function AdminDashboard() {
<div className="mt-8">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Service Status
</h3>
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Service Status</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">API Service</span>
@ -159,8 +140,8 @@ export default function AdminDashboard() {
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
This admin dashboard is being developed to provide comprehensive
monitoring and management capabilities for the Banatie service.
This admin dashboard is being developed to provide comprehensive monitoring
and management capabilities for the Banatie service.
</p>
</div>
</div>
@ -170,5 +151,5 @@ export default function AdminDashboard() {
</div>
</main>
</div>
)
);
}

View File

@ -13,9 +13,7 @@ module.exports = [
'@typescript-eslint': require('@typescript-eslint/eslint-plugin'),
prettier: require('eslint-plugin-prettier'),
},
extends: [
require('eslint-config-prettier'),
],
extends: [require('eslint-config-prettier')],
rules: {
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
@ -30,12 +28,6 @@ module.exports = [
},
},
{
ignores: [
'node_modules/**',
'dist/**',
'*.js',
'*.mjs',
'eslint.config.js'
],
ignores: ['node_modules/**', 'dist/**', '*.js', '*.mjs', 'eslint.config.js'],
},
];

View File

@ -1,24 +1,24 @@
import express, { Application } from "express";
import cors from "cors";
import { config } from "dotenv";
import { Config } from "./types/api";
import { generateRouter } from "./routes/generate";
import { enhanceRouter } from "./routes/enhance";
import { textToImageRouter } from "./routes/textToImage";
import { imagesRouter } from "./routes/images";
import bootstrapRoutes from "./routes/bootstrap";
import adminKeysRoutes from "./routes/admin/keys";
import { errorHandler, notFoundHandler } from "./middleware/errorHandler";
import express, { Application } from 'express';
import cors from 'cors';
import { config } from 'dotenv';
import { Config } from './types/api';
import { generateRouter } from './routes/generate';
import { enhanceRouter } from './routes/enhance';
import { textToImageRouter } from './routes/textToImage';
import { imagesRouter } from './routes/images';
import bootstrapRoutes from './routes/bootstrap';
import adminKeysRoutes from './routes/admin/keys';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
// Load environment variables
config();
// Application configuration
export const appConfig: Config = {
port: parseInt(process.env["PORT"] || "3000"),
geminiApiKey: process.env["GEMINI_API_KEY"] || "",
resultsDir: "./results",
uploadsDir: "./uploads/temp",
port: parseInt(process.env['PORT'] || '3000'),
geminiApiKey: process.env['GEMINI_API_KEY'] || '',
resultsDir: './results',
uploadsDir: './uploads/temp',
maxFileSize: 5 * 1024 * 1024, // 5MB
maxFiles: 3,
};
@ -32,30 +32,30 @@ export const createApp = (): Application => {
cors({
origin: true, // Allow all origins
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-API-Key"],
exposedHeaders: ["X-Request-ID"],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
exposedHeaders: ['X-Request-ID'],
}),
);
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Request ID middleware for logging
app.use((req, res, next) => {
req.requestId = Math.random().toString(36).substr(2, 9);
res.setHeader("X-Request-ID", req.requestId);
res.setHeader('X-Request-ID', req.requestId);
next();
});
// Health check endpoint
app.get("/health", (_req, res) => {
app.get('/health', (_req, res) => {
const health = {
status: "healthy",
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env["NODE_ENV"] || "development",
version: process.env["npm_package_version"] || "1.0.0",
environment: process.env['NODE_ENV'] || 'development',
version: process.env['npm_package_version'] || '1.0.0',
};
console.log(`[${health.timestamp}] Health check - ${health.status}`);
@ -63,34 +63,31 @@ export const createApp = (): Application => {
});
// API info endpoint
app.get("/api/info", async (req: any, res) => {
app.get('/api/info', async (req: any, res) => {
const info: any = {
name: "Banatie - Nano Banana Image Generation API",
version: "1.0.0",
name: 'Banatie - Nano Banana Image Generation API',
version: '1.0.0',
description:
"REST API service for AI-powered image generation using Gemini Flash Image model",
'REST API service for AI-powered image generation using Gemini Flash Image model',
endpoints: {
"GET /health": "Health check",
"GET /api/info": "API information",
"POST /api/generate":
"Generate images from text prompt with optional reference images",
"POST /api/text-to-image":
"Generate images from text prompt only (JSON)",
"POST /api/enhance":
"Enhance and optimize prompts for better image generation",
'GET /health': 'Health check',
'GET /api/info': 'API information',
'POST /api/generate': 'Generate images from text prompt with optional reference images',
'POST /api/text-to-image': 'Generate images from text prompt only (JSON)',
'POST /api/enhance': 'Enhance and optimize prompts for better image generation',
},
limits: {
maxFileSize: `${appConfig.maxFileSize / (1024 * 1024)}MB`,
maxFiles: appConfig.maxFiles,
supportedFormats: ["PNG", "JPEG", "JPG", "WebP"],
supportedFormats: ['PNG', 'JPEG', 'JPG', 'WebP'],
},
};
// If API key is provided, validate and return key info
const providedKey = req.headers["x-api-key"] as string;
const providedKey = req.headers['x-api-key'] as string;
if (providedKey) {
try {
const { ApiKeyService } = await import("./services/ApiKeyService");
const { ApiKeyService } = await import('./services/ApiKeyService');
const apiKeyService = new ApiKeyService();
const apiKey = await apiKeyService.validateKey(providedKey);
@ -117,16 +114,16 @@ export const createApp = (): Application => {
// Public routes (no authentication)
// Bootstrap route (no auth, but works only once)
app.use("/api/bootstrap", bootstrapRoutes);
app.use('/api/bootstrap', bootstrapRoutes);
// Admin routes (require master key)
app.use("/api/admin/keys", adminKeysRoutes);
app.use('/api/admin/keys', adminKeysRoutes);
// Protected API routes (require valid API key)
app.use("/api", generateRouter);
app.use("/api", enhanceRouter);
app.use("/api", textToImageRouter);
app.use("/api", imagesRouter);
app.use('/api', generateRouter);
app.use('/api', enhanceRouter);
app.use('/api', textToImageRouter);
app.use('/api', imagesRouter);
// Error handling middleware (must be last)
app.use(notFoundHandler);

View File

@ -1,8 +1,11 @@
import { createDbClient } from '@banatie/database';
const DATABASE_URL = process.env['DATABASE_URL'] ||
const DATABASE_URL =
process.env['DATABASE_URL'] ||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
export const db = createDbClient(DATABASE_URL);
console.log(`[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`);
console.log(
`[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`,
);

View File

@ -37,7 +37,7 @@ class RateLimiter {
return {
allowed: true,
remaining: this.limit - record.count,
resetAt: record.resetAt
resetAt: record.resetAt,
};
}
@ -57,11 +57,7 @@ const rateLimiter = new RateLimiter(100, 60 * 60 * 1000); // 100 requests per ho
* Rate limiting middleware
* Must be used AFTER validateApiKey middleware
*/
export function rateLimitByApiKey(
req: Request,
res: Response,
next: NextFunction
): void {
export function rateLimitByApiKey(req: Request, res: Response, next: NextFunction): void {
if (!req.apiKey) {
next();
return;
@ -77,9 +73,12 @@ export function rateLimitByApiKey(
if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
console.warn(`[${new Date().toISOString()}] Rate limit exceeded: ${req.apiKey.id} (${req.apiKey.keyType}) - reset: ${new Date(result.resetAt).toISOString()}`);
console.warn(
`[${new Date().toISOString()}] Rate limit exceeded: ${req.apiKey.id} (${req.apiKey.keyType}) - reset: ${new Date(result.resetAt).toISOString()}`,
);
res.status(429)
res
.status(429)
.setHeader('Retry-After', retryAfter.toString())
.json({
error: 'Rate limit exceeded',

View File

@ -4,11 +4,7 @@ import { Request, Response, NextFunction } from 'express';
* Middleware to ensure the API key is a master key
* Must be used AFTER validateApiKey middleware
*/
export function requireMasterKey(
req: Request,
res: Response,
next: NextFunction
): void {
export function requireMasterKey(req: Request, res: Response, next: NextFunction): void {
if (!req.apiKey) {
res.status(401).json({
error: 'Authentication required',
@ -18,7 +14,9 @@ export function requireMasterKey(
}
if (req.apiKey.keyType !== 'master') {
console.warn(`[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`);
console.warn(
`[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`,
);
res.status(403).json({
error: 'Master key required',

View File

@ -4,11 +4,7 @@ import { Request, Response, NextFunction } from 'express';
* Middleware to ensure only project keys can access generation endpoints
* Master keys are for admin purposes only
*/
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
if (!req.apiKey) {
res.status(401).json({
@ -22,7 +18,8 @@ export function requireProjectKey(
if (req.apiKey.keyType === 'master') {
res.status(403).json({
error: 'Forbidden',
message: 'Master keys cannot be used for image generation. Please use a project-specific API key.',
message:
'Master keys cannot be used for image generation. Please use a project-specific API key.',
});
return;
}
@ -36,7 +33,9 @@ export function requireProjectKey(
return;
}
console.log(`[${new Date().toISOString()}] Project key validated for generation: ${req.apiKey.id}`);
console.log(
`[${new Date().toISOString()}] Project key validated for generation: ${req.apiKey.id}`,
);
next();
}

View File

@ -18,7 +18,7 @@ const apiKeyService = new ApiKeyService();
export async function validateApiKey(
req: Request,
res: Response,
next: NextFunction
next: NextFunction,
): Promise<void> {
const providedKey = req.headers['x-api-key'] as string;
@ -44,7 +44,9 @@ export async function validateApiKey(
// Attach to request for use in routes
req.apiKey = apiKey;
console.log(`[${new Date().toISOString()}] API key validated: ${apiKey.id} (${apiKey.keyType})`);
console.log(
`[${new Date().toISOString()}] API key validated: ${apiKey.id} (${apiKey.keyType})`,
);
next();
} catch (error) {

View File

@ -1,17 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { GenerateImageResponse } from "../types/api";
import { Request, Response, NextFunction } from 'express';
import { GenerateImageResponse } from '../types/api';
/**
* Global error handler for the Express application
*/
export const errorHandler = (
error: Error,
req: Request,
res: Response,
next: NextFunction,
) => {
export const errorHandler = (error: Error, req: Request, res: Response, next: NextFunction) => {
const timestamp = new Date().toISOString();
const requestId = req.requestId || "unknown";
const requestId = req.requestId || 'unknown';
// Log the error
console.error(`[${timestamp}] [${requestId}] ERROR:`, {
@ -30,52 +25,40 @@ export const errorHandler = (
// Determine error type and status code
let statusCode = 500;
let errorMessage = "Internal server error";
let errorType = "INTERNAL_ERROR";
let errorMessage = 'Internal server error';
let errorType = 'INTERNAL_ERROR';
if (error.name === "ValidationError") {
if (error.name === 'ValidationError') {
statusCode = 400;
errorMessage = error.message;
errorType = "VALIDATION_ERROR";
} else if (
error.message.includes("API key") ||
error.message.includes("authentication")
) {
errorType = 'VALIDATION_ERROR';
} else if (error.message.includes('API key') || error.message.includes('authentication')) {
statusCode = 401;
errorMessage = "Authentication failed";
errorType = "AUTH_ERROR";
} else if (
error.message.includes("not found") ||
error.message.includes("404")
) {
errorMessage = 'Authentication failed';
errorType = 'AUTH_ERROR';
} else if (error.message.includes('not found') || error.message.includes('404')) {
statusCode = 404;
errorMessage = "Resource not found";
errorType = "NOT_FOUND";
} else if (
error.message.includes("timeout") ||
error.message.includes("503")
) {
errorMessage = 'Resource not found';
errorType = 'NOT_FOUND';
} else if (error.message.includes('timeout') || error.message.includes('503')) {
statusCode = 503;
errorMessage = "Service temporarily unavailable";
errorType = "SERVICE_UNAVAILABLE";
} else if (
error.message.includes("overloaded") ||
error.message.includes("rate limit")
) {
errorMessage = 'Service temporarily unavailable';
errorType = 'SERVICE_UNAVAILABLE';
} else if (error.message.includes('overloaded') || error.message.includes('rate limit')) {
statusCode = 429;
errorMessage = "Service overloaded, please try again later";
errorType = "RATE_LIMITED";
errorMessage = 'Service overloaded, please try again later';
errorType = 'RATE_LIMITED';
}
// Create error response
const errorResponse: GenerateImageResponse = {
success: false,
message: "Request failed",
message: 'Request failed',
error: errorMessage,
};
// Add additional debug info in development
if (process.env["NODE_ENV"] === "development") {
if (process.env['NODE_ENV'] === 'development') {
(errorResponse as any).debug = {
originalError: error.message,
errorType,
@ -96,15 +79,13 @@ export const errorHandler = (
*/
export const notFoundHandler = (req: Request, res: Response) => {
const timestamp = new Date().toISOString();
const requestId = req.requestId || "unknown";
const requestId = req.requestId || 'unknown';
console.log(
`[${timestamp}] [${requestId}] 404 - Route not found: ${req.method} ${req.path}`,
);
console.log(`[${timestamp}] [${requestId}] 404 - Route not found: ${req.method} ${req.path}`);
const notFoundResponse: GenerateImageResponse = {
success: false,
message: "Route not found",
message: 'Route not found',
error: `The requested endpoint ${req.method} ${req.path} does not exist`,
};
@ -114,7 +95,6 @@ export const notFoundHandler = (req: Request, res: Response) => {
/**
* Async error wrapper to catch errors in async route handlers
*/
export const asyncHandler =
(fn: Function) => (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
export const asyncHandler = (fn: Function) => (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};

View File

@ -1,5 +1,5 @@
import { Response, NextFunction } from "express";
import { sanitizeFilename } from "./validation";
import { Response, NextFunction } from 'express';
import { sanitizeFilename } from './validation';
// Validation rules (same as existing validation but for JSON)
const VALIDATION_RULES = {
@ -18,16 +18,16 @@ const VALIDATION_RULES = {
// Valid aspect ratios supported by Gemini SDK
const VALID_ASPECT_RATIOS = [
"1:1", // Square (1024x1024)
"2:3", // Portrait (832x1248)
"3:2", // Landscape (1248x832)
"3:4", // Portrait (864x1184)
"4:3", // Landscape (1184x864)
"4:5", // Portrait (896x1152)
"5:4", // Landscape (1152x896)
"9:16", // Vertical (768x1344)
"16:9", // Widescreen (1344x768)
"21:9", // Ultrawide (1536x672)
'1:1', // Square (1024x1024)
'2:3', // Portrait (832x1248)
'3:2', // Landscape (1248x832)
'3:4', // Portrait (864x1184)
'4:3', // Landscape (1184x864)
'4:5', // Portrait (896x1152)
'5:4', // Landscape (1152x896)
'9:16', // Vertical (768x1344)
'16:9', // Widescreen (1344x768)
'21:9', // Ultrawide (1536x672)
] as const;
/**
@ -42,16 +42,14 @@ export const validateTextToImageRequest = (
const { prompt, filename, aspectRatio, autoEnhance, enhancementOptions } = req.body;
const errors: string[] = [];
console.log(
`[${timestamp}] [${req.requestId}] Validating text-to-image JSON request`,
);
console.log(`[${timestamp}] [${req.requestId}] Validating text-to-image JSON request`);
// Validate that request body exists
if (!req.body || typeof req.body !== "object") {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({
success: false,
error: "Request body must be valid JSON",
message: "Invalid request format",
error: 'Request body must be valid JSON',
message: 'Invalid request format',
});
}
@ -63,67 +61,54 @@ export const validateTextToImageRequest = (
// Default template to "photorealistic" in enhancementOptions
if (req.body.enhancementOptions && !req.body.enhancementOptions.template) {
req.body.enhancementOptions.template = "photorealistic";
req.body.enhancementOptions.template = 'photorealistic';
} else if (!req.body.enhancementOptions && req.body.autoEnhance !== false) {
// If autoEnhance is true (default) and no enhancementOptions, create it with default template
req.body.enhancementOptions = { template: "photorealistic" };
req.body.enhancementOptions = { template: 'photorealistic' };
}
// Validate prompt
if (!prompt) {
errors.push("Prompt is required");
} else if (typeof prompt !== "string") {
errors.push("Prompt must be a string");
errors.push('Prompt is required');
} else if (typeof prompt !== 'string') {
errors.push('Prompt must be a string');
} else if (prompt.trim().length < VALIDATION_RULES.prompt.minLength) {
errors.push(
`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`,
);
errors.push(`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`);
} else if (prompt.length > VALIDATION_RULES.prompt.maxLength) {
errors.push(
`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`,
);
errors.push(`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`);
}
// Validate filename
if (!filename) {
errors.push("Filename is required");
} else if (typeof filename !== "string") {
errors.push("Filename must be a string");
errors.push('Filename is required');
} else if (typeof filename !== 'string') {
errors.push('Filename must be a string');
} else if (filename.trim().length < VALIDATION_RULES.filename.minLength) {
errors.push("Filename cannot be empty");
errors.push('Filename cannot be empty');
} else if (filename.length > VALIDATION_RULES.filename.maxLength) {
errors.push(
`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`,
);
errors.push(`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`);
} else if (!VALIDATION_RULES.filename.pattern.test(filename)) {
errors.push(
"Filename can only contain letters, numbers, underscores, and hyphens",
);
errors.push('Filename can only contain letters, numbers, underscores, and hyphens');
}
// Validate aspectRatio (optional, defaults to "1:1")
if (aspectRatio !== undefined) {
if (typeof aspectRatio !== "string") {
errors.push("aspectRatio must be a string");
if (typeof aspectRatio !== 'string') {
errors.push('aspectRatio must be a string');
} else if (!VALID_ASPECT_RATIOS.includes(aspectRatio as any)) {
errors.push(
`Invalid aspectRatio. Must be one of: ${VALID_ASPECT_RATIOS.join(", ")}`
);
errors.push(`Invalid aspectRatio. Must be one of: ${VALID_ASPECT_RATIOS.join(', ')}`);
}
}
// Validate autoEnhance (optional boolean)
if (autoEnhance !== undefined && typeof autoEnhance !== "boolean") {
errors.push("autoEnhance must be a boolean");
if (autoEnhance !== undefined && typeof autoEnhance !== 'boolean') {
errors.push('autoEnhance must be a boolean');
}
// Validate enhancementOptions (optional object)
if (enhancementOptions !== undefined) {
if (
typeof enhancementOptions !== "object" ||
Array.isArray(enhancementOptions)
) {
errors.push("enhancementOptions must be an object");
if (typeof enhancementOptions !== 'object' || Array.isArray(enhancementOptions)) {
errors.push('enhancementOptions must be an object');
} else {
const { template } = enhancementOptions;
@ -131,17 +116,17 @@ export const validateTextToImageRequest = (
if (
template !== undefined &&
![
"photorealistic",
"illustration",
"minimalist",
"sticker",
"product",
"comic",
"general",
'photorealistic',
'illustration',
'minimalist',
'sticker',
'product',
'comic',
'general',
].includes(template)
) {
errors.push(
"Invalid template in enhancementOptions. Must be one of: photorealistic, illustration, minimalist, sticker, product, comic, general",
'Invalid template in enhancementOptions. Must be one of: photorealistic, illustration, minimalist, sticker, product, comic, general',
);
}
}
@ -149,19 +134,16 @@ export const validateTextToImageRequest = (
// Validate meta (optional object)
if (req.body.meta !== undefined) {
if (
typeof req.body.meta !== "object" ||
Array.isArray(req.body.meta)
) {
errors.push("meta must be an object");
if (typeof req.body.meta !== 'object' || Array.isArray(req.body.meta)) {
errors.push('meta must be an object');
} else if (req.body.meta.tags !== undefined) {
if (!Array.isArray(req.body.meta.tags)) {
errors.push("meta.tags must be an array");
errors.push('meta.tags must be an array');
} else {
// Validate each tag is a string
for (const tag of req.body.meta.tags) {
if (typeof tag !== "string") {
errors.push("Each tag in meta.tags must be a string");
if (typeof tag !== 'string') {
errors.push('Each tag in meta.tags must be a string');
break;
}
}
@ -170,28 +152,19 @@ export const validateTextToImageRequest = (
}
// Check for XSS attempts in prompt
const xssPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/<iframe/i,
/<object/i,
/<embed/i,
];
const xssPatterns = [/<script/i, /javascript:/i, /on\w+\s*=/i, /<iframe/i, /<object/i, /<embed/i];
if (prompt && xssPatterns.some((pattern) => pattern.test(prompt))) {
errors.push("Invalid characters detected in prompt");
errors.push('Invalid characters detected in prompt');
}
// Log validation results
if (errors.length > 0) {
console.log(
`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(", ")}`,
);
console.log(`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(', ')}`);
return res.status(400).json({
success: false,
error: "Validation failed",
message: errors.join(", "),
error: 'Validation failed',
message: errors.join(', '),
});
}
@ -217,40 +190,23 @@ export const validateTextToImageRequest = (
/**
* Log text-to-image request details for debugging
*/
export const logTextToImageRequest = (
req: any,
_res: Response,
next: NextFunction,
): void => {
export const logTextToImageRequest = (req: any, _res: Response, next: NextFunction): void => {
const timestamp = new Date().toISOString();
const { prompt, filename, autoEnhance, enhancementOptions } = req.body;
console.log(
`[${timestamp}] [${req.requestId}] === TEXT-TO-IMAGE REQUEST ===`,
);
console.log(`[${timestamp}] [${req.requestId}] === TEXT-TO-IMAGE REQUEST ===`);
console.log(`[${timestamp}] [${req.requestId}] Method: ${req.method}`);
console.log(`[${timestamp}] [${req.requestId}] Path: ${req.path}`);
console.log(`[${timestamp}] [${req.requestId}] Content-Type: ${req.get('Content-Type')}`);
console.log(
`[${timestamp}] [${req.requestId}] Content-Type: ${req.get("Content-Type")}`,
);
console.log(
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? "..." : ""}"`,
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? '...' : ''}"`,
);
console.log(`[${timestamp}] [${req.requestId}] Filename: "${filename}"`);
console.log(
`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`,
);
console.log(`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`);
if (enhancementOptions) {
console.log(
`[${timestamp}] [${req.requestId}] Enhancement options:`,
enhancementOptions,
);
console.log(`[${timestamp}] [${req.requestId}] Enhancement options:`, enhancementOptions);
}
console.log(
`[${timestamp}] [${req.requestId}] Reference files: 0 (text-only endpoint)`,
);
console.log(
`[${timestamp}] [${req.requestId}] =====================================`,
);
console.log(`[${timestamp}] [${req.requestId}] Reference files: 0 (text-only endpoint)`);
console.log(`[${timestamp}] [${req.requestId}] =====================================`);
next();
};

View File

@ -1,6 +1,6 @@
import { Request, Response } from "express";
import { PromptEnhancementService } from "../services/promptEnhancement";
import { EnhancedGenerateImageRequest } from "../types/api";
import { Request, Response } from 'express';
import { PromptEnhancementService } from '../services/promptEnhancement';
import { EnhancedGenerateImageRequest } from '../types/api';
let promptEnhancementService: PromptEnhancementService | null = null;
@ -28,19 +28,15 @@ export const autoEnhancePrompt = async (
const shouldEnhance = autoEnhance !== false;
if (!shouldEnhance) {
console.log(
`[${timestamp}] [${requestId}] Auto-enhancement explicitly disabled, skipping`,
);
console.log(`[${timestamp}] [${requestId}] Auto-enhancement explicitly disabled, skipping`);
return next();
}
console.log(
`[${timestamp}] [${requestId}] Auto-enhancement enabled, processing prompt`,
);
console.log(`[${timestamp}] [${requestId}] Auto-enhancement enabled, processing prompt`);
try {
if (!promptEnhancementService) {
const apiKey = process.env["GEMINI_API_KEY"];
const apiKey = process.env['GEMINI_API_KEY'];
if (!apiKey) {
console.error(
`[${timestamp}] [${requestId}] Cannot initialize prompt enhancement: Missing API key`,
@ -51,8 +47,8 @@ export const autoEnhancePrompt = async (
}
// Extract orgId and projectId from validated API key
const orgId = req.apiKey?.organizationSlug || "unknown";
const projectId = req.apiKey?.projectSlug || "unknown";
const orgId = req.apiKey?.organizationSlug || 'unknown';
const projectId = req.apiKey?.projectSlug || 'unknown';
const result = await promptEnhancementService.enhancePrompt(
prompt,
@ -69,9 +65,7 @@ export const autoEnhancePrompt = async (
if (result.success && result.enhancedPrompt) {
console.log(`[${timestamp}] [${requestId}] Prompt enhanced successfully`);
console.log(
`[${timestamp}] [${requestId}] Original: "${prompt.substring(0, 50)}..."`,
);
console.log(`[${timestamp}] [${requestId}] Original: "${prompt.substring(0, 50)}..."`);
console.log(
`[${timestamp}] [${requestId}] Enhanced: "${result.enhancedPrompt.substring(0, 50)}..."`,
);
@ -90,21 +84,12 @@ export const autoEnhancePrompt = async (
req.body.prompt = result.enhancedPrompt;
} else {
console.warn(
`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`,
);
console.log(
`[${timestamp}] [${requestId}] Proceeding with original prompt`,
);
console.warn(`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`);
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);
}
} catch (error) {
console.error(
`[${timestamp}] [${requestId}] Error during auto-enhancement:`,
error,
);
console.log(
`[${timestamp}] [${requestId}] Proceeding with original prompt`,
);
console.error(`[${timestamp}] [${requestId}] Error during auto-enhancement:`, error);
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);
}
next();

View File

@ -1,16 +1,12 @@
import multer from "multer";
import { Request, RequestHandler } from "express";
import multer from 'multer';
import { Request, RequestHandler } from 'express';
// Configure multer for memory storage (we'll process files in memory)
const storage = multer.memoryStorage();
// File filter for image types only
const fileFilter = (
_req: Request,
file: Express.Multer.File,
cb: multer.FileFilterCallback,
) => {
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
const fileFilter = (_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
console.log(
@ -21,11 +17,7 @@ const fileFilter = (
console.log(
`[${new Date().toISOString()}] Rejected file: ${file.originalname} (${file.mimetype})`,
);
cb(
new Error(
`Unsupported file type: ${file.mimetype}. Allowed: PNG, JPEG, WebP`,
),
);
cb(new Error(`Unsupported file type: ${file.mimetype}. Allowed: PNG, JPEG, WebP`));
}
};
@ -44,59 +36,50 @@ export const upload = multer({
});
// Middleware for handling reference images
export const uploadReferenceImages: RequestHandler = upload.array(
"referenceImages",
MAX_FILES,
);
export const uploadReferenceImages: RequestHandler = upload.array('referenceImages', MAX_FILES);
// Error handler for multer errors
export const handleUploadErrors = (
error: any,
_req: Request,
res: any,
next: any,
) => {
export const handleUploadErrors = (error: any, _req: Request, res: any, next: any) => {
if (error instanceof multer.MulterError) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] Multer error:`, error.message);
switch (error.code) {
case "LIMIT_FILE_SIZE":
case 'LIMIT_FILE_SIZE':
return res.status(400).json({
success: false,
error: `File too large. Maximum size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`,
message: "File upload failed",
message: 'File upload failed',
});
case "LIMIT_FILE_COUNT":
case 'LIMIT_FILE_COUNT':
return res.status(400).json({
success: false,
error: `Too many files. Maximum: ${MAX_FILES} files`,
message: "File upload failed",
message: 'File upload failed',
});
case "LIMIT_UNEXPECTED_FILE":
case 'LIMIT_UNEXPECTED_FILE':
return res.status(400).json({
success: false,
error:
'Unexpected file field. Use "referenceImages" for image uploads',
message: "File upload failed",
error: 'Unexpected file field. Use "referenceImages" for image uploads',
message: 'File upload failed',
});
default:
return res.status(400).json({
success: false,
error: error.message,
message: "File upload failed",
message: 'File upload failed',
});
}
}
if (error.message.includes("Unsupported file type")) {
if (error.message.includes('Unsupported file type')) {
return res.status(400).json({
success: false,
error: error.message,
message: "File validation failed",
message: 'File validation failed',
});
}

View File

@ -1,4 +1,4 @@
import { Response, NextFunction } from "express";
import { Response, NextFunction } from 'express';
// Validation rules
const VALIDATION_RULES = {
@ -20,9 +20,9 @@ const VALIDATION_RULES = {
*/
export const sanitizeFilename = (filename: string): string => {
return filename
.replace(/[^a-zA-Z0-9_-]/g, "_") // Replace invalid chars with underscore
.replace(/_{2,}/g, "_") // Replace multiple underscores with single
.replace(/^_+|_+$/g, "") // Remove leading/trailing underscores
.replace(/[^a-zA-Z0-9_-]/g, '_') // Replace invalid chars with underscore
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
.replace(/^_+|_+$/g, '') // Remove leading/trailing underscores
.substring(0, 100); // Limit length
};
@ -39,17 +39,17 @@ export const validateGenerateRequest = (
const errors: string[] = [];
// Convert string values from multipart form data
if (typeof autoEnhance === "string") {
autoEnhance = autoEnhance.toLowerCase() === "true";
if (typeof autoEnhance === 'string') {
autoEnhance = autoEnhance.toLowerCase() === 'true';
req.body.autoEnhance = autoEnhance;
}
if (typeof enhancementOptions === "string") {
if (typeof enhancementOptions === 'string') {
try {
enhancementOptions = JSON.parse(enhancementOptions);
req.body.enhancementOptions = enhancementOptions;
} catch {
errors.push("enhancementOptions must be valid JSON");
errors.push('enhancementOptions must be valid JSON');
}
}
@ -57,111 +57,79 @@ export const validateGenerateRequest = (
// Validate prompt
if (!prompt) {
errors.push("Prompt is required");
} else if (typeof prompt !== "string") {
errors.push("Prompt must be a string");
errors.push('Prompt is required');
} else if (typeof prompt !== 'string') {
errors.push('Prompt must be a string');
} else if (prompt.trim().length < VALIDATION_RULES.prompt.minLength) {
errors.push(
`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`,
);
errors.push(`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`);
} else if (prompt.length > VALIDATION_RULES.prompt.maxLength) {
errors.push(
`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`,
);
errors.push(`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`);
}
// Validate filename
if (!filename) {
errors.push("Filename is required");
} else if (typeof filename !== "string") {
errors.push("Filename must be a string");
errors.push('Filename is required');
} else if (typeof filename !== 'string') {
errors.push('Filename must be a string');
} else if (filename.trim().length < VALIDATION_RULES.filename.minLength) {
errors.push("Filename cannot be empty");
errors.push('Filename cannot be empty');
} else if (filename.length > VALIDATION_RULES.filename.maxLength) {
errors.push(
`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`,
);
errors.push(`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`);
} else if (!VALIDATION_RULES.filename.pattern.test(filename)) {
errors.push(
"Filename can only contain letters, numbers, underscores, and hyphens",
);
errors.push('Filename can only contain letters, numbers, underscores, and hyphens');
}
// Validate autoEnhance (optional boolean)
if (autoEnhance !== undefined && typeof autoEnhance !== "boolean") {
errors.push("autoEnhance must be a boolean");
if (autoEnhance !== undefined && typeof autoEnhance !== 'boolean') {
errors.push('autoEnhance must be a boolean');
}
// Validate enhancementOptions (optional object)
if (enhancementOptions !== undefined) {
if (
typeof enhancementOptions !== "object" ||
Array.isArray(enhancementOptions)
) {
errors.push("enhancementOptions must be an object");
if (typeof enhancementOptions !== 'object' || Array.isArray(enhancementOptions)) {
errors.push('enhancementOptions must be an object');
} else {
const {
imageStyle,
aspectRatio,
mood,
lighting,
cameraAngle,
negativePrompts,
} = enhancementOptions;
const { imageStyle, aspectRatio, mood, lighting, cameraAngle, negativePrompts } =
enhancementOptions;
if (
imageStyle !== undefined &&
![
"photorealistic",
"illustration",
"minimalist",
"sticker",
"product",
"comic",
].includes(imageStyle)
!['photorealistic', 'illustration', 'minimalist', 'sticker', 'product', 'comic'].includes(
imageStyle,
)
) {
errors.push("Invalid imageStyle in enhancementOptions");
errors.push('Invalid imageStyle in enhancementOptions');
}
if (
aspectRatio !== undefined &&
!["square", "portrait", "landscape", "wide", "ultrawide"].includes(
aspectRatio,
)
!['square', 'portrait', 'landscape', 'wide', 'ultrawide'].includes(aspectRatio)
) {
errors.push("Invalid aspectRatio in enhancementOptions");
errors.push('Invalid aspectRatio in enhancementOptions');
}
if (
mood !== undefined &&
(typeof mood !== "string" || mood.length > 100)
) {
errors.push("mood must be a string with max 100 characters");
if (mood !== undefined && (typeof mood !== 'string' || mood.length > 100)) {
errors.push('mood must be a string with max 100 characters');
}
if (
lighting !== undefined &&
(typeof lighting !== "string" || lighting.length > 100)
) {
errors.push("lighting must be a string with max 100 characters");
if (lighting !== undefined && (typeof lighting !== 'string' || lighting.length > 100)) {
errors.push('lighting must be a string with max 100 characters');
}
if (
cameraAngle !== undefined &&
(typeof cameraAngle !== "string" || cameraAngle.length > 100)
(typeof cameraAngle !== 'string' || cameraAngle.length > 100)
) {
errors.push("cameraAngle must be a string with max 100 characters");
errors.push('cameraAngle must be a string with max 100 characters');
}
if (negativePrompts !== undefined) {
if (!Array.isArray(negativePrompts) || negativePrompts.length > 10) {
errors.push("negativePrompts must be an array with max 10 items");
errors.push('negativePrompts must be an array with max 10 items');
} else {
for (const item of negativePrompts) {
if (typeof item !== "string" || item.length > 100) {
errors.push(
"Each negative prompt must be a string with max 100 characters",
);
if (typeof item !== 'string' || item.length > 100) {
errors.push('Each negative prompt must be a string with max 100 characters');
break;
}
}
@ -171,28 +139,19 @@ export const validateGenerateRequest = (
}
// Check for XSS attempts in prompt
const xssPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/<iframe/i,
/<object/i,
/<embed/i,
];
const xssPatterns = [/<script/i, /javascript:/i, /on\w+\s*=/i, /<iframe/i, /<object/i, /<embed/i];
if (prompt && xssPatterns.some((pattern) => pattern.test(prompt))) {
errors.push("Invalid characters detected in prompt");
errors.push('Invalid characters detected in prompt');
}
// Log validation results
if (errors.length > 0) {
console.log(
`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(", ")}`,
);
console.log(`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(', ')}`);
return res.status(400).json({
success: false,
error: "Validation failed",
message: errors.join(", "),
error: 'Validation failed',
message: errors.join(', '),
});
}
@ -218,11 +177,7 @@ export const validateGenerateRequest = (
/**
* Log request details for debugging
*/
export const logRequestDetails = (
req: any,
_res: Response,
next: NextFunction,
): void => {
export const logRequestDetails = (req: any, _res: Response, next: NextFunction): void => {
const timestamp = new Date().toISOString();
const { prompt, filename, autoEnhance, enhancementOptions } = req.body;
const files = (req.files as Express.Multer.File[]) || [];
@ -231,21 +186,14 @@ export const logRequestDetails = (
console.log(`[${timestamp}] [${req.requestId}] Method: ${req.method}`);
console.log(`[${timestamp}] [${req.requestId}] Path: ${req.path}`);
console.log(
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? "..." : ""}"`,
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? '...' : ''}"`,
);
console.log(`[${timestamp}] [${req.requestId}] Filename: "${filename}"`);
console.log(
`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`,
);
console.log(`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`);
if (enhancementOptions) {
console.log(
`[${timestamp}] [${req.requestId}] Enhancement options:`,
enhancementOptions,
);
console.log(`[${timestamp}] [${req.requestId}] Enhancement options:`, enhancementOptions);
}
console.log(
`[${timestamp}] [${req.requestId}] Reference files: ${files.length}`,
);
console.log(`[${timestamp}] [${req.requestId}] Reference files: ${files.length}`);
if (files.length > 0) {
files.forEach((file, index) => {

View File

@ -25,7 +25,7 @@ router.post('/', async (req, res) => {
organizationName,
projectName,
name,
expiresInDays
expiresInDays,
} = req.body;
// Validation
@ -73,11 +73,13 @@ router.post('/', async (req, res) => {
finalOrgId,
name,
req.apiKey!.id,
expiresInDays || 90
expiresInDays || 90,
);
}
console.log(`[${new Date().toISOString()}] New API key created by admin: ${result.metadata.id} (${result.metadata.keyType}) - by: ${req.apiKey!.id}`);
console.log(
`[${new Date().toISOString()}] New API key created by admin: ${result.metadata.id} (${result.metadata.keyType}) - by: ${req.apiKey!.id}`,
);
res.status(201).json({
apiKey: result.key,
@ -111,7 +113,7 @@ router.get('/', async (req, res) => {
const keys = await apiKeyService.listKeys();
// Don't expose key hashes
const safeKeys = keys.map(key => ({
const safeKeys = keys.map((key) => ({
id: key.id,
type: key.keyType,
projectId: key.projectId,

View File

@ -1,5 +1,5 @@
import express from "express";
import { ApiKeyService } from "../services/ApiKeyService";
import express from 'express';
import { ApiKeyService } from '../services/ApiKeyService';
const router = express.Router();
const apiKeyService = new ApiKeyService();
@ -10,25 +10,21 @@ const apiKeyService = new ApiKeyService();
*
* POST /api/bootstrap/initial-key
*/
router.post("/initial-key", async (req, res) => {
router.post('/initial-key', async (req, res) => {
try {
// Check if any keys already exist
const hasKeys = await apiKeyService.hasAnyKeys();
if (hasKeys) {
console.warn(
`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`,
);
console.warn(`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`);
return res.status(403).json({
error: "Bootstrap not allowed",
message:
"API keys already exist. Use /api/admin/keys to create new keys.",
error: 'Bootstrap not allowed',
message: 'API keys already exist. Use /api/admin/keys to create new keys.',
});
}
// Create first master key
const { key, metadata } =
await apiKeyService.createMasterKey("Initial Master Key");
const { key, metadata } = await apiKeyService.createMasterKey('Initial Master Key');
console.log(
`[${new Date().toISOString()}] Initial master key created via bootstrap: ${metadata.id}`,
@ -39,13 +35,13 @@ router.post("/initial-key", async (req, res) => {
type: metadata.keyType,
name: metadata.name,
expiresAt: metadata.expiresAt,
message: "IMPORTANT: Save this key securely. You will not see it again!",
message: 'IMPORTANT: Save this key securely. You will not see it again!',
});
} catch (error) {
console.error(`[${new Date().toISOString()}] Bootstrap error:`, error);
res.status(500).json({
error: "Bootstrap failed",
message: "Failed to create initial API key",
error: 'Bootstrap failed',
message: 'Failed to create initial API key',
});
}
});

View File

@ -1,76 +1,66 @@
import { Request, Response, Router } from "express";
import type { Router as RouterType } from "express";
import { PromptEnhancementService } from "../services/promptEnhancement";
import { asyncHandler } from "../middleware/errorHandler";
import {
PromptEnhancementRequest,
PromptEnhancementResponse,
} from "../types/api";
import { body, validationResult } from "express-validator";
import { validateApiKey } from "../middleware/auth/validateApiKey";
import { rateLimitByApiKey } from "../middleware/auth/rateLimiter";
import { Request, Response, Router } from 'express';
import type { Router as RouterType } from 'express';
import { PromptEnhancementService } from '../services/promptEnhancement';
import { asyncHandler } from '../middleware/errorHandler';
import { PromptEnhancementRequest, PromptEnhancementResponse } from '../types/api';
import { body, validationResult } from 'express-validator';
import { validateApiKey } from '../middleware/auth/validateApiKey';
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
export const enhanceRouter: RouterType = Router();
let promptEnhancementService: PromptEnhancementService;
const validateEnhanceRequest = [
body("prompt")
body('prompt')
.notEmpty()
.withMessage("Prompt is required")
.withMessage('Prompt is required')
.isLength({ min: 1, max: 5000 })
.withMessage("Prompt must be between 1 and 5000 characters")
.withMessage('Prompt must be between 1 and 5000 characters')
.trim(),
body("options.imageStyle")
body('options.imageStyle')
.optional()
.isIn([
"photorealistic",
"illustration",
"minimalist",
"sticker",
"product",
"comic",
])
.withMessage("Invalid image style"),
.isIn(['photorealistic', 'illustration', 'minimalist', 'sticker', 'product', 'comic'])
.withMessage('Invalid image style'),
body("options.aspectRatio")
body('options.aspectRatio')
.optional()
.isIn(["square", "portrait", "landscape", "wide", "ultrawide"])
.withMessage("Invalid aspect ratio"),
.isIn(['square', 'portrait', 'landscape', 'wide', 'ultrawide'])
.withMessage('Invalid aspect ratio'),
body("options.mood")
body('options.mood')
.optional()
.isLength({ max: 100 })
.withMessage("Mood description too long")
.withMessage('Mood description too long')
.trim(),
body("options.lighting")
body('options.lighting')
.optional()
.isLength({ max: 100 })
.withMessage("Lighting description too long")
.withMessage('Lighting description too long')
.trim(),
body("options.cameraAngle")
body('options.cameraAngle')
.optional()
.isLength({ max: 100 })
.withMessage("Camera angle description too long")
.withMessage('Camera angle description too long')
.trim(),
body("options.outputFormat")
body('options.outputFormat')
.optional()
.isIn(["text", "markdown", "detailed"])
.withMessage("Invalid output format"),
.isIn(['text', 'markdown', 'detailed'])
.withMessage('Invalid output format'),
body("options.negativePrompts")
body('options.negativePrompts')
.optional()
.isArray({ max: 10 })
.withMessage("Too many negative prompts (max 10)"),
.withMessage('Too many negative prompts (max 10)'),
body("options.negativePrompts.*")
body('options.negativePrompts.*')
.optional()
.isLength({ max: 100 })
.withMessage("Negative prompt too long")
.withMessage('Negative prompt too long')
.trim(),
];
@ -80,16 +70,14 @@ const logEnhanceRequest = (req: Request, _res: Response, next: Function) => {
const { prompt, options } = req.body as PromptEnhancementRequest;
console.log(`[${timestamp}] [${requestId}] POST /api/enhance`);
console.log(
`[${timestamp}] [${requestId}] Prompt length: ${prompt?.length || 0} characters`,
);
console.log(`[${timestamp}] [${requestId}] Options:`, options || "none");
console.log(`[${timestamp}] [${requestId}] Prompt length: ${prompt?.length || 0} characters`);
console.log(`[${timestamp}] [${requestId}] Options:`, options || 'none');
next();
};
enhanceRouter.post(
"/enhance",
'/enhance',
// Authentication middleware
validateApiKey,
rateLimitByApiKey,
@ -99,12 +87,12 @@ enhanceRouter.post(
asyncHandler(async (req: Request, res: Response) => {
if (!promptEnhancementService) {
const apiKey = process.env["GEMINI_API_KEY"];
const apiKey = process.env['GEMINI_API_KEY'];
if (!apiKey) {
return res.status(500).json({
success: false,
originalPrompt: "",
error: "Server configuration error: GEMINI_API_KEY not configured",
originalPrompt: '',
error: 'Server configuration error: GEMINI_API_KEY not configured',
} as PromptEnhancementResponse);
}
promptEnhancementService = new PromptEnhancementService(apiKey);
@ -118,11 +106,11 @@ enhanceRouter.post(
);
return res.status(400).json({
success: false,
originalPrompt: req.body.prompt || "",
originalPrompt: req.body.prompt || '',
error: `Validation failed: ${errors
.array()
.map((e) => e.msg)
.join(", ")}`,
.join(', ')}`,
} as PromptEnhancementResponse);
}
@ -134,17 +122,13 @@ enhanceRouter.post(
try {
// Extract orgId and projectId from validated API key
const orgId = req.apiKey?.organizationSlug || "unknown";
const projectId = req.apiKey?.projectSlug || "unknown";
const orgId = req.apiKey?.organizationSlug || 'unknown';
const projectId = req.apiKey?.projectSlug || 'unknown';
const result = await promptEnhancementService.enhancePrompt(
prompt,
options || {},
{
orgId,
projectId,
},
);
const result = await promptEnhancementService.enhancePrompt(prompt, options || {}, {
orgId,
projectId,
});
console.log(`[${timestamp}] [${requestId}] Enhancement completed:`, {
success: result.success,
@ -174,25 +158,19 @@ enhanceRouter.post(
const errorResponse: PromptEnhancementResponse = {
success: false,
originalPrompt: result.originalPrompt,
error: result.error || "Unknown error occurred",
error: result.error || 'Unknown error occurred',
};
console.log(
`[${timestamp}] [${requestId}] Sending error response: ${result.error}`,
);
console.log(`[${timestamp}] [${requestId}] Sending error response: ${result.error}`);
return res.status(500).json(errorResponse);
}
} catch (error) {
console.error(
`[${timestamp}] [${requestId}] Unhandled error in enhance endpoint:`,
error,
);
console.error(`[${timestamp}] [${requestId}] Unhandled error in enhance endpoint:`, error);
const errorResponse: PromptEnhancementResponse = {
success: false,
originalPrompt: prompt,
error:
error instanceof Error ? error.message : "Unknown error occurred",
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
return res.status(500).json(errorResponse);

View File

@ -1,23 +1,14 @@
import { Response, Router } from "express";
import type { Router as RouterType } from "express";
import { ImageGenService } from "../services/ImageGenService";
import {
uploadReferenceImages,
handleUploadErrors,
} from "../middleware/upload";
import {
validateGenerateRequest,
logRequestDetails,
} from "../middleware/validation";
import {
autoEnhancePrompt,
logEnhancementResult,
} from "../middleware/promptEnhancement";
import { asyncHandler } from "../middleware/errorHandler";
import { validateApiKey } from "../middleware/auth/validateApiKey";
import { requireProjectKey } from "../middleware/auth/requireProjectKey";
import { rateLimitByApiKey } from "../middleware/auth/rateLimiter";
import { GenerateImageResponse } from "../types/api";
import { Response, Router } from 'express';
import type { Router as RouterType } from 'express';
import { ImageGenService } from '../services/ImageGenService';
import { uploadReferenceImages, handleUploadErrors } from '../middleware/upload';
import { validateGenerateRequest, logRequestDetails } from '../middleware/validation';
import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement';
import { asyncHandler } from '../middleware/errorHandler';
import { validateApiKey } from '../middleware/auth/validateApiKey';
import { requireProjectKey } from '../middleware/auth/requireProjectKey';
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
import { GenerateImageResponse } from '../types/api';
// Create router
export const generateRouter: RouterType = Router();
@ -28,7 +19,7 @@ let imageGenService: ImageGenService;
* POST /api/generate - Generate image from text prompt with optional reference images
*/
generateRouter.post(
"/generate",
'/generate',
// Authentication middleware
validateApiKey,
requireProjectKey,
@ -50,12 +41,12 @@ generateRouter.post(
asyncHandler(async (req: any, res: Response) => {
// Initialize service if not already done
if (!imageGenService) {
const apiKey = process.env["GEMINI_API_KEY"];
const apiKey = process.env['GEMINI_API_KEY'];
if (!apiKey) {
return res.status(500).json({
success: false,
message: "Server configuration error",
error: "GEMINI_API_KEY not configured",
message: 'Server configuration error',
error: 'GEMINI_API_KEY not configured',
} as GenerateImageResponse);
}
imageGenService = new ImageGenService(apiKey);
@ -84,26 +75,20 @@ generateRouter.post(
);
return res.status(400).json({
success: false,
message: "Reference image validation failed",
message: 'Reference image validation failed',
error: validation.error,
} as GenerateImageResponse);
}
console.log(
`[${timestamp}] [${requestId}] Reference images validation passed`,
);
console.log(`[${timestamp}] [${requestId}] Reference images validation passed`);
}
// Convert files to reference images
const referenceImages =
files.length > 0
? ImageGenService.convertFilesToReferenceImages(files)
: undefined;
files.length > 0 ? ImageGenService.convertFilesToReferenceImages(files) : undefined;
// Generate the image
console.log(
`[${timestamp}] [${requestId}] Calling ImageGenService.generateImage()`,
);
console.log(`[${timestamp}] [${requestId}] Calling ImageGenService.generateImage()`);
const result = await imageGenService.generateImage({
prompt,
@ -127,7 +112,7 @@ generateRouter.post(
if (result.success) {
const successResponse: GenerateImageResponse = {
success: true,
message: "Image generated successfully",
message: 'Image generated successfully',
data: {
filename: result.filename!,
filepath: result.filepath!,
@ -151,26 +136,20 @@ generateRouter.post(
} else {
const errorResponse: GenerateImageResponse = {
success: false,
message: "Image generation failed",
error: result.error || "Unknown error occurred",
message: 'Image generation failed',
error: result.error || 'Unknown error occurred',
};
console.log(
`[${timestamp}] [${requestId}] Sending error response: ${result.error}`,
);
console.log(`[${timestamp}] [${requestId}] Sending error response: ${result.error}`);
return res.status(500).json(errorResponse);
}
} catch (error) {
console.error(
`[${timestamp}] [${requestId}] Unhandled error in generate endpoint:`,
error,
);
console.error(`[${timestamp}] [${requestId}] Unhandled error in generate endpoint:`, error);
const errorResponse: GenerateImageResponse = {
success: false,
message: "Image generation failed",
error:
error instanceof Error ? error.message : "Unknown error occurred",
message: 'Image generation failed',
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
return res.status(500).json(errorResponse);

View File

@ -1,6 +1,6 @@
import { Router, Request, Response } from "express";
import { StorageFactory } from "../services/StorageFactory";
import { asyncHandler } from "../middleware/errorHandler";
import { Router, Request, Response } from 'express';
import { StorageFactory } from '../services/StorageFactory';
import { asyncHandler } from '../middleware/errorHandler';
export const imagesRouter = Router();
@ -9,15 +9,15 @@ export const imagesRouter = Router();
* Serves images via presigned URLs (redirect approach)
*/
imagesRouter.get(
"/images/:orgId/:projectId/:category/:filename",
'/images/:orgId/:projectId/:category/:filename',
asyncHandler(async (req: Request, res: Response) => {
const { orgId, projectId, category, filename } = req.params;
// Validate category
if (!["uploads", "generated", "references"].includes(category)) {
if (!['uploads', 'generated', 'references'].includes(category)) {
return res.status(400).json({
success: false,
message: "Invalid category",
message: 'Invalid category',
});
}
@ -28,36 +28,36 @@ imagesRouter.get(
const exists = await storageService.fileExists(
orgId,
projectId,
category as "uploads" | "generated" | "references",
category as 'uploads' | 'generated' | 'references',
filename,
);
if (!exists) {
return res.status(404).json({
success: false,
message: "File not found",
message: 'File not found',
});
}
// Determine content type from filename
const ext = filename.toLowerCase().split(".").pop();
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";
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
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "public, max-age=86400, immutable"); // 24 hours + immutable
res.setHeader("ETag", `"${orgId}-${projectId}-${filename}"`); // Simple ETag
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); // 24 hours + immutable
res.setHeader('ETag', `"${orgId}-${projectId}-${filename}"`); // Simple ETag
// Handle conditional requests (304 Not Modified)
const ifNoneMatch = req.headers["if-none-match"];
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) {
return res.status(304).end(); // Not Modified
}
@ -66,17 +66,17 @@ imagesRouter.get(
const fileStream = await storageService.streamFile(
orgId,
projectId,
category as "uploads" | "generated" | "references",
category as 'uploads' | 'generated' | 'references',
filename,
);
// Handle stream errors
fileStream.on("error", (streamError) => {
console.error("Stream error:", streamError);
fileStream.on('error', (streamError) => {
console.error('Stream error:', streamError);
if (!res.headersSent) {
res.status(500).json({
success: false,
message: "Error streaming file",
message: 'Error streaming file',
});
}
});
@ -84,10 +84,10 @@ imagesRouter.get(
// Stream the file without loading into memory
fileStream.pipe(res);
} catch (error) {
console.error("Failed to stream file:", error);
console.error('Failed to stream file:', error);
return res.status(404).json({
success: false,
message: "File not found",
message: 'File not found',
});
}
}),
@ -98,15 +98,15 @@ imagesRouter.get(
* Returns a presigned URL instead of redirecting
*/
imagesRouter.get(
"/images/url/:orgId/:projectId/:category/:filename",
'/images/url/:orgId/:projectId/:category/:filename',
asyncHandler(async (req: Request, res: Response) => {
const { orgId, projectId, category, filename } = req.params;
const { expiry = "3600" } = req.query; // Default 1 hour
const { expiry = '3600' } = req.query; // Default 1 hour
if (!["uploads", "generated", "references"].includes(category)) {
if (!['uploads', 'generated', 'references'].includes(category)) {
return res.status(400).json({
success: false,
message: "Invalid category",
message: 'Invalid category',
});
}
@ -116,7 +116,7 @@ imagesRouter.get(
const presignedUrl = await storageService.getPresignedDownloadUrl(
orgId,
projectId,
category as "uploads" | "generated" | "references",
category as 'uploads' | 'generated' | 'references',
filename,
parseInt(expiry as string, 10),
);
@ -127,10 +127,10 @@ imagesRouter.get(
expiresIn: parseInt(expiry as string, 10),
});
} catch (error) {
console.error("Failed to generate presigned URL:", error);
console.error('Failed to generate presigned URL:', error);
return res.status(404).json({
success: false,
message: "File not found or access denied",
message: 'File not found or access denied',
});
}
}),

View File

@ -1,19 +1,13 @@
import { Response, Router } from "express";
import type { Router as RouterType } from "express";
import { ImageGenService } from "../services/ImageGenService";
import {
validateTextToImageRequest,
logTextToImageRequest,
} from "../middleware/jsonValidation";
import {
autoEnhancePrompt,
logEnhancementResult,
} from "../middleware/promptEnhancement";
import { asyncHandler } from "../middleware/errorHandler";
import { validateApiKey } from "../middleware/auth/validateApiKey";
import { requireProjectKey } from "../middleware/auth/requireProjectKey";
import { rateLimitByApiKey } from "../middleware/auth/rateLimiter";
import { GenerateImageResponse } from "../types/api";
import { Response, Router } from 'express';
import type { Router as RouterType } from 'express';
import { ImageGenService } from '../services/ImageGenService';
import { validateTextToImageRequest, logTextToImageRequest } from '../middleware/jsonValidation';
import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement';
import { asyncHandler } from '../middleware/errorHandler';
import { validateApiKey } from '../middleware/auth/validateApiKey';
import { requireProjectKey } from '../middleware/auth/requireProjectKey';
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
import { GenerateImageResponse } from '../types/api';
export const textToImageRouter: RouterType = Router();
@ -23,7 +17,7 @@ let imageGenService: ImageGenService;
* POST /api/text-to-image - Generate image from text prompt only (JSON)
*/
textToImageRouter.post(
"/text-to-image",
'/text-to-image',
// Authentication middleware
validateApiKey,
requireProjectKey,
@ -41,12 +35,12 @@ textToImageRouter.post(
asyncHandler(async (req: any, res: Response) => {
// Initialize service if not already done
if (!imageGenService) {
const apiKey = process.env["GEMINI_API_KEY"];
const apiKey = process.env['GEMINI_API_KEY'];
if (!apiKey) {
return res.status(500).json({
success: false,
message: "Server configuration error",
error: "GEMINI_API_KEY not configured",
message: 'Server configuration error',
error: 'GEMINI_API_KEY not configured',
} as GenerateImageResponse);
}
imageGenService = new ImageGenService(apiKey);
@ -80,21 +74,18 @@ textToImageRouter.post(
});
// Log the result
console.log(
`[${timestamp}] [${requestId}] Text-to-image generation completed:`,
{
success: result.success,
model: result.model,
filename: result.filename,
hasError: !!result.error,
},
);
console.log(`[${timestamp}] [${requestId}] Text-to-image generation completed:`, {
success: result.success,
model: result.model,
filename: result.filename,
hasError: !!result.error,
});
// Send response
if (result.success) {
const successResponse: GenerateImageResponse = {
success: true,
message: "Image generated successfully",
message: 'Image generated successfully',
data: {
filename: result.filename!,
filepath: result.filepath!,
@ -120,13 +111,11 @@ textToImageRouter.post(
} else {
const errorResponse: GenerateImageResponse = {
success: false,
message: "Image generation failed",
error: result.error || "Unknown error occurred",
message: 'Image generation failed',
error: result.error || 'Unknown error occurred',
};
console.log(
`[${timestamp}] [${requestId}] Sending error response: ${result.error}`,
);
console.log(`[${timestamp}] [${requestId}] Sending error response: ${result.error}`);
return res.status(500).json(errorResponse);
}
} catch (error) {
@ -137,9 +126,8 @@ textToImageRouter.post(
const errorResponse: GenerateImageResponse = {
success: false,
message: "Image generation failed",
error:
error instanceof Error ? error.message : "Unknown error occurred",
message: 'Image generation failed',
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
return res.status(500).json(errorResponse);

View File

@ -3,13 +3,9 @@ import fs from 'fs';
// Ensure required directories exist
const ensureDirectoriesExist = () => {
const directories = [
appConfig.resultsDir,
appConfig.uploadsDir,
'./logs'
];
const directories = [appConfig.resultsDir, appConfig.uploadsDir, './logs'];
directories.forEach(dir => {
directories.forEach((dir) => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`[${new Date().toISOString()}] Created directory: ${dir}`);
@ -32,7 +28,10 @@ const validateEnvironment = () => {
}
log('INFO', 'Environment validation passed');
log('INFO', `Server configuration: port=${appConfig.port}, maxFileSize=${appConfig.maxFileSize}bytes, maxFiles=${appConfig.maxFiles}`);
log(
'INFO',
`Server configuration: port=${appConfig.port}, maxFileSize=${appConfig.maxFileSize}bytes, maxFiles=${appConfig.maxFiles}`,
);
};
// Graceful shutdown
@ -76,7 +75,6 @@ const startServer = async () => {
// Setup graceful shutdown
setupGracefulShutdown(server);
} catch (error) {
log('ERROR', `Failed to start server: ${error instanceof Error ? error.message : error}`);
process.exit(1);

View File

@ -1,7 +1,7 @@
import crypto from "crypto";
import { db } from "../db";
import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from "@banatie/database";
import { eq, and, desc } from "drizzle-orm";
import crypto from 'crypto';
import { db } from '../db';
import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from '@banatie/database';
import { eq, and, desc } from 'drizzle-orm';
// Extended API key type with slugs for storage paths
export interface ApiKeyWithSlugs extends ApiKey {
@ -19,12 +19,12 @@ export class ApiKeyService {
keyHash: string;
keyPrefix: string;
} {
const secret = crypto.randomBytes(32).toString("hex"); // 64 chars
const keyPrefix = "bnt_";
const secret = crypto.randomBytes(32).toString('hex'); // 64 chars
const keyPrefix = 'bnt_';
const fullKey = keyPrefix + secret;
// Hash for storage (SHA-256)
const keyHash = crypto.createHash("sha256").update(fullKey).digest("hex");
const keyHash = crypto.createHash('sha256').update(fullKey).digest('hex');
return { fullKey, keyHash, keyPrefix };
}
@ -43,10 +43,10 @@ export class ApiKeyService {
.values({
keyHash,
keyPrefix,
keyType: "master",
keyType: 'master',
projectId: null,
scopes: ["*"], // Full access
name: name || "Master Key",
scopes: ['*'], // Full access
name: name || 'Master Key',
expiresAt: null, // Never expires
createdBy: createdBy || null,
})
@ -79,10 +79,10 @@ export class ApiKeyService {
.values({
keyHash,
keyPrefix,
keyType: "project",
keyType: 'project',
projectId,
organizationId: organizationId || null,
scopes: ["generate", "read"],
scopes: ['generate', 'read'],
name: name || `Project Key - ${projectId}`,
expiresAt,
createdBy: createdBy || null,
@ -102,15 +102,12 @@ export class ApiKeyService {
* Returns API key with organization and project slugs for storage paths
*/
async validateKey(providedKey: string): Promise<ApiKeyWithSlugs | null> {
if (!providedKey || !providedKey.startsWith("bnt_")) {
if (!providedKey || !providedKey.startsWith('bnt_')) {
return null;
}
// Hash the provided key
const keyHash = crypto
.createHash("sha256")
.update(providedKey)
.digest("hex");
const keyHash = crypto.createHash('sha256').update(providedKey).digest('hex');
// Find in database with left joins to get slugs
const [result] = await db
@ -160,10 +157,7 @@ export class ApiKeyService {
.where(eq(apiKeys.id, result.id))
.execute()
.catch((err) =>
console.error(
`[${new Date().toISOString()}] Failed to update lastUsedAt:`,
err,
),
console.error(`[${new Date().toISOString()}] Failed to update lastUsedAt:`, err),
);
return result as ApiKeyWithSlugs;
@ -207,11 +201,7 @@ export class ApiKeyService {
* Get or create organization by slug
* If organization doesn't exist, create it with provided name (or use slug as name)
*/
async getOrCreateOrganization(
slug: string,
name?: string,
email?: string,
): Promise<string> {
async getOrCreateOrganization(slug: string, name?: string, email?: string): Promise<string> {
// Try to find existing organization
const [existing] = await db
.select({ id: organizations.id })
@ -233,9 +223,7 @@ export class ApiKeyService {
})
.returning({ id: organizations.id });
console.log(
`[${new Date().toISOString()}] Organization created: ${newOrg?.id} - ${slug}`,
);
console.log(`[${new Date().toISOString()}] Organization created: ${newOrg?.id} - ${slug}`);
return newOrg!.id;
}
@ -244,21 +232,12 @@ export class ApiKeyService {
* Get or create project by slug within an organization
* If project doesn't exist, create it with provided name (or use slug as name)
*/
async getOrCreateProject(
organizationId: string,
slug: string,
name?: string,
): Promise<string> {
async getOrCreateProject(organizationId: string, slug: string, name?: string): Promise<string> {
// Try to find existing project
const [existing] = await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.organizationId, organizationId),
eq(projects.slug, slug),
),
)
.where(and(eq(projects.organizationId, organizationId), eq(projects.slug, slug)))
.limit(1);
if (existing) {

View File

@ -1,23 +1,23 @@
import { GoogleGenAI } from "@google/genai";
import { GoogleGenAI } from '@google/genai';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mime = require("mime") as any;
const mime = require('mime') as any;
import {
ImageGenerationOptions,
ImageGenerationResult,
ReferenceImage,
GeneratedImageData,
GeminiParams,
} from "../types/api";
import { StorageFactory } from "./StorageFactory";
import { TTILogger, TTILogEntry } from "./TTILogger";
} from '../types/api';
import { StorageFactory } from './StorageFactory';
import { TTILogger, TTILogEntry } from './TTILogger';
export class ImageGenService {
private ai: GoogleGenAI;
private primaryModel = "gemini-2.5-flash-image";
private primaryModel = 'gemini-2.5-flash-image';
constructor(apiKey: string) {
if (!apiKey) {
throw new Error("Gemini API key is required");
throw new Error('Gemini API key is required');
}
this.ai = new GoogleGenAI({ apiKey });
}
@ -26,16 +26,13 @@ export class ImageGenService {
* Generate an image from text prompt with optional reference images
* 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, filename, referenceImages, aspectRatio, orgId, projectId, meta } = options;
// Use default values if not provided
const finalOrgId = orgId || process.env["DEFAULT_ORG_ID"] || "default";
const finalProjectId =
projectId || process.env["DEFAULT_PROJECT_ID"] || "main";
const finalAspectRatio = aspectRatio || "1:1"; // Default to square
const finalOrgId = orgId || process.env['DEFAULT_ORG_ID'] || 'default';
const finalProjectId = projectId || process.env['DEFAULT_PROJECT_ID'] || 'main';
const finalAspectRatio = aspectRatio || '1:1'; // Default to square
// Step 1: Generate image from Gemini AI
let generatedData: GeneratedImageData;
@ -56,9 +53,8 @@ export class ImageGenService {
return {
success: false,
model: this.primaryModel,
error:
error instanceof Error ? error.message : "Image generation failed",
errorType: "generation",
error: error instanceof Error ? error.message : 'Image generation failed',
errorType: 'generation',
};
}
@ -69,7 +65,7 @@ export class ImageGenService {
const uploadResult = await storageService.uploadFile(
finalOrgId,
finalProjectId,
"generated",
'generated',
finalFilename,
generatedData.buffer,
generatedData.mimeType,
@ -93,8 +89,8 @@ export class ImageGenService {
success: false,
model: this.primaryModel,
geminiParams,
error: `Image generated successfully but storage failed: ${uploadResult.error || "Unknown storage error"}`,
errorType: "storage",
error: `Image generated successfully but storage failed: ${uploadResult.error || 'Unknown storage error'}`,
errorType: 'storage',
generatedImageData: generatedData,
...(generatedData.description && {
description: generatedData.description,
@ -107,8 +103,8 @@ export class ImageGenService {
success: false,
model: this.primaryModel,
geminiParams,
error: `Image generated successfully but storage failed: ${error instanceof Error ? error.message : "Unknown storage error"}`,
errorType: "storage",
error: `Image generated successfully but storage failed: ${error instanceof Error ? error.message : 'Unknown storage error'}`,
errorType: 'storage',
generatedImageData: generatedData,
...(generatedData.description && {
description: generatedData.description,
@ -140,7 +136,7 @@ export class ImageGenService {
contentParts.push({
inlineData: {
mimeType: refImage.mimetype,
data: refImage.buffer.toString("base64"),
data: refImage.buffer.toString('base64'),
},
});
}
@ -155,13 +151,13 @@ export class ImageGenService {
// These exact objects will be passed to both SDK and logger
const contents = [
{
role: "user" as const,
role: 'user' as const,
parts: contentParts,
},
];
const config = {
responseModalities: ["IMAGE", "TEXT"],
responseModalities: ['IMAGE', 'TEXT'],
imageConfig: {
aspectRatio,
},
@ -172,7 +168,7 @@ export class ImageGenService {
model: this.primaryModel,
config,
contentsStructure: {
role: "user",
role: 'user',
partsCount: contentParts.length,
hasReferenceImages: !!(referenceImages && referenceImages.length > 0),
},
@ -209,12 +205,8 @@ export class ImageGenService {
});
// Parse response
if (
!response.candidates ||
!response.candidates[0] ||
!response.candidates[0].content
) {
throw new Error("No response received from Gemini AI");
if (!response.candidates || !response.candidates[0] || !response.candidates[0].content) {
throw new Error('No response received from Gemini AI');
}
const content = response.candidates[0].content;
@ -224,8 +216,8 @@ export class ImageGenService {
// Extract image data and description from response
for (const part of content.parts || []) {
if (part.inlineData) {
const buffer = Buffer.from(part.inlineData.data || "", "base64");
const mimeType = part.inlineData.mimeType || "image/png";
const buffer = Buffer.from(part.inlineData.data || '', 'base64');
const mimeType = part.inlineData.mimeType || 'image/png';
imageData = { buffer, mimeType };
} else if (part.text) {
generatedDescription = part.text;
@ -233,10 +225,10 @@ export class ImageGenService {
}
if (!imageData) {
throw new Error("No image data received from Gemini AI");
throw new Error('No image data received from Gemini AI');
}
const fileExtension = mime.getExtension(imageData.mimeType) || "png";
const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
const generatedData: GeneratedImageData = {
buffer: imageData.buffer,
@ -254,7 +246,7 @@ export class ImageGenService {
if (error instanceof Error) {
throw new Error(`Gemini AI generation failed: ${error.message}`);
}
throw new Error("Gemini AI generation failed: Unknown error");
throw new Error('Gemini AI generation failed: Unknown error');
}
}
@ -263,10 +255,10 @@ export class ImageGenService {
error?: string;
} {
if (files.length > 3) {
return { valid: false, error: "Maximum 3 reference images allowed" };
return { valid: false, error: 'Maximum 3 reference images allowed' };
}
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
const maxSize = 5 * 1024 * 1024; // 5MB
for (const file of files) {
@ -288,9 +280,7 @@ export class ImageGenService {
return { valid: true };
}
static convertFilesToReferenceImages(
files: Express.Multer.File[],
): ReferenceImage[] {
static convertFilesToReferenceImages(files: Express.Multer.File[]): ReferenceImage[] {
return files.map((file) => ({
buffer: file.buffer,
mimetype: file.mimetype,

View File

@ -1,5 +1,5 @@
import { Client as MinioClient } from "minio";
import { StorageService, FileMetadata, UploadResult } from "./StorageService";
import { Client as MinioClient } from 'minio';
import { StorageService, FileMetadata, UploadResult } from './StorageService';
export class MinioStorageService implements StorageService {
private client: MinioClient;
@ -11,12 +11,12 @@ export class MinioStorageService implements StorageService {
accessKey: string,
secretKey: string,
useSSL: boolean = false,
bucketName: string = "banatie",
bucketName: string = 'banatie',
publicUrl?: string,
) {
// Parse endpoint to separate hostname and port
const cleanEndpoint = endpoint.replace(/^https?:\/\//, "");
const [hostname, portStr] = cleanEndpoint.split(":");
const cleanEndpoint = endpoint.replace(/^https?:\/\//, '');
const [hostname, portStr] = cleanEndpoint.split(':');
const port = portStr ? parseInt(portStr, 10) : useSSL ? 443 : 9000;
if (!hostname) {
@ -31,13 +31,13 @@ export class MinioStorageService implements StorageService {
secretKey,
});
this.bucketName = bucketName;
this.publicUrl = publicUrl || `${useSSL ? "https" : "http"}://${endpoint}`;
this.publicUrl = publicUrl || `${useSSL ? 'https' : 'http'}://${endpoint}`;
}
private getFilePath(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
): string {
// Simplified path without date folder for now
@ -50,11 +50,9 @@ export class MinioStorageService implements StorageService {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
const ext = sanitized.includes(".")
? sanitized.substring(sanitized.lastIndexOf("."))
: "";
const name = sanitized.includes(".")
? sanitized.substring(0, sanitized.lastIndexOf("."))
const ext = sanitized.includes('.') ? sanitized.substring(sanitized.lastIndexOf('.')) : '';
const name = sanitized.includes('.')
? sanitized.substring(0, sanitized.lastIndexOf('.'))
: sanitized;
return `${name}-${timestamp}-${random}${ext}`;
@ -63,9 +61,9 @@ export class MinioStorageService implements StorageService {
private sanitizeFilename(filename: string): string {
// Remove dangerous characters and path traversal attempts
return filename
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "") // Remove dangerous chars
.replace(/\.\./g, "") // Remove path traversal
.replace(/^\.+/, "") // Remove leading dots
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars
.replace(/\.\./g, '') // Remove path traversal
.replace(/^\.+/, '') // Remove leading dots
.trim()
.substring(0, 255); // Limit length
}
@ -79,54 +77,42 @@ export class MinioStorageService implements StorageService {
// Validate orgId
if (!orgId || !/^[a-zA-Z0-9_-]+$/.test(orgId) || orgId.length > 50) {
throw new Error(
"Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars",
'Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars',
);
}
// Validate projectId
if (
!projectId ||
!/^[a-zA-Z0-9_-]+$/.test(projectId) ||
projectId.length > 50
) {
if (!projectId || !/^[a-zA-Z0-9_-]+$/.test(projectId) || projectId.length > 50) {
throw new Error(
"Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars",
'Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars',
);
}
// Validate category
if (!["uploads", "generated", "references"].includes(category)) {
throw new Error(
"Invalid category: must be uploads, generated, or references",
);
if (!['uploads', 'generated', 'references'].includes(category)) {
throw new Error('Invalid category: must be uploads, generated, or references');
}
// Validate filename
if (!filename || filename.length === 0 || filename.length > 255) {
throw new Error("Invalid filename: must be 1-255 characters");
throw new Error('Invalid filename: must be 1-255 characters');
}
// Check for path traversal and dangerous patterns
if (
filename.includes("..") ||
filename.includes("/") ||
filename.includes("\\")
) {
throw new Error(
"Invalid characters in filename: path traversal not allowed",
);
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw new Error('Invalid characters in filename: path traversal not allowed');
}
// Prevent null bytes and control characters
if (/[\x00-\x1f]/.test(filename)) {
throw new Error("Invalid filename: control characters not allowed");
throw new Error('Invalid filename: control characters not allowed');
}
}
async createBucket(): Promise<void> {
const exists = await this.client.bucketExists(this.bucketName);
if (!exists) {
await this.client.makeBucket(this.bucketName, "us-east-1");
await this.client.makeBucket(this.bucketName, 'us-east-1');
console.log(`Created bucket: ${this.bucketName}`);
}
@ -141,7 +127,7 @@ export class MinioStorageService implements StorageService {
async uploadFile(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
buffer: Buffer,
contentType: string,
@ -150,11 +136,11 @@ export class MinioStorageService implements StorageService {
this.validateFilePath(orgId, projectId, category, filename);
if (!buffer || buffer.length === 0) {
throw new Error("Buffer cannot be empty");
throw new Error('Buffer cannot be empty');
}
if (!contentType || contentType.trim().length === 0) {
throw new Error("Content type is required");
throw new Error('Content type is required');
}
// Ensure bucket exists
@ -162,20 +148,15 @@ export class MinioStorageService implements StorageService {
// Generate unique filename to avoid conflicts
const uniqueFilename = this.generateUniqueFilename(filename);
const filePath = this.getFilePath(
orgId,
projectId,
category,
uniqueFilename,
);
const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename);
const metadata = {
"Content-Type": contentType,
"X-Amz-Meta-Original-Name": filename,
"X-Amz-Meta-Category": category,
"X-Amz-Meta-Project": projectId,
"X-Amz-Meta-Organization": orgId,
"X-Amz-Meta-Upload-Time": new Date().toISOString(),
'Content-Type': contentType,
'X-Amz-Meta-Original-Name': filename,
'X-Amz-Meta-Category': category,
'X-Amz-Meta-Project': projectId,
'X-Amz-Meta-Organization': orgId,
'X-Amz-Meta-Upload-Time': new Date().toISOString(),
};
console.log(`Uploading file to: ${this.bucketName}/${filePath}`);
@ -205,7 +186,7 @@ export class MinioStorageService implements StorageService {
async downloadFile(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<Buffer> {
this.validateFilePath(orgId, projectId, category, filename);
@ -215,18 +196,18 @@ export class MinioStorageService implements StorageService {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
async streamFile(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<import("stream").Readable> {
): Promise<import('stream').Readable> {
this.validateFilePath(orgId, projectId, category, filename);
const filePath = this.getFilePath(orgId, projectId, category, filename);
@ -237,7 +218,7 @@ export class MinioStorageService implements StorageService {
async deleteFile(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<void> {
this.validateFilePath(orgId, projectId, category, filename);
@ -248,19 +229,19 @@ export class MinioStorageService implements StorageService {
getPublicUrl(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
): string {
this.validateFilePath(orgId, projectId, category, filename);
// Production-ready: Return API URL for presigned URL access
const apiBaseUrl = process.env["API_BASE_URL"] || "http://localhost:3000";
const apiBaseUrl = process.env['API_BASE_URL'] || 'http://localhost:3000';
return `${apiBaseUrl}/api/images/${orgId}/${projectId}/${category}/${filename}`;
}
async getPresignedUploadUrl(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
expirySeconds: number,
contentType: string,
@ -268,21 +249,17 @@ export class MinioStorageService implements StorageService {
this.validateFilePath(orgId, projectId, category, filename);
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(orgId, projectId, category, filename);
return await this.client.presignedPutObject(
this.bucketName,
filePath,
expirySeconds,
);
return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds);
}
async getPresignedDownloadUrl(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
expirySeconds: number = 86400, // 24 hours default
): Promise<string> {
@ -296,14 +273,10 @@ export class MinioStorageService implements StorageService {
// Replace internal Docker hostname with public URL if configured
if (this.publicUrl) {
const clientEndpoint =
this.client.host + (this.client.port ? `:${this.client.port}` : "");
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, "");
const clientEndpoint = this.client.host + (this.client.port ? `:${this.client.port}` : '');
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, '');
return presignedUrl.replace(
`${this.client.protocol}//${clientEndpoint}`,
this.publicUrl,
);
return presignedUrl.replace(`${this.client.protocol}//${clientEndpoint}`, this.publicUrl);
}
return presignedUrl;
@ -312,32 +285,24 @@ export class MinioStorageService implements StorageService {
async listProjectFiles(
orgId: string,
projectId: string,
category?: "uploads" | "generated" | "references",
category?: 'uploads' | 'generated' | 'references',
): Promise<FileMetadata[]> {
const prefix = category
? `${orgId}/${projectId}/${category}/`
: `${orgId}/${projectId}/`;
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) => {
stream.on('data', async (obj) => {
try {
if (!obj.name) return;
const metadata = await this.client.statObject(
this.bucketName,
obj.name,
);
const metadata = await this.client.statObject(this.bucketName, obj.name);
const pathParts = obj.name.split("/");
const pathParts = obj.name.split('/');
const filename = pathParts[pathParts.length - 1];
const categoryFromPath = pathParts[2] as
| "uploads"
| "generated"
| "references";
const categoryFromPath = pathParts[2] as 'uploads' | 'generated' | 'references';
if (!filename || !categoryFromPath) {
return;
@ -346,29 +311,23 @@ export class MinioStorageService implements StorageService {
files.push({
key: `${this.bucketName}/${obj.name}`,
filename,
contentType:
metadata.metaData?.["content-type"] || "application/octet-stream",
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
size: obj.size || 0,
url: this.getPublicUrl(
orgId,
projectId,
categoryFromPath,
filename,
),
url: this.getPublicUrl(orgId, projectId, categoryFromPath, filename),
createdAt: obj.lastModified || new Date(),
});
} catch (error) {}
});
stream.on("end", () => resolve(files));
stream.on("error", reject);
stream.on('end', () => resolve(files));
stream.on('error', reject);
});
}
parseKey(key: string): {
orgId: string;
projectId: string;
category: "uploads" | "generated" | "references";
category: 'uploads' | 'generated' | 'references';
filename: string;
} | null {
try {
@ -389,7 +348,7 @@ export class MinioStorageService implements StorageService {
return {
orgId,
projectId,
category: category as "uploads" | "generated" | "references",
category: category as 'uploads' | 'generated' | 'references',
filename,
};
} catch {
@ -400,7 +359,7 @@ export class MinioStorageService implements StorageService {
async fileExists(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<boolean> {
try {
@ -416,10 +375,10 @@ export class MinioStorageService implements StorageService {
async listFiles(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
prefix?: string,
): Promise<FileMetadata[]> {
this.validateFilePath(orgId, projectId, category, "dummy.txt");
this.validateFilePath(orgId, projectId, category, 'dummy.txt');
const basePath = `${orgId}/${projectId}/${category}/`;
const searchPrefix = prefix ? `${basePath}${prefix}` : basePath;
@ -427,31 +386,23 @@ export class MinioStorageService implements StorageService {
const files: FileMetadata[] = [];
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', async (obj) => {
if (!obj.name || !obj.size) return;
try {
const pathParts = obj.name.split("/");
const pathParts = obj.name.split('/');
const filename = pathParts[pathParts.length - 1];
if (!filename) return;
const metadata = await this.client.statObject(
this.bucketName,
obj.name,
);
const metadata = await this.client.statObject(this.bucketName, obj.name);
files.push({
filename,
size: obj.size,
contentType:
metadata.metaData?.["content-type"] || "application/octet-stream",
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
lastModified: obj.lastModified || new Date(),
etag: metadata.etag,
path: obj.name,
@ -459,8 +410,8 @@ export class MinioStorageService implements StorageService {
} catch (error) {}
});
stream.on("end", () => resolve(files));
stream.on("error", reject);
stream.on('end', () => resolve(files));
stream.on('error', reject);
});
}
}

View File

@ -1,5 +1,5 @@
import { StorageService } from "./StorageService";
import { MinioStorageService } from "./MinioStorageService";
import { StorageService } from './StorageService';
import { MinioStorageService } from './MinioStorageService';
export class StorageFactory {
private static instance: StorageService | null = null;
@ -30,9 +30,7 @@ export class StorageFactory {
try {
this.instance = this.createStorageService();
} catch (error) {
throw new Error(
"Storage service unavailable. Please check MinIO configuration.",
);
throw new Error('Storage service unavailable. Please check MinIO configuration.');
}
}
return this.instance;
@ -53,7 +51,7 @@ export class StorageFactory {
if (attempt === maxRetries) {
throw new Error(
`Failed to initialize storage service after ${maxRetries} attempts. ` +
`Last error: ${error instanceof Error ? error.message : "Unknown error"}`,
`Last error: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
@ -62,7 +60,7 @@ export class StorageFactory {
}
}
throw new Error("Unexpected error in storage service creation");
throw new Error('Unexpected error in storage service creation');
}
private static sleep(ms: number): Promise<void> {
@ -70,21 +68,21 @@ export class StorageFactory {
}
private static createStorageService(): StorageService {
const storageType = process.env["STORAGE_TYPE"] || "minio";
const storageType = process.env['STORAGE_TYPE'] || 'minio';
try {
switch (storageType.toLowerCase()) {
case "minio": {
const endpoint = process.env["MINIO_ENDPOINT"];
const accessKey = process.env["MINIO_ACCESS_KEY"];
const secretKey = process.env["MINIO_SECRET_KEY"];
const useSSL = process.env["MINIO_USE_SSL"] === "true";
const bucketName = process.env["MINIO_BUCKET_NAME"] || "banatie";
const publicUrl = process.env["MINIO_PUBLIC_URL"];
case 'minio': {
const endpoint = process.env['MINIO_ENDPOINT'];
const accessKey = process.env['MINIO_ACCESS_KEY'];
const secretKey = process.env['MINIO_SECRET_KEY'];
const useSSL = process.env['MINIO_USE_SSL'] === 'true';
const bucketName = process.env['MINIO_BUCKET_NAME'] || 'banatie';
const publicUrl = process.env['MINIO_PUBLIC_URL'];
if (!endpoint || !accessKey || !secretKey) {
throw new Error(
"MinIO configuration missing. Required: MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY",
'MinIO configuration missing. Required: MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY',
);
}

View File

@ -1,4 +1,4 @@
import { Readable } from "stream";
import { Readable } from 'stream';
export interface FileMetadata {
filename: string;
@ -42,7 +42,7 @@ export interface StorageService {
uploadFile(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
buffer: Buffer,
contentType: string,
@ -58,7 +58,7 @@ export interface StorageService {
downloadFile(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<Buffer>;
@ -72,7 +72,7 @@ export interface StorageService {
streamFile(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<Readable>;
@ -87,7 +87,7 @@ export interface StorageService {
getPresignedDownloadUrl(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
expirySeconds: number,
): Promise<string>;
@ -104,7 +104,7 @@ export interface StorageService {
getPresignedUploadUrl(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
expirySeconds: number,
contentType: string,
@ -120,7 +120,7 @@ export interface StorageService {
listFiles(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
prefix?: string,
): Promise<FileMetadata[]>;
@ -134,7 +134,7 @@ export interface StorageService {
deleteFile(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<void>;
@ -148,7 +148,7 @@ export interface StorageService {
fileExists(
orgId: string,
projectId: string,
category: "uploads" | "generated" | "references",
category: 'uploads' | 'generated' | 'references',
filename: string,
): Promise<boolean>;
}

View File

@ -1,5 +1,5 @@
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
import { dirname } from "path";
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
import { dirname } from 'path';
export interface TTILogEntry {
timestamp: string;
@ -24,7 +24,7 @@ export class TTILogger {
private isEnabled: boolean = false;
private constructor() {
const ttiLogPath = process.env["TTI_LOG"];
const ttiLogPath = process.env['TTI_LOG'];
if (ttiLogPath) {
this.logFilePath = ttiLogPath;
@ -51,8 +51,8 @@ export class TTILogger {
}
// Reset/clear the log file on service start
writeFileSync(this.logFilePath, "# Text-to-Image Generation Log\n\n", {
encoding: "utf-8",
writeFileSync(this.logFilePath, '# Text-to-Image Generation Log\n\n', {
encoding: 'utf-8',
});
console.log(`[TTILogger] Log file initialized: ${this.logFilePath}`);
@ -72,19 +72,15 @@ export class TTILogger {
// Read existing content
const existingContent = existsSync(this.logFilePath)
? readFileSync(this.logFilePath, "utf-8")
: "# Text-to-Image Generation Log\n\n";
? readFileSync(this.logFilePath, 'utf-8')
: '# Text-to-Image Generation Log\n\n';
// Insert new entry AFTER header but BEFORE old entries
const headerEnd = existingContent.indexOf("\n\n") + 2;
const headerEnd = existingContent.indexOf('\n\n') + 2;
const header = existingContent.slice(0, headerEnd);
const oldEntries = existingContent.slice(headerEnd);
writeFileSync(
this.logFilePath,
header + newLogEntry + oldEntries,
"utf-8",
);
writeFileSync(this.logFilePath, header + newLogEntry + oldEntries, 'utf-8');
} catch (error) {
console.error(`[TTILogger] Failed to write log entry:`, error);
}
@ -95,7 +91,7 @@ export class TTILogger {
// Format date from ISO timestamp
const date = new Date(timestamp);
const formattedDate = date.toISOString().replace("T", " ").slice(0, 19);
const formattedDate = date.toISOString().replace('T', ' ').slice(0, 19);
let logText = `## ${formattedDate}\n`;
logText += `${orgId}/${projectId}\n\n`;
@ -103,16 +99,16 @@ export class TTILogger {
// Add tags if present
if (meta?.tags && meta.tags.length > 0) {
logText += `**Tags:** ${meta.tags.join(", ")}\n\n`;
logText += `**Tags:** ${meta.tags.join(', ')}\n\n`;
}
if (referenceImages && referenceImages.length > 0) {
logText += `**Reference Images:** ${referenceImages.length} image${referenceImages.length > 1 ? "s" : ""}\n`;
logText += `**Reference Images:** ${referenceImages.length} image${referenceImages.length > 1 ? 's' : ''}\n`;
for (const img of referenceImages) {
const sizeMB = (img.size / (1024 * 1024)).toFixed(2);
logText += `- ${img.originalname} (${img.mimetype}, ${sizeMB} MB)\n`;
}
logText += "\n";
logText += '\n';
}
logText += `**Model:** ${model}\n`;

View File

@ -1,6 +1,6 @@
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
import { dirname } from "path";
import { EnhancementLogEntry } from "./types";
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
import { dirname } from 'path';
import { EnhancementLogEntry } from './types';
export class EnhancementLogger {
private static instance: EnhancementLogger | null = null;
@ -8,7 +8,7 @@ export class EnhancementLogger {
private isEnabled: boolean = false;
private constructor() {
const enhLogPath = process.env["ENH_LOG"];
const enhLogPath = process.env['ENH_LOG'];
if (enhLogPath) {
this.logFilePath = enhLogPath;
@ -35,18 +35,13 @@ export class EnhancementLogger {
}
// Reset/clear the log file on service start
writeFileSync(this.logFilePath, "# Prompt Enhancement Log\n\n", {
encoding: "utf-8",
writeFileSync(this.logFilePath, '# Prompt Enhancement Log\n\n', {
encoding: 'utf-8',
});
console.log(
`[EnhancementLogger] Log file initialized: ${this.logFilePath}`,
);
console.log(`[EnhancementLogger] Log file initialized: ${this.logFilePath}`);
} catch (error) {
console.error(
`[EnhancementLogger] Failed to initialize log file:`,
error,
);
console.error(`[EnhancementLogger] Failed to initialize log file:`, error);
this.isEnabled = false;
}
}
@ -61,19 +56,15 @@ export class EnhancementLogger {
// Read existing content
const existingContent = existsSync(this.logFilePath)
? readFileSync(this.logFilePath, "utf-8")
: "# Prompt Enhancement Log\n\n";
? readFileSync(this.logFilePath, 'utf-8')
: '# Prompt Enhancement Log\n\n';
// Insert new entry AFTER header but BEFORE old entries
const headerEnd = existingContent.indexOf("\n\n") + 2;
const headerEnd = existingContent.indexOf('\n\n') + 2;
const header = existingContent.slice(0, headerEnd);
const oldEntries = existingContent.slice(headerEnd);
writeFileSync(
this.logFilePath,
header + newLogEntry + oldEntries,
"utf-8",
);
writeFileSync(this.logFilePath, header + newLogEntry + oldEntries, 'utf-8');
} catch (error) {
console.error(`[EnhancementLogger] Failed to write log entry:`, error);
}
@ -95,7 +86,7 @@ export class EnhancementLogger {
// Format date from ISO timestamp
const date = new Date(timestamp);
const formattedDate = date.toISOString().replace("T", " ").slice(0, 19);
const formattedDate = date.toISOString().replace('T', ' ').slice(0, 19);
let logText = `## ${formattedDate}\n`;
logText += `${orgId}/${projectId}\n\n`;
@ -104,7 +95,7 @@ export class EnhancementLogger {
// Add tags if present
if (meta?.tags && meta.tags.length > 0) {
logText += `**Tags:** ${meta.tags.join(", ")}\n\n`;
logText += `**Tags:** ${meta.tags.join(', ')}\n\n`;
}
logText += `**Template:** ${template}\n`;
@ -112,7 +103,7 @@ export class EnhancementLogger {
logText += `**Language:** ${detectedLanguage}\n`;
}
if (enhancements.length > 0) {
logText += `**Enhancements:** ${enhancements.join(", ")}\n`;
logText += `**Enhancements:** ${enhancements.join(', ')}\n`;
}
logText += `**Model:** ${model}\n\n`;
logText += `---\n\n`;

View File

@ -2,18 +2,18 @@ import {
PromptEnhancementOptions,
PromptEnhancementContext,
PromptEnhancementResult,
} from "./types";
import { getAgent } from "./agents";
import { validatePromptLength } from "./validators";
import { EnhancementLogger } from "./EnhancementLogger";
} from './types';
import { getAgent } from './agents';
import { validatePromptLength } from './validators';
import { EnhancementLogger } from './EnhancementLogger';
export class PromptEnhancementService {
private apiKey: string;
private model = "gemini-2.5-flash";
private model = 'gemini-2.5-flash';
constructor(apiKey: string) {
if (!apiKey) {
throw new Error("Gemini API key is required");
throw new Error('Gemini API key is required');
}
this.apiKey = apiKey;
}
@ -28,11 +28,9 @@ export class PromptEnhancementService {
console.log(
`[${timestamp}] Starting prompt enhancement for: "${rawPrompt.substring(0, 50)}..."`,
);
console.log(
`[${timestamp}] Template: ${options.template || "general (auto-select)"}`,
);
console.log(`[${timestamp}] Template: ${options.template || 'general (auto-select)'}`);
if (options.tags && options.tags.length > 0) {
console.log(`[${timestamp}] Tags: ${options.tags.join(", ")}`);
console.log(`[${timestamp}] Tags: ${options.tags.join(', ')}`);
}
// Pre-validate input prompt
@ -41,7 +39,7 @@ export class PromptEnhancementService {
return {
success: false,
originalPrompt: rawPrompt,
error: inputValidation.error || "Validation failed",
error: inputValidation.error || 'Validation failed',
};
}
@ -56,23 +54,17 @@ export class PromptEnhancementService {
return {
success: false,
originalPrompt: rawPrompt,
error: agentResult.error || "Enhancement failed",
error: agentResult.error || 'Enhancement failed',
};
}
// Post-validate enhanced prompt length
const outputValidation = validatePromptLength(
agentResult.enhancedPrompt,
2000,
);
const outputValidation = validatePromptLength(agentResult.enhancedPrompt, 2000);
if (!outputValidation.valid) {
console.warn(
`[${timestamp}] Enhanced prompt exceeds 2000 characters (${agentResult.enhancedPrompt.length}), truncating...`,
);
agentResult.enhancedPrompt = agentResult.enhancedPrompt.substring(
0,
2000,
);
agentResult.enhancedPrompt = agentResult.enhancedPrompt.substring(0, 2000);
}
const result: PromptEnhancementResult = {
@ -82,10 +74,9 @@ export class PromptEnhancementService {
...(agentResult.detectedLanguage && {
detectedLanguage: agentResult.detectedLanguage,
}),
appliedTemplate:
agentResult.appliedTemplate || options.template || "general",
appliedTemplate: agentResult.appliedTemplate || options.template || 'general',
metadata: {
style: agentResult.appliedTemplate || options.template || "general",
style: agentResult.appliedTemplate || options.template || 'general',
enhancements: agentResult.enhancements,
},
};
@ -99,8 +90,7 @@ export class PromptEnhancementService {
originalPrompt: rawPrompt,
enhancedPrompt: agentResult.enhancedPrompt,
...(context.meta && { meta: context.meta }),
template:
agentResult.appliedTemplate || options.template || "general",
template: agentResult.appliedTemplate || options.template || 'general',
...(agentResult.detectedLanguage && {
detectedLanguage: agentResult.detectedLanguage,
}),
@ -116,7 +106,7 @@ export class PromptEnhancementService {
return {
success: false,
originalPrompt: rawPrompt,
error: error instanceof Error ? error.message : "Enhancement failed",
error: error instanceof Error ? error.message : 'Enhancement failed',
};
}
}

View File

@ -1,20 +1,16 @@
import { GoogleGenAI } from "@google/genai";
import {
IPromptAgent,
PromptEnhancementOptions,
AgentResult,
} from "../types";
import { detectLanguage, detectEnhancements } from "../utils";
import { GoogleGenAI } from '@google/genai';
import { IPromptAgent, PromptEnhancementOptions, AgentResult } from '../types';
import { detectLanguage, detectEnhancements } from '../utils';
export abstract class BaseAgent implements IPromptAgent {
protected ai: GoogleGenAI;
protected model = "gemini-2.5-flash";
protected model = 'gemini-2.5-flash';
abstract readonly templateType: string;
constructor(apiKey: string) {
if (!apiKey) {
throw new Error("Gemini API key is required");
throw new Error('Gemini API key is required');
}
this.ai = new GoogleGenAI({ apiKey });
}
@ -22,10 +18,7 @@ export abstract class BaseAgent implements IPromptAgent {
protected abstract getSystemPrompt(): string;
protected abstract getTemplate(): string;
async enhance(
rawPrompt: string,
_options: PromptEnhancementOptions,
): Promise<AgentResult> {
async enhance(rawPrompt: string, _options: PromptEnhancementOptions): Promise<AgentResult> {
const timestamp = new Date().toISOString();
console.log(
@ -38,26 +31,20 @@ export abstract class BaseAgent implements IPromptAgent {
const response = await this.ai.models.generateContent({
model: this.model,
config: { responseModalities: ["TEXT"] },
config: { responseModalities: ['TEXT'] },
contents: [
{
role: "user" as const,
role: 'user' as const,
parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }],
},
],
});
if (
response.candidates &&
response.candidates[0] &&
response.candidates[0].content
) {
if (response.candidates && response.candidates[0] && response.candidates[0].content) {
const content = response.candidates[0].content;
const enhancedPrompt = content.parts?.[0]?.text?.trim() || "";
const enhancedPrompt = content.parts?.[0]?.text?.trim() || '';
console.log(
`[${timestamp}] [${this.templateType}Agent] Enhancement successful`,
);
console.log(`[${timestamp}] [${this.templateType}Agent] Enhancement successful`);
return {
success: true,
@ -70,17 +57,14 @@ export abstract class BaseAgent implements IPromptAgent {
return {
success: false,
error: "No enhanced prompt received from API",
error: 'No enhanced prompt received from API',
enhancements: [],
};
} catch (error) {
console.error(
`[${timestamp}] [${this.templateType}Agent] Enhancement failed:`,
error,
);
console.error(`[${timestamp}] [${this.templateType}Agent] Enhancement failed:`, error);
return {
success: false,
error: error instanceof Error ? error.message : "Enhancement failed",
error: error instanceof Error ? error.message : 'Enhancement failed',
enhancements: [],
};
}

View File

@ -1,4 +1,4 @@
import { BaseAgent } from "./BaseAgent";
import { BaseAgent } from './BaseAgent';
export const COMIC_TEMPLATE = `A single comic book panel in a [art style] style. In the foreground, [character description and action]. In the background, [setting details]. The panel has a [dialogue/caption box] with the text "[Text]". The lighting creates a [mood] mood. [Aspect ratio].
@ -6,7 +6,7 @@ Example:
A single comic book panel in a gritty, noir art style with high-contrast black and white inks. In the foreground, a detective in a trench coat stands under a flickering streetlamp, rain soaking his shoulders. In the background, the neon sign of a desolate bar reflects in a puddle. A caption box at the top reads "The city was a tough place to keep secrets." The lighting is harsh, creating a dramatic, somber mood. Landscape.`;
export class ComicAgent extends BaseAgent {
readonly templateType = "comic";
readonly templateType = 'comic';
protected getTemplate(): string {
return COMIC_TEMPLATE;

View File

@ -1,13 +1,13 @@
import { BaseAgent } from "./BaseAgent";
import { PHOTOREALISTIC_TEMPLATE } from "./PhotorealisticAgent";
import { ILLUSTRATION_TEMPLATE } from "./IllustrationAgent";
import { MINIMALIST_TEMPLATE } from "./MinimalistAgent";
import { STICKER_TEMPLATE } from "./StickerAgent";
import { PRODUCT_TEMPLATE } from "./ProductAgent";
import { COMIC_TEMPLATE } from "./ComicAgent";
import { BaseAgent } from './BaseAgent';
import { PHOTOREALISTIC_TEMPLATE } from './PhotorealisticAgent';
import { ILLUSTRATION_TEMPLATE } from './IllustrationAgent';
import { MINIMALIST_TEMPLATE } from './MinimalistAgent';
import { STICKER_TEMPLATE } from './StickerAgent';
import { PRODUCT_TEMPLATE } from './ProductAgent';
import { COMIC_TEMPLATE } from './ComicAgent';
export class GeneralAgent extends BaseAgent {
readonly templateType = "general";
readonly templateType = 'general';
protected getTemplate(): string {
return `AVAILABLE TEMPLATES:

View File

@ -1,4 +1,4 @@
import { BaseAgent } from "./BaseAgent";
import { BaseAgent } from './BaseAgent';
export const ILLUSTRATION_TEMPLATE = `A [style] illustration of [subject], featuring [key characteristics] with [color palette]. The art style is [art technique description], with [line work style] and [shading technique]. The composition includes [composition details].
@ -6,7 +6,7 @@ Example:
A watercolor illustration of a magical forest clearing at twilight, featuring glowing fireflies and an ancient stone archway covered in luminescent moss. The art style is whimsical and dreamlike, with soft, flowing brushstrokes and gentle color bleeding. The color palette consists of deep purples, soft blues, and warm golden yellows. The illustration uses delicate line work for fine details and subtle wet-on-wet shading to create atmospheric depth.`;
export class IllustrationAgent extends BaseAgent {
readonly templateType = "illustration";
readonly templateType = 'illustration';
protected getTemplate(): string {
return ILLUSTRATION_TEMPLATE;

View File

@ -1,4 +1,4 @@
import { BaseAgent } from "./BaseAgent";
import { BaseAgent } from './BaseAgent';
export const MINIMALIST_TEMPLATE = `A minimalist composition featuring a single [subject] positioned in the [position in frame] of the frame. The background is a vast, empty [color/description] canvas, creating significant negative space. Soft, subtle lighting. [Aspect ratio].
@ -6,7 +6,7 @@ Example:
A minimalist composition featuring a single, delicate red maple leaf positioned in the bottom-right of the frame. The background is a vast, empty off-white canvas, creating significant negative space for text. Soft, diffused lighting from the top left. Square image.`;
export class MinimalistAgent extends BaseAgent {
readonly templateType = "minimalist";
readonly templateType = 'minimalist';
protected getTemplate(): string {
return MINIMALIST_TEMPLATE;

View File

@ -1,4 +1,4 @@
import { BaseAgent } from "./BaseAgent";
import { BaseAgent } from './BaseAgent';
export const PHOTOREALISTIC_TEMPLATE = `A photorealistic [shot type] of [subject], [action or expression], set in [environment]. The scene is illuminated by [lighting description], creating a [mood] atmosphere. Captured with a [camera/lens details], emphasizing [key textures and details]. The image should be in a [aspect ratio] format.
@ -6,7 +6,7 @@ Example:
A photorealistic close-up portrait of an elderly Japanese ceramicist with deep, sun-etched wrinkles and a warm, knowing smile. He is carefully inspecting a freshly glazed tea bowl. The setting is his rustic, sun-drenched workshop. The scene is illuminated by soft, golden hour light streaming through a window, highlighting the fine texture of the clay. Captured with an 85mm portrait lens, resulting in a soft, blurred background (bokeh). The overall mood is serene and masterful. Vertical portrait orientation.`;
export class PhotorealisticAgent extends BaseAgent {
readonly templateType = "photorealistic";
readonly templateType = 'photorealistic';
protected getTemplate(): string {
return PHOTOREALISTIC_TEMPLATE;

View File

@ -1,4 +1,4 @@
import { BaseAgent } from "./BaseAgent";
import { BaseAgent } from './BaseAgent';
export const PRODUCT_TEMPLATE = `A high-resolution, studio-lit product photograph of a [product description] on a [background surface/description]. The lighting is a [lighting setup, e.g., three-point softbox setup] to [lighting purpose]. The camera angle is a [angle type] to showcase [specific feature]. Ultra-realistic, with sharp focus on [key detail]. [Aspect ratio].
@ -6,7 +6,7 @@ Example:
A high-resolution, studio-lit product photograph of a minimalist ceramic coffee mug in matte black, presented on a polished concrete surface. The lighting is a three-point softbox setup designed to create soft, diffused highlights and eliminate harsh shadows. The camera angle is a slightly elevated 45-degree shot to showcase its clean lines. Ultra-realistic, with sharp focus on the steam rising from the coffee. Square image.`;
export class ProductAgent extends BaseAgent {
readonly templateType = "product";
readonly templateType = 'product';
protected getTemplate(): string {
return PRODUCT_TEMPLATE;

View File

@ -1,4 +1,4 @@
import { BaseAgent } from "./BaseAgent";
import { BaseAgent } from './BaseAgent';
export const STICKER_TEMPLATE = `A [style] sticker of a [subject], featuring [key characteristics] and a [color palette]. The design should have [line style] and [shading style]. The background must be transparent.
@ -6,7 +6,7 @@ Example:
A kawaii-style sticker of a happy red panda wearing a tiny bamboo hat. It's munching on a green bamboo leaf. The design features bold, clean outlines, simple cel-shading, and a vibrant color palette. The background must be white.`;
export class StickerAgent extends BaseAgent {
readonly templateType = "sticker";
readonly templateType = 'sticker';
protected getTemplate(): string {
return STICKER_TEMPLATE;

View File

@ -1,11 +1,11 @@
import { IPromptAgent } from "../types";
import { PhotorealisticAgent } from "./PhotorealisticAgent";
import { IllustrationAgent } from "./IllustrationAgent";
import { MinimalistAgent } from "./MinimalistAgent";
import { StickerAgent } from "./StickerAgent";
import { ProductAgent } from "./ProductAgent";
import { ComicAgent } from "./ComicAgent";
import { GeneralAgent } from "./GeneralAgent";
import { IPromptAgent } from '../types';
import { PhotorealisticAgent } from './PhotorealisticAgent';
import { IllustrationAgent } from './IllustrationAgent';
import { MinimalistAgent } from './MinimalistAgent';
import { StickerAgent } from './StickerAgent';
import { ProductAgent } from './ProductAgent';
import { ComicAgent } from './ComicAgent';
import { GeneralAgent } from './GeneralAgent';
type AgentConstructor = new (apiKey: string) => IPromptAgent;
@ -26,9 +26,7 @@ export function getAgent(apiKey: string, template?: string): IPromptAgent {
const AgentClass = AGENT_REGISTRY[template];
if (!AgentClass) {
console.warn(
`Unknown template "${template}", falling back to GeneralAgent`,
);
console.warn(`Unknown template "${template}", falling back to GeneralAgent`);
return new GeneralAgent(apiKey);
}

View File

@ -1,5 +1,5 @@
export { PromptEnhancementService } from "./PromptEnhancementService";
export { EnhancementLogger } from "./EnhancementLogger";
export { PromptEnhancementService } from './PromptEnhancementService';
export { EnhancementLogger } from './EnhancementLogger';
export type {
PromptEnhancementOptions,
@ -9,4 +9,4 @@ export type {
IPromptAgent,
AgentResult,
ValidationResult,
} from "./types";
} from './types';

View File

@ -1,12 +1,12 @@
export interface PromptEnhancementOptions {
template?:
| "photorealistic"
| "illustration"
| "minimalist"
| "sticker"
| "product"
| "comic"
| "general";
| 'photorealistic'
| 'illustration'
| 'minimalist'
| 'sticker'
| 'product'
| 'comic'
| 'general';
tags?: string[];
}
@ -43,10 +43,7 @@ export interface AgentResult {
export interface IPromptAgent {
readonly templateType: string;
enhance(
prompt: string,
options: PromptEnhancementOptions,
): Promise<AgentResult>;
enhance(prompt: string, options: PromptEnhancementOptions): Promise<AgentResult>;
}
export interface ValidationResult {

View File

@ -1,46 +1,36 @@
export function detectLanguage(text: string): string {
if (/[\u4e00-\u9fff]/.test(text)) return "Chinese";
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return "Japanese";
if (/[\uac00-\ud7af]/.test(text)) return "Korean";
if (/[àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(text))
return "Romance Language";
if (/[а-яё]/.test(text.toLowerCase())) return "Russian";
if (/[α-ωΑ-Ω]/.test(text)) return "Greek";
if (/[أ-ي]/.test(text)) return "Arabic";
if (/[א-ת]/.test(text)) return "Hebrew";
return "English";
if (/[\u4e00-\u9fff]/.test(text)) return 'Chinese';
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return 'Japanese';
if (/[\uac00-\ud7af]/.test(text)) return 'Korean';
if (/[àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(text)) return 'Romance Language';
if (/[а-яё]/.test(text.toLowerCase())) return 'Russian';
if (/[α-ωΑ-Ω]/.test(text)) return 'Greek';
if (/[أ-ي]/.test(text)) return 'Arabic';
if (/[א-ת]/.test(text)) return 'Hebrew';
return 'English';
}
export function detectEnhancements(
originalPrompt: string,
enhancedPrompt: string,
): string[] {
export function detectEnhancements(originalPrompt: string, enhancedPrompt: string): string[] {
const enhancements: string[] = [];
if (enhancedPrompt.length > originalPrompt.length * 1.5) {
enhancements.push("Added detailed descriptions");
enhancements.push('Added detailed descriptions');
}
if (
enhancedPrompt.includes("photorealistic") ||
enhancedPrompt.includes("shot") ||
enhancedPrompt.includes("lens")
enhancedPrompt.includes('photorealistic') ||
enhancedPrompt.includes('shot') ||
enhancedPrompt.includes('lens')
) {
enhancements.push("Applied photography terminology");
enhancements.push('Applied photography terminology');
}
if (
enhancedPrompt.includes("lighting") ||
enhancedPrompt.includes("illuminated")
) {
enhancements.push("Enhanced lighting description");
if (enhancedPrompt.includes('lighting') || enhancedPrompt.includes('illuminated')) {
enhancements.push('Enhanced lighting description');
}
if (
enhancedPrompt.includes("texture") ||
enhancedPrompt.includes("surface")
) {
enhancements.push("Added texture details");
if (enhancedPrompt.includes('texture') || enhancedPrompt.includes('surface')) {
enhancements.push('Added texture details');
}
return enhancements;

View File

@ -1,13 +1,10 @@
import { ValidationResult } from "./types";
import { ValidationResult } from './types';
export function validatePromptLength(
prompt: string,
maxLength: number = 2000,
): ValidationResult {
export function validatePromptLength(prompt: string, maxLength: number = 2000): ValidationResult {
if (!prompt || prompt.trim().length === 0) {
return {
valid: false,
error: "Prompt cannot be empty",
error: 'Prompt cannot be empty',
};
}

View File

@ -1,4 +1,4 @@
import { Request } from "express";
import { Request } from 'express';
// API Request/Response types
export interface GenerateImageRequest {
@ -13,13 +13,13 @@ export interface TextToImageRequest {
autoEnhance?: boolean; // Defaults to true
enhancementOptions?: {
template?:
| "photorealistic"
| "illustration"
| "minimalist"
| "sticker"
| "product"
| "comic"
| "general"; // Defaults to "photorealistic"
| 'photorealistic'
| 'illustration'
| 'minimalist'
| 'sticker'
| 'product'
| 'comic'
| 'general'; // Defaults to "photorealistic"
};
meta?: {
tags?: string[]; // Optional array of tags for tracking/grouping (not stored, only logged)
@ -98,7 +98,7 @@ export interface ImageGenerationResult {
model: string;
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
error?: string;
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
}
@ -123,13 +123,13 @@ export interface PromptEnhancementRequest {
prompt: string;
options?: {
template?:
| "photorealistic"
| "illustration"
| "minimalist"
| "sticker"
| "product"
| "comic"
| "general"; // Defaults to "photorealistic"
| 'photorealistic'
| 'illustration'
| 'minimalist'
| 'sticker'
| 'product'
| 'comic'
| 'general'; // Defaults to "photorealistic"
};
}
@ -153,13 +153,13 @@ export interface EnhancedGenerateImageRequest extends GenerateImageRequest {
autoEnhance?: boolean; // Defaults to true
enhancementOptions?: {
template?:
| "photorealistic"
| "illustration"
| "minimalist"
| "sticker"
| "product"
| "comic"
| "general"; // Defaults to "photorealistic"
| 'photorealistic'
| 'illustration'
| 'minimalist'
| 'sticker'
| 'product'
| 'comic'
| 'general'; // Defaults to "photorealistic"
};
meta?: {
tags?: string[];

View File

@ -35,14 +35,8 @@
"@/utils/*": ["src/utils/*"]
}
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"tests/**/*"
],
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests/**/*"],
"ts-node": {
"esm": true
}

View File

@ -1,8 +1,5 @@
{
"extends": [
"next/core-web-vitals",
"prettier"
],
"extends": ["next/core-web-vitals", "prettier"],
"plugins": ["prettier"],
"rules": {
"prettier/prettier": "error"

View File

@ -7,11 +7,13 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
## Components Architecture
### 1. MinimizedApiKey Component
**Location:** `src/components/demo/MinimizedApiKey.tsx`
**Purpose:** Minimizes API key section to a badge after validation, freeing up valuable screen space.
**Features:**
- Fixed position in top-right corner (z-index: 40)
- Collapsed state: Shows `org/project` slugs with green status indicator
- Expanded state: Full card with API key visibility toggle and revoke button
@ -20,11 +22,13 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
- ARIA labels for screen readers
**Design Patterns:**
- Badge: `px-4 py-2 bg-slate-900/95 backdrop-blur-sm border border-slate-700 rounded-full`
- Green indicator: `w-2 h-2 rounded-full bg-green-400`
- Hover states with amber accent
**Accessibility:**
- `aria-label` on all buttons
- Focus ring on interactions: `focus:ring-2 focus:ring-amber-500`
- Keyboard navigation support
@ -33,11 +37,13 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
---
### 2. PromptReuseButton Component
**Location:** `src/components/demo/PromptReuseButton.tsx`
**Purpose:** Allows users to quickly reuse prompts from previous generations.
**Features:**
- Small, compact button next to prompt text
- Visual feedback on click (changes to "Inserted" state)
- Auto-resets after 1 second
@ -45,12 +51,14 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
- Icon + text label for clarity
**Design Patterns:**
- Compact size: `px-2 py-1 text-xs`
- Slate background with amber hover: `bg-slate-800/50 hover:bg-amber-600/20`
- Border transition: `border-slate-700 hover:border-amber-600/50`
- Refresh icon (↻) for "reuse" action
**Accessibility:**
- Descriptive `aria-label` with context
- Title attribute for tooltip
- Focus indicator
@ -59,15 +67,18 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
---
### 3. GenerationTimer Component
**Location:** `src/components/demo/GenerationTimer.tsx`
**Purpose:** Shows live generation time during API calls and final duration on results.
**Components:**
- `GenerationTimer`: Live timer during generation (updates every 100ms)
- `CompletedTimerBadge`: Static badge showing final duration
**Features:**
- Live updates during generation with spinning icon
- Format: "⏱️ 2.3s"
- Two variants: `inline` (with spinner) and `badge` (compact)
@ -75,12 +86,14 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
- Green badge for completed generations
**Design Patterns:**
- Inline: `text-sm text-gray-400` with amber clock icon
- Badge: `bg-slate-900/80 border border-slate-700 rounded-md`
- Completed: `bg-green-900/20 border border-green-700/50 text-green-400`
- Spinning animation on clock icon during generation
**Accessibility:**
- Live region for screen readers (implicit via state updates)
- Clear visual distinction between active/completed states
- Sufficient color contrast (WCAG AA compliant)
@ -88,11 +101,13 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
---
### 4. InspectMode Component
**Location:** `src/components/demo/InspectMode.tsx`
**Purpose:** Developer tool to inspect raw API request/response data and Gemini parameters.
**Features:**
- Two-column layout (left: original, right: enhanced)
- Three collapsible sections per column:
- API Request
@ -104,6 +119,7 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
- Max height with scroll for long data
**Design Patterns:**
- Grid layout: `grid md:grid-cols-2 gap-4`
- Collapsible headers: `bg-slate-900/50 hover:bg-slate-900/70`
- JSON container: `bg-slate-950/50 border border-slate-700 rounded-lg`
@ -114,6 +130,7 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
- Booleans/null: `text-purple-400`
**Accessibility:**
- `aria-expanded` on collapsible buttons
- Descriptive `aria-label` for each section
- Keyboard navigation (Enter/Space to toggle)
@ -121,6 +138,7 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
- Scrollable with overflow for long content
**Technical Details:**
- JSON escaping for safe HTML rendering
- `dangerouslySetInnerHTML` used ONLY for pre-sanitized content
- Each section independently collapsible
@ -129,11 +147,13 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
---
### 5. ResultCard Component
**Location:** `src/components/demo/ResultCard.tsx`
**Purpose:** Enhanced result display with preview/inspect modes and code examples.
**Features:**
- **View Mode Toggle:** Switch between Preview (images) and Inspect (data)
- **Image Preview Mode:**
- Side-by-side image comparison (horizontal scroll)
@ -151,6 +171,7 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
- Terminal-style UI with traffic light dots
**Design Patterns:**
- Card: `p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl`
- Mode toggle: `bg-slate-950/50 border border-slate-700 rounded-lg`
- Active tab: `bg-amber-600 text-white`
@ -158,6 +179,7 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
- Code block: Terminal UI with red/yellow/green dots
**Accessibility:**
- `aria-pressed` on mode toggle buttons
- Semantic HTML for tab structure
- Keyboard navigation (Tab, Arrow keys)
@ -166,6 +188,7 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
- Download button with descriptive label
**Responsive Behavior:**
- Mobile (< 768px):
- Single column layout
- Horizontal scroll for images
@ -178,6 +201,7 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
- Full layout with optimal spacing
**Code Examples - REST Format:**
```
### Generate Image - Text to Image
POST http://localhost:3000/api/text-to-image
@ -193,6 +217,7 @@ X-API-Key: your-api-key
---
## Main Page Refactoring
**Location:** `src/app/demo/tti/page.tsx`
### Changes Made
@ -232,6 +257,7 @@ X-API-Key: your-api-key
All components strictly follow the Banatie design system:
### Colors
- Backgrounds: `bg-slate-950`, `bg-slate-900/80`, `bg-slate-800`
- Gradients: `from-amber-600 to-orange-600`
- Text: `text-white`, `text-gray-300`, `text-gray-400`, `text-gray-500`
@ -239,24 +265,28 @@ All components strictly follow the Banatie design system:
- Accents: Amber for primary actions, green for success, red for errors
### Typography
- Headings: `text-3xl md:text-4xl lg:text-5xl font-bold text-white`
- Subheadings: `text-lg font-semibold text-white`
- Body: `text-sm text-gray-300`
- Small: `text-xs text-gray-400`
### Spacing
- Container: `max-w-7xl mx-auto px-6`
- Card padding: `p-5` (compact) or `p-6` (standard)
- Section gaps: `space-y-6` or `space-y-8`
- Button padding: `px-6 py-2.5` (compact), `px-8 py-3` (standard)
### Rounded Corners
- Cards: `rounded-2xl`
- Buttons: `rounded-lg`
- Inputs: `rounded-lg`
- Badges: `rounded-full` (minimized API key), `rounded-md` (small badges)
### Transitions
- All interactive elements: `transition-all` or `transition-colors`
- Hover states smooth and predictable
- Animations: `animate-fade-in` (0.5s ease-out)
@ -266,11 +296,13 @@ All components strictly follow the Banatie design system:
## Accessibility Compliance (WCAG 2.1 AA)
### Semantic HTML
- Proper heading hierarchy: h1 → h2 (no skipped levels)
- Landmark regions: `<header>`, `<section>`, `<main>` (implicit)
- Form labels properly associated
### Keyboard Navigation
- All interactive elements keyboard accessible
- Tab order logical and sequential
- Focus indicators visible on all focusable elements
@ -278,11 +310,13 @@ All components strictly follow the Banatie design system:
- Enter key validation support
### Color Contrast
- Text on backgrounds: Minimum 4.5:1 (tested with Banatie colors)
- Interactive elements clearly distinguishable
- Disabled states visible but distinct
### ARIA Attributes
- `aria-label` on icon-only buttons
- `aria-pressed` on toggle buttons
- `aria-expanded` on collapsible sections
@ -291,6 +325,7 @@ All components strictly follow the Banatie design system:
- `aria-label` on sections for screen reader context
### Screen Reader Support
- Meaningful alt text on images
- Button labels descriptive ("Close zoomed image" not just "Close")
- State changes announced (via ARIA live regions)
@ -320,6 +355,7 @@ All components strictly follow the Banatie design system:
## Responsive Breakpoints
### Mobile (< 768px)
- Single column layouts
- Stacked buttons with wrapping
- Horizontal scroll for image comparison
@ -327,17 +363,20 @@ All components strictly follow the Banatie design system:
- Text sizes: base, sm, xs
### Tablet (>= 768px, md:)
- Two-column inspect mode
- Side-by-side images maintained
- Increased padding
- Text sizes: md scale up
### Desktop (>= 1024px, lg:)
- Optimal spacing
- Full feature display
- Larger text sizes
### XL (>= 1280px, xl:)
- Max width container constrains growth
- Centered content
@ -366,6 +405,7 @@ apps/landing/src/
## Usage Examples
### Reusing a Prompt
1. Generate images
2. Find the prompt you want to reuse
3. Click "Reuse" button next to the prompt
@ -373,6 +413,7 @@ apps/landing/src/
5. Focus shifts to textarea for editing
### Inspecting API Data
1. Generate images
2. Click "Inspect" mode toggle in result card
3. View request/response data in two columns
@ -380,6 +421,7 @@ apps/landing/src/
5. Copy JSON with copy buttons
### Using REST Code Example
1. Generate images
2. Navigate to code examples section
3. Click "REST" tab
@ -408,12 +450,14 @@ apps/landing/src/
## Browser Compatibility
Tested and designed for:
- Chrome/Edge (Chromium)
- Firefox
- Safari
- Mobile browsers (iOS Safari, Chrome Android)
Uses standard web APIs:
- Clipboard API (navigator.clipboard)
- CSS Grid and Flexbox
- CSS Custom Properties

View File

@ -1,4 +1,4 @@
import type { NextConfig } from "next";
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'export',

View File

@ -87,7 +87,8 @@ export default function ApiKeysPage() {
<div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1>
<p className="text-slate-400">
Generate API keys for your projects. Organizations and projects will be created automatically if they don't exist.
Generate API keys for your projects. Organizations and projects will be created
automatically if they don't exist.
</p>
</div>
@ -160,9 +161,13 @@ export default function ApiKeysPage() {
{apiKeys.map((key) => (
<tr key={key.id} className="border-b border-slate-800">
<td className="py-3 text-sm text-slate-300">
<span className={`px-2 py-1 rounded text-xs font-medium ${
key.keyType === 'master' ? 'bg-amber-900/30 text-amber-400' : 'bg-blue-900/30 text-blue-400'
}`}>
<span
className={`px-2 py-1 rounded text-xs font-medium ${
key.keyType === 'master'
? 'bg-amber-900/30 text-amber-400'
: 'bg-blue-900/30 text-blue-400'
}`}
>
{key.keyType}
</span>
</td>
@ -180,9 +185,13 @@ export default function ApiKeysPage() {
{key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : 'Never'}
</td>
<td className="py-3 text-sm">
<span className={`px-2 py-1 rounded text-xs font-medium ${
key.isActive ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400'
}`}>
<span
className={`px-2 py-1 rounded text-xs font-medium ${
key.isActive
? 'bg-green-900/30 text-green-400'
: 'bg-red-900/30 text-red-400'
}`}
>
{key.isActive ? 'Active' : 'Inactive'}
</span>
</td>

View File

@ -88,7 +88,8 @@ export default function MasterKeyPage() {
<div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1>
<p className="text-slate-400">
Bootstrap your master key or manually configure it. This key is required to generate project API keys.
Bootstrap your master key or manually configure it. This key is required to generate
project API keys.
</p>
</div>

View File

@ -229,8 +229,8 @@ export default function DemoTTIPage() {
aspectRatio,
autoEnhance: false, // Explicitly disable enhancement for left image
meta: {
tags: [pairId, 'simple'] // NEW: Pair ID + "simple" tag
}
tags: [pairId, 'simple'], // NEW: Pair ID + "simple" tag
},
}),
}),
fetch(`${API_BASE_URL}/api/text-to-image`, {
@ -248,8 +248,8 @@ export default function DemoTTIPage() {
template: template || 'photorealistic', // Only template parameter
},
meta: {
tags: [pairId, 'enhanced'] // NEW: Pair ID + "enhanced" tag
}
tags: [pairId, 'enhanced'], // NEW: Pair ID + "enhanced" tag
},
}),
}),
]);
@ -289,8 +289,8 @@ export default function DemoTTIPage() {
aspectRatio,
autoEnhance: false,
meta: {
tags: [pairId, 'simple']
}
tags: [pairId, 'simple'],
},
},
response: leftData,
geminiParams: leftData.data?.geminiParams || {},
@ -305,8 +305,8 @@ export default function DemoTTIPage() {
template: template || 'photorealistic',
},
meta: {
tags: [pairId, 'enhanced']
}
tags: [pairId, 'enhanced'],
},
},
response: rightData,
geminiParams: rightData.data?.geminiParams || {},
@ -315,8 +315,8 @@ export default function DemoTTIPage() {
enhancementOptions: {
template,
meta: {
tags: [pairId, 'enhanced']
}
tags: [pairId, 'enhanced'],
},
},
};
@ -334,9 +334,7 @@ export default function DemoTTIPage() {
// Clear prompt
setPrompt('');
} catch (error) {
setGenerationError(
error instanceof Error ? error.message : 'Failed to generate images'
);
setGenerationError(error instanceof Error ? error.message : 'Failed to generate images');
} finally {
setGenerating(false);
setGenerationStartTime(undefined);
@ -404,7 +402,10 @@ export default function DemoTTIPage() {
{/* API Key Section - Only show when not validated */}
{!apiKeyValidated && (
<section className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl" aria-label="API Key Validation">
<section
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
aria-label="API Key Validation"
>
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
<div className="flex gap-3">
<div className="flex-1 relative">
@ -430,12 +431,27 @@ export default function DemoTTIPage() {
>
{apiKeyVisible ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
@ -458,7 +474,10 @@ export default function DemoTTIPage() {
)}
{/* Unified Prompt & Generation Card */}
<section className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl" aria-label="Image Generation">
<section
className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
aria-label="Image Generation"
>
{/* Prompt Textarea */}
<div className="mb-4">
<label htmlFor="prompt-input" className="block text-lg font-semibold text-white mb-3">
@ -482,7 +501,10 @@ export default function DemoTTIPage() {
<div className="mb-4 flex flex-col md:flex-row gap-3 items-start md:items-end">
{/* Aspect Ratio */}
<div className="flex-1 min-w-[150px]">
<label htmlFor="aspect-ratio" className="block text-xs font-medium text-gray-400 mb-1.5">
<label
htmlFor="aspect-ratio"
className="block text-xs font-medium text-gray-400 mb-1.5"
>
Aspect Ratio
</label>
<select

View File

@ -1,4 +1,4 @@
@import "tailwindcss";
@import 'tailwindcss';
:root {
--background: #0f172a;
@ -15,12 +15,18 @@
body {
background: var(--background);
color: var(--foreground);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
/* Custom animations */
@keyframes gradient-shift {
0%, 100% {
0%,
100% {
background-position: 0% 50%;
}
50% {

View File

@ -1,35 +1,45 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Image from "next/image";
import "./globals.css";
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Image from 'next/image';
import './globals.css';
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
variable: '--font-inter',
subsets: ['latin'],
display: 'swap',
});
export const metadata: Metadata = {
title: "Banatie - AI Image Generation API | Developer-First Platform",
description: "Transform text and reference images into production-ready visuals with Banatie's developer-first AI image generation API. Powered by Google Gemini & Imagen 4.0. Join the beta.",
keywords: ["AI image generation", "image generation API", "text to image", "Gemini API", "developer tools", "REST API", "image AI"],
authors: [{ name: "Banatie Team" }],
creator: "Banatie",
publisher: "Banatie",
metadataBase: new URL("https://banatie.com"),
title: 'Banatie - AI Image Generation API | Developer-First Platform',
description:
"Transform text and reference images into production-ready visuals with Banatie's developer-first AI image generation API. Powered by Google Gemini & Imagen 4.0. Join the beta.",
keywords: [
'AI image generation',
'image generation API',
'text to image',
'Gemini API',
'developer tools',
'REST API',
'image AI',
],
authors: [{ name: 'Banatie Team' }],
creator: 'Banatie',
publisher: 'Banatie',
metadataBase: new URL('https://banatie.com'),
openGraph: {
type: "website",
locale: "en_US",
url: "https://banatie.com",
title: "Banatie - AI Image Generation API for Developers",
description: "Developer-first API for AI-powered image generation. Transform text and reference images into production-ready visuals in seconds.",
siteName: "Banatie",
type: 'website',
locale: 'en_US',
url: 'https://banatie.com',
title: 'Banatie - AI Image Generation API for Developers',
description:
'Developer-first API for AI-powered image generation. Transform text and reference images into production-ready visuals in seconds.',
siteName: 'Banatie',
},
twitter: {
card: "summary_large_image",
title: "Banatie - AI Image Generation API",
description: "Developer-first API for AI-powered image generation. Join the beta.",
creator: "@banatie",
card: 'summary_large_image',
title: 'Banatie - AI Image Generation API',
description: 'Developer-first API for AI-powered image generation. Join the beta.',
creator: '@banatie',
},
robots: {
index: true,
@ -37,13 +47,13 @@ export const metadata: Metadata = {
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
verification: {
google: "google-site-verification-code",
google: 'google-site-verification-code',
},
};
@ -105,10 +115,18 @@ export default function RootLayout({
/>
</div>
<div className="flex gap-8 text-sm text-gray-400">
<a href="#" className="hover:text-white transition-colors">Documentation</a>
<a href="#" className="hover:text-white transition-colors">API Reference</a>
<a href="#" className="hover:text-white transition-colors">Pricing</a>
<a href="#" className="hover:text-white transition-colors">Contact</a>
<a href="#" className="hover:text-white transition-colors">
Documentation
</a>
<a href="#" className="hover:text-white transition-colors">
API Reference
</a>
<a href="#" className="hover:text-white transition-colors">
Pricing
</a>
<a href="#" className="hover:text-white transition-colors">
Contact
</a>
</div>
</div>
<div className="mt-8 text-center text-sm text-gray-500">

View File

@ -14,7 +14,7 @@ export default function Home() {
// TODO: Replace with actual API endpoint
setTimeout(() => {
setStatus('success');
setMessage('You\'re on the list! Check your email for beta access details.');
setMessage("You're on the list! Check your email for beta access details.");
setEmail('');
}, 1000);
};
@ -60,7 +60,11 @@ export default function Home() {
disabled={status === 'loading' || status === 'success'}
className="px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-cyan-600 text-white font-semibold hover:from-purple-500 hover:to-cyan-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-purple-500/25"
>
{status === 'loading' ? 'Joining...' : status === 'success' ? 'Joined!' : 'Join Beta'}
{status === 'loading'
? 'Joining...'
: status === 'success'
? 'Joined!'
: 'Join Beta'}
</button>
</form>
{status === 'success' && (
@ -126,11 +130,10 @@ export default function Home() {
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
🚀
</div>
<h3 className="text-xl font-semibold text-white mb-3">
Instant Integration
</h3>
<h3 className="text-xl font-semibold text-white mb-3">Instant Integration</h3>
<p className="text-gray-400 leading-relaxed">
Clean REST API that works with any stack. From hobbyist to enterprise, start generating images in minutes.
Clean REST API that works with any stack. From hobbyist to enterprise, start
generating images in minutes.
</p>
</div>
@ -139,11 +142,10 @@ export default function Home() {
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
🎯
</div>
<h3 className="text-xl font-semibold text-white mb-3">
Reference-Guided Generation
</h3>
<h3 className="text-xl font-semibold text-white mb-3">Reference-Guided Generation</h3>
<p className="text-gray-400 leading-relaxed">
Upload up to 3 reference images alongside your prompt. Perfect for brand consistency and style matching.
Upload up to 3 reference images alongside your prompt. Perfect for brand consistency
and style matching.
</p>
</div>
@ -152,11 +154,10 @@ export default function Home() {
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
</div>
<h3 className="text-xl font-semibold text-white mb-3">
Smart Prompt Enhancement
</h3>
<h3 className="text-xl font-semibold text-white mb-3">Smart Prompt Enhancement</h3>
<p className="text-gray-400 leading-relaxed">
AI-powered prompt optimization with language detection. Get better results automatically.
AI-powered prompt optimization with language detection. Get better results
automatically.
</p>
</div>
@ -165,11 +166,10 @@ export default function Home() {
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
🔐
</div>
<h3 className="text-xl font-semibold text-white mb-3">
Enterprise-Ready Security
</h3>
<h3 className="text-xl font-semibold text-white mb-3">Enterprise-Ready Security</h3>
<p className="text-gray-400 leading-relaxed">
API key management, rate limiting, multi-tenant architecture. Built for production from day one.
API key management, rate limiting, multi-tenant architecture. Built for production
from day one.
</p>
</div>
@ -178,11 +178,10 @@ export default function Home() {
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
</div>
<h3 className="text-xl font-semibold text-white mb-3">
Dual AI Models
</h3>
<h3 className="text-xl font-semibold text-white mb-3">Dual AI Models</h3>
<p className="text-gray-400 leading-relaxed">
Powered by Google Gemini 2.5 Flash and Imagen 4.0. Automatic fallback ensures reliability.
Powered by Google Gemini 2.5 Flash and Imagen 4.0. Automatic fallback ensures
reliability.
</p>
</div>
@ -191,11 +190,10 @@ export default function Home() {
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
📦
</div>
<h3 className="text-xl font-semibold text-white mb-3">
Organized Cloud Storage
</h3>
<h3 className="text-xl font-semibold text-white mb-3">Organized Cloud Storage</h3>
<p className="text-gray-400 leading-relaxed">
Automatic file organization by org/project/category. CDN-ready URLs for instant delivery.
Automatic file organization by org/project/category. CDN-ready URLs for instant
delivery.
</p>
</div>
</div>
@ -204,9 +202,7 @@ export default function Home() {
{/* CTA Section */}
<section className="relative z-10 max-w-4xl mx-auto px-6 py-16 md:py-24">
<div className="text-center p-12 rounded-3xl bg-gradient-to-br from-purple-600/20 via-cyan-600/20 to-purple-600/20 border border-purple-500/30 backdrop-blur-sm">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Join the Beta Program
</h2>
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">Join the Beta Program</h2>
<p className="text-xl text-gray-300 mb-8">
Get early access, shape the product, and lock in founder pricing.
</p>

View File

@ -15,7 +15,8 @@ export default function AdminButton({
disabled = false,
className = '',
}: AdminButtonProps) {
const baseClasses = 'px-6 py-3 font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed';
const baseClasses =
'px-6 py-3 font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary: 'bg-amber-600 text-white hover:bg-amber-700 shadow-lg shadow-amber-900/30',

View File

@ -25,9 +25,7 @@ export default function CopyButton({ text, label = 'Copy', className = '' }: Cop
<button
onClick={handleCopy}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
copied
? 'bg-green-600 text-white'
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
copied ? 'bg-green-600 text-white' : 'bg-slate-700 text-slate-200 hover:bg-slate-600'
} ${className}`}
>
{copied ? '✓ Copied!' : label}

View File

@ -171,7 +171,10 @@ export function AdvancedOptionsModal({
{/* Negative Prompts */}
<div>
<label htmlFor="negative-prompts" className="block text-sm font-medium text-gray-300 mb-2">
<label
htmlFor="negative-prompts"
className="block text-sm font-medium text-gray-300 mb-2"
>
Negative Prompts
</label>
<textarea

View File

@ -8,7 +8,11 @@ interface GenerationTimerProps {
variant?: 'inline' | 'badge';
}
export function GenerationTimer({ isGenerating, startTime, variant = 'inline' }: GenerationTimerProps) {
export function GenerationTimer({
isGenerating,
startTime,
variant = 'inline',
}: GenerationTimerProps) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
@ -35,8 +39,18 @@ export function GenerationTimer({ isGenerating, startTime, variant = 'inline' }:
if (variant === 'badge') {
return (
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-md text-xs text-gray-300">
<svg className="w-3.5 h-3.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
className="w-3.5 h-3.5 text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">{formatTime(elapsed)}s</span>
</div>
@ -46,8 +60,19 @@ export function GenerationTimer({ isGenerating, startTime, variant = 'inline' }:
return (
<span className="inline-flex items-center gap-1.5 text-sm text-gray-400">
<svg className="w-4 h-4 text-amber-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="font-medium">{formatTime(elapsed)}s</span>
</span>
@ -66,7 +91,12 @@ export function CompletedTimerBadge({ durationMs }: CompletedTimerBadgeProps) {
return (
<div className="inline-flex items-center gap-1 px-2 py-1 bg-green-900/20 border border-green-700/50 rounded text-xs text-green-400">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">{formatTime(durationMs)}s</span>
</div>

View File

@ -34,11 +34,7 @@ export function InspectMode({ leftData, rightData, onCopy }: InspectModeProps) {
onCopy={onCopy}
defaultOpen={true}
/>
<CollapsibleSection
title="API Response"
data={leftData.response}
onCopy={onCopy}
/>
<CollapsibleSection title="API Response" data={leftData.response} onCopy={onCopy} />
<CollapsibleSection
title="Gemini Parameters"
data={leftData.geminiParams}
@ -55,11 +51,7 @@ export function InspectMode({ leftData, rightData, onCopy }: InspectModeProps) {
onCopy={onCopy}
defaultOpen={true}
/>
<CollapsibleSection
title="API Response"
data={rightData.response}
onCopy={onCopy}
/>
<CollapsibleSection title="API Response" data={rightData.response} onCopy={onCopy} />
<CollapsibleSection
title="Gemini Parameters"
data={rightData.geminiParams}

View File

@ -62,12 +62,7 @@ export function MinimizedApiKey({
className="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-gray-400 hover:text-white flex items-center justify-center transition-colors"
aria-label="Minimize API key details"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -91,12 +86,27 @@ export function MinimizedApiKey({
>
{keyVisible ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>

View File

@ -34,7 +34,12 @@ export function PromptReuseButton({ prompt, onReuse, label }: PromptReuseButtonP
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span className="font-medium">Reuse</span>
</>

View File

@ -75,15 +75,22 @@ export function ResultCard({
if (!result.enhancementOptions) return '';
const opts: any = {};
if (result.enhancementOptions.imageStyle) opts.imageStyle = result.enhancementOptions.imageStyle;
if (result.enhancementOptions.aspectRatio) opts.aspectRatio = result.enhancementOptions.aspectRatio;
if (result.enhancementOptions.imageStyle)
opts.imageStyle = result.enhancementOptions.imageStyle;
if (result.enhancementOptions.aspectRatio)
opts.aspectRatio = result.enhancementOptions.aspectRatio;
if (result.enhancementOptions.mood) opts.mood = result.enhancementOptions.mood;
if (result.enhancementOptions.lighting) opts.lighting = result.enhancementOptions.lighting;
if (result.enhancementOptions.cameraAngle) opts.cameraAngle = result.enhancementOptions.cameraAngle;
if (result.enhancementOptions.negativePrompts) opts.negativePrompts = result.enhancementOptions.negativePrompts;
if (result.enhancementOptions.cameraAngle)
opts.cameraAngle = result.enhancementOptions.cameraAngle;
if (result.enhancementOptions.negativePrompts)
opts.negativePrompts = result.enhancementOptions.negativePrompts;
if (Object.keys(opts).length === 0) return '';
return JSON.stringify(opts, null, 2).split('\n').map((line, i) => i === 0 ? line : ` ${line}`).join('\n');
return JSON.stringify(opts, null, 2)
.split('\n')
.map((line, i) => (i === 0 ? line : ` ${line}`))
.join('\n');
};
const enhancementOptionsJson = buildEnhancementOptionsJson();
@ -174,9 +181,7 @@ X-API-Key: ${apiKey}
{/* Header */}
<div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">
{result.timestamp.toLocaleString()}
</span>
<span className="text-sm text-gray-500">{result.timestamp.toLocaleString()}</span>
{result.durationMs && <CompletedTimerBadge durationMs={result.durationMs} />}
</div>
@ -185,9 +190,7 @@ X-API-Key: ${apiKey}
<button
onClick={() => setViewMode('preview')}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === 'preview'
? 'bg-amber-600 text-white'
: 'text-gray-400 hover:text-white'
viewMode === 'preview' ? 'bg-amber-600 text-white' : 'text-gray-400 hover:text-white'
}`}
aria-pressed={viewMode === 'preview'}
>
@ -196,9 +199,7 @@ X-API-Key: ${apiKey}
<button
onClick={() => setViewMode('inspect')}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === 'inspect'
? 'bg-amber-600 text-white'
: 'text-gray-400 hover:text-white'
viewMode === 'inspect' ? 'bg-amber-600 text-white' : 'text-gray-400 hover:text-white'
}`}
aria-pressed={viewMode === 'inspect'}
>

View File

@ -26,7 +26,11 @@ interface CreateKeyResponse {
message: string;
}
export async function bootstrapMasterKey(): Promise<{ success: boolean; apiKey?: string; error?: string }> {
export async function bootstrapMasterKey(): Promise<{
success: boolean;
apiKey?: string;
error?: string;
}> {
try {
const response = await fetch(`${API_BASE_URL}/api/bootstrap/initial-key`, {
method: 'POST',
@ -51,7 +55,7 @@ export async function bootstrapMasterKey(): Promise<{ success: boolean; apiKey?:
export async function createProjectApiKey(
masterKey: string,
orgSlug: string,
projectSlug: string
projectSlug: string,
): Promise<{ success: boolean; apiKey?: string; error?: string }> {
try {
// Call API service to create the project key (API auto-creates org/project)

View File

@ -29,7 +29,7 @@ export async function getOrCreateProject(organizationId: string, name: string):
export async function getOrCreateOrgAndProject(
email: string,
orgName: string,
projectName: string
projectName: string,
): Promise<{ organization: Organization; project: Project }> {
// Get or create organization
const organization = await getOrCreateOrganization(email, orgName);

View File

@ -2,7 +2,9 @@ import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '@banatie/database';
const connectionString = process.env.DATABASE_URL || 'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
const connectionString =
process.env.DATABASE_URL ||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
// Create postgres client
const client = postgres(connectionString);

View File

@ -13,17 +13,11 @@ export async function getOrganizationByEmail(email: string): Promise<Organizatio
}
export async function createOrganization(data: NewOrganization): Promise<Organization> {
const [org] = await db
.insert(organizations)
.values(data)
.returning();
const [org] = await db.insert(organizations).values(data).returning();
return org!;
}
export async function listOrganizations(): Promise<Organization[]> {
return db
.select()
.from(organizations)
.orderBy(organizations.createdAt);
return db.select().from(organizations).orderBy(organizations.createdAt);
}

View File

@ -2,26 +2,21 @@ import { db } from '../client';
import { projects, type Project, type NewProject } from '@banatie/database';
import { eq, and } from 'drizzle-orm';
export async function getProjectByName(organizationId: string, name: string): Promise<Project | null> {
export async function getProjectByName(
organizationId: string,
name: string,
): Promise<Project | null> {
const [project] = await db
.select()
.from(projects)
.where(
and(
eq(projects.organizationId, organizationId),
eq(projects.name, name)
)
)
.where(and(eq(projects.organizationId, organizationId), eq(projects.name, name)))
.limit(1);
return project || null;
}
export async function createProject(data: NewProject): Promise<Project> {
const [project] = await db
.insert(projects)
.values(data)
.returning();
const [project] = await db.insert(projects).values(data).returning();
return project!;
}

View File

@ -1,8 +1,5 @@
{
"extends": [
"next/core-web-vitals",
"prettier"
],
"extends": ["next/core-web-vitals", "prettier"],
"plugins": ["prettier"],
"rules": {
"prettier/prettier": "error"

View File

@ -11,6 +11,6 @@ const nextConfig = {
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
},
}
};
module.exports = nextConfig
module.exports = nextConfig;

View File

@ -1,22 +1,16 @@
import type { Metadata } from 'next'
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Banatie Studio - AI Image Generation SaaS',
description: 'Professional AI image generation platform with subscription management',
}
};
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-50">
<div className="min-h-screen">
{children}
</div>
<div className="min-h-screen">{children}</div>
</body>
</html>
)
);
}

View File

@ -3,12 +3,10 @@ export default function StudioPage() {
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="container mx-auto px-4 py-12">
<div className="text-center">
<h1 className="text-5xl font-bold text-gray-900 mb-6">
Banatie Studio
</h1>
<h1 className="text-5xl font-bold text-gray-900 mb-6">Banatie Studio</h1>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
Professional AI image generation platform with subscription management,
user authentication, and billing integration.
Professional AI image generation platform with subscription management, user
authentication, and billing integration.
</p>
<div className="grid md:grid-cols-3 gap-8 mt-12 max-w-4xl mx-auto">
@ -37,13 +35,13 @@ export default function StudioPage() {
<div className="mt-12">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 max-w-2xl mx-auto">
<p className="text-yellow-800">
<strong>Coming Soon:</strong> This SaaS platform is under development.
Based on Vercel&apos;s Next.js SaaS starter template.
<strong>Coming Soon:</strong> This SaaS platform is under development. Based on
Vercel&apos;s Next.js SaaS starter template.
</p>
</div>
</div>
</div>
</div>
</div>
)
);
}

View File

@ -7,6 +7,7 @@ This document defines the complete containerization and deployment architecture
## Architecture Summary
### Core Principles
- **Complete Service Isolation**: Banatie ecosystem is fully isolated from core VPS services
- **Dedicated Database**: Separate PostgreSQL container exclusively for Banatie
- **S3-Compatible Storage**: MinIO for scalable object storage with multi-tenant support
@ -30,29 +31,31 @@ Banatie Ecosystem (Isolated)
networks:
banatie-network:
driver: bridge
internal: true # No external internet access
internal: true # No external internet access
proxy-network:
external: true # Existing Caddy reverse proxy network
external: true # Existing Caddy reverse proxy network
```
### Network Access Matrix
| Service | banatie-network | proxy-network | External Access |
|---------|----------------|---------------|-----------------|
| Banatie App | ✅ Internal | ✅ HTTP only | ❌ Direct |
| PostgreSQL | ✅ Internal | ❌ None | ❌ None |
| MinIO | ✅ Internal | ✅ Console only | ❌ Direct |
| Caddy Proxy | ❌ None | ✅ Routing | ✅ Internet |
| Service | banatie-network | proxy-network | External Access |
| ----------- | --------------- | --------------- | --------------- |
| Banatie App | ✅ Internal | ✅ HTTP only | ❌ Direct |
| PostgreSQL | ✅ Internal | ❌ None | ❌ None |
| MinIO | ✅ Internal | ✅ Console only | ❌ Direct |
| Caddy Proxy | ❌ None | ✅ Routing | ✅ Internet |
### VPS Integration Points
**Isolation from Core Services:**
- **NO** shared resources with existing PostgreSQL (`/opt/services/`)
- **NO** access to NextCloud, Gitea, or other core services
- **ONLY** connection point: Caddy reverse proxy for HTTP routing
**Shared Infrastructure:**
- Caddy reverse proxy for SSL termination and routing
- Host filesystem for persistent data storage
- UFW firewall rules and security policies
@ -60,6 +63,7 @@ networks:
## Directory Structure
### Development Environment
```
banatie-service/
├── src/ # Application source code
@ -73,6 +77,7 @@ banatie-service/
```
### Production Environment (VPS)
```
/opt/banatie/ # Isolated deployment directory
├── docker-compose.yml # Production configuration
@ -115,6 +120,7 @@ CMD ["node", "dist/server.js"]
```
**Key Features:**
- Hot-reload support in development
- Production-optimized build with dependencies pruning
- Health check endpoints for monitoring
@ -127,6 +133,7 @@ CMD ["node", "dist/server.js"]
**Data**: User accounts, image metadata, upload sessions, organization settings
**Database Schema:**
- `users` - User authentication and profiles
- `organizations` - Multi-tenant organization data
- `images` - Generated image metadata and references
@ -140,6 +147,7 @@ CMD ["node", "dist/server.js"]
**Ports**: 9000 (S3 API), 9001 (Web Console)
**Storage Strategy:**
- Persistent volumes for data durability
- Bucket-per-organization architecture
- Lifecycle policies for temporary file cleanup
@ -169,12 +177,14 @@ banatie-{org-id}/ # Organization bucket (e.g., banatie-demo)
### Demo Organization Setup
**Default Configuration:**
- **Organization**: `demo` (org-id: demo)
- **User**: `guest` (user-id: guest)
- **Bucket**: `banatie-demo`
- **Access**: Public read for demo images
**Demo Bucket Structure:**
```
banatie-demo/
├── users/
@ -268,9 +278,9 @@ services:
target: development
container_name: banatie-app-dev
ports:
- "3000:3000"
- '3000:3000'
volumes:
- ./src:/app/src # Hot reload
- ./src:/app/src # Hot reload
- ./logs:/app/logs
networks:
- banatie-dev
@ -286,7 +296,7 @@ services:
image: postgres:15-alpine
container_name: banatie-postgres-dev
ports:
- "5433:5432" # Avoid conflicts with system PostgreSQL
- '5433:5432' # Avoid conflicts with system PostgreSQL
volumes:
- ./data/postgres:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql
@ -297,7 +307,7 @@ services:
POSTGRES_USER: banatie_user
POSTGRES_PASSWORD: development_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U banatie_user -d banatie_db"]
test: ['CMD-SHELL', 'pg_isready -U banatie_user -d banatie_db']
interval: 30s
timeout: 10s
retries: 3
@ -306,8 +316,8 @@ services:
image: minio/minio:latest
container_name: banatie-minio-dev
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web Console
- '9000:9000' # S3 API
- '9001:9001' # Web Console
volumes:
- ./data/minio:/data
networks:
@ -317,7 +327,7 @@ services:
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
interval: 30s
timeout: 10s
retries: 3
@ -355,7 +365,7 @@ services:
volumes:
- ./logs:/app/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
interval: 30s
timeout: 10s
retries: 3
@ -375,7 +385,7 @@ services:
POSTGRES_USER: banatie_user
POSTGRES_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U banatie_user -d banatie_db"]
test: ['CMD-SHELL', 'pg_isready -U banatie_user -d banatie_db']
interval: 30s
timeout: 10s
retries: 3
@ -394,7 +404,7 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
interval: 30s
timeout: 10s
retries: 3
@ -402,10 +412,10 @@ services:
networks:
banatie-network:
driver: bridge
internal: true # No external internet access
internal: true # No external internet access
proxy-network:
external: true # Existing Caddy network
external: true # Existing Caddy network
```
## Caddy Integration
@ -504,13 +514,29 @@ export interface StorageService {
listBuckets(): Promise<string[]>;
// File operations
uploadFile(orgId: string, userId: string, fileName: string, buffer: Buffer, contentType: string): Promise<string>;
uploadFile(
orgId: string,
userId: string,
fileName: string,
buffer: Buffer,
contentType: string,
): Promise<string>;
downloadFile(orgId: string, userId: string, fileName: string): Promise<Buffer>;
deleteFile(orgId: string, userId: string, fileName: string): Promise<void>;
// URL generation
getPresignedUploadUrl(orgId: string, userId: string, fileName: string, expirySeconds: number): Promise<string>;
getPresignedDownloadUrl(orgId: string, userId: string, fileName: string, expirySeconds: number): Promise<string>;
getPresignedUploadUrl(
orgId: string,
userId: string,
fileName: string,
expirySeconds: number,
): Promise<string>;
getPresignedDownloadUrl(
orgId: string,
userId: string,
fileName: string,
expirySeconds: number,
): Promise<string>;
// File management
listUserFiles(orgId: string, userId: string): Promise<FileMetadata[]>;
@ -536,14 +562,14 @@ export class MinioStorageService implements StorageService {
accessKey: string,
secretKey: string,
useSSL: boolean = false,
bucketPrefix: string = 'banatie'
bucketPrefix: string = 'banatie',
) {
this.client = new MinioClient({
endPoint: endpoint.replace(/^https?:\/\//, ''),
port: useSSL ? 443 : 9000,
useSSL,
accessKey,
secretKey
secretKey,
});
this.bucketPrefix = bucketPrefix;
}
@ -552,7 +578,11 @@ export class MinioStorageService implements StorageService {
return `${this.bucketPrefix}-${orgId}`;
}
private getFilePath(userId: string, category: 'generated' | 'references' | 'temp', fileName: string): string {
private getFilePath(
userId: string,
category: 'generated' | 'references' | 'temp',
fileName: string,
): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
@ -639,23 +669,27 @@ INSERT INTO users (id, organization_id, username, role) VALUES
### Local Development Setup
1. **Clone Repository**
```bash
git clone <repository-url>
cd banatie-service
```
2. **Environment Configuration**
```bash
cp .env.example .env
# Edit .env with development settings
```
3. **Start Development Environment**
```bash
docker-compose up -d
```
4. **Verify Services**
```bash
# Check service status
docker-compose ps
@ -670,6 +704,7 @@ docker-compose logs -f app
```
5. **Development with Hot Reload**
```bash
# Edit source files in src/
# Changes automatically reload in container
@ -693,6 +728,7 @@ docker-compose exec app pnpm lint
### VPS Deployment Process
1. **Prepare VPS Directory**
```bash
# SSH to VPS
ssh usul-vps
@ -704,6 +740,7 @@ cd /opt/banatie
```
2. **Clone and Configure**
```bash
# Clone repository
git clone <repository-url> .
@ -714,6 +751,7 @@ cp .env.example .env
```
3. **Generate Secure Credentials**
```bash
# Generate secure passwords
DB_PASSWORD=$(openssl rand -base64 32 | tr -d '\n\r ')
@ -732,6 +770,7 @@ chmod 600 .env
```
4. **Update Caddy Configuration**
```bash
# Add Banatie routes to Caddyfile
sudo nano /opt/services/configs/caddy/Caddyfile
@ -743,6 +782,7 @@ cd /opt/services
```
5. **Deploy Services**
```bash
cd /opt/banatie
@ -755,6 +795,7 @@ docker-compose logs -f
```
6. **Verify Deployment**
```bash
# Check health endpoints
curl https://banatie.app/health
@ -790,6 +831,7 @@ curl https://banatie.app/health
### Health Checks
**Application Health Check** (`/health`):
```json
{
"status": "healthy",
@ -804,23 +846,27 @@ curl https://banatie.app/health
```
**MinIO Health Check** (`/minio/health/live`):
- Returns 200 OK when MinIO is operational
- Used by Docker healthcheck and monitoring
### Log Management
**Application Logs**:
- Location: `/opt/banatie/logs/`
- Format: Structured JSON
- Rotation: Daily with 30-day retention
**Access Logs**:
- Caddy logs: `/opt/services/logs/banatie_*.log`
- Format: JSON with request/response details
### Backup Strategy
**Database Backup**:
```bash
# Create backup
docker exec banatie-postgres pg_dump -U banatie_user banatie_db > banatie_db_backup.sql
@ -830,6 +876,7 @@ docker exec -i banatie-postgres psql -U banatie_user banatie_db < banatie_db_bac
```
**MinIO Data Backup**:
```bash
# Backup MinIO data
sudo tar -czf banatie_minio_backup.tar.gz -C /opt/banatie/data minio/
@ -841,24 +888,28 @@ sudo tar -xzf banatie_minio_backup.tar.gz -C /opt/banatie/data
## Security Considerations
### Container Security
- Non-root users in containers
- Read-only root filesystems where possible
- Resource limits (memory, CPU)
- Health checks for automatic restart
### Network Security
- Internal network isolation
- No direct external access to database or MinIO
- Rate limiting at proxy level
- HTTPS-only external access
### Data Security
- Encrypted environment variables
- Secure secret generation
- Regular credential rotation
- Audit logging
### Access Control
- MinIO bucket policies per organization
- PostgreSQL row-level security
- JWT-based API authentication
@ -867,12 +918,14 @@ sudo tar -xzf banatie_minio_backup.tar.gz -C /opt/banatie/data
## Future Scalability
### Horizontal Scaling Options
- Multiple Banatie app containers behind load balancer
- MinIO distributed mode for storage scaling
- PostgreSQL read replicas for read scaling
- Redis for session storage and caching
### Multi-Region Deployment
- Regional MinIO clusters
- Database replication
- CDN integration for static assets
@ -883,6 +936,7 @@ sudo tar -xzf banatie_minio_backup.tar.gz -C /opt/banatie/data
### Common Issues
**Container Won't Start**:
```bash
# Check logs
docker-compose logs [service-name]
@ -896,6 +950,7 @@ docker-compose up -d [service-name]
```
**Database Connection Issues**:
```bash
# Test database connectivity
docker exec banatie-postgres pg_isready -U banatie_user -d banatie_db
@ -908,6 +963,7 @@ docker-compose restart banatie-postgres
```
**MinIO Access Issues**:
```bash
# Check MinIO status
docker exec banatie-minio mc admin info local
@ -920,6 +976,7 @@ docker exec banatie-app env | grep MINIO_
```
**Network Connectivity**:
```bash
# Check networks
docker network ls

View File

@ -1,17 +1,20 @@
# MinIO Setup Technical Specification
## Project Status
Starting MinIO integration from scratch. Previous implementation had compatibility issues with bucket policies in SNSD mode. New implementation uses SNMD (Single Node Multi Drive) configuration with presigned URLs for reliable file access.
## Architecture Overview
### Storage Strategy
- **Mode**: SNMD (4 virtual drives) for full S3 compatibility
- **Access Method**: Presigned URLs only (no bucket policies)
- **Bucket Structure**: Single bucket `banatie` with path-based organization
- **File Organization**: `orgId/projectId/category/year-month/filename`
### Technology Stack
- MinIO latest (`quay.io/minio/minio:latest`)
- Docker Compose for orchestration
- PostgreSQL for application data
@ -20,6 +23,7 @@ Starting MinIO integration from scratch. Previous implementation had compatibili
## Configuration Files Status
### Completed Files
- `docker-compose.yml` - SNMD configuration with 4 virtual drives
- `.env.docker` - Environment variables for development
- `src/services/MinioStorageService.ts` - Updated service implementation
@ -29,6 +33,7 @@ Starting MinIO integration from scratch. Previous implementation had compatibili
### Integration Requirements
#### 1. Update Application Router
Add images router to main application in `src/app.ts`:
```typescript
@ -39,6 +44,7 @@ app.use('/api', imagesRouter);
```
#### 2. Environment Variables Update
Update existing `.env` file with MinIO configuration:
```bash
@ -60,9 +66,11 @@ PRESIGNED_URL_EXPIRY=86400
```
#### 3. Database Script Update
Update `scripts/init-db.sql` to use new database name `banatie` instead of previous naming.
#### 4. Service Dependencies Update
Update existing image generation services to use new storage configuration:
```typescript
@ -74,7 +82,7 @@ const uploadResult = await storageService.uploadFile(
'generated',
filename,
buffer,
'image/png'
'image/png',
);
// Use uploadResult.url (returns API URL for presigned access)
```
@ -82,13 +90,16 @@ const uploadResult = await storageService.uploadFile(
## Setup Instructions
### 1. Directory Structure
Create required directories:
```bash
mkdir -p data/storage/{drive1,drive2,drive3,drive4}
mkdir -p data/postgres
```
### 2. Service Startup
```bash
# Start all services
docker-compose up -d
@ -106,11 +117,13 @@ docker logs banatie-storage-init
### 3. Verification Steps
#### MinIO Console Access
- URL: http://localhost:9001
- Username: banatie_admin
- Password: banatie_storage_secure_key_2024
#### Test Presigned URL Generation
```bash
# Test image upload endpoint
curl -X POST http://localhost:3000/api/upload \
@ -123,6 +136,7 @@ curl -I "http://localhost:3000/api/images/default/main/generated/test-image.png"
```
#### Verify SNMD Mode
```bash
# Check MinIO is in erasure coding mode
docker exec banatie-storage mc admin info local
@ -132,6 +146,7 @@ docker exec banatie-storage mc admin info local
## Integration Testing
### Required Tests
1. **Storage Service Initialization**
- Verify StorageFactory creates MinioStorageService
- Confirm bucket creation and accessibility
@ -152,6 +167,7 @@ docker exec banatie-storage mc admin info local
- Test MinIO connection failures
### Test Script Template
```bash
#!/bin/bash
# test-minio-integration.sh
@ -181,6 +197,7 @@ fi
## Production Considerations
### Environment Differences
Development and production use identical configuration with different values:
```bash
@ -191,12 +208,14 @@ MINIO_USE_SSL=true
```
### Security Notes
- All passwords use environment variables (no hardcoded values)
- Presigned URLs expire after 24 hours by default
- Service user has minimal required permissions
- MinIO admin access separate from application access
### Scaling Path
- Current SNMD setup supports development and small production loads
- Can migrate to distributed MinIO cluster when needed
- Presigned URL architecture remains unchanged during scaling
@ -204,11 +223,13 @@ MINIO_USE_SSL=true
## Troubleshooting
### Common Issues
1. **403 Errors**: Check presigned URL generation and MinIO service user permissions
2. **404 Errors**: Verify file paths and bucket configuration
3. **Connection Errors**: Confirm MinIO service health and network connectivity
### Debug Commands
```bash
# Check MinIO health
docker exec banatie-storage mc admin info local
@ -222,6 +243,7 @@ docker logs banatie-app
```
### Recovery Procedures
```bash
# Reset MinIO data (development only)
docker-compose down
@ -235,16 +257,19 @@ docker exec banatie-storage mc mb storage/banatie
## Implementation Priority
### Phase 1 (Immediate)
1. Update app.ts with images router
2. Update environment configuration
3. Test basic upload/download functionality
### Phase 2 (Next)
1. Update existing image generation services
2. Implement comprehensive error handling
3. Add integration tests
### Phase 3 (Future)
1. Add monitoring and logging
2. Implement file cleanup policies
3. Add CDN integration capability
@ -252,6 +277,7 @@ docker exec banatie-storage mc mb storage/banatie
## Acceptance Criteria
Integration is complete when:
- [ ] All services start successfully via docker-compose
- [ ] MinIO operates in SNMD mode with 4 drives
- [ ] Image upload returns API URL format

View File

@ -34,8 +34,8 @@
"typecheck:studio": "pnpm --filter @banatie/studio typecheck",
"typecheck:admin": "pnpm --filter @banatie/admin typecheck",
"test": "pnpm --filter @banatie/api-service test",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format": "prettier --write \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
"format:check": "prettier --check \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
"clean": "pnpm -r clean && rm -rf node_modules",
"install:all": "pnpm install"
},

View File

@ -5,6 +5,8 @@ export default {
out: './migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db',
url:
process.env.DATABASE_URL ||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db',
},
} satisfies Config;

View File

@ -55,16 +55,12 @@
"organizations_slug_unique": {
"name": "organizations_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
"columns": ["slug"]
},
"organizations_email_unique": {
"name": "organizations_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@ -121,12 +117,8 @@
"name": "projects_organization_id_organizations_id_fk",
"tableFrom": "projects",
"tableTo": "organizations",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -136,10 +128,7 @@
"projects_organization_id_slug_unique": {
"name": "projects_organization_id_slug_unique",
"nullsNotDistinct": false,
"columns": [
"organization_id",
"slug"
]
"columns": ["organization_id", "slug"]
}
},
"policies": {},
@ -240,12 +229,8 @@
"name": "api_keys_organization_id_organizations_id_fk",
"tableFrom": "api_keys",
"tableTo": "organizations",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@ -253,12 +238,8 @@
"name": "api_keys_project_id_projects_id_fk",
"tableFrom": "api_keys",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["project_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -268,9 +249,7 @@
"api_keys_key_hash_unique": {
"name": "api_keys_key_hash_unique",
"nullsNotDistinct": false,
"columns": [
"key_hash"
]
"columns": ["key_hash"]
}
},
"policies": {},

View File

@ -13,7 +13,9 @@ export const apiKeys = pgTable('api_keys', {
keyType: text('key_type').notNull().$type<'master' | 'project'>(),
// Foreign key relationships
organizationId: uuid('organization_id').references(() => organizations.id, { onDelete: 'cascade' }),
organizationId: uuid('organization_id').references(() => organizations.id, {
onDelete: 'cascade',
}),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }),
// Permissions (for future use)

View File

@ -10,7 +10,10 @@ export const organizations = pgTable('organizations', {
// Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});
export type Organization = typeof organizations.$inferSelect;

View File

@ -1,21 +1,30 @@
import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core';
import { organizations } from './organizations';
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
export const projects = pgTable(
'projects',
{
id: uuid('id').primaryKey().defaultRandom(),
// Project details
name: text('name').notNull(),
slug: text('slug').notNull(), // URL-friendly identifier for storage paths
organizationId: uuid('organization_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
// Project details
name: text('name').notNull(),
slug: text('slug').notNull(), // URL-friendly identifier for storage paths
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
// Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
}, (table) => ({
// Unique constraint: one project slug per organization
uniqueOrgProjectSlug: unique().on(table.organizationId, table.slug),
}));
// Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => ({
// Unique constraint: one project slug per organization
uniqueOrgProjectSlug: unique().on(table.organizationId, table.slug),
}),
);
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;