From e2cfd6e27f2399f8318da1dcbcc4b89f95de9389 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Sun, 5 Oct 2025 16:50:09 +0700 Subject: [PATCH] fix: service config --- .claude/commands/dev-api.md | 31 ++ .env.docker | 49 ---- CLAUDE.md | 33 ++- apps/api-service/Dockerfile.mono | 6 +- apps/api-service/package.json | 2 +- apps/api-service/src/routes/bootstrap.ts | 30 +- apps/api-service/src/routes/images.ts | 93 +++--- .../api-service/src/services/ApiKeyService.ts | 126 ++++---- .../src/services/ImageGenService.ts | 272 +++++++++--------- .../src/services/MinioStorageService.ts | 258 ++++++++++------- .../src/services/StorageFactory.ts | 41 ++- apps/api-service/src/types/api.ts | 10 + docker-compose.yml | 23 +- 13 files changed, 527 insertions(+), 447 deletions(-) create mode 100644 .claude/commands/dev-api.md delete mode 100644 .env.docker diff --git a/.claude/commands/dev-api.md b/.claude/commands/dev-api.md new file mode 100644 index 0000000..3f72dc7 --- /dev/null +++ b/.claude/commands/dev-api.md @@ -0,0 +1,31 @@ +--- +description: Start API dev server with automatic error monitoring and cleanup +--- + +You need to launch the API development server. Follow these steps carefully: + +1. **Kill any existing server on port 3000**: + - First check if there's a background shell running the dev server in Claude Code and kill it using the KillShell tool + - Then check for any process using port 3000 with `lsof -ti:3000` and kill it with `kill -9 $(lsof -ti:3000)` if found + - This ensures a clean restart regardless of where the server was started + +2. **Start the dev server in background**: + - Navigate to the API service directory and start the server: `cd /projects/my-projects/banatie-service/apps/api-service && pnpm dev` + - Run this command in the background using the Bash tool with `run_in_background: true` + - Save the shell ID for monitoring + +3. **Monitor the logs**: + - Wait 3-5 seconds for the server to start + - Use BashOutput tool to check the background process output for errors + - Read the api-dev.log file to verify the server started successfully + - Look for: + - Server startup message (should show "Server running on port 3000") + - Any error messages or stack traces + - Database/MinIO connection status + +4. **Report status**: + - Inform the user if the server started successfully + - Show any errors found in the logs + - Provide the shell ID so the user can monitor it later if needed + +CRITICAL: Always kill existing servers before starting a new one to avoid port conflicts. diff --git a/.env.docker b/.env.docker deleted file mode 100644 index 9bf3c3b..0000000 --- a/.env.docker +++ /dev/null @@ -1,49 +0,0 @@ -# Application Configuration -NODE_ENV=development -PORT=3000 -LOG_LEVEL=info -API_BASE_URL=http://localhost:3000 - -# CORS Configuration -CORS_ORIGIN=* - -# Database Configuration -DB_HOST=postgres -DB_PORT=5432 -DB_NAME=banatie_db -DB_USER=banatie_user -DB_PASSWORD=banatie_secure_password -DATABASE_URL=postgresql://banatie_user:banatie_secure_password@postgres:5432/banatie_db - -# MinIO Storage Configuration (SNMD) -MINIO_ROOT_USER=banatie_admin -MINIO_ROOT_PASSWORD=banatie_storage_secure_key_2024 -STORAGE_TYPE=minio -MINIO_ENDPOINT=minio:9000 -MINIO_ACCESS_KEY=banatie_service -MINIO_SECRET_KEY=banatie_service_key_2024 -MINIO_USE_SSL=false -MINIO_BUCKET_NAME=banatie -MINIO_PUBLIC_URL=http://localhost:9000 - -# AI Service Configuration -GEMINI_API_KEY=AIzaSyBaOt9JMPGKA3811FL-ssf1n5Hh9Jauly8 - -# File Upload Configuration -MAX_FILE_SIZE=5242880 -MAX_FILES=3 - -# Multi-tenancy Configuration (Production-Ready Names) -DEFAULT_ORG_ID=default -DEFAULT_PROJECT_ID=main -DEFAULT_USER_ID=system - -# Presigned URL Configuration -PRESIGNED_URL_EXPIRY=86400 # 24 hours - -# Directory Configuration -RESULTS_DIR=/app/results -UPLOADS_DIR=/app/uploads/temp - -# Logging Configuration -LOG_LEVEL=info \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 6a863c2..785c88f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,25 +122,36 @@ Key table: `api_keys` ## Environment Configuration -### Root Environment (`.env.docker`) +**Important**: We use TWO `.env` files with different purposes: -- `DATABASE_URL` - PostgreSQL connection string (for Docker: `postgresql://banatie_user:banatie_secure_password@postgres:5432/banatie_db`) -- `MINIO_ROOT_USER` - MinIO admin username -- `MINIO_ROOT_PASSWORD` - MinIO admin password +### Root `.env` (Docker Compose Infrastructure) -### API Service Environment (`apps/api-service/.env`) +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 +- All variables are passed to the app container via docker-compose.yml environment section -Required environment variables: +### API Service `.env` (Local Development Only) -- `DATABASE_URL` - PostgreSQL connection string (for local dev: `postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db`) +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) + +### Required Environment Variables + +- `DATABASE_URL` - PostgreSQL connection string - `GEMINI_API_KEY` - Google Gemini API key (required) -- `MINIO_ENDPOINT` - MinIO endpoint (`localhost:9000` for local dev, `minio:9000` for Docker) -- `MINIO_ACCESS_KEY` - MinIO service account key -- `MINIO_SECRET_KEY` - MinIO service account secret +- `MINIO_ENDPOINT` - MinIO endpoint +- `MINIO_ACCESS_KEY` - MinIO service account key (`banatie_service`) +- `MINIO_SECRET_KEY` - MinIO service account secret (`banatie_service_key_2024`) - `MINIO_BUCKET_NAME` - Storage bucket name (default: `banatie`) +- `MINIO_ROOT_USER` - MinIO admin user (`banatie_admin`) +- `MINIO_ROOT_PASSWORD` - MinIO admin password - `PORT` - Server port (default: 3000) - `NODE_ENV` - Environment mode -- `CORS_ORIGIN` - CORS origin setting (default: multiple localhost URLs for frontend apps) +- `CORS_ORIGIN` - CORS origin setting ## Key Dependencies diff --git a/apps/api-service/Dockerfile.mono b/apps/api-service/Dockerfile.mono index 454f408..e4d3376 100644 --- a/apps/api-service/Dockerfile.mono +++ b/apps/api-service/Dockerfile.mono @@ -11,7 +11,11 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ # Copy all workspace packages COPY packages/ ./packages/ -COPY apps/api-service/ ./apps/api-service/ + +# Copy API service (exclude .env file - it's for local dev only) +COPY apps/api-service/package.json ./apps/api-service/ +COPY apps/api-service/tsconfig.json ./apps/api-service/ +COPY apps/api-service/src/ ./apps/api-service/src/ # Install all dependencies (workspace-aware) RUN pnpm install --frozen-lockfile diff --git a/apps/api-service/package.json b/apps/api-service/package.json index 76fef4a..a4b707d 100644 --- a/apps/api-service/package.json +++ b/apps/api-service/package.json @@ -4,7 +4,7 @@ "description": "Nano Banana Image Generation Service - REST API for AI-powered image generation using Gemini Flash Image model", "main": "dist/server.js", "scripts": { - "dev": "tsx --watch src/server.ts", + "dev": "echo 'Logs will be saved to api-dev.log' && tsx --watch src/server.ts 2>&1 | tee api-dev.log", "start": "node dist/server.js", "build": "tsc", "typecheck": "tsc --noEmit", diff --git a/apps/api-service/src/routes/bootstrap.ts b/apps/api-service/src/routes/bootstrap.ts index 73011d7..7e6963b 100644 --- a/apps/api-service/src/routes/bootstrap.ts +++ b/apps/api-service/src/routes/bootstrap.ts @@ -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,38 +10,44 @@ 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}`); + console.log( + `[${new Date().toISOString()}] Initial master key created via bootstrap: ${metadata.id}`, + ); res.status(201).json({ apiKey: key, 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", }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/apps/api-service/src/routes/images.ts b/apps/api-service/src/routes/images.ts index c1082ad..c4f873c 100644 --- a/apps/api-service/src/routes/images.ts +++ b/apps/api-service/src/routes/images.ts @@ -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,54 +9,55 @@ 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", }); } - const storageService = StorageFactory.getInstance(); + const storageService = await StorageFactory.getInstance(); try { // Check if file exists first (fast check) const exists = await storageService.fileExists( orgId, projectId, - category as 'uploads' | 'generated' | 'references', - filename + 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 contentType = { - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - 'webp': 'image/webp', - 'svg': 'image/svg+xml' - }[ext || ''] || 'application/octet-stream'; + const ext = filename.toLowerCase().split(".").pop(); + const contentType = + { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + }[ext || ""] || "application/octet-stream"; // Set headers for optimal caching and performance - 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 } @@ -65,32 +66,31 @@ imagesRouter.get( const fileStream = await storageService.streamFile( orgId, projectId, - category as 'uploads' | 'generated' | 'references', - filename + 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", }); } }); // 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,41 +98,40 @@ 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 - - if (!['uploads', 'generated', 'references'].includes(category)) { + const { expiry = "3600" } = req.query; // Default 1 hour + + if (!["uploads", "generated", "references"].includes(category)) { return res.status(400).json({ success: false, - message: 'Invalid category' + message: "Invalid category", }); } - const storageService = StorageFactory.getInstance(); + const storageService = await StorageFactory.getInstance(); try { const presignedUrl = await storageService.getPresignedDownloadUrl( orgId, projectId, - category as 'uploads' | 'generated' | 'references', + category as "uploads" | "generated" | "references", filename, - parseInt(expiry as string, 10) + parseInt(expiry as string, 10), ); return res.json({ success: true, url: presignedUrl, - expiresIn: parseInt(expiry as string, 10) + 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", }); } - }) -); \ No newline at end of file + }), +); diff --git a/apps/api-service/src/services/ApiKeyService.ts b/apps/api-service/src/services/ApiKeyService.ts index bce5426..87c9842 100644 --- a/apps/api-service/src/services/ApiKeyService.ts +++ b/apps/api-service/src/services/ApiKeyService.ts @@ -1,23 +1,24 @@ -import crypto from 'crypto'; -import { db } from '../db'; -import { apiKeys, type ApiKey, type NewApiKey } from '@banatie/database'; -import { eq, and, desc } from 'drizzle-orm'; +import crypto from "crypto"; +import { db } from "../db"; +import { apiKeys, type ApiKey, type NewApiKey } from "@banatie/database"; +import { eq, and, desc } from "drizzle-orm"; export class ApiKeyService { /** * Generate a new API key * Format: bnt_{64_hex_chars} */ - private generateKey(): { fullKey: string; keyHash: string; keyPrefix: string } { - const secret = crypto.randomBytes(32).toString('hex'); // 64 chars - const keyPrefix = 'bnt_'; + private generateKey(): { + fullKey: string; + keyHash: string; + keyPrefix: string; + } { + 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 }; } @@ -25,21 +26,29 @@ export class ApiKeyService { /** * Create a master key (admin access, never expires) */ - async createMasterKey(name?: string, createdBy?: string): Promise<{ key: string; metadata: ApiKey }> { + async createMasterKey( + name?: string, + createdBy?: string, + ): Promise<{ key: string; metadata: ApiKey }> { const { fullKey, keyHash, keyPrefix } = this.generateKey(); - const [newKey] = await db.insert(apiKeys).values({ - keyHash, - keyPrefix, - keyType: 'master', - projectId: null, - scopes: ['*'], // Full access - name: name || 'Master Key', - expiresAt: null, // Never expires - createdBy: createdBy || null, - }).returning(); + const [newKey] = await db + .insert(apiKeys) + .values({ + keyHash, + keyPrefix, + keyType: "master", + projectId: null, + scopes: ["*"], // Full access + name: name || "Master Key", + expiresAt: null, // Never expires + createdBy: createdBy || null, + }) + .returning(); - console.log(`[${new Date().toISOString()}] Master key created: ${newKey?.id} - ${newKey?.name}`); + console.log( + `[${new Date().toISOString()}] Master key created: ${newKey?.id} - ${newKey?.name}`, + ); return { key: fullKey, metadata: newKey! }; } @@ -52,26 +61,31 @@ export class ApiKeyService { organizationId?: string, name?: string, createdBy?: string, - expiresInDays: number = 90 + expiresInDays: number = 90, ): Promise<{ key: string; metadata: ApiKey }> { const { fullKey, keyHash, keyPrefix } = this.generateKey(); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + expiresInDays); - const [newKey] = await db.insert(apiKeys).values({ - keyHash, - keyPrefix, - keyType: 'project', - projectId, - organizationId: organizationId || null, - scopes: ['generate', 'read'], - name: name || `Project Key - ${projectId}`, - expiresAt, - createdBy: createdBy || null, - }).returning(); + const [newKey] = await db + .insert(apiKeys) + .values({ + keyHash, + keyPrefix, + keyType: "project", + projectId, + organizationId: organizationId || null, + scopes: ["generate", "read"], + name: name || `Project Key - ${projectId}`, + expiresAt, + createdBy: createdBy || null, + }) + .returning(); - console.log(`[${new Date().toISOString()}] Project key created: ${newKey?.id} - ${projectId} - expires: ${expiresAt.toISOString()}`); + console.log( + `[${new Date().toISOString()}] Project key created: ${newKey?.id} - ${projectId} - expires: ${expiresAt.toISOString()}`, + ); return { key: fullKey, metadata: newKey! }; } @@ -81,36 +95,35 @@ export class ApiKeyService { * Returns null if invalid/expired/revoked */ async validateKey(providedKey: string): Promise { - if (!providedKey || !providedKey.startsWith('bnt_')) { + if (!providedKey || !providedKey.startsWith("bnt_")) { return null; } // Hash the provided key const keyHash = crypto - .createHash('sha256') + .createHash("sha256") .update(providedKey) - .digest('hex'); + .digest("hex"); // Find in database const [key] = await db .select() .from(apiKeys) - .where( - and( - eq(apiKeys.keyHash, keyHash), - eq(apiKeys.isActive, true) - ) - ) + .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true))) .limit(1); if (!key) { - console.warn(`[${new Date().toISOString()}] Invalid API key attempt: ${providedKey.substring(0, 10)}...`); + console.warn( + `[${new Date().toISOString()}] Invalid API key attempt: ${providedKey.substring(0, 10)}...`, + ); return null; } // Check expiration if (key.expiresAt && key.expiresAt < new Date()) { - console.warn(`[${new Date().toISOString()}] Expired API key used: ${key.id} - expired: ${key.expiresAt.toISOString()}`); + console.warn( + `[${new Date().toISOString()}] Expired API key used: ${key.id} - expired: ${key.expiresAt.toISOString()}`, + ); return null; } @@ -119,7 +132,12 @@ export class ApiKeyService { .set({ lastUsedAt: new Date() }) .where(eq(apiKeys.id, key.id)) .execute() - .catch(err => console.error(`[${new Date().toISOString()}] Failed to update lastUsedAt:`, err)); + .catch((err) => + console.error( + `[${new Date().toISOString()}] Failed to update lastUsedAt:`, + err, + ), + ); return key; } @@ -146,21 +164,15 @@ export class ApiKeyService { * List all keys (for admin) */ async listKeys(): Promise { - return db - .select() - .from(apiKeys) - .orderBy(desc(apiKeys.createdAt)); + return db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)); } /** * Check if any keys exist (for bootstrap) */ async hasAnyKeys(): Promise { - const keys = await db - .select({ id: apiKeys.id }) - .from(apiKeys) - .limit(1); + const keys = await db.select({ id: apiKeys.id }).from(apiKeys).limit(1); return keys.length > 0; } -} \ No newline at end of file +} diff --git a/apps/api-service/src/services/ImageGenService.ts b/apps/api-service/src/services/ImageGenService.ts index caba69f..0167f7d 100644 --- a/apps/api-service/src/services/ImageGenService.ts +++ b/apps/api-service/src/services/ImageGenService.ts @@ -1,19 +1,17 @@ import { GoogleGenAI } from "@google/genai"; // eslint-disable-next-line @typescript-eslint/no-var-requires const mime = require("mime") as any; -import path from "path"; import { ImageGenerationOptions, ImageGenerationResult, ReferenceImage, + GeneratedImageData, } from "../types/api"; import { StorageFactory } from "./StorageFactory"; -import { UploadResult } from "./StorageService"; export class ImageGenService { private ai: GoogleGenAI; private primaryModel = "gemini-2.5-flash-image-preview"; - private fallbackModel = "imagen-4.0-generate-001"; constructor(apiKey: string) { if (!apiKey) { @@ -24,182 +22,168 @@ 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 { - const { prompt, filename, referenceImages, orgId, projectId, userId } = - options; - const timestamp = new Date().toISOString(); + const { prompt, filename, referenceImages, orgId, projectId } = 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 finalUserId = userId || process.env["DEFAULT_USER_ID"] || "system"; - + // Step 1: Generate image from Gemini AI + let generatedData: GeneratedImageData; try { - const result = await this.tryGeneration({ - model: this.primaryModel, - config: { responseModalities: ["IMAGE", "TEXT"] }, - prompt, - filename, - orgId: finalOrgId, - projectId: finalProjectId, - userId: finalUserId, - ...(referenceImages && { referenceImages }), - modelName: "Primary Model", - }); - - if (result.success) { - return result; - } - - - return await this.tryGeneration({ - model: this.fallbackModel, - config: { responseModalities: ["IMAGE"] }, - prompt, - filename: `${filename}_fallback`, - orgId: finalOrgId, - projectId: finalProjectId, - userId: finalUserId, - ...(referenceImages && { referenceImages }), - modelName: "Fallback Model", - }); + generatedData = await this.generateImageWithAI(prompt, referenceImages); } catch (error) { + // Generation failed - return explicit error return { success: false, - model: "none", + model: this.primaryModel, error: - error instanceof Error ? error.message : "Unknown error occurred", + error instanceof Error ? error.message : "Image generation failed", + errorType: "generation", + }; + } + + // Step 2: Save generated image to storage + try { + const finalFilename = `${filename}.${generatedData.fileExtension}`; + const storageService = await StorageFactory.getInstance(); + const uploadResult = await storageService.uploadFile( + finalOrgId, + finalProjectId, + "generated", + finalFilename, + generatedData.buffer, + generatedData.mimeType, + ); + + if (uploadResult.success) { + return { + success: true, + filename: uploadResult.filename, + filepath: uploadResult.path, + url: uploadResult.url, + model: this.primaryModel, + ...(generatedData.description && { + description: generatedData.description, + }), + }; + } else { + // Storage failed but image was generated + return { + success: false, + model: this.primaryModel, + error: `Image generated successfully but storage failed: ${uploadResult.error || "Unknown storage error"}`, + errorType: "storage", + generatedImageData: generatedData, + ...(generatedData.description && { + description: generatedData.description, + }), + }; + } + } catch (error) { + // Storage exception - image was generated but couldn't be saved + return { + success: false, + model: this.primaryModel, + error: `Image generated successfully but storage failed: ${error instanceof Error ? error.message : "Unknown storage error"}`, + errorType: "storage", + generatedImageData: generatedData, + ...(generatedData.description && { + description: generatedData.description, + }), }; } } - private async tryGeneration(params: { - model: string; - config: { responseModalities: string[] }; - prompt: string; - filename: string; - orgId: string; - projectId: string; - userId: string; - referenceImages?: ReferenceImage[]; - modelName: string; - }): Promise { - const { - model, - config, - prompt, - filename, - orgId, - projectId, - userId, - referenceImages, - modelName, - } = params; + /** + * Generate image using Gemini AI - isolated from storage logic + * @throws Error if generation fails + */ + private async generateImageWithAI( + prompt: string, + referenceImages?: ReferenceImage[], + ): Promise { + const contentParts: any[] = []; + + // Add reference images if provided + if (referenceImages && referenceImages.length > 0) { + for (const refImage of referenceImages) { + contentParts.push({ + inlineData: { + mimeType: refImage.mimetype, + data: refImage.buffer.toString("base64"), + }, + }); + } + } + + // Add text prompt + contentParts.push({ + text: prompt, + }); + + const contents = [ + { + role: "user" as const, + parts: contentParts, + }, + ]; try { - const contentParts: any[] = []; - - if (referenceImages && referenceImages.length > 0) { - - for (const refImage of referenceImages) { - contentParts.push({ - inlineData: { - mimeType: refImage.mimetype, - data: refImage.buffer.toString("base64"), - }, - }); - } - } - - contentParts.push({ - text: prompt, - }); - - const contents = [ - { - role: "user" as const, - parts: contentParts, - }, - ]; - - const response = await this.ai.models.generateContent({ - model, - config, + model: this.primaryModel, + config: { responseModalities: ["IMAGE", "TEXT"] }, contents, }); - + // Parse response if ( - response.candidates && - response.candidates[0] && - response.candidates[0].content + !response.candidates || + !response.candidates[0] || + !response.candidates[0].content ) { - const content = response.candidates[0].content; - let generatedDescription = ""; - let uploadResult: UploadResult | null = null; + throw new Error("No response received from Gemini AI"); + } - for (let index = 0; index < (content.parts?.length || 0); index++) { - const part = content.parts?.[index]; - if (!part) continue; + const content = response.candidates[0].content; + let generatedDescription: string | undefined; + let imageData: { buffer: Buffer; mimeType: string } | null = null; - if (part.inlineData) { - const fileExtension = mime.getExtension( - part.inlineData.mimeType || "", - ); - const finalFilename = `${filename}.${fileExtension}`; - const contentType = - part.inlineData.mimeType || `image/${fileExtension}`; - - - const buffer = Buffer.from(part.inlineData.data || "", "base64"); - - const storageService = StorageFactory.getInstance(); - const result = (await storageService).uploadFile( - orgId, - projectId, - "generated", - finalFilename, - buffer, - contentType, - ); - - uploadResult = await result; - - } else if (part.text) { - generatedDescription = part.text; - } - } - - - if (uploadResult && uploadResult.success) { - return { - success: true, - filename: uploadResult.filename, - filepath: uploadResult.path, - url: uploadResult.url, - description: generatedDescription, - model: modelName, - }; + // 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"; + imageData = { buffer, mimeType }; + } else if (part.text) { + generatedDescription = part.text; } } + if (!imageData) { + throw new Error("No image data received from Gemini AI"); + } + + const fileExtension = mime.getExtension(imageData.mimeType) || "png"; + return { - success: false, - model: modelName, - error: "No image data received from API", + buffer: imageData.buffer, + mimeType: imageData.mimeType, + fileExtension, + ...(generatedDescription && { description: generatedDescription }), }; } catch (error) { - return { - success: false, - model: modelName, - error: error instanceof Error ? error.message : "Generation failed", - }; + // Re-throw with clear error message + if (error instanceof Error) { + throw new Error(`Gemini AI generation failed: ${error.message}`); + } + throw new Error("Gemini AI generation failed: Unknown error"); } } diff --git a/apps/api-service/src/services/MinioStorageService.ts b/apps/api-service/src/services/MinioStorageService.ts index ade4692..410e89a 100644 --- a/apps/api-service/src/services/MinioStorageService.ts +++ b/apps/api-service/src/services/MinioStorageService.ts @@ -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,13 +11,13 @@ export class MinioStorageService implements StorageService { accessKey: string, secretKey: string, useSSL: boolean = false, - bucketName: string = 'banatie', - publicUrl?: string + bucketName: string = "banatie", + publicUrl?: string, ) { // Parse endpoint to separate hostname and port - const cleanEndpoint = endpoint.replace(/^https?:\/\//, ''); - const [hostname, portStr] = cleanEndpoint.split(':'); - const port = portStr ? parseInt(portStr, 10) : (useSSL ? 443 : 9000); + const cleanEndpoint = endpoint.replace(/^https?:\/\//, ""); + const [hostname, portStr] = cleanEndpoint.split(":"); + const port = portStr ? parseInt(portStr, 10) : useSSL ? 443 : 9000; if (!hostname) { throw new Error(`Invalid MinIO endpoint: ${endpoint}`); @@ -25,20 +25,20 @@ export class MinioStorageService implements StorageService { this.client = new MinioClient({ endPoint: hostname, - port: port, + port, useSSL, accessKey, - secretKey + 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', - filename: string + category: "uploads" | "generated" | "references", + filename: string, ): string { // Simplified path without date folder for now return `${orgId}/${projectId}/${category}/${filename}`; @@ -50,11 +50,11 @@ 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,49 +63,70 @@ 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 } - private validateFilePath(orgId: string, projectId: string, category: string, filename: string): void { + private validateFilePath( + orgId: string, + projectId: string, + category: string, + filename: string, + ): void { // Validate orgId if (!orgId || !/^[a-zA-Z0-9_-]+$/.test(orgId) || orgId.length > 50) { - throw new Error('Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars'); + throw new Error( + "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) { - throw new Error('Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars'); + 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", + ); } // 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 { 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}`); } @@ -120,20 +141,20 @@ 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 + contentType: string, ): Promise { // Validate inputs first 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 @@ -141,15 +162,20 @@ 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}`); @@ -159,7 +185,7 @@ export class MinioStorageService implements StorageService { filePath, buffer, buffer.length, - metadata + metadata, ); const url = this.getPublicUrl(orgId, projectId, category, uniqueFilename); @@ -172,15 +198,15 @@ export class MinioStorageService implements StorageService { path: filePath, url, size: buffer.length, - contentType + contentType, }; } async downloadFile( orgId: string, projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string + category: "uploads" | "generated" | "references", + filename: string, ): Promise { this.validateFilePath(orgId, projectId, category, filename); const filePath = this.getFilePath(orgId, projectId, category, filename); @@ -189,18 +215,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', - filename: string - ): Promise { + category: "uploads" | "generated" | "references", + filename: string, + ): Promise { this.validateFilePath(orgId, projectId, category, filename); const filePath = this.getFilePath(orgId, projectId, category, filename); @@ -211,8 +237,8 @@ export class MinioStorageService implements StorageService { async deleteFile( orgId: string, projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string + category: "uploads" | "generated" | "references", + filename: string, ): Promise { this.validateFilePath(orgId, projectId, category, filename); const filePath = this.getFilePath(orgId, projectId, category, filename); @@ -222,52 +248,61 @@ export class MinioStorageService implements StorageService { getPublicUrl( orgId: string, projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string + category: "uploads" | "generated" | "references", + filename: string, ): string { this.validateFilePath(orgId, projectId, category, filename); // Production-ready: Return API URL for presigned URL access - const apiBaseUrl = process.env['API_BASE_URL'] || 'http://localhost:3000'; + 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 + contentType: string, ): Promise { 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 + expirySeconds: number = 86400, // 24 hours default ): Promise { this.validateFilePath(orgId, projectId, category, filename); const filePath = this.getFilePath(orgId, projectId, category, filename); - const presignedUrl = await this.client.presignedGetObject(this.bucketName, filePath, expirySeconds); + const presignedUrl = await this.client.presignedGetObject( + this.bucketName, + filePath, + expirySeconds, + ); // 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 + this.publicUrl, ); } @@ -277,24 +312,32 @@ export class MinioStorageService implements StorageService { async listProjectFiles( orgId: string, projectId: string, - category?: 'uploads' | 'generated' | 'references' + category?: "uploads" | "generated" | "references", ): Promise { - 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; @@ -303,28 +346,35 @@ 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), - createdAt: obj.lastModified || new Date() + url: this.getPublicUrl( + orgId, + projectId, + categoryFromPath, + filename, + ), + createdAt: obj.lastModified || new Date(), }); - } catch (error) { - } + } 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 { - const match = key.match(/^banatie\/([^/]+)\/([^/]+)\/(uploads|generated|references)\/[^/]+\/(.+)$/); + const match = key.match( + /^banatie\/([^/]+)\/([^/]+)\/(uploads|generated|references)\/[^/]+\/(.+)$/, + ); if (!match) { return null; @@ -339,20 +389,19 @@ export class MinioStorageService implements StorageService { return { orgId, projectId, - category: category as 'uploads' | 'generated' | 'references', - filename + category: category as "uploads" | "generated" | "references", + filename, }; } catch { return null; } } - async fileExists( orgId: string, projectId: string, - category: 'uploads' | 'generated' | 'references', - filename: string + category: "uploads" | "generated" | "references", + filename: string, ): Promise { try { this.validateFilePath(orgId, projectId, category, filename); @@ -367,10 +416,10 @@ export class MinioStorageService implements StorageService { async listFiles( orgId: string, projectId: string, - category: 'uploads' | 'generated' | 'references', - prefix?: string + category: "uploads" | "generated" | "references", + prefix?: string, ): Promise { - 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; @@ -378,33 +427,40 @@ 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 + path: obj.name, }); - } catch (error) { - } + } catch (error) {} }); - stream.on('end', () => resolve(files)); - stream.on('error', reject); + stream.on("end", () => resolve(files)); + stream.on("error", reject); }); } -} \ No newline at end of file +} diff --git a/apps/api-service/src/services/StorageFactory.ts b/apps/api-service/src/services/StorageFactory.ts index 9bbb502..9e841bb 100644 --- a/apps/api-service/src/services/StorageFactory.ts +++ b/apps/api-service/src/services/StorageFactory.ts @@ -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,32 +30,30 @@ 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; } - private static async createStorageServiceWithRetry(): Promise { const maxRetries = 3; const baseDelay = 1000; // 1 second for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - const service = this.createStorageService(); await service.bucketExists(); return service; - } catch (error) { - 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"}`, ); } @@ -64,40 +62,39 @@ 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 { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } 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", ); } - return new MinioStorageService( endpoint, accessKey, secretKey, useSSL, bucketName, - publicUrl + publicUrl, ); } @@ -113,4 +110,4 @@ export class StorageFactory { this.instance = null; this.initializationPromise = null; } -} \ No newline at end of file +} diff --git a/apps/api-service/src/types/api.ts b/apps/api-service/src/types/api.ts index d9d3a68..f7fbc09 100644 --- a/apps/api-service/src/types/api.ts +++ b/apps/api-service/src/types/api.ts @@ -77,6 +77,16 @@ export interface ImageGenerationResult { description?: string; model: string; error?: string; + errorType?: "generation" | "storage"; // Distinguish between generation and storage errors + generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails +} + +// Intermediate result after image generation, before storage +export interface GeneratedImageData { + buffer: Buffer; + mimeType: string; + fileExtension: string; + description?: string; } // Logging types diff --git a/docker-compose.yml b/docker-compose.yml index 6ab033d..6570e3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,8 +21,27 @@ services: condition: service_healthy environment: - NODE_ENV=development - env_file: - - .env.docker + - DATABASE_URL=${DATABASE_URL} + - GEMINI_API_KEY=${GEMINI_API_KEY} + - STORAGE_TYPE=${STORAGE_TYPE} + - MINIO_ENDPOINT=${MINIO_ENDPOINT} + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} + - MINIO_USE_SSL=${MINIO_USE_SSL} + - MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME} + - MINIO_PUBLIC_URL=${MINIO_PUBLIC_URL} + - API_BASE_URL=${API_BASE_URL} + - DEFAULT_ORG_ID=${DEFAULT_ORG_ID} + - DEFAULT_PROJECT_ID=${DEFAULT_PROJECT_ID} + - DEFAULT_USER_ID=${DEFAULT_USER_ID} + - PRESIGNED_URL_EXPIRY=${PRESIGNED_URL_EXPIRY} + - MAX_FILE_SIZE=${MAX_FILE_SIZE} + - MAX_FILES=${MAX_FILES} + - RESULTS_DIR=${RESULTS_DIR} + - UPLOADS_DIR=${UPLOADS_DIR} + - LOG_LEVEL=${LOG_LEVEL} + - PORT=${PORT} + - CORS_ORIGIN=${CORS_ORIGIN} restart: unless-stopped postgres: