Feat: Refactor postgress

This commit is contained in:
Till-JS 2025-11-27 02:25:37 +01:00
parent 046a0e3fe7
commit 98efa6f6e8
134 changed files with 9459 additions and 1904 deletions

View file

@ -5,6 +5,7 @@ import { StorageModule } from './storage/storage.module';
import { HealthModule } from './health/health.module';
import { GeminiModule } from './gemini/gemini.module';
import { MealsModule } from './meals/meals.module';
import { SyncModule } from './sync/sync.module';
@Module({
imports: [
@ -17,6 +18,7 @@ import { MealsModule } from './meals/meals.module';
HealthModule,
GeminiModule,
MealsModule,
SyncModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
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;
},
);

View file

@ -0,0 +1,66 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
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';
// 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');
}
const { valid, payload } = await response.json();
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,
};
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;
}
}

View file

@ -11,9 +11,6 @@ export class AnalyzeMealTextDto {
}
export class CreateMealDto {
@IsString()
userId: string;
@IsString()
foodName: string;
@ -45,9 +42,6 @@ export class UploadMealDto {
@IsString()
imageBase64: string;
@IsString()
userId: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';

View file

@ -9,6 +9,7 @@ import {
Query,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { MealsService } from './meals.service';
import {
@ -18,8 +19,11 @@ import {
UpdateMealDto,
UploadMealDto,
} from './dto/analyze-meal.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
@Controller('meals')
@UseGuards(JwtAuthGuard)
export class MealsController {
constructor(private readonly mealsService: MealsService) {}
@ -36,44 +40,60 @@ export class MealsController {
}
@Post()
async createMeal(@Body() dto: CreateMealDto) {
return this.mealsService.createMeal(dto);
async createMeal(
@Body() dto: CreateMealDto,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.createMeal(dto, user.userId);
}
@Post('upload')
async uploadMeal(@Body() dto: UploadMealDto) {
return this.mealsService.uploadAndAnalyzeMeal(dto);
async uploadMeal(
@Body() dto: UploadMealDto,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.uploadAndAnalyzeMeal(dto, user.userId);
}
@Get('user/:userId')
async getMealsByUser(
@Param('userId') userId: string,
@Get()
async getMeals(
@CurrentUser() user: CurrentUserData,
@Query('date') date?: string,
) {
return this.mealsService.getMealsByUser(userId, date);
return this.mealsService.getMealsByUser(user.userId, date);
}
@Get('user/:userId/summary')
@Get('summary')
async getDailySummary(
@Param('userId') userId: string,
@CurrentUser() user: CurrentUserData,
@Query('date') date: string,
) {
return this.mealsService.getDailySummary(userId, date);
return this.mealsService.getDailySummary(user.userId, date);
}
@Get(':id')
async getMealById(@Param('id') id: string) {
return this.mealsService.getMealById(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) {
return this.mealsService.updateMeal(id, dto);
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) {
return this.mealsService.deleteMeal(id);
async deleteMeal(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.deleteMeal(id, user.userId);
}
}

View file

@ -87,8 +87,8 @@ export class MealsService {
/**
* Upload an image to storage, analyze it, and create a meal
*/
async uploadAndAnalyzeMeal(dto: UploadMealDto): Promise<Meal> {
this.logger.log(`Uploading and analyzing meal for user: ${dto.userId}`);
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;
@ -114,7 +114,7 @@ export class MealsService {
// Step 3: Create the meal record
const [result] = await this.db.insert(meals).values({
userId: dto.userId,
userId,
foodName: analysis.foodName || 'Unbekanntes Gericht',
imageUrl,
storagePath,
@ -134,11 +134,11 @@ export class MealsService {
return this.mapDbMealToMeal(result);
}
async createMeal(dto: CreateMealDto): Promise<Meal> {
this.logger.log(`Creating meal for user: ${dto.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: dto.userId,
userId,
foodName: dto.foodName,
imageUrl: dto.imageUrl,
calories: dto.calories,
@ -193,11 +193,11 @@ export class MealsService {
return results.map(this.mapDbMealToMeal);
}
async getMealById(id: string): Promise<Meal> {
async getMealById(id: string, userId: string): Promise<Meal> {
const [result] = await this.db
.select()
.from(meals)
.where(eq(meals.id, id));
.where(and(eq(meals.id, id), eq(meals.userId, userId)));
if (!result) {
throw new NotFoundException(`Meal with id ${id} not found`);
@ -206,8 +206,8 @@ export class MealsService {
return this.mapDbMealToMeal(result);
}
async updateMeal(id: string, dto: UpdateMealDto): Promise<Meal> {
this.logger.log(`Updating meal: ${id}`);
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(),
@ -228,7 +228,7 @@ export class MealsService {
const [result] = await this.db
.update(meals)
.set(updateData)
.where(eq(meals.id, id))
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
.returning();
if (!result) {
@ -238,12 +238,12 @@ export class MealsService {
return this.mapDbMealToMeal(result);
}
async deleteMeal(id: string): Promise<void> {
this.logger.log(`Deleting meal: ${id}`);
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(eq(meals.id, id))
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
.returning();
if (result.length === 0) {

View file

@ -0,0 +1,146 @@
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;
@IsOptional()
@IsString()
cloudId?: string;
@IsString()
foodName: string;
@IsOptional()
@IsString()
imageUrl?: string;
@IsOptional()
calories?: number;
@IsOptional()
protein?: number;
@IsOptional()
carbohydrates?: number;
@IsOptional()
fat?: number;
@IsOptional()
fiber?: number;
@IsOptional()
sugar?: number;
@IsOptional()
sodium?: number;
@IsOptional()
@IsString()
servingSize?: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
@IsOptional()
@IsString()
analysisStatus?: string;
@IsOptional()
healthScore?: number;
@IsOptional()
@IsString()
healthCategory?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
userRating?: number;
@IsOptional()
foodItems?: any[];
@IsNumber()
version: number;
@IsString()
createdAt: string;
@IsString()
updatedAt: string;
}
/**
* Push request - local changes to server
*/
export class SyncPushDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => LocalMealDto)
meals: LocalMealDto[];
@IsArray()
@IsString({ each: true })
deletedIds: string[];
@IsOptional()
@IsString()
lastSyncAt?: string;
}
/**
* Push response
*/
export interface SyncPushResponse {
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;
}
/**
* Pull query parameters
*/
export class SyncPullQueryDto {
@IsOptional()
@IsString()
since?: string;
}
/**
* Pull response
*/
export interface SyncPullResponse {
meals: any[];
deletedIds: string[];
serverTime: string;
}
/**
* Sync status response
*/
export interface SyncStatusResponse {
lastSyncAt: string | null;
pendingChanges: number;
serverTime: string;
}

View file

@ -0,0 +1,50 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { SyncService } from './sync.service';
import {
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';
@Controller('sync')
@UseGuards(JwtAuthGuard)
export class SyncController {
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);
}
/**
* 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);
}
}

View file

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

View file

@ -0,0 +1,251 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
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,
} from './dto/sync.dto';
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
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`);
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 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 };
}
/**
* 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();
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 mappedMeals = results.map((meal) => this.mapDbMealToSync(meal));
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();
// 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,
};
}
/**
* 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;
}
/**
* 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 };
}
// 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!));
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',
},
};
}
/**
* 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(),
};
}
}