refactor: restructure

monorepo with apps/ and services/
  directories
This commit is contained in:
Wuesteon 2025-11-26 03:03:24 +01:00
parent 25824ed0ac
commit ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions

View file

@ -0,0 +1,20 @@
# Google Gemini Configuration
GEMINI_API_KEY=your-gemini-api-key-here
# PostgreSQL Database (using nutriphi-database package)
DATABASE_URL=postgresql://nutriphi:nutriphi_dev_password@localhost:5435/nutriphi
# Hetzner Object Storage (S3-compatible)
# Create in Coolify Dashboard -> S3 Storages
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_BUCKET_NAME=nutriphi-meals
S3_REGION=fsn1
S3_PUBLIC_URL=https://nutriphi-meals.fsn1.your-objectstorage.com
# Mana Core Auth Service
MANACORE_AUTH_URL=https://auth.manacore.de
# Server Configuration
PORT=3002

View file

@ -0,0 +1,49 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json pnpm-lock.yaml ./
# Copy the nutriphi-database package
COPY packages/nutriphi-database ./packages/nutriphi-database
# Copy the backend
COPY apps/nutriphi/apps/backend ./apps/nutriphi/apps/backend
# Install dependencies
RUN pnpm install --frozen-lockfile --filter @nutriphi/backend...
# Build the database package first
RUN pnpm --filter @manacore/nutriphi-database build
# Build the backend
RUN pnpm --filter @nutriphi/backend build
# Production stage
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Copy built files
COPY --from=builder /app/apps/nutriphi/apps/backend/dist ./dist
COPY --from=builder /app/apps/nutriphi/apps/backend/package.json ./
COPY --from=builder /app/apps/nutriphi/apps/backend/node_modules ./node_modules
COPY --from=builder /app/packages/nutriphi-database/dist ./node_modules/@manacore/nutriphi-database/dist
COPY --from=builder /app/packages/nutriphi-database/package.json ./node_modules/@manacore/nutriphi-database/
# Set environment
ENV NODE_ENV=production
# Expose port
EXPOSE 3002
# Start the application
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,31 @@
version: '3.8'
services:
nutriphi-backend:
build:
context: .
dockerfile: Dockerfile
container_name: nutriphi-backend
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=${PORT:-3002}
- DATABASE_URL=${DATABASE_URL}
- GEMINI_API_KEY=${GEMINI_API_KEY}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_BUCKET_NAME=${S3_BUCKET_NAME}
- S3_REGION=${S3_REGION:-fsn1}
- S3_PUBLIC_URL=${S3_PUBLIC_URL}
- MANACORE_AUTH_URL=${MANACORE_AUTH_URL}
ports:
- "${PORT:-3002}:${PORT:-3002}"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-3002}/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "coolify.managed=true"

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,46 @@
{
"name": "@nutriphi/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@google/generative-ai": "^0.24.1",
"@manacore/nutriphi-database": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { StorageModule } from './storage/storage.module';
import { HealthModule } from './health/health.module';
import { GeminiModule } from './gemini/gemini.module';
import { MealsModule } from './meals/meals.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
StorageModule,
HealthModule,
GeminiModule,
MealsModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, closeDb, type Database } from '@manacore/nutriphi-database';
export const DATABASE_TOKEN = 'DATABASE';
@Global()
@Module({
providers: [
{
provide: DATABASE_TOKEN,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return createClient(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_TOKEN],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeDb();
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { GeminiService } from './gemini.service';
@Module({
providers: [GeminiService],
exports: [GeminiService],
})
export class GeminiModule {}

View file

@ -0,0 +1,128 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
export interface NutritionAnalysis {
foodName: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
servingSize: string;
confidence: number;
ingredients?: string[];
healthTips?: string[];
}
@Injectable()
export class GeminiService {
private readonly logger = new Logger(GeminiService.name);
private readonly genAI: GoogleGenerativeAI;
private readonly model;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('GEMINI_API_KEY');
if (!apiKey) {
throw new Error('GEMINI_API_KEY is not configured');
}
this.genAI = new GoogleGenerativeAI(apiKey);
this.model = this.genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
}
async analyzeFoodImage(imageBase64: string): Promise<NutritionAnalysis> {
this.logger.log('Analyzing food image with Gemini Vision');
const prompt = `Analyze this food image and provide detailed nutritional information.
Return a JSON object with the following structure:
{
"foodName": "Name of the food/meal",
"calories": number (kcal),
"protein": number (grams),
"carbohydrates": number (grams),
"fat": number (grams),
"fiber": number (grams),
"sugar": number (grams),
"sodium": number (mg),
"servingSize": "estimated serving size",
"confidence": number (0-1, how confident you are in the analysis),
"ingredients": ["list", "of", "visible", "ingredients"],
"healthTips": ["optional health tips about this food"]
}
Be as accurate as possible with the nutritional estimates based on what you can see.
Only return valid JSON, no additional text.`;
try {
const result = await this.model.generateContent([
prompt,
{
inlineData: {
mimeType: 'image/jpeg',
data: imageBase64,
},
},
]);
const response = result.response.text();
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No valid JSON found in response');
}
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
return analysis;
} catch (error) {
this.logger.error('Failed to analyze food image', error);
throw error;
}
}
async analyzeFoodText(description: string): Promise<NutritionAnalysis> {
this.logger.log('Analyzing food description with Gemini');
const prompt = `Based on this food description, provide detailed nutritional information: "${description}"
Return a JSON object with the following structure:
{
"foodName": "Name of the food/meal",
"calories": number (kcal),
"protein": number (grams),
"carbohydrates": number (grams),
"fat": number (grams),
"fiber": number (grams),
"sugar": number (grams),
"sodium": number (mg),
"servingSize": "estimated serving size",
"confidence": number (0-1, how confident you are),
"ingredients": ["list", "of", "likely", "ingredients"],
"healthTips": ["optional health tips about this food"]
}
Only return valid JSON, no additional text.`;
try {
const result = await this.model.generateContent(prompt);
const response = result.response.text();
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No valid JSON found in response');
}
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
return analysis;
} catch (error) {
this.logger.error('Failed to analyze food description', error);
throw error;
}
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'nutriphi-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,36 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for mobile and web apps
app.enableCors({
origin: [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:8081',
'exp://localhost:8081',
],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Set global prefix for API routes
app.setGlobalPrefix('api');
const port = process.env.PORT || 3002;
await app.listen(port);
console.log(`Nutriphi backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,93 @@
import { IsString, IsOptional, IsBase64 } from 'class-validator';
export class AnalyzeMealImageDto {
@IsString()
imageBase64: string;
}
export class AnalyzeMealTextDto {
@IsString()
description: string;
}
export class CreateMealDto {
@IsString()
userId: string;
@IsString()
foodName: string;
@IsOptional()
@IsString()
imageUrl?: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
@IsString()
servingSize: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
@IsOptional()
@IsString()
notes?: string;
}
export class UploadMealDto {
@IsString()
imageBase64: string;
@IsString()
userId: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
}
export class UpdateMealDto {
@IsOptional()
@IsString()
foodName?: string;
@IsOptional()
calories?: number;
@IsOptional()
protein?: number;
@IsOptional()
carbohydrates?: number;
@IsOptional()
fat?: number;
@IsOptional()
fiber?: number;
@IsOptional()
sugar?: number;
@IsOptional()
sodium?: number;
@IsOptional()
@IsString()
servingSize?: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
@IsOptional()
@IsString()
notes?: string;
}

View file

@ -0,0 +1,79 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { MealsService } from './meals.service';
import {
AnalyzeMealImageDto,
AnalyzeMealTextDto,
CreateMealDto,
UpdateMealDto,
UploadMealDto,
} from './dto/analyze-meal.dto';
@Controller('meals')
export class MealsController {
constructor(private readonly mealsService: MealsService) {}
@Post('analyze/image')
@HttpCode(HttpStatus.OK)
async analyzeImage(@Body() dto: AnalyzeMealImageDto) {
return this.mealsService.analyzeImage(dto.imageBase64);
}
@Post('analyze/text')
@HttpCode(HttpStatus.OK)
async analyzeText(@Body() dto: AnalyzeMealTextDto) {
return this.mealsService.analyzeText(dto.description);
}
@Post()
async createMeal(@Body() dto: CreateMealDto) {
return this.mealsService.createMeal(dto);
}
@Post('upload')
async uploadMeal(@Body() dto: UploadMealDto) {
return this.mealsService.uploadAndAnalyzeMeal(dto);
}
@Get('user/:userId')
async getMealsByUser(
@Param('userId') userId: string,
@Query('date') date?: string,
) {
return this.mealsService.getMealsByUser(userId, date);
}
@Get('user/:userId/summary')
async getDailySummary(
@Param('userId') userId: string,
@Query('date') date: string,
) {
return this.mealsService.getDailySummary(userId, date);
}
@Get(':id')
async getMealById(@Param('id') id: string) {
return this.mealsService.getMealById(id);
}
@Put(':id')
async updateMeal(@Param('id') id: string, @Body() dto: UpdateMealDto) {
return this.mealsService.updateMeal(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteMeal(@Param('id') id: string) {
return this.mealsService.deleteMeal(id);
}
}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MealsController } from './meals.controller';
import { MealsService } from './meals.service';
import { GeminiModule } from '../gemini/gemini.module';
@Module({
imports: [GeminiModule],
controllers: [MealsController],
providers: [MealsService],
})
export class MealsModule {}

View file

@ -0,0 +1,281 @@
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
import {
type Database,
meals,
eq,
and,
gte,
lte,
desc,
type Meal as DbMeal,
} from '@manacore/nutriphi-database';
import { DATABASE_TOKEN } from '../database/database.module';
import { StorageService } from '../storage/storage.service';
import { GeminiService, NutritionAnalysis } from '../gemini/gemini.service';
import { CreateMealDto, UpdateMealDto, UploadMealDto } from './dto/analyze-meal.dto';
export interface Meal {
id: string;
user_id: string;
food_name: string;
image_url?: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
serving_size: string;
meal_type?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface DailySummary {
date: string;
totalCalories: number;
totalProtein: number;
totalCarbohydrates: number;
totalFat: number;
totalFiber: number;
totalSugar: number;
totalSodium: number;
mealCount: number;
}
@Injectable()
export class MealsService {
private readonly logger = new Logger(MealsService.name);
constructor(
@Inject(DATABASE_TOKEN) private readonly db: Database,
private geminiService: GeminiService,
private storageService: StorageService,
) {}
private mapDbMealToMeal(dbMeal: DbMeal): Meal {
return {
id: dbMeal.id,
user_id: dbMeal.userId,
food_name: dbMeal.foodName,
image_url: dbMeal.imageUrl ?? undefined,
calories: dbMeal.calories ?? 0,
protein: dbMeal.protein ?? 0,
carbohydrates: dbMeal.carbohydrates ?? 0,
fat: dbMeal.fat ?? 0,
fiber: dbMeal.fiber ?? 0,
sugar: dbMeal.sugar ?? 0,
sodium: dbMeal.sodium ?? 0,
serving_size: dbMeal.servingSize ?? '',
meal_type: dbMeal.mealType ?? undefined,
notes: dbMeal.notes ?? undefined,
created_at: dbMeal.createdAt.toISOString(),
updated_at: dbMeal.updatedAt.toISOString(),
};
}
async analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
return this.geminiService.analyzeFoodImage(imageBase64);
}
async analyzeText(description: string): Promise<NutritionAnalysis> {
return this.geminiService.analyzeFoodText(description);
}
/**
* Upload an image to storage, analyze it, and create a meal
*/
async uploadAndAnalyzeMeal(dto: UploadMealDto): Promise<Meal> {
this.logger.log(`Uploading and analyzing meal for user: ${dto.userId}`);
// Step 1: Upload image to storage
let imageUrl: string | undefined;
let storagePath: string | undefined;
try {
const uploadResult = await this.storageService.uploadBase64(dto.imageBase64, 'meals');
imageUrl = uploadResult.url;
storagePath = uploadResult.key;
this.logger.log(`Image uploaded: ${storagePath}`);
} catch (error) {
this.logger.warn('Storage not configured, skipping image upload', error);
}
// Step 2: Analyze the image with Gemini
// Extract base64 data without the data URL prefix
let base64Data = dto.imageBase64;
if (base64Data.includes(',')) {
base64Data = base64Data.split(',')[1];
}
const analysis = await this.geminiService.analyzeFoodImage(base64Data);
// Step 3: Create the meal record
const [result] = await this.db.insert(meals).values({
userId: dto.userId,
foodName: analysis.foodName || 'Unbekanntes Gericht',
imageUrl,
storagePath,
calories: analysis.calories,
protein: analysis.protein,
carbohydrates: analysis.carbohydrates,
fat: analysis.fat,
fiber: analysis.fiber,
sugar: analysis.sugar,
servingSize: analysis.servingSize || '1 Portion',
mealType: dto.mealType,
analysisStatus: 'completed',
}).returning();
this.logger.log(`Meal created: ${result.id}`);
return this.mapDbMealToMeal(result);
}
async createMeal(dto: CreateMealDto): Promise<Meal> {
this.logger.log(`Creating meal for user: ${dto.userId}`);
const [result] = await this.db.insert(meals).values({
userId: dto.userId,
foodName: dto.foodName,
imageUrl: dto.imageUrl,
calories: dto.calories,
protein: dto.protein,
carbohydrates: dto.carbohydrates,
fat: dto.fat,
fiber: dto.fiber,
sugar: dto.sugar,
sodium: dto.sodium,
servingSize: dto.servingSize,
mealType: dto.mealType,
notes: dto.notes,
}).returning();
return this.mapDbMealToMeal(result);
}
async getMealsByUser(
userId: string,
date?: string,
): Promise<Meal[]> {
this.logger.log(`Fetching meals for user: ${userId}`);
let query;
if (date) {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
query = this.db
.select()
.from(meals)
.where(
and(
eq(meals.userId, userId),
gte(meals.createdAt, startOfDay),
lte(meals.createdAt, endOfDay)
)
)
.orderBy(desc(meals.createdAt));
} else {
query = this.db
.select()
.from(meals)
.where(eq(meals.userId, userId))
.orderBy(desc(meals.createdAt));
}
const results = await query;
return results.map(this.mapDbMealToMeal);
}
async getMealById(id: string): Promise<Meal> {
const [result] = await this.db
.select()
.from(meals)
.where(eq(meals.id, id));
if (!result) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
return this.mapDbMealToMeal(result);
}
async updateMeal(id: string, dto: UpdateMealDto): Promise<Meal> {
this.logger.log(`Updating meal: ${id}`);
const updateData: Partial<typeof meals.$inferInsert> = {
updatedAt: new Date(),
};
if (dto.foodName !== undefined) updateData.foodName = dto.foodName;
if (dto.calories !== undefined) updateData.calories = dto.calories;
if (dto.protein !== undefined) updateData.protein = dto.protein;
if (dto.carbohydrates !== undefined) updateData.carbohydrates = dto.carbohydrates;
if (dto.fat !== undefined) updateData.fat = dto.fat;
if (dto.fiber !== undefined) updateData.fiber = dto.fiber;
if (dto.sugar !== undefined) updateData.sugar = dto.sugar;
if (dto.sodium !== undefined) updateData.sodium = dto.sodium;
if (dto.servingSize !== undefined) updateData.servingSize = dto.servingSize;
if (dto.mealType !== undefined) updateData.mealType = dto.mealType;
if (dto.notes !== undefined) updateData.notes = dto.notes;
const [result] = await this.db
.update(meals)
.set(updateData)
.where(eq(meals.id, id))
.returning();
if (!result) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
return this.mapDbMealToMeal(result);
}
async deleteMeal(id: string): Promise<void> {
this.logger.log(`Deleting meal: ${id}`);
const result = await this.db
.delete(meals)
.where(eq(meals.id, id))
.returning();
if (result.length === 0) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
}
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
const userMeals = await this.getMealsByUser(userId, date);
const summary: DailySummary = {
date,
totalCalories: 0,
totalProtein: 0,
totalCarbohydrates: 0,
totalFat: 0,
totalFiber: 0,
totalSugar: 0,
totalSodium: 0,
mealCount: userMeals.length,
};
for (const meal of userMeals) {
summary.totalCalories += meal.calories || 0;
summary.totalProtein += meal.protein || 0;
summary.totalCarbohydrates += meal.carbohydrates || 0;
summary.totalFat += meal.fat || 0;
summary.totalFiber += meal.fiber || 0;
summary.totalSugar += meal.sugar || 0;
summary.totalSodium += meal.sodium || 0;
}
return summary;
}
}

View file

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { StorageService } from './storage.service';
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -0,0 +1,166 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
export interface UploadResult {
key: string;
url: string;
}
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private readonly s3Client: S3Client;
private readonly bucketName: string;
private readonly publicUrl: string;
constructor(private configService: ConfigService) {
// Hetzner Object Storage (S3-compatible)
const endpoint = this.configService.get<string>('S3_ENDPOINT');
const accessKeyId = this.configService.get<string>('S3_ACCESS_KEY_ID');
const secretAccessKey = this.configService.get<string>('S3_SECRET_ACCESS_KEY');
const region = this.configService.get<string>('S3_REGION') || 'fsn1';
this.bucketName = this.configService.get<string>('S3_BUCKET_NAME') || 'nutriphi-meals';
this.publicUrl = this.configService.get<string>('S3_PUBLIC_URL') || '';
if (!endpoint || !accessKeyId || !secretAccessKey) {
this.logger.warn('S3 configuration incomplete - storage features disabled');
this.s3Client = null as unknown as S3Client;
return;
}
this.s3Client = new S3Client({
region,
endpoint,
credentials: {
accessKeyId,
secretAccessKey,
},
forcePathStyle: true, // Required for Hetzner Object Storage
});
this.logger.log('Hetzner Object Storage initialized successfully');
}
private isConfigured(): boolean {
return this.s3Client !== null;
}
/**
* Upload a file to R2 storage
* @param buffer - File buffer
* @param contentType - MIME type of the file
* @param folder - Optional folder path (e.g., 'meals', 'avatars')
* @returns Upload result with key and public URL
*/
async upload(
buffer: Buffer,
contentType: string,
folder = 'meals',
): Promise<UploadResult> {
if (!this.isConfigured()) {
throw new Error('R2 storage is not configured');
}
const extension = this.getExtensionFromContentType(contentType);
const key = `${folder}/${randomUUID()}${extension}`;
this.logger.log(`Uploading file to R2: ${key}`);
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: buffer,
ContentType: contentType,
}),
);
const url = this.publicUrl
? `${this.publicUrl}/${key}`
: await this.getSignedUrl(key);
return { key, url };
}
/**
* Upload a base64-encoded image
* @param base64Data - Base64 encoded image data (with or without data URI prefix)
* @param folder - Optional folder path
* @returns Upload result with key and public URL
*/
async uploadBase64(base64Data: string, folder = 'meals'): Promise<UploadResult> {
let data = base64Data;
let contentType = 'image/jpeg';
// Extract content type from data URI if present
if (data.includes(',')) {
const matches = data.match(/^data:(.+);base64,/);
if (matches) {
contentType = matches[1];
data = data.split(',')[1];
}
}
const buffer = Buffer.from(data, 'base64');
return this.upload(buffer, contentType, folder);
}
/**
* Delete a file from R2 storage
* @param key - File key/path in the bucket
*/
async delete(key: string): Promise<void> {
if (!this.isConfigured()) {
throw new Error('R2 storage is not configured');
}
this.logger.log(`Deleting file from R2: ${key}`);
await this.s3Client.send(
new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
}),
);
}
/**
* Get a signed URL for temporary access to a file
* @param key - File key/path in the bucket
* @param expiresIn - URL expiration time in seconds (default: 1 hour)
* @returns Signed URL
*/
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
if (!this.isConfigured()) {
throw new Error('R2 storage is not configured');
}
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
return getSignedUrl(this.s3Client, command, { expiresIn });
}
private getExtensionFromContentType(contentType: string): string {
const mapping: Record<string, string> = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/heic': '.heic',
'image/heif': '.heif',
};
return mapping[contentType] || '.jpg';
}
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
}
}