style: auto-format codebase with Prettier

Applied formatting to 1487+ files using pnpm format:write
  - TypeScript/JavaScript files
  - Svelte components
  - Astro pages
  - JSON configs
  - Markdown docs

  13 files still need manual review (Astro JSX comments)
This commit is contained in:
Wuesteon 2025-11-27 18:33:16 +01:00
parent 0241f5554c
commit d36b321d9d
3952 changed files with 661498 additions and 739751 deletions

View file

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

View file

@ -1,46 +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"
}
"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

@ -8,17 +8,17 @@ import { MealsModule } from './meals/meals.module';
import { SyncModule } from './sync/sync.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
StorageModule,
HealthModule,
GeminiModule,
MealsModule,
SyncModule,
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
StorageModule,
HealthModule,
GeminiModule,
MealsModule,
SyncModule,
],
})
export class AppModule {}

View file

@ -1,15 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
userId: string;
email: string;
role: string;
sessionId?: string;
userId: string;
email: string;
role: string;
sessionId?: string;
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
}
);

View file

@ -1,66 +1,60 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') ||
'http://localhost:3001';
try {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
const { valid, payload } = await response.json();
const { valid, payload } = await response.json();
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -6,23 +6,23 @@ 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],
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();
}
async onModuleDestroy() {
await closeDb();
}
}

View file

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

View file

@ -3,39 +3,39 @@ 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[];
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;
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' });
}
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');
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.
const prompt = `Analyze this food image and provide detailed nutritional information.
Return a JSON object with the following structure:
{
@ -56,38 +56,38 @@ export class GeminiService {
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,
},
},
]);
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]*\}/);
const response = result.response.text();
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No valid JSON found in response');
}
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}`);
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;
}
}
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');
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}"
const prompt = `Based on this food description, provide detailed nutritional information: "${description}"
Return a JSON object with the following structure:
{
@ -107,22 +107,22 @@ export class GeminiService {
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]*\}/);
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');
}
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}`);
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;
}
}
return analysis;
} catch (error) {
this.logger.error('Failed to analyze food description', error);
throw error;
}
}
}

View file

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

View file

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

View file

@ -3,34 +3,34 @@ import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
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 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,
}),
);
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api');
// 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}`);
const port = process.env.PORT || 3002;
await app.listen(port);
console.log(`Nutriphi backend running on http://localhost:${port}`);
}
bootstrap();

View file

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

View file

@ -1,23 +1,23 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
UseGuards,
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { MealsService } from './meals.service';
import {
AnalyzeMealImageDto,
AnalyzeMealTextDto,
CreateMealDto,
UpdateMealDto,
UploadMealDto,
AnalyzeMealImageDto,
AnalyzeMealTextDto,
CreateMealDto,
UpdateMealDto,
UploadMealDto,
} from './dto/analyze-meal.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
@ -25,75 +25,57 @@ import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.
@Controller('meals')
@UseGuards(JwtAuthGuard)
export class MealsController {
constructor(private readonly mealsService: MealsService) {}
constructor(private readonly mealsService: MealsService) {}
@Post('analyze/image')
@HttpCode(HttpStatus.OK)
async analyzeImage(@Body() dto: AnalyzeMealImageDto) {
return this.mealsService.analyzeImage(dto.imageBase64);
}
@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('analyze/text')
@HttpCode(HttpStatus.OK)
async analyzeText(@Body() dto: AnalyzeMealTextDto) {
return this.mealsService.analyzeText(dto.description);
}
@Post()
async createMeal(
@Body() dto: CreateMealDto,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.createMeal(dto, user.userId);
}
@Post()
async createMeal(@Body() dto: CreateMealDto, @CurrentUser() user: CurrentUserData) {
return this.mealsService.createMeal(dto, user.userId);
}
@Post('upload')
async uploadMeal(
@Body() dto: UploadMealDto,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.uploadAndAnalyzeMeal(dto, user.userId);
}
@Post('upload')
async uploadMeal(@Body() dto: UploadMealDto, @CurrentUser() user: CurrentUserData) {
return this.mealsService.uploadAndAnalyzeMeal(dto, user.userId);
}
@Get()
async getMeals(
@CurrentUser() user: CurrentUserData,
@Query('date') date?: string,
) {
return this.mealsService.getMealsByUser(user.userId, date);
}
@Get()
async getMeals(@CurrentUser() user: CurrentUserData, @Query('date') date?: string) {
return this.mealsService.getMealsByUser(user.userId, date);
}
@Get('summary')
async getDailySummary(
@CurrentUser() user: CurrentUserData,
@Query('date') date: string,
) {
return this.mealsService.getDailySummary(user.userId, date);
}
@Get('summary')
async getDailySummary(@CurrentUser() user: CurrentUserData, @Query('date') date: string) {
return this.mealsService.getDailySummary(user.userId, date);
}
@Get(':id')
async getMealById(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.getMealById(id, user.userId);
}
@Get(':id')
async getMealById(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
return this.mealsService.getMealById(id, user.userId);
}
@Put(':id')
async updateMeal(
@Param('id') id: string,
@Body() dto: UpdateMealDto,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.updateMeal(id, dto, user.userId);
}
@Put(':id')
async updateMeal(
@Param('id') id: string,
@Body() dto: UpdateMealDto,
@CurrentUser() user: CurrentUserData
) {
return this.mealsService.updateMeal(id, dto, user.userId);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteMeal(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.deleteMeal(id, user.userId);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteMeal(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
return this.mealsService.deleteMeal(id, user.userId);
}
}

View file

@ -4,8 +4,8 @@ import { MealsService } from './meals.service';
import { GeminiModule } from '../gemini/gemini.module';
@Module({
imports: [GeminiModule],
controllers: [MealsController],
providers: [MealsService],
imports: [GeminiModule],
controllers: [MealsController],
providers: [MealsService],
})
export class MealsModule {}

View file

@ -1,13 +1,13 @@
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
import {
type Database,
meals,
eq,
and,
gte,
lte,
desc,
type Meal as DbMeal,
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';
@ -15,267 +15,270 @@ 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;
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;
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);
private readonly logger = new Logger(MealsService.name);
constructor(
@Inject(DATABASE_TOKEN) private readonly db: Database,
private geminiService: GeminiService,
private storageService: StorageService,
) {}
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(),
};
}
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 analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
return this.geminiService.analyzeFoodImage(imageBase64);
}
async analyzeText(description: string): Promise<NutritionAnalysis> {
return this.geminiService.analyzeFoodText(description);
}
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, userId: string): Promise<Meal> {
this.logger.log(`Uploading and analyzing meal for user: ${userId}`);
/**
* Upload an image to storage, analyze it, and create a meal
*/
async uploadAndAnalyzeMeal(dto: UploadMealDto, userId: string): Promise<Meal> {
this.logger.log(`Uploading and analyzing meal for user: ${userId}`);
// Step 1: Upload image to storage
let imageUrl: string | undefined;
let storagePath: string | undefined;
// 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);
}
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];
}
// 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);
const analysis = await this.geminiService.analyzeFoodImage(base64Data);
// Step 3: Create the meal record
const [result] = await this.db.insert(meals).values({
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();
// Step 3: Create the meal record
const [result] = await this.db
.insert(meals)
.values({
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}`);
this.logger.log(`Meal created: ${result.id}`);
return this.mapDbMealToMeal(result);
}
return this.mapDbMealToMeal(result);
}
async createMeal(dto: CreateMealDto, userId: string): Promise<Meal> {
this.logger.log(`Creating meal for user: ${userId}`);
async createMeal(dto: CreateMealDto, userId: string): Promise<Meal> {
this.logger.log(`Creating meal for user: ${userId}`);
const [result] = await this.db.insert(meals).values({
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();
const [result] = await this.db
.insert(meals)
.values({
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);
}
return this.mapDbMealToMeal(result);
}
async getMealsByUser(
userId: string,
date?: string,
): Promise<Meal[]> {
this.logger.log(`Fetching meals for user: ${userId}`);
async getMealsByUser(userId: string, date?: string): Promise<Meal[]> {
this.logger.log(`Fetching meals for user: ${userId}`);
let query;
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);
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));
}
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);
}
const results = await query;
return results.map(this.mapDbMealToMeal);
}
async getMealById(id: string, userId: string): Promise<Meal> {
const [result] = await this.db
.select()
.from(meals)
.where(and(eq(meals.id, id), eq(meals.userId, userId)));
async getMealById(id: string, userId: string): Promise<Meal> {
const [result] = await this.db
.select()
.from(meals)
.where(and(eq(meals.id, id), eq(meals.userId, userId)));
if (!result) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
if (!result) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
return this.mapDbMealToMeal(result);
}
return this.mapDbMealToMeal(result);
}
async updateMeal(id: string, dto: UpdateMealDto, userId: string): Promise<Meal> {
this.logger.log(`Updating meal: ${id} for user: ${userId}`);
async updateMeal(id: string, dto: UpdateMealDto, userId: string): Promise<Meal> {
this.logger.log(`Updating meal: ${id} for user: ${userId}`);
const updateData: Partial<typeof meals.$inferInsert> = {
updatedAt: new Date(),
};
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;
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(and(eq(meals.id, id), eq(meals.userId, userId)))
.returning();
const [result] = await this.db
.update(meals)
.set(updateData)
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
.returning();
if (!result) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
if (!result) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
return this.mapDbMealToMeal(result);
}
return this.mapDbMealToMeal(result);
}
async deleteMeal(id: string, userId: string): Promise<void> {
this.logger.log(`Deleting meal: ${id} for user: ${userId}`);
async deleteMeal(id: string, userId: string): Promise<void> {
this.logger.log(`Deleting meal: ${id} for user: ${userId}`);
const result = await this.db
.delete(meals)
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
.returning();
const result = await this.db
.delete(meals)
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
.returning();
if (result.length === 0) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
}
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);
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,
};
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;
}
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;
}
return summary;
}
}

View file

@ -3,7 +3,7 @@ import { StorageService } from './storage.service';
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -1,166 +1,160 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
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;
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;
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') || '';
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;
}
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.s3Client = new S3Client({
region,
endpoint,
credentials: {
accessKeyId,
secretAccessKey,
},
forcePathStyle: true, // Required for Hetzner Object Storage
});
this.logger.log('Hetzner Object Storage initialized successfully');
}
this.logger.log('Hetzner Object Storage initialized successfully');
}
private isConfigured(): boolean {
return this.s3Client !== null;
}
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');
}
/**
* 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}`;
const extension = this.getExtensionFromContentType(contentType);
const key = `${folder}/${randomUUID()}${extension}`;
this.logger.log(`Uploading file to R2: ${key}`);
this.logger.log(`Uploading file to R2: ${key}`);
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: buffer,
ContentType: contentType,
}),
);
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);
const url = this.publicUrl ? `${this.publicUrl}/${key}` : await this.getSignedUrl(key);
return { key, url };
}
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';
/**
* 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];
}
}
// 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);
}
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');
}
/**
* 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}`);
this.logger.log(`Deleting file from R2: ${key}`);
await this.s3Client.send(
new DeleteObjectCommand({
Bucket: this.bucketName,
Key: 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');
}
/**
* 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,
});
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
return getSignedUrl(this.s3Client, command, { expiresIn });
}
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';
}
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

@ -1,146 +1,153 @@
import { IsString, IsOptional, IsArray, ValidateNested, IsNumber, IsBoolean } from 'class-validator';
import {
IsString,
IsOptional,
IsArray,
ValidateNested,
IsNumber,
IsBoolean,
} from 'class-validator';
import { Type } from 'class-transformer';
/**
* Local meal data from mobile app
*/
export class LocalMealDto {
@IsNumber()
localId: number;
@IsNumber()
localId: number;
@IsOptional()
@IsString()
cloudId?: string;
@IsOptional()
@IsString()
cloudId?: string;
@IsString()
foodName: string;
@IsString()
foodName: string;
@IsOptional()
@IsString()
imageUrl?: string;
@IsOptional()
@IsString()
imageUrl?: string;
@IsOptional()
calories?: number;
@IsOptional()
calories?: number;
@IsOptional()
protein?: number;
@IsOptional()
protein?: number;
@IsOptional()
carbohydrates?: number;
@IsOptional()
carbohydrates?: number;
@IsOptional()
fat?: number;
@IsOptional()
fat?: number;
@IsOptional()
fiber?: number;
@IsOptional()
fiber?: number;
@IsOptional()
sugar?: number;
@IsOptional()
sugar?: number;
@IsOptional()
sodium?: number;
@IsOptional()
sodium?: number;
@IsOptional()
@IsString()
servingSize?: string;
@IsOptional()
@IsString()
servingSize?: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
@IsOptional()
@IsString()
analysisStatus?: string;
@IsOptional()
@IsString()
analysisStatus?: string;
@IsOptional()
healthScore?: number;
@IsOptional()
healthScore?: number;
@IsOptional()
@IsString()
healthCategory?: string;
@IsOptional()
@IsString()
healthCategory?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
userRating?: number;
@IsOptional()
userRating?: number;
@IsOptional()
foodItems?: any[];
@IsOptional()
foodItems?: any[];
@IsNumber()
version: number;
@IsNumber()
version: number;
@IsString()
createdAt: string;
@IsString()
createdAt: string;
@IsString()
updatedAt: string;
@IsString()
updatedAt: string;
}
/**
* Push request - local changes to server
*/
export class SyncPushDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => LocalMealDto)
meals: LocalMealDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => LocalMealDto)
meals: LocalMealDto[];
@IsArray()
@IsString({ each: true })
deletedIds: string[];
@IsArray()
@IsString({ each: true })
deletedIds: string[];
@IsOptional()
@IsString()
lastSyncAt?: string;
@IsOptional()
@IsString()
lastSyncAt?: string;
}
/**
* Push response
*/
export interface SyncPushResponse {
created: { localId: number; cloudId: string }[];
updated: string[];
conflicts: ConflictInfo[];
serverTime: string;
created: { localId: number; cloudId: string }[];
updated: string[];
conflicts: ConflictInfo[];
serverTime: string;
}
/**
* Conflict information
*/
export interface ConflictInfo {
cloudId: string;
localVersion: number;
serverVersion: number;
serverData: any;
message: string;
cloudId: string;
localVersion: number;
serverVersion: number;
serverData: any;
message: string;
}
/**
* Pull query parameters
*/
export class SyncPullQueryDto {
@IsOptional()
@IsString()
since?: string;
@IsOptional()
@IsString()
since?: string;
}
/**
* Pull response
*/
export interface SyncPullResponse {
meals: any[];
deletedIds: string[];
serverTime: string;
meals: any[];
deletedIds: string[];
serverTime: string;
}
/**
* Sync status response
*/
export interface SyncStatusResponse {
lastSyncAt: string | null;
pendingChanges: number;
serverTime: string;
lastSyncAt: string | null;
pendingChanges: number;
serverTime: string;
}

View file

@ -1,11 +1,11 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { SyncService } from './sync.service';
import {
SyncPushDto,
SyncPushResponse,
SyncPullQueryDto,
SyncPullResponse,
SyncStatusResponse,
SyncPushDto,
SyncPushResponse,
SyncPullQueryDto,
SyncPullResponse,
SyncStatusResponse,
} from './dto/sync.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
@ -13,38 +13,38 @@ import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.
@Controller('sync')
@UseGuards(JwtAuthGuard)
export class SyncController {
constructor(private readonly syncService: SyncService) {}
constructor(private readonly syncService: SyncService) {}
/**
* Push local changes to server
* POST /api/sync/push
*/
@Post('push')
async pushChanges(
@Body() dto: SyncPushDto,
@CurrentUser() user: CurrentUserData,
): Promise<SyncPushResponse> {
return this.syncService.pushChanges(user.userId, dto);
}
/**
* Push local changes to server
* POST /api/sync/push
*/
@Post('push')
async pushChanges(
@Body() dto: SyncPushDto,
@CurrentUser() user: CurrentUserData
): Promise<SyncPushResponse> {
return this.syncService.pushChanges(user.userId, dto);
}
/**
* Pull changes from server
* GET /api/sync/pull?since=2024-01-01T00:00:00Z
*/
@Get('pull')
async pullChanges(
@Query() query: SyncPullQueryDto,
@CurrentUser() user: CurrentUserData,
): Promise<SyncPullResponse> {
return this.syncService.pullChanges(user.userId, query.since);
}
/**
* Pull changes from server
* GET /api/sync/pull?since=2024-01-01T00:00:00Z
*/
@Get('pull')
async pullChanges(
@Query() query: SyncPullQueryDto,
@CurrentUser() user: CurrentUserData
): Promise<SyncPullResponse> {
return this.syncService.pullChanges(user.userId, query.since);
}
/**
* Get sync status
* GET /api/sync/status
*/
@Get('status')
async getStatus(@CurrentUser() user: CurrentUserData): Promise<SyncStatusResponse> {
return this.syncService.getStatus(user.userId);
}
/**
* Get sync status
* GET /api/sync/status
*/
@Get('status')
async getStatus(@CurrentUser() user: CurrentUserData): Promise<SyncStatusResponse> {
return this.syncService.getStatus(user.userId);
}
}

View file

@ -3,8 +3,8 @@ import { SyncController } from './sync.controller';
import { SyncService } from './sync.service';
@Module({
controllers: [SyncController],
providers: [SyncService],
exports: [SyncService],
controllers: [SyncController],
providers: [SyncService],
exports: [SyncService],
})
export class SyncModule {}

View file

@ -1,251 +1,246 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
type Database,
meals,
eq,
and,
gt,
type Meal as DbMeal,
type Database,
meals,
eq,
and,
gt,
type Meal as DbMeal,
} from '@manacore/nutriphi-database';
import { DATABASE_TOKEN } from '../database/database.module';
import {
LocalMealDto,
SyncPushDto,
SyncPushResponse,
SyncPullResponse,
SyncStatusResponse,
ConflictInfo,
LocalMealDto,
SyncPushDto,
SyncPushResponse,
SyncPullResponse,
SyncStatusResponse,
ConflictInfo,
} from './dto/sync.dto';
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
private readonly logger = new Logger(SyncService.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
/**
* Push local changes to server
*/
async pushChanges(userId: string, dto: SyncPushDto): Promise<SyncPushResponse> {
this.logger.log(`Processing sync push for user: ${userId}, ${dto.meals.length} meals`);
/**
* Push local changes to server
*/
async pushChanges(userId: string, dto: SyncPushDto): Promise<SyncPushResponse> {
this.logger.log(`Processing sync push for user: ${userId}, ${dto.meals.length} meals`);
const created: { localId: number; cloudId: string }[] = [];
const updated: string[] = [];
const conflicts: ConflictInfo[] = [];
const serverTime = new Date().toISOString();
const created: { localId: number; cloudId: string }[] = [];
const updated: string[] = [];
const conflicts: ConflictInfo[] = [];
const serverTime = new Date().toISOString();
// Process each meal
for (const localMeal of dto.meals) {
try {
if (localMeal.cloudId) {
// Update existing meal
const result = await this.updateExistingMeal(userId, localMeal);
if (result.conflict) {
conflicts.push(result.conflict);
} else if (result.updated) {
updated.push(localMeal.cloudId);
}
} else {
// Create new meal
const cloudId = await this.createNewMeal(userId, localMeal);
created.push({ localId: localMeal.localId, cloudId });
}
} catch (error) {
this.logger.error(`Error processing meal ${localMeal.localId}:`, error);
}
}
// Process each meal
for (const localMeal of dto.meals) {
try {
if (localMeal.cloudId) {
// Update existing meal
const result = await this.updateExistingMeal(userId, localMeal);
if (result.conflict) {
conflicts.push(result.conflict);
} else if (result.updated) {
updated.push(localMeal.cloudId);
}
} else {
// Create new meal
const cloudId = await this.createNewMeal(userId, localMeal);
created.push({ localId: localMeal.localId, cloudId });
}
} catch (error) {
this.logger.error(`Error processing meal ${localMeal.localId}:`, error);
}
}
// Process deletions
for (const cloudId of dto.deletedIds) {
try {
await this.db
.delete(meals)
.where(and(eq(meals.id, cloudId), eq(meals.userId, userId)));
this.logger.log(`Deleted meal: ${cloudId}`);
} catch (error) {
this.logger.error(`Error deleting meal ${cloudId}:`, error);
}
}
// Process deletions
for (const cloudId of dto.deletedIds) {
try {
await this.db.delete(meals).where(and(eq(meals.id, cloudId), eq(meals.userId, userId)));
this.logger.log(`Deleted meal: ${cloudId}`);
} catch (error) {
this.logger.error(`Error deleting meal ${cloudId}:`, error);
}
}
return { created, updated, conflicts, serverTime };
}
return { created, updated, conflicts, serverTime };
}
/**
* Pull changes from server since given timestamp
*/
async pullChanges(userId: string, since?: string): Promise<SyncPullResponse> {
this.logger.log(`Processing sync pull for user: ${userId}, since: ${since}`);
/**
* Pull changes from server since given timestamp
*/
async pullChanges(userId: string, since?: string): Promise<SyncPullResponse> {
this.logger.log(`Processing sync pull for user: ${userId}, since: ${since}`);
const serverTime = new Date().toISOString();
const serverTime = new Date().toISOString();
let query;
if (since) {
const sinceDate = new Date(since);
query = this.db
.select()
.from(meals)
.where(and(eq(meals.userId, userId), gt(meals.updatedAt, sinceDate)));
} else {
// Full sync - get all meals
query = this.db.select().from(meals).where(eq(meals.userId, userId));
}
let query;
if (since) {
const sinceDate = new Date(since);
query = this.db
.select()
.from(meals)
.where(and(eq(meals.userId, userId), gt(meals.updatedAt, sinceDate)));
} else {
// Full sync - get all meals
query = this.db.select().from(meals).where(eq(meals.userId, userId));
}
const results = await query;
const results = await query;
const mappedMeals = results.map((meal) => this.mapDbMealToSync(meal));
const mappedMeals = results.map((meal) => this.mapDbMealToSync(meal));
return {
meals: mappedMeals,
deletedIds: [], // TODO: Implement soft deletes to track deleted meals
serverTime,
};
}
return {
meals: mappedMeals,
deletedIds: [], // TODO: Implement soft deletes to track deleted meals
serverTime,
};
}
/**
* Get sync status
*/
async getStatus(userId: string): Promise<SyncStatusResponse> {
const serverTime = new Date().toISOString();
/**
* Get sync status
*/
async getStatus(userId: string): Promise<SyncStatusResponse> {
const serverTime = new Date().toISOString();
// Count user's meals
const result = await this.db
.select()
.from(meals)
.where(eq(meals.userId, userId));
// Count user's meals
const result = await this.db.select().from(meals).where(eq(meals.userId, userId));
return {
lastSyncAt: null, // Could be stored in a user preferences table
pendingChanges: 0,
serverTime,
};
}
return {
lastSyncAt: null, // Could be stored in a user preferences table
pendingChanges: 0,
serverTime,
};
}
/**
* Create a new meal from local data
*/
private async createNewMeal(userId: string, localMeal: LocalMealDto): Promise<string> {
const [result] = await this.db
.insert(meals)
.values({
userId,
foodName: localMeal.foodName,
imageUrl: localMeal.imageUrl,
calories: localMeal.calories ?? 0,
protein: localMeal.protein ?? 0,
carbohydrates: localMeal.carbohydrates ?? 0,
fat: localMeal.fat ?? 0,
fiber: localMeal.fiber ?? 0,
sugar: localMeal.sugar ?? 0,
sodium: localMeal.sodium ?? 0,
servingSize: localMeal.servingSize,
mealType: localMeal.mealType,
analysisStatus: localMeal.analysisStatus ?? 'completed',
healthScore: localMeal.healthScore,
healthCategory: localMeal.healthCategory,
notes: localMeal.notes,
userRating: localMeal.userRating,
foodItems: localMeal.foodItems ?? [],
createdAt: new Date(localMeal.createdAt),
updatedAt: new Date(localMeal.updatedAt),
})
.returning();
/**
* Create a new meal from local data
*/
private async createNewMeal(userId: string, localMeal: LocalMealDto): Promise<string> {
const [result] = await this.db
.insert(meals)
.values({
userId,
foodName: localMeal.foodName,
imageUrl: localMeal.imageUrl,
calories: localMeal.calories ?? 0,
protein: localMeal.protein ?? 0,
carbohydrates: localMeal.carbohydrates ?? 0,
fat: localMeal.fat ?? 0,
fiber: localMeal.fiber ?? 0,
sugar: localMeal.sugar ?? 0,
sodium: localMeal.sodium ?? 0,
servingSize: localMeal.servingSize,
mealType: localMeal.mealType,
analysisStatus: localMeal.analysisStatus ?? 'completed',
healthScore: localMeal.healthScore,
healthCategory: localMeal.healthCategory,
notes: localMeal.notes,
userRating: localMeal.userRating,
foodItems: localMeal.foodItems ?? [],
createdAt: new Date(localMeal.createdAt),
updatedAt: new Date(localMeal.updatedAt),
})
.returning();
this.logger.log(`Created meal: ${result.id} for local: ${localMeal.localId}`);
return result.id;
}
this.logger.log(`Created meal: ${result.id} for local: ${localMeal.localId}`);
return result.id;
}
/**
* Update existing meal, checking for conflicts
*/
private async updateExistingMeal(
userId: string,
localMeal: LocalMealDto,
): Promise<{ updated: boolean; conflict?: ConflictInfo }> {
// Get current server version
const [serverMeal] = await this.db
.select()
.from(meals)
.where(and(eq(meals.id, localMeal.cloudId!), eq(meals.userId, userId)));
/**
* Update existing meal, checking for conflicts
*/
private async updateExistingMeal(
userId: string,
localMeal: LocalMealDto
): Promise<{ updated: boolean; conflict?: ConflictInfo }> {
// Get current server version
const [serverMeal] = await this.db
.select()
.from(meals)
.where(and(eq(meals.id, localMeal.cloudId!), eq(meals.userId, userId)));
if (!serverMeal) {
this.logger.warn(`Meal not found: ${localMeal.cloudId}`);
return { updated: false };
}
if (!serverMeal) {
this.logger.warn(`Meal not found: ${localMeal.cloudId}`);
return { updated: false };
}
// Simple last-write-wins strategy
// In production, you might want more sophisticated conflict resolution
const localUpdateTime = new Date(localMeal.updatedAt);
const serverUpdateTime = serverMeal.updatedAt;
// Simple last-write-wins strategy
// In production, you might want more sophisticated conflict resolution
const localUpdateTime = new Date(localMeal.updatedAt);
const serverUpdateTime = serverMeal.updatedAt;
// If local is newer, update server
if (localUpdateTime >= serverUpdateTime) {
await this.db
.update(meals)
.set({
foodName: localMeal.foodName,
imageUrl: localMeal.imageUrl,
calories: localMeal.calories ?? 0,
protein: localMeal.protein ?? 0,
carbohydrates: localMeal.carbohydrates ?? 0,
fat: localMeal.fat ?? 0,
fiber: localMeal.fiber ?? 0,
sugar: localMeal.sugar ?? 0,
sodium: localMeal.sodium ?? 0,
servingSize: localMeal.servingSize,
mealType: localMeal.mealType,
analysisStatus: localMeal.analysisStatus,
healthScore: localMeal.healthScore,
healthCategory: localMeal.healthCategory,
notes: localMeal.notes,
userRating: localMeal.userRating,
foodItems: localMeal.foodItems ?? [],
updatedAt: new Date(),
})
.where(eq(meals.id, localMeal.cloudId!));
// If local is newer, update server
if (localUpdateTime >= serverUpdateTime) {
await this.db
.update(meals)
.set({
foodName: localMeal.foodName,
imageUrl: localMeal.imageUrl,
calories: localMeal.calories ?? 0,
protein: localMeal.protein ?? 0,
carbohydrates: localMeal.carbohydrates ?? 0,
fat: localMeal.fat ?? 0,
fiber: localMeal.fiber ?? 0,
sugar: localMeal.sugar ?? 0,
sodium: localMeal.sodium ?? 0,
servingSize: localMeal.servingSize,
mealType: localMeal.mealType,
analysisStatus: localMeal.analysisStatus,
healthScore: localMeal.healthScore,
healthCategory: localMeal.healthCategory,
notes: localMeal.notes,
userRating: localMeal.userRating,
foodItems: localMeal.foodItems ?? [],
updatedAt: new Date(),
})
.where(eq(meals.id, localMeal.cloudId!));
this.logger.log(`Updated meal: ${localMeal.cloudId}`);
return { updated: true };
}
this.logger.log(`Updated meal: ${localMeal.cloudId}`);
return { updated: true };
}
// Server is newer - report conflict
return {
updated: false,
conflict: {
cloudId: localMeal.cloudId!,
localVersion: localMeal.version,
serverVersion: 1, // Would need version tracking in DB
serverData: this.mapDbMealToSync(serverMeal),
message: 'Server has newer data',
},
};
}
// Server is newer - report conflict
return {
updated: false,
conflict: {
cloudId: localMeal.cloudId!,
localVersion: localMeal.version,
serverVersion: 1, // Would need version tracking in DB
serverData: this.mapDbMealToSync(serverMeal),
message: 'Server has newer data',
},
};
}
/**
* Map database meal to sync format
*/
private mapDbMealToSync(meal: DbMeal): any {
return {
cloudId: meal.id,
userId: meal.userId,
foodName: meal.foodName,
imageUrl: meal.imageUrl,
calories: meal.calories,
protein: meal.protein,
carbohydrates: meal.carbohydrates,
fat: meal.fat,
fiber: meal.fiber,
sugar: meal.sugar,
sodium: meal.sodium,
servingSize: meal.servingSize,
mealType: meal.mealType,
analysisStatus: meal.analysisStatus,
healthScore: meal.healthScore,
healthCategory: meal.healthCategory,
notes: meal.notes,
userRating: meal.userRating,
foodItems: meal.foodItems,
createdAt: meal.createdAt.toISOString(),
updatedAt: meal.updatedAt.toISOString(),
};
}
/**
* Map database meal to sync format
*/
private mapDbMealToSync(meal: DbMeal): any {
return {
cloudId: meal.id,
userId: meal.userId,
foodName: meal.foodName,
imageUrl: meal.imageUrl,
calories: meal.calories,
protein: meal.protein,
carbohydrates: meal.carbohydrates,
fat: meal.fat,
fiber: meal.fiber,
sugar: meal.sugar,
sodium: meal.sodium,
servingSize: meal.servingSize,
mealType: meal.mealType,
analysisStatus: meal.analysisStatus,
healthScore: meal.healthScore,
healthCategory: meal.healthCategory,
notes: meal.notes,
userRating: meal.userRating,
foodItems: meal.foodItems,
createdAt: meal.createdAt.toISOString(),
updatedAt: meal.updatedAt.toISOString(),
};
}
}

View file

@ -1,21 +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
}
"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
}
}