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

@ -70,47 +70,63 @@ pnpm type-check # Run Astro checks
- **Mobile**: React Native 0.79 + Expo SDK 53, NativeWind, Expo Router, Zustand
- **Web**: SvelteKit 2.x, Svelte 5, Tailwind CSS 4
- **Landing**: Astro 5.x, Tailwind CSS
- **Backend**: NestJS 10, Google Gemini Vision API, Supabase
- **Authentication**: Mana Core Auth (shared with ecosystem)
- **Backend**: NestJS 10, Google Gemini Vision API, PostgreSQL + Drizzle ORM
- **Authentication**: Mana Core Auth (JWT via middleware)
- **Database**: PostgreSQL (via Drizzle ORM), SQLite (mobile offline)
## Architecture
### Backend API Endpoints
All endpoints (except health) require JWT authentication via `Authorization: Bearer <token>` header.
#### Meals API
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/health` | GET | Health check |
| `/api/health` | GET | Health check (public) |
| `/api/meals/analyze/image` | POST | Analyze food image with AI |
| `/api/meals/analyze/text` | POST | Analyze food description |
| `/api/meals` | GET | Get user's meals |
| `/api/meals` | POST | Create new meal entry |
| `/api/meals/user/:userId` | GET | Get user's meals |
| `/api/meals/user/:userId/summary` | GET | Get daily nutrition summary |
| `/api/meals/summary` | GET | Get daily nutrition summary |
| `/api/meals/:id` | GET | Get meal by ID |
| `/api/meals/:id` | PUT | Update meal |
| `/api/meals/:id` | DELETE | Delete meal |
#### Sync API
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/sync/push` | POST | Push local changes to server |
| `/api/sync/pull` | GET | Pull changes from server |
| `/api/sync/status` | GET | Get sync status |
### Environment Variables
#### Backend (.env)
```
DATABASE_URL=postgresql://nutriphi:password@localhost:5435/nutriphi
GEMINI_API_KEY=your-gemini-api-key
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-key
MANACORE_AUTH_URL=https://auth.manacore.de
MANA_CORE_AUTH_URL=http://localhost:3001
S3_ENDPOINT=https://...
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_BUCKET_NAME=nutriphi
S3_REGION=eu-central-1
S3_PUBLIC_URL=https://...
PORT=3002
```
#### Mobile (.env)
```
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
EXPO_PUBLIC_MANA_MIDDLEWARE_URL=https://api.manacore.de
EXPO_PUBLIC_MIDDLEWARE_APP_ID=nutriphi
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
```
#### Web (.env)
```
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
PUBLIC_NUTRIPHI_MIDDLEWARE_URL=https://api.manacore.de
PUBLIC_MIDDLEWARE_APP_ID=nutriphi
PUBLIC_BACKEND_URL=http://localhost:3002
```
@ -121,6 +137,8 @@ PUBLIC_BACKEND_URL=http://localhost:3002
3. **Daily Tracking**: View daily summaries of calories, protein, carbs, fat, fiber
4. **Meal History**: Browse and edit past meal entries
5. **Health Tips**: Receive personalized nutrition recommendations
6. **Offline-First**: SQLite local storage with cloud sync
7. **Cross-Device Sync**: Meals sync across devices via backend API
## Mobile App Architecture
@ -130,7 +148,14 @@ PUBLIC_BACKEND_URL=http://localhost:3002
- `_layout.tsx` - Root layout with Stack navigation
- `components/` - Reusable UI components
- `store/` - Zustand state management
- `AuthStore.ts` - Authentication state
- `MealStore.ts` - Meal data state
- `AppStore.ts` - App-wide state
- `services/` - API and database services
- `auth/` - Authentication (authService, tokenManager)
- `sync/` - Cloud synchronization (SyncService)
- `database/` - SQLite local storage
- `storage/` - Photo storage
- `hooks/` - Custom React hooks
- `utils/` - Utility functions
@ -139,17 +164,40 @@ PUBLIC_BACKEND_URL=http://localhost:3002
- Components use `className` prop with Tailwind utility classes
### State Management
- Zustand stores for meals, user settings
- Zustand stores for auth, meals, app settings
- SQLite for local offline storage
- Supabase for cloud sync
- Cloud sync via backend API
### Authentication Flow
1. User signs in via Mana Middleware
2. Tokens stored securely in expo-secure-store
3. JWT sent with all API requests
4. Auto-refresh on token expiry
## Backend Architecture
### Authentication Guard
- `JwtAuthGuard` validates tokens against Mana Core Auth
- `CurrentUser` decorator extracts user data from JWT
- All protected endpoints use `@UseGuards(JwtAuthGuard)`
### Database
- PostgreSQL via Drizzle ORM (`@manacore/nutriphi-database` package)
- Schema: `meals`, `nutrition_goals` tables
- User isolation via `userId` field in all queries
### Sync Strategy
- **Push**: Local changes uploaded with version tracking
- **Pull**: Server changes downloaded since last sync
- **Conflict Resolution**: Last-write-wins with client priority
## Shared Packages Used
- `@manacore/nutriphi-database` - Database schema and client
- `@manacore/shared-auth-ui` - Authentication UI components
- `@manacore/shared-branding` - Branding assets
- `@manacore/shared-i18n` - Internationalization
- `@manacore/shared-icons` - Icon library
- `@manacore/shared-supabase` - Supabase client utilities
- `@manacore/shared-tailwind` - Tailwind configuration
- `@manacore/shared-theme` - Theme tokens
- `@manacore/shared-theme-ui` - Theme UI components
@ -167,6 +215,7 @@ PUBLIC_BACKEND_URL=http://localhost:3002
## Important Notes
1. **Security**: API keys are stored in the backend only - never in client apps
2. **Authentication**: Uses Mana Core Auth, shared with ecosystem
3. **Database**: Supabase PostgreSQL with RLS policies
2. **Authentication**: Uses Mana Core Auth via JWT middleware
3. **Database**: PostgreSQL with Drizzle ORM (no Supabase dependency)
4. **Deployment**: Backend runs on port 3002 by default
5. **Offline-First**: Mobile app works offline, syncs when online

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(),
};
}
}

View file

@ -22,8 +22,10 @@
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-clipboard/clipboard": "^1.16.2",
"@react-navigation/native": "^7.0.3",
"@supabase/supabase-js": "^2.38.4",
"expo": "^53.0.11",
"expo-application": "~6.1.4",
"expo-device": "~7.1.4",
"expo-secure-store": "~14.2.3",
"expo-blur": "^14.1.5",
"expo-camera": "^16.1.8",
"expo-constants": "~17.1.4",

View file

@ -4,6 +4,8 @@ import { SQLiteService } from './database/SQLiteService';
import { PhotoService } from './storage/PhotoService';
import { useMealStore } from '../store/MealStore';
import { useAppStore } from '../store/AppStore';
import { useAuthStore } from '../store/AuthStore';
import { tokenManager } from './auth/tokenManager';
export class DataClearingService {
private static instance: DataClearingService;
@ -46,8 +48,12 @@ export class DataClearingService {
errors.push(`AsyncStorage clearing failed: ${error}`);
}
// Note: Supabase integration will be added later
// For now, we skip Supabase sign out
try {
// 5. Sign out and clear auth tokens
await this.signOutAndClearAuth();
} catch (error) {
errors.push(`Auth clearing failed: ${error}`);
}
return {
success: errors.length === 0,
@ -55,6 +61,13 @@ export class DataClearingService {
};
}
private async signOutAndClearAuth(): Promise<void> {
// Sign out from auth store
await useAuthStore.getState().signOut();
// Clear all tokens
await tokenManager.clearTokens();
}
private async clearDatabase(): Promise<void> {
const db = SQLiteService.getInstance();
@ -120,14 +133,6 @@ export class DataClearingService {
}
}
// TODO: Implement when Supabase is configured
// private async signOutSupabase(): Promise<void> {
// const { error } = await supabase.auth.signOut();
// if (error) {
// throw new Error(`Supabase sign out error: ${error.message}`);
// }
// }
// Optional: Clear everything including theme preference
async clearAllDataIncludingTheme(): Promise<{ success: boolean; errors: string[] }> {
const result = await this.clearAllData();

View file

@ -0,0 +1,439 @@
/**
* Authentication service for Nutriphi Mobile
* Uses Mana middleware for authentication
*/
import * as Device from 'expo-device';
import * as Application from 'expo-application';
import { Platform } from 'react-native';
const MIDDLEWARE_URL = process.env.EXPO_PUBLIC_MANA_MIDDLEWARE_URL || 'https://api.manacore.de';
const APP_ID = process.env.EXPO_PUBLIC_MIDDLEWARE_APP_ID || 'nutriphi';
/**
* Get device information for authentication
*/
function getDeviceInfo() {
return {
deviceId: Application.getIosIdForVendorAsync ?
Application.androidId || `${Platform.OS}-${Date.now()}` :
`${Platform.OS}-${Date.now()}`,
deviceName: Device.deviceName || `${Device.brand} ${Device.modelName}`,
deviceType: Device.isDevice ? 'mobile' : 'simulator',
platform: Platform.OS,
};
}
/**
* Decode JWT token
*/
function decodeToken(token: string): any | null {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// Use atob equivalent for React Native
const payload = JSON.parse(
decodeURIComponent(
Array.from(atob(base64))
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
)
);
return payload;
} catch (error) {
console.error('Error decoding token:', error);
return null;
}
}
/**
* Check if token is expired
*/
function isTokenExpired(token: string): boolean {
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) return true;
// Add 10 second buffer
const bufferTime = 10 * 1000;
return Date.now() >= payload.exp * 1000 - bufferTime;
} catch {
return true;
}
}
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
appToken?: string;
refreshToken?: string;
email?: string;
}
export interface UserData {
id: string;
email: string;
role: string;
}
/**
* Authentication service
*/
export const authService = {
/**
* Sign in with email and password
*/
async signIn(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (response.status === 401) {
if (
errorData.message?.includes('Firebase user detected') ||
errorData.message?.includes('password reset required')
) {
return {
success: false,
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
};
}
if (
errorData.message?.includes('Email not confirmed') ||
errorData.message?.includes('Email not verified')
) {
return {
success: false,
error: 'EMAIL_NOT_VERIFIED',
};
}
return {
success: false,
error: 'INVALID_CREDENTIALS',
};
}
return {
success: false,
error: errorData.message || 'Sign in failed',
};
}
const { appToken, refreshToken } = await response.json();
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during sign in',
};
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (response.status === 409) {
return {
success: false,
error: 'This email is already in use',
};
}
return {
success: false,
error: errorData.message || 'Registration failed',
};
}
const responseData = await response.json();
if (responseData.confirmationRequired) {
return {
success: true,
needsVerification: true,
};
}
const { appToken, refreshToken } = responseData;
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing up:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during registration',
};
}
},
/**
* Sign in with Google ID token
*/
async signInWithGoogle(idToken: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: idToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Google Sign-In failed',
};
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
let email = responseData.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || payload?.user_metadata?.email || '';
}
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in with Google:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
};
}
},
/**
* Sign in with Apple ID token
*/
async signInWithApple(idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/apple-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: idToken, user, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Apple Sign-In failed',
};
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
let email = responseData.email || user?.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || '';
}
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in with Apple:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Apple Sign-In',
};
}
},
/**
* Refresh authentication tokens
*/
async refreshTokens(
currentRefreshToken: string
): Promise<{
appToken: string;
refreshToken: string;
userData?: UserData | null;
}> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to refresh tokens');
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
if (!appToken || !refreshToken) {
throw new Error('Invalid response from token refresh');
}
let userData: UserData | null = null;
try {
const payload = decodeToken(appToken);
if (payload) {
userData = {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
}
} catch (error) {
console.error('Error decoding refreshed token:', error);
}
return { appToken, refreshToken, userData };
} catch (error) {
console.error('Error refreshing tokens:', error);
throw error;
}
},
/**
* Sign out
*/
async signOut(refreshToken: string): Promise<void> {
try {
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
}).catch((err) => console.error('Error logging out on server:', err));
} catch (error) {
console.error('Error signing out:', error);
}
},
/**
* Forgot password
*/
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const errorData = await response.json();
if (errorData.message?.includes('rate limit')) {
return {
success: false,
error:
'Too many password reset attempts. Please wait a few minutes before trying again.',
};
}
return {
success: false,
error: errorData.message || 'Password reset failed',
};
}
return { success: true };
} catch (error) {
console.error('Error sending password reset email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during password reset',
};
}
},
/**
* Get user data from token
*/
getUserFromToken(appToken: string): UserData | null {
try {
const payload = decodeToken(appToken);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
} catch (error) {
console.error('Error getting user from token:', error);
return null;
}
},
/**
* Check if token is valid locally (without network call)
*/
isTokenValidLocally(token: string): boolean {
return !isTokenExpired(token);
},
};

View file

@ -0,0 +1,120 @@
import * as SecureStore from 'expo-secure-store';
const STORAGE_KEYS = {
APP_TOKEN: 'nutriphi_app_token',
REFRESH_TOKEN: 'nutriphi_refresh_token',
USER_EMAIL: 'nutriphi_user_email',
};
/**
* Token Manager for secure storage of authentication tokens
* Uses Expo SecureStore for encrypted storage on device
*/
export const tokenManager = {
/**
* Get the app token (JWT)
*/
async getAppToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.APP_TOKEN);
} catch (error) {
console.error('Error getting app token:', error);
return null;
}
},
/**
* Set the app token
*/
async setAppToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.APP_TOKEN, token);
} catch (error) {
console.error('Error setting app token:', error);
throw error;
}
},
/**
* Get the refresh token
*/
async getRefreshToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.REFRESH_TOKEN);
} catch (error) {
console.error('Error getting refresh token:', error);
return null;
}
},
/**
* Set the refresh token
*/
async setRefreshToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.REFRESH_TOKEN, token);
} catch (error) {
console.error('Error setting refresh token:', error);
throw error;
}
},
/**
* Get the user email
*/
async getUserEmail(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.USER_EMAIL);
} catch (error) {
console.error('Error getting user email:', error);
return null;
}
},
/**
* Set the user email
*/
async setUserEmail(email: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.USER_EMAIL, email);
} catch (error) {
console.error('Error setting user email:', error);
throw error;
}
},
/**
* Clear all tokens (logout)
*/
async clearTokens(): Promise<void> {
try {
await Promise.all([
SecureStore.deleteItemAsync(STORAGE_KEYS.APP_TOKEN),
SecureStore.deleteItemAsync(STORAGE_KEYS.REFRESH_TOKEN),
SecureStore.deleteItemAsync(STORAGE_KEYS.USER_EMAIL),
]);
} catch (error) {
console.error('Error clearing tokens:', error);
throw error;
}
},
/**
* Get Authorization header for API requests
*/
async getAuthHeader(): Promise<{ Authorization: string } | Record<string, never>> {
const token = await this.getAppToken();
if (token) {
return { Authorization: `Bearer ${token}` };
}
return {};
},
/**
* Check if user has tokens stored
*/
async hasTokens(): Promise<boolean> {
const token = await this.getAppToken();
return !!token;
},
};

View file

@ -400,4 +400,179 @@ export class SQLiteService {
if (!this.db) throw new Error('Database not initialized');
return await this.db.runAsync(sql, params);
}
// ==================== Sync Methods ====================
/**
* Get all unsynced meals (sync_status = 'local' or 'pending')
*/
public async getUnsyncedMeals(): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.getAllAsync<Meal>(
`SELECT * FROM meals WHERE sync_status IN ('local', 'pending') ORDER BY created_at DESC`
);
}
/**
* Get meal by cloud ID
*/
public async getMealByCloudId(cloudId: string): Promise<Meal | null> {
if (!this.db) throw new Error('Database not initialized');
const result = await this.db.getFirstAsync<Meal>(
'SELECT * FROM meals WHERE cloud_id = ?',
[cloudId]
);
return result || null;
}
/**
* Update cloud_id for a local meal
*/
public async updateCloudId(localId: number, cloudId: string): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.runAsync(
`UPDATE meals SET cloud_id = ?, updated_at = datetime('now') WHERE id = ?`,
[cloudId, localId]
);
}
/**
* Mark a meal as synced
*/
public async markSynced(localId: number): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.runAsync(
`UPDATE meals SET sync_status = 'synced', last_sync_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`,
[localId]
);
}
/**
* Delete a meal by cloud ID
*/
public async deleteByCloudId(cloudId: string): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.runAsync('DELETE FROM meals WHERE cloud_id = ?', [cloudId]);
}
/**
* Create a meal from server data
*/
public async createMealFromServer(serverMeal: any): Promise<number> {
if (!this.db) throw new Error('Database not initialized');
const analysisResult = serverMeal.foodItems
? JSON.stringify({
foodName: serverMeal.foodName,
foodItems: serverMeal.foodItems,
})
: null;
const result = await this.db.runAsync(
`INSERT INTO meals (
cloud_id, sync_status, version, last_sync_at,
photo_path, photo_url, timestamp, created_at, updated_at,
meal_type, analysis_result, analysis_status,
total_calories, total_protein, total_carbs, total_fat, total_fiber, total_sugar,
health_score, health_category, user_notes, user_rating
) VALUES (?, ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
serverMeal.cloudId,
'synced',
1,
serverMeal.imageUrl || '',
serverMeal.imageUrl || null,
serverMeal.createdAt,
serverMeal.createdAt,
serverMeal.updatedAt,
serverMeal.mealType || null,
analysisResult,
serverMeal.analysisStatus || 'completed',
serverMeal.calories || null,
serverMeal.protein || null,
serverMeal.carbohydrates || null,
serverMeal.fat || null,
serverMeal.fiber || null,
serverMeal.sugar || null,
serverMeal.healthScore || null,
serverMeal.healthCategory || null,
serverMeal.notes || null,
serverMeal.userRating || null,
]
);
return result.lastInsertRowId;
}
/**
* Update a local meal from server data
*/
public async updateMealFromServer(localId: number, serverMeal: any): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const analysisResult = serverMeal.foodItems
? JSON.stringify({
foodName: serverMeal.foodName,
foodItems: serverMeal.foodItems,
})
: null;
await this.db.runAsync(
`UPDATE meals SET
sync_status = 'synced',
last_sync_at = datetime('now'),
photo_url = ?,
meal_type = ?,
analysis_result = ?,
analysis_status = ?,
total_calories = ?,
total_protein = ?,
total_carbs = ?,
total_fat = ?,
total_fiber = ?,
total_sugar = ?,
health_score = ?,
health_category = ?,
user_notes = ?,
user_rating = ?,
updated_at = ?
WHERE id = ?`,
[
serverMeal.imageUrl || null,
serverMeal.mealType || null,
analysisResult,
serverMeal.analysisStatus || 'completed',
serverMeal.calories || null,
serverMeal.protein || null,
serverMeal.carbohydrates || null,
serverMeal.fat || null,
serverMeal.fiber || null,
serverMeal.sugar || null,
serverMeal.healthScore || null,
serverMeal.healthCategory || null,
serverMeal.notes || null,
serverMeal.userRating || null,
serverMeal.updatedAt,
localId,
]
);
}
/**
* Get meals modified since a given timestamp
*/
public async getMealsModifiedSince(since: string): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.getAllAsync<Meal>(
`SELECT * FROM meals WHERE updated_at > ? ORDER BY updated_at ASC`,
[since]
);
}
}

View file

@ -0,0 +1,347 @@
import { tokenManager } from '../auth/tokenManager';
import { SQLiteService } from '../database/SQLiteService';
import type { Meal } from '../../types/Database';
const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3002';
export interface SyncResult {
success: boolean;
created: number;
updated: number;
deleted: number;
conflicts: ConflictInfo[];
error?: string;
}
export interface ConflictInfo {
cloudId: string;
localVersion: number;
serverVersion: number;
serverData: any;
message: string;
}
export interface LocalMealForSync {
localId: number;
cloudId?: string;
foodName: string;
imageUrl?: string;
calories?: number;
protein?: number;
carbohydrates?: number;
fat?: number;
fiber?: number;
sugar?: number;
sodium?: number;
servingSize?: string;
mealType?: string;
analysisStatus?: string;
healthScore?: number;
healthCategory?: string;
notes?: string;
userRating?: number;
foodItems?: any[];
version: number;
createdAt: string;
updatedAt: string;
}
/**
* Sync Service for synchronizing local SQLite data with the backend
*/
export class SyncService {
private static instance: SyncService;
private isSyncing = false;
private lastSyncAt: string | null = null;
private constructor() {}
public static getInstance(): SyncService {
if (!SyncService.instance) {
SyncService.instance = new SyncService();
}
return SyncService.instance;
}
/**
* Check if sync is currently in progress
*/
public isSyncInProgress(): boolean {
return this.isSyncing;
}
/**
* Get last sync timestamp
*/
public getLastSyncAt(): string | null {
return this.lastSyncAt;
}
/**
* Perform a full sync (push + pull)
*/
public async fullSync(): Promise<SyncResult> {
if (this.isSyncing) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Sync already in progress',
};
}
this.isSyncing = true;
try {
// First push local changes
const pushResult = await this.pushChanges();
if (!pushResult.success) {
return pushResult;
}
// Then pull server changes
const pullResult = await this.pullChanges();
return {
success: pullResult.success,
created: pushResult.created + pullResult.created,
updated: pushResult.updated + pullResult.updated,
deleted: pullResult.deleted,
conflicts: pushResult.conflicts,
error: pullResult.error,
};
} finally {
this.isSyncing = false;
}
}
/**
* Push local changes to server
*/
public async pushChanges(): Promise<SyncResult> {
try {
const authHeader = await tokenManager.getAuthHeader();
if (!authHeader.Authorization) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Not authenticated',
};
}
const db = SQLiteService.getInstance();
// Get unsynced meals
const unsyncedMeals = await db.getUnsyncedMeals();
if (unsyncedMeals.length === 0) {
return {
success: true,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
};
}
// Map to sync format
const mealsForSync: LocalMealForSync[] = unsyncedMeals.map((meal) =>
this.mapMealToSyncFormat(meal)
);
// Get deleted meals (meals marked for deletion)
const deletedIds: string[] = []; // TODO: Implement delete tracking
const response = await fetch(`${BACKEND_URL}/api/sync/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeader,
},
body: JSON.stringify({
meals: mealsForSync,
deletedIds,
lastSyncAt: this.lastSyncAt,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error.message || 'Push failed',
};
}
const result = await response.json();
// Update local records with cloud IDs
for (const created of result.created) {
await db.updateCloudId(created.localId, created.cloudId);
await db.markSynced(created.localId);
}
// Mark updated records as synced
for (const cloudId of result.updated) {
const meal = unsyncedMeals.find((m) => m.cloud_id === cloudId);
if (meal && meal.id) {
await db.markSynced(meal.id);
}
}
this.lastSyncAt = result.serverTime;
return {
success: true,
created: result.created.length,
updated: result.updated.length,
deleted: 0,
conflicts: result.conflicts || [],
};
} catch (error) {
console.error('Push sync error:', error);
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error instanceof Error ? error.message : 'Push failed',
};
}
}
/**
* Pull changes from server
*/
public async pullChanges(): Promise<SyncResult> {
try {
const authHeader = await tokenManager.getAuthHeader();
if (!authHeader.Authorization) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Not authenticated',
};
}
const url = new URL(`${BACKEND_URL}/api/sync/pull`);
if (this.lastSyncAt) {
url.searchParams.set('since', this.lastSyncAt);
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...authHeader,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error.message || 'Pull failed',
};
}
const result = await response.json();
const db = SQLiteService.getInstance();
let created = 0;
let updated = 0;
let deleted = 0;
// Process server meals
for (const serverMeal of result.meals) {
const existingMeal = await db.getMealByCloudId(serverMeal.cloudId);
if (existingMeal) {
// Update existing local meal
await db.updateMealFromServer(existingMeal.id!, serverMeal);
updated++;
} else {
// Create new local meal
await db.createMealFromServer(serverMeal);
created++;
}
}
// Process deletions
for (const cloudId of result.deletedIds) {
await db.deleteByCloudId(cloudId);
deleted++;
}
this.lastSyncAt = result.serverTime;
return {
success: true,
created,
updated,
deleted,
conflicts: [],
};
} catch (error) {
console.error('Pull sync error:', error);
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error instanceof Error ? error.message : 'Pull failed',
};
}
}
/**
* Map local meal to sync format
*/
private mapMealToSyncFormat(meal: Meal): LocalMealForSync {
return {
localId: meal.id!,
cloudId: meal.cloud_id || undefined,
foodName: meal.analysis_result
? JSON.parse(meal.analysis_result).foodName || 'Unbekanntes Gericht'
: 'Unbekanntes Gericht',
imageUrl: meal.photo_url || undefined,
calories: meal.total_calories || undefined,
protein: meal.total_protein || undefined,
carbohydrates: meal.total_carbs || undefined,
fat: meal.total_fat || undefined,
fiber: meal.total_fiber || undefined,
sugar: meal.total_sugar || undefined,
servingSize: undefined,
mealType: meal.meal_type || undefined,
analysisStatus: meal.analysis_status || 'completed',
healthScore: meal.health_score || undefined,
healthCategory: meal.health_category || undefined,
notes: meal.user_notes || undefined,
userRating: meal.user_rating || undefined,
foodItems: meal.analysis_result
? JSON.parse(meal.analysis_result).foodItems
: [],
version: meal.version || 1,
createdAt: meal.created_at || new Date().toISOString(),
updatedAt: meal.updated_at || new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,300 @@
import { create } from 'zustand';
import { authService, type UserData, type AuthResult } from '../services/auth/authService';
import { tokenManager } from '../services/auth/tokenManager';
interface AuthState {
// State
user: UserData | null;
isAuthenticated: boolean;
isLoading: boolean;
isInitialized: boolean;
error: string | null;
// Actions
initialize: () => Promise<void>;
signIn: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
signUp: (email: string, password: string) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
signInWithGoogle: (idToken: string) => Promise<{ success: boolean; error?: string }>;
signInWithApple: (idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }) => Promise<{ success: boolean; error?: string }>;
signOut: () => Promise<void>;
forgotPassword: (email: string) => Promise<{ success: boolean; error?: string }>;
refreshAuth: () => Promise<boolean>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: false,
isInitialized: false,
error: null,
/**
* Initialize auth state from stored tokens
*/
initialize: async () => {
if (get().isInitialized) return;
set({ isLoading: true });
try {
const token = await tokenManager.getAppToken();
if (!token) {
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
return;
}
// Check if token is still valid
if (authService.isTokenValidLocally(token)) {
const userData = authService.getUserFromToken(token);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
return;
}
}
// Try to refresh token
const refreshToken = await tokenManager.getRefreshToken();
if (refreshToken) {
try {
const result = await authService.refreshTokens(refreshToken);
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
return;
}
}
} catch (error) {
console.error('Failed to refresh token on init:', error);
}
}
// Clear invalid tokens
await tokenManager.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
} catch (error) {
console.error('Error initializing auth:', error);
set({
user: null,
isAuthenticated: false,
isLoading: false,
isInitialized: true,
error: 'Failed to initialize authentication',
});
}
},
/**
* Sign in with email and password
*/
signIn: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signIn(email, password);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Sign in failed' });
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign in';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
signUp: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signUp(email, password);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Sign up failed' });
return { success: false, error: result.error };
}
if (result.needsVerification) {
set({ isLoading: false, error: null });
return { success: true, needsVerification: true };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign up';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign in with Google
*/
signInWithGoogle: async (idToken: string) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signInWithGoogle(idToken);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Google Sign-In failed' });
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during Google Sign-In';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign in with Apple
*/
signInWithApple: async (idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signInWithApple(idToken, user);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Apple Sign-In failed' });
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during Apple Sign-In';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign out
*/
signOut: async () => {
set({ isLoading: true });
try {
const refreshToken = await tokenManager.getRefreshToken();
if (refreshToken) {
await authService.signOut(refreshToken);
}
} catch (error) {
console.error('Error during sign out:', error);
} finally {
await tokenManager.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
}
},
/**
* Forgot password
*/
forgotPassword: async (email: string) => {
return authService.forgotPassword(email);
},
/**
* Refresh authentication tokens
*/
refreshAuth: async () => {
try {
const refreshToken = await tokenManager.getRefreshToken();
if (!refreshToken) {
return false;
}
const result = await authService.refreshTokens(refreshToken);
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.userData) {
set({ user: result.userData });
}
return true;
}
return false;
} catch (error) {
console.error('Error refreshing auth:', error);
return false;
}
},
/**
* Clear error state
*/
clearError: () => set({ error: null }),
}));

View file

@ -1,14 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});