mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 04:43:40 +02:00
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:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
|||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { StorageService } from './storage.service';
|
|||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue