feat(chat,picture): add OpenRouter integration and credit system

Chat:
- Add OpenRouter as primary AI provider with multiple models
- Update chat service with new model configurations
- Add model seed data for Llama, DeepSeek, Mistral, Claude, GPT-4o

Picture:
- Integrate @mana-core/nestjs-integration for credit system
- Implement freemium model (3 free generations, then 10 credits)
- Migrate storage to @manacore/shared-storage
- Add comprehensive project documentation
This commit is contained in:
Wuesteon 2025-12-10 20:46:33 +01:00
parent 422fcd6b34
commit 6f74e1d9a6
12 changed files with 878 additions and 468 deletions

View file

@ -66,7 +66,7 @@ pnpm preview # Preview production build
- **Mobile**: React Native 0.76.7 + Expo SDK 52, NativeWind, Expo Router
- **Web**: SvelteKit 2.x, Svelte 5, Tailwind CSS 4
- **Landing**: Astro 5.16, Tailwind CSS
- **Backend**: NestJS 10, Google Gemini AI, Supabase
- **Backend**: NestJS 10, OpenRouter/Gemini AI, Supabase
- **Types**: TypeScript 5.x
## Architecture
@ -89,11 +89,12 @@ pnpm preview # Preview production build
#### Backend (.env)
```
GOOGLE_GENAI_API_KEY=...
OPENROUTER_API_KEY=... # Get at https://openrouter.ai/keys
GOOGLE_GENAI_API_KEY=... # Optional: For Gemini models
SUPABASE_URL=https://...
SUPABASE_SERVICE_KEY=...
PORT=3002
DEV_BYPASS_AUTH=true # Optional: Skip auth in development
DEV_BYPASS_AUTH=true # Optional: Skip auth in development
```
#### Mobile (.env)
@ -114,11 +115,33 @@ EXPO_PUBLIC_BACKEND_URL=http://localhost:3001
## AI Models Available
| Model ID | Name | Description | Default |
| ------------------------------------ | --------------------- | ------------------------- | ------- |
| 550e8400-e29b-41d4-a716-446655440101 | Gemini 2.5 Flash | Fast, efficient responses | Yes |
| 550e8400-e29b-41d4-a716-446655440102 | Gemini 2.0 Flash-Lite | Ultra-lightweight model | No |
| 550e8400-e29b-41d4-a716-446655440103 | Gemini 2.5 Pro | Most capable model | No |
### OpenRouter Models (Recommended)
| Model ID | Name | Price | Best For |
| -------- | ---- | ----- | -------- |
| ...440201 | Llama 3.1 8B | $0.05/M | Everyday tasks, cheap |
| ...440202 | Llama 3.1 70B | $0.35/M | Complex reasoning |
| ...440203 | DeepSeek V3 | $0.14/M | Reasoning at low cost |
| ...440204 | Mistral Small | $0.10/M | General tasks |
| ...440205 | Claude 3.5 Sonnet | $3/M | Best quality |
| ...440206 | GPT-4o Mini | $0.15/M | Balanced performance |
### Google Gemini Models
| Model ID | Name | Description | Default |
| -------- | ---- | ----------- | ------- |
| ...440101 | Gemini 2.5 Flash | Fast, efficient responses | Yes |
| ...440102 | Gemini 2.0 Flash-Lite | Ultra-lightweight model | No |
| ...440103 | Gemini 2.5 Pro | Most capable model | No |
## OpenRouter Setup
To enable OpenRouter models:
- [ ] Get API key at https://openrouter.ai/keys
- [ ] Add `OPENROUTER_API_KEY=sk-or-v1-xxx` to `apps/chat/apps/backend/.env`
- [ ] Re-seed database: `pnpm --filter @chat/backend db:seed`
- [ ] Test: `pnpm dev:chat:backend`
## Important Notes

View file

@ -1,4 +1,11 @@
# Azure OpenAI Configuration
# OpenRouter Configuration (Recommended - multi-model access)
# Get your API key at https://openrouter.ai/keys
OPENROUTER_API_KEY=your-openrouter-api-key
# Google Gemini Configuration
GOOGLE_GENAI_API_KEY=your-google-api-key
# Azure OpenAI Configuration (Optional)
AZURE_OPENAI_ENDPOINT=https://your-azure-openai-endpoint.openai.azure.com
AZURE_OPENAI_API_KEY=your-api-key-here
AZURE_OPENAI_API_VERSION=2024-12-01-preview

View file

@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import { AsyncResult, ok, err, ValidationError, ServiceError } from '@manacore/shared-errors';
import { GoogleGenerativeAI } from '@google/generative-ai';
import OpenAI from 'openai';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { models } from '../db/schema/models.schema';
@ -19,6 +20,8 @@ export class ChatService {
private readonly azureApiVersion: string;
// Google Gemini config
private readonly geminiClient: GoogleGenerativeAI | null = null;
// OpenRouter config
private readonly openRouterClient: OpenAI | null = null;
constructor(
private configService: ConfigService,
@ -41,6 +44,22 @@ export class ChatService {
this.logger.warn('GOOGLE_GENAI_API_KEY is not set - Gemini models unavailable');
}
// OpenRouter setup
const openRouterApiKey = this.configService.get<string>('OPENROUTER_API_KEY');
if (openRouterApiKey) {
this.openRouterClient = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: openRouterApiKey,
defaultHeaders: {
'HTTP-Referer': this.configService.get<string>('APP_URL') || 'http://localhost:3002',
'X-Title': 'Mana Chat',
},
});
this.logger.log('OpenRouter client initialized');
} else {
this.logger.warn('OPENROUTER_API_KEY is not set - OpenRouter models unavailable');
}
if (!this.azureApiKey) {
this.logger.warn('AZURE_OPENAI_API_KEY is not set - Azure models unavailable');
}
@ -84,6 +103,8 @@ export class ChatService {
// Route to appropriate provider
if (model.provider === 'gemini') {
return this.createGeminiCompletion(model, dto);
} else if (model.provider === 'openrouter') {
return this.createOpenRouterCompletion(model, dto);
} else {
return this.createAzureCompletion(model, dto);
}
@ -250,4 +271,62 @@ export class ChatService {
);
}
}
private async createOpenRouterCompletion(
model: Model,
dto: ChatCompletionDto
): AsyncResult<ChatCompletionResponseDto> {
if (!this.openRouterClient) {
return err(ServiceError.externalError('OpenRouter', 'OpenRouter client not configured'));
}
const params = model.parameters as {
model?: string;
temperature?: number;
max_tokens?: number;
} | null;
const modelName = params?.model || 'meta-llama/llama-3.1-8b-instruct';
const temperature = dto.temperature ?? params?.temperature ?? 0.7;
const maxTokens = dto.maxTokens ?? params?.max_tokens ?? 4096;
this.logger.log(`Sending request to OpenRouter model: ${modelName}`);
try {
const response = await this.openRouterClient.chat.completions.create({
model: modelName,
messages: dto.messages.map((msg) => ({
role: msg.role as 'system' | 'user' | 'assistant',
content: msg.content,
})),
temperature,
max_tokens: maxTokens,
});
const messageContent = response.choices?.[0]?.message?.content;
if (!messageContent) {
this.logger.warn('No message content in OpenRouter response');
return err(ServiceError.generationFailed('OpenRouter', 'No response generated'));
}
return ok({
content: messageContent,
usage: {
prompt_tokens: response.usage?.prompt_tokens || 0,
completion_tokens: response.usage?.completion_tokens || 0,
total_tokens: response.usage?.total_tokens || 0,
},
});
} catch (error) {
this.logger.error('Error calling OpenRouter API', error);
return err(
ServiceError.generationFailed(
'OpenRouter',
error instanceof Error ? error.message : 'Unknown error',
error instanceof Error ? error : undefined
)
);
}
}
}

View file

@ -75,6 +75,87 @@ async function seed() {
isDefault: false,
},
// ============================================
// OpenRouter Models (Multi-provider, cost-effective)
// ============================================
{
id: '550e8400-e29b-41d4-a716-446655440201',
name: 'Llama 3.1 8B',
description: 'Fast & cheap - great for everyday tasks ($0.05/M tokens)',
provider: 'openrouter',
parameters: {
model: 'meta-llama/llama-3.1-8b-instruct',
temperature: 0.7,
max_tokens: 4096,
},
isActive: true,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440202',
name: 'Llama 3.1 70B',
description: 'Powerful open model - complex reasoning ($0.35/M tokens)',
provider: 'openrouter',
parameters: {
model: 'meta-llama/llama-3.1-70b-instruct',
temperature: 0.7,
max_tokens: 8192,
},
isActive: true,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440203',
name: 'DeepSeek V3',
description: 'Excellent reasoning at low cost ($0.14/M tokens)',
provider: 'openrouter',
parameters: {
model: 'deepseek/deepseek-chat',
temperature: 0.7,
max_tokens: 8192,
},
isActive: true,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440204',
name: 'Mistral Small',
description: 'Fast European model - good for general tasks ($0.10/M tokens)',
provider: 'openrouter',
parameters: {
model: 'mistralai/mistral-small-24b-instruct-2501',
temperature: 0.7,
max_tokens: 4096,
},
isActive: true,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440205',
name: 'Claude 3.5 Sonnet',
description: 'Best overall quality - coding & analysis ($3/M tokens)',
provider: 'openrouter',
parameters: {
model: 'anthropic/claude-3.5-sonnet',
temperature: 0.7,
max_tokens: 8192,
},
isActive: true,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440206',
name: 'GPT-4o Mini',
description: 'OpenAI fast model - balanced performance ($0.15/M tokens)',
provider: 'openrouter',
parameters: {
model: 'openai/gpt-4o-mini',
temperature: 0.7,
max_tokens: 4096,
},
isActive: true,
isDefault: false,
},
// ============================================
// Azure OpenAI GPT-5 Family (Inactive - no deployment)
// ============================================
{

194
apps/picture/CLAUDE.md Normal file
View file

@ -0,0 +1,194 @@
# Picture App - CLAUDE.md
AI image generation app using Replicate API with freemium credit system.
## Project Structure
```
apps/picture/
├── apps/
│ ├── backend/ # NestJS API (port 3006)
│ ├── mobile/ # Expo React Native app
│ ├── web/ # SvelteKit web app
│ └── landing/ # Astro marketing page
└── packages/ # Shared code
```
## Quick Start
```bash
# From monorepo root
pnpm dev:picture:full # Start backend + web + auto DB setup
# Individual apps
pnpm --filter @picture/backend dev # Backend only (port 3006)
pnpm --filter @picture/web dev # Web only
pnpm --filter @picture/mobile dev # Mobile only
```
## Backend Architecture
### Key Services
| Service | Purpose |
|---------|---------|
| `GenerateService` | AI image generation with freemium/credit logic |
| `ReplicateService` | Replicate API integration |
| `StorageService` | MinIO/S3 storage via `@manacore/shared-storage` |
| `CreditClientService` | Credit system via `@mana-core/nestjs-integration` |
### Freemium Model
- **Free tier**: 3 free generations per user
- **Paid tier**: 10 credits per generation
- **Enforcement**: Only in staging (`NODE_ENV=staging`)
- **Development**: Fail-open (no credit enforcement)
### Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `REPLICATE_API_TOKEN` | Replicate API key | Yes |
| `DATABASE_URL` | PostgreSQL connection | Yes |
| `S3_ENDPOINT` | MinIO/S3 endpoint | Yes |
| `MANA_CORE_AUTH_URL` | Auth service URL | Yes |
| `MANA_CORE_SERVICE_KEY` | Service key for credits | Staging only |
| `APP_ID` | App identifier | Yes |
---
## TODO List
### Testing Required
- [ ] **Test freemium flow with new user**
- Create new user ID and verify 3 free generations work
- Verify `freeGenerationsRemaining` decrements correctly (3 → 2 → 1 → 0)
- Verify 4th generation still works in development (fail-open)
- [ ] **Test staging credit enforcement**
- Set `NODE_ENV=staging` and test credit check
- Verify HTTP 402 returned when credits insufficient
- Test with valid `MANA_CORE_SERVICE_KEY`
- [ ] **Test async generation (webhook mode)**
- Test generation without `waitForResult: true`
- Verify webhook receives completion callback
- Verify credits consumed on webhook success
- [ ] **Test error handling**
- Test with invalid model ID
- Test with invalid Replicate API token
- Test storage upload failures
- [ ] **Integration tests**
- Write Jest tests for `GenerateService`
- Mock `CreditClientService` calls
- Test all generation paths (free/paid, sync/async)
### Features to Implement
- [ ] **Add credit balance endpoint**
- GET `/api/v1/credits/balance` - Return user's credit balance
- Use `CreditClientService.getBalance()`
- [ ] **Add generation history endpoint**
- GET `/api/v1/generate/history` - User's generation history
- Include credits used per generation
- [ ] **Improve error messages**
- Add proper error codes for credit failures
- Return helpful messages for insufficient credits
- [ ] **Rate limiting**
- Add rate limits for generation endpoints
- Prevent abuse of free tier
### Web App Tasks
- [ ] **Show free generations remaining**
- Display counter in UI
- Show warning when approaching limit
- [ ] **Credit purchase flow**
- Integrate with mana-core credit purchase
- Show credit balance in header
- [ ] **Generation queue UI**
- Show pending generations
- Poll for status updates
### Mobile App Tasks
- [ ] **Implement generation screen**
- Model selection
- Prompt input with suggestions
- Generation progress indicator
- [ ] **Gallery view**
- Grid view of user's generated images
- Favorites functionality
### DevOps Tasks
- [ ] **Staging deployment**
- Deploy backend to staging server
- Configure `MANA_CORE_SERVICE_KEY` in staging
- Test credit system end-to-end
- [ ] **Monitoring**
- Add logging for credit operations
- Track generation success/failure rates
- Monitor Replicate API usage
---
## API Endpoints
### Generate
```bash
# Generate image (sync)
POST /api/v1/generate
{
"prompt": "A beautiful sunset",
"modelId": "uuid",
"waitForResult": true
}
# Check status
GET /api/v1/generate/:id/status
# Cancel generation
DELETE /api/v1/generate/:id
# Webhook (internal)
POST /api/v1/generate/webhook
```
### Models
```bash
GET /api/v1/models # List all models
GET /api/v1/models/:id # Get model details
```
### Images
```bash
GET /api/v1/images # List user's images
GET /api/v1/images/:id # Get image details
DELETE /api/v1/images/:id # Delete image
```
---
## Recent Changes
### 2025-12-10: Credit System Integration
- Added `@mana-core/nestjs-integration` for credit system
- Implemented freemium model (3 free, then 10 credits)
- Credit enforcement only in staging environment
- Updated `GenerateService` with `checkGenerationAccess()`
- Response includes `freeGenerationsRemaining` count

View file

@ -19,8 +19,10 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@mana-core/nestjs-integration": "workspace:*",
"@manacore/shared-errors": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",

View file

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ManaCoreModule } from '@mana-core/nestjs-integration';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { ModelModule } from './model/model.module';
@ -19,6 +20,16 @@ import { BatchModule } from './batch/batch.module';
isGlobal: true,
envFilePath: '.env',
}),
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
appId: configService.get('APP_ID', 'picture-app'),
serviceKey: configService.get('MANA_CORE_SERVICE_KEY', ''),
authUrl: configService.get('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
debug: configService.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
DatabaseModule,
HealthModule,
ModelModule,

View file

@ -1,6 +1,15 @@
import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import { eq, and, count } from 'drizzle-orm';
import { CreditClientService } from '@mana-core/nestjs-integration';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { imageGenerations, images, models } from '../db/schema';
@ -9,25 +18,104 @@ import { ReplicateService, GenerationParams } from './replicate.service';
import { StorageService } from '../upload/storage.service';
import { GenerateImageDto } from './dto/generate.dto';
const FREE_GENERATIONS_LIMIT = 3;
const CREDITS_PER_GENERATION = 10;
export interface GenerateResponse {
generationId: string;
status: string;
image?: Image;
creditsUsed?: number;
freeGenerationsRemaining?: number;
}
@Injectable()
export class GenerateService {
private readonly logger = new Logger(GenerateService.name);
private readonly webhookBaseUrl: string;
private readonly isStaging: boolean;
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private readonly replicateService: ReplicateService,
private readonly storageService: StorageService,
private readonly creditClient: CreditClientService,
private configService: ConfigService
) {
this.webhookBaseUrl =
this.configService.get<string>('WEBHOOK_BASE_URL') || 'http://localhost:3003';
// Freemium/credit system only enforced in staging
this.isStaging = this.configService.get<string>('NODE_ENV') === 'staging';
}
/**
* Get count of completed generations for a user
*/
private async getUserGenerationCount(userId: string): Promise<number> {
const result = await this.db
.select({ count: count() })
.from(imageGenerations)
.where(and(eq(imageGenerations.userId, userId), eq(imageGenerations.status, 'completed')));
return result[0]?.count ?? 0;
}
/**
* Check if user can generate (has free generations or credits)
* Returns: { canGenerate, isFree, freeRemaining, creditsRequired }
*/
async checkGenerationAccess(userId: string): Promise<{
canGenerate: boolean;
isFree: boolean;
freeGenerationsRemaining: number;
creditsRequired: number;
currentBalance?: number;
}> {
const completedCount = await this.getUserGenerationCount(userId);
const freeRemaining = Math.max(0, FREE_GENERATIONS_LIMIT - completedCount);
// If user has free generations, they can proceed
if (freeRemaining > 0) {
return {
canGenerate: true,
isFree: true,
freeGenerationsRemaining: freeRemaining,
creditsRequired: 0,
};
}
// No free generations - check credits (only in staging)
if (!this.isStaging) {
// In development/production without credit enforcement, allow generation
return {
canGenerate: true,
isFree: false,
freeGenerationsRemaining: 0,
creditsRequired: CREDITS_PER_GENERATION,
};
}
// In staging, check actual credit balance
try {
const balance = await this.creditClient.getBalance(userId);
const hasEnoughCredits = balance.balance >= CREDITS_PER_GENERATION;
return {
canGenerate: hasEnoughCredits,
isFree: false,
freeGenerationsRemaining: 0,
creditsRequired: CREDITS_PER_GENERATION,
currentBalance: balance.balance,
};
} catch (error) {
this.logger.warn(`Failed to check credit balance for user ${userId}`, error);
// On error, allow generation (fail open for better UX)
return {
canGenerate: true,
isFree: false,
freeGenerationsRemaining: 0,
creditsRequired: CREDITS_PER_GENERATION,
};
}
}
/**
@ -35,6 +123,16 @@ export class GenerateService {
*/
async generateImage(userId: string, dto: GenerateImageDto): Promise<GenerateResponse> {
try {
// Check if user can generate (freemium/credit check)
const access = await this.checkGenerationAccess(userId);
if (!access.canGenerate) {
throw new HttpException(
`Insufficient credits. You need ${access.creditsRequired} credits. Current balance: ${access.currentBalance ?? 0}`,
HttpStatus.PAYMENT_REQUIRED
);
}
// Get model info
const modelResult = await this.db
.select()
@ -70,6 +168,7 @@ export class GenerateService {
.returning();
const generation = generationResult[0];
const isFreeGeneration = access.isFree;
// Build generation params
const generationParams: GenerationParams = {
@ -89,13 +188,25 @@ export class GenerateService {
// If waitForResult is true, use synchronous generation with polling
if (dto.waitForResult) {
return this.generateSync(generation, generationParams);
const result = await this.generateSync(generation, generationParams);
// Consume credits after successful generation (if not free)
if (result.status === 'completed' && !isFreeGeneration && this.isStaging) {
await this.consumeCreditsForGeneration(userId, generation.id);
result.creditsUsed = CREDITS_PER_GENERATION;
}
// Add free generations remaining info
const newAccess = await this.checkGenerationAccess(userId);
result.freeGenerationsRemaining = newAccess.freeGenerationsRemaining;
return result;
}
// Otherwise use async generation with webhook
return this.generateAsync(generation, model, generationParams);
// Otherwise use async generation with webhook (credits consumed on webhook completion)
return this.generateAsync(generation, model, generationParams, isFreeGeneration);
} catch (error) {
if (error instanceof NotFoundException) {
if (error instanceof NotFoundException || error instanceof HttpException) {
throw error;
}
this.logger.error('Error generating image', error);
@ -103,6 +214,24 @@ export class GenerateService {
}
}
/**
* Consume credits for a generation
*/
private async consumeCreditsForGeneration(userId: string, generationId: string): Promise<void> {
try {
await this.creditClient.consumeCredits(
userId,
'image_generation',
CREDITS_PER_GENERATION,
`Image generation: ${generationId}`
);
this.logger.log(`Consumed ${CREDITS_PER_GENERATION} credits for user ${userId}`);
} catch (error) {
this.logger.error(`Failed to consume credits for generation ${generationId}`, error);
// Don't fail the generation if credit consumption fails
}
}
/**
* Synchronous generation - polls until complete
*/
@ -200,7 +329,8 @@ export class GenerateService {
private async generateAsync(
generation: ImageGeneration,
model: any,
params: GenerationParams
params: GenerationParams,
isFreeGeneration: boolean
): Promise<GenerateResponse> {
try {
const webhookUrl = `${this.webhookBaseUrl}/api/generate/webhook`;
@ -212,12 +342,15 @@ export class GenerateService {
webhookUrl
);
// Update generation with prediction ID
// Update generation with prediction ID and free generation flag (in metadata)
await this.db
.update(imageGenerations)
.set({
replicatePredictionId: prediction.id,
status: 'processing',
// Store isFreeGeneration in a way that can be retrieved in webhook
// We'll use the errorMessage field temporarily for metadata (cleared on success)
errorMessage: isFreeGeneration ? 'FREE_GENERATION' : null,
})
.where(eq(imageGenerations.id, generation.id));
@ -401,8 +534,16 @@ export class GenerateService {
const generation = result[0];
// Check if this was a free generation (stored in errorMessage field temporarily)
const isFreeGeneration = generation.errorMessage === 'FREE_GENERATION';
if (status === 'succeeded' && output) {
await this.processCompletedGeneration(generation, output);
// Consume credits for paid generations in staging
if (!isFreeGeneration && this.isStaging) {
await this.consumeCreditsForGeneration(generation.userId, generation.id);
}
} else if (status === 'failed') {
await this.db
.update(imageGenerations)

View file

@ -1,80 +1,32 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import * as fs from 'fs/promises';
import * as path from 'path';
createPictureStorage,
StorageClient,
generateUserFileKey,
getContentType,
} from '@manacore/shared-storage';
export type StorageMode = 'local' | 's3';
export type StorageMode = 's3';
@Injectable()
export class StorageService {
export class StorageService implements OnModuleInit {
private readonly logger = new Logger(StorageService.name);
private mode: StorageMode;
private s3Client: S3Client | null = null;
private readonly bucket: string;
private readonly localStoragePath: string;
private readonly publicUrlBase: string;
private storage!: StorageClient;
private publicUrl: string;
constructor(private configService: ConfigService) {
// Determine storage mode from environment
const storageMode = this.configService.get<string>('STORAGE_MODE', 'local');
this.mode = storageMode === 's3' ? 's3' : 'local';
// S3 configuration (Hetzner Object Storage is S3-compatible)
const s3Endpoint = this.configService.get<string>('S3_ENDPOINT');
const s3Region = this.configService.get<string>('S3_REGION', 'eu-central-1');
const s3AccessKey = this.configService.get<string>('S3_ACCESS_KEY');
const s3SecretKey = this.configService.get<string>('S3_SECRET_KEY');
this.bucket = this.configService.get<string>('S3_BUCKET', 'picture-uploads');
// Local storage configuration
this.localStoragePath = this.configService.get<string>(
'LOCAL_STORAGE_PATH',
path.join(process.cwd(), 'uploads')
);
// Public URL base for serving files
const backendUrl = this.configService.get<string>('BACKEND_URL', 'http://localhost:3003');
this.publicUrlBase = this.configService.get<string>(
// Get public URL from config
this.publicUrl = this.configService.get<string>(
'STORAGE_PUBLIC_URL',
this.mode === 'local' ? `${backendUrl}/uploads` : `https://${this.bucket}.${s3Endpoint}`
'http://localhost:9000/picture-storage'
);
if (this.mode === 's3') {
if (s3Endpoint && s3AccessKey && s3SecretKey) {
this.s3Client = new S3Client({
endpoint: s3Endpoint.startsWith('http') ? s3Endpoint : `https://${s3Endpoint}`,
region: s3Region,
credentials: {
accessKeyId: s3AccessKey,
secretAccessKey: s3SecretKey,
},
forcePathStyle: false, // Hetzner uses virtual-hosted style
});
this.logger.log(`Storage initialized in S3 mode (endpoint: ${s3Endpoint})`);
} else {
this.logger.warn('S3 credentials not configured, falling back to local storage');
this.mode = 'local';
}
}
if (this.mode === 'local') {
this.logger.log(`Storage initialized in local mode (path: ${this.localStoragePath})`);
this.ensureLocalStorageDirectory();
}
}
private async ensureLocalStorageDirectory(): Promise<void> {
try {
await fs.mkdir(this.localStoragePath, { recursive: true });
} catch (error) {
this.logger.error('Failed to create local storage directory', error);
}
onModuleInit() {
// Initialize storage client
this.storage = createPictureStorage(this.publicUrl);
this.logger.log(`Storage initialized with @manacore/shared-storage (bucket: picture-storage)`);
}
async uploadFile(
@ -88,59 +40,16 @@ export class StorageService {
const ext = filename.split('.').pop() || 'jpg';
const storagePath = `${userId}/${timestamp}-${randomId}.${ext}`;
if (this.mode === 's3' && this.s3Client) {
return this.uploadToS3(buffer, storagePath, contentType);
} else {
return this.uploadToLocal(buffer, storagePath);
}
}
private async uploadToS3(
buffer: Buffer,
storagePath: string,
contentType: string
): Promise<{ storagePath: string; publicUrl: string }> {
if (!this.s3Client) {
throw new Error('S3 client not configured');
}
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: storagePath,
Body: buffer,
ContentType: contentType,
ACL: 'public-read',
const result = await this.storage.upload(storagePath, buffer, {
contentType,
public: true,
});
try {
await this.s3Client.send(command);
const publicUrl = `${this.publicUrlBase}/${storagePath}`;
const publicUrl = result.url || this.getPublicUrl(storagePath);
return { storagePath, publicUrl };
} catch (error) {
this.logger.error('Error uploading file to S3', error);
throw error;
}
}
this.logger.debug(`Uploaded file to ${storagePath}`);
private async uploadToLocal(
buffer: Buffer,
storagePath: string
): Promise<{ storagePath: string; publicUrl: string }> {
const fullPath = path.join(this.localStoragePath, storagePath);
const directory = path.dirname(fullPath);
try {
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(fullPath, buffer);
const publicUrl = `${this.publicUrlBase}/${storagePath}`;
return { storagePath, publicUrl };
} catch (error) {
this.logger.error('Error uploading file to local storage', error);
throw error;
}
return { storagePath, publicUrl };
}
async uploadFromUrl(
@ -161,109 +70,42 @@ export class StorageService {
}
async deleteFile(storagePath: string): Promise<void> {
if (this.mode === 's3' && this.s3Client) {
return this.deleteFromS3(storagePath);
} else {
return this.deleteFromLocal(storagePath);
}
}
private async deleteFromS3(storagePath: string): Promise<void> {
if (!this.s3Client) {
throw new Error('S3 client not configured');
}
const command = new DeleteObjectCommand({
Bucket: this.bucket,
Key: storagePath,
});
try {
await this.s3Client.send(command);
await this.storage.delete(storagePath);
this.logger.debug(`Deleted file: ${storagePath}`);
} catch (error) {
this.logger.error(`Error deleting file ${storagePath} from S3`, error);
this.logger.error(`Error deleting file ${storagePath}`, error);
throw error;
}
}
private async deleteFromLocal(storagePath: string): Promise<void> {
const fullPath = path.join(this.localStoragePath, storagePath);
try {
await fs.unlink(fullPath);
} catch (error) {
// Ignore if file doesn't exist
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
this.logger.error(`Error deleting file ${storagePath} from local storage`, error);
throw error;
}
}
}
async uploadBoardThumbnail(boardId: string, dataUrl: string): Promise<string> {
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
const storagePath = `boards/${boardId}/thumbnail-${Date.now()}.png`;
if (this.mode === 's3' && this.s3Client) {
const result = await this.uploadToS3(buffer, storagePath, 'image/png');
return result.publicUrl;
} else {
const result = await this.uploadToLocal(buffer, storagePath);
return result.publicUrl;
}
const result = await this.storage.upload(storagePath, buffer, {
contentType: 'image/png',
public: true,
});
return result.url || this.getPublicUrl(storagePath);
}
async getFile(storagePath: string): Promise<Buffer | null> {
if (this.mode === 's3' && this.s3Client) {
return this.getFromS3(storagePath);
} else {
return this.getFromLocal(storagePath);
}
}
private async getFromS3(storagePath: string): Promise<Buffer | null> {
if (!this.s3Client) {
throw new Error('S3 client not configured');
}
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: storagePath,
});
try {
const response = await this.s3Client.send(command);
if (response.Body) {
const byteArray = await response.Body.transformToByteArray();
return Buffer.from(byteArray);
}
return null;
return await this.storage.download(storagePath);
} catch (error) {
this.logger.error(`Error getting file ${storagePath} from S3`, error);
return null;
}
}
private async getFromLocal(storagePath: string): Promise<Buffer | null> {
const fullPath = path.join(this.localStoragePath, storagePath);
try {
return await fs.readFile(fullPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
this.logger.error(`Error getting file ${storagePath} from local storage`, error);
this.logger.error(`Error getting file ${storagePath}`, error);
return null;
}
}
getStorageMode(): StorageMode {
return this.mode;
return 's3';
}
getPublicUrl(storagePath: string): string {
return `${this.publicUrlBase}/${storagePath}`;
return this.storage.getPublicUrl(storagePath) || `${this.publicUrl}/${storagePath}`;
}
}