mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 20:46:41 +02:00
Feat: Refactor postgress
This commit is contained in:
parent
046a0e3fe7
commit
98efa6f6e8
134 changed files with 9459 additions and 1904 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
146
apps/nutriphi/apps/backend/src/sync/dto/sync.dto.ts
Normal file
146
apps/nutriphi/apps/backend/src/sync/dto/sync.dto.ts
Normal 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;
|
||||
}
|
||||
50
apps/nutriphi/apps/backend/src/sync/sync.controller.ts
Normal file
50
apps/nutriphi/apps/backend/src/sync/sync.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/nutriphi/apps/backend/src/sync/sync.module.ts
Normal file
10
apps/nutriphi/apps/backend/src/sync/sync.module.ts
Normal 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 {}
|
||||
251
apps/nutriphi/apps/backend/src/sync/sync.service.ts
Normal file
251
apps/nutriphi/apps/backend/src/sync/sync.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
439
apps/nutriphi/apps/mobile/services/auth/authService.ts
Normal file
439
apps/nutriphi/apps/mobile/services/auth/authService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
120
apps/nutriphi/apps/mobile/services/auth/tokenManager.ts
Normal file
120
apps/nutriphi/apps/mobile/services/auth/tokenManager.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
347
apps/nutriphi/apps/mobile/services/sync/SyncService.ts
Normal file
347
apps/nutriphi/apps/mobile/services/sync/SyncService.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
300
apps/nutriphi/apps/mobile/store/AuthStore.ts
Normal file
300
apps/nutriphi/apps/mobile/store/AuthStore.ts
Normal 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 }),
|
||||
}));
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue