mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 03:01:26 +02:00
refactor: restructure
monorepo with apps/ and services/ directories
This commit is contained in:
parent
25824ed0ac
commit
ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions
20
apps/nutriphi/apps/backend/.env.example
Normal file
20
apps/nutriphi/apps/backend/.env.example
Normal 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
|
||||
49
apps/nutriphi/apps/backend/Dockerfile
Normal file
49
apps/nutriphi/apps/backend/Dockerfile
Normal 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"]
|
||||
31
apps/nutriphi/apps/backend/docker-compose.coolify.yml
Normal file
31
apps/nutriphi/apps/backend/docker-compose.coolify.yml
Normal 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"
|
||||
8
apps/nutriphi/apps/backend/nest-cli.json
Normal file
8
apps/nutriphi/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
46
apps/nutriphi/apps/backend/package.json
Normal file
46
apps/nutriphi/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
apps/nutriphi/apps/backend/src/app.module.ts
Normal file
22
apps/nutriphi/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
28
apps/nutriphi/apps/backend/src/database/database.module.ts
Normal file
28
apps/nutriphi/apps/backend/src/database/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
8
apps/nutriphi/apps/backend/src/gemini/gemini.module.ts
Normal file
8
apps/nutriphi/apps/backend/src/gemini/gemini.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GeminiService } from './gemini.service';
|
||||
|
||||
@Module({
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class GeminiModule {}
|
||||
128
apps/nutriphi/apps/backend/src/gemini/gemini.service.ts
Normal file
128
apps/nutriphi/apps/backend/src/gemini/gemini.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
apps/nutriphi/apps/backend/src/health/health.controller.ts
Normal file
13
apps/nutriphi/apps/backend/src/health/health.controller.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/nutriphi/apps/backend/src/health/health.module.ts
Normal file
7
apps/nutriphi/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
36
apps/nutriphi/apps/backend/src/main.ts
Normal file
36
apps/nutriphi/apps/backend/src/main.ts
Normal 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();
|
||||
93
apps/nutriphi/apps/backend/src/meals/dto/analyze-meal.dto.ts
Normal file
93
apps/nutriphi/apps/backend/src/meals/dto/analyze-meal.dto.ts
Normal 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;
|
||||
}
|
||||
79
apps/nutriphi/apps/backend/src/meals/meals.controller.ts
Normal file
79
apps/nutriphi/apps/backend/src/meals/meals.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/nutriphi/apps/backend/src/meals/meals.module.ts
Normal file
11
apps/nutriphi/apps/backend/src/meals/meals.module.ts
Normal 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 {}
|
||||
281
apps/nutriphi/apps/backend/src/meals/meals.service.ts
Normal file
281
apps/nutriphi/apps/backend/src/meals/meals.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
apps/nutriphi/apps/backend/src/storage/storage.module.ts
Normal file
9
apps/nutriphi/apps/backend/src/storage/storage.module.ts
Normal 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 {}
|
||||
166
apps/nutriphi/apps/backend/src/storage/storage.service.ts
Normal file
166
apps/nutriphi/apps/backend/src/storage/storage.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
21
apps/nutriphi/apps/backend/tsconfig.json
Normal file
21
apps/nutriphi/apps/backend/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue