From 98efa6f6e897fb3d0b0115bbfbd8992e11185222 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Thu, 27 Nov 2025 02:25:37 +0100 Subject: [PATCH] Feat: Refactor postgress --- apps/chat/apps/mobile/.env.example | 4 - apps/chat/apps/mobile/app/api/models+api.ts | 90 +-- apps/chat/apps/mobile/app/api/usage+api.ts | 94 +-- apps/chat/apps/mobile/app/archive.tsx | 24 +- apps/chat/apps/mobile/app/auth/login.tsx | 53 +- .../apps/mobile/app/conversation/[id].tsx | 221 +++---- apps/chat/apps/mobile/app/conversations.tsx | 26 +- apps/chat/apps/mobile/app/documents.tsx | 105 ++- apps/chat/apps/mobile/app/index.tsx | 16 +- apps/chat/apps/mobile/app/profile.tsx | 45 +- .../mobile/components/ChatPromptInput.tsx | 1 - .../mobile/components/ConversationStarter.tsx | 1 - apps/chat/apps/mobile/package.json | 7 +- apps/chat/apps/mobile/utils/supabase.ts | 35 - apps/chat/apps/web/.env.example | 4 - apps/chat/apps/web/package.json | 3 - .../apps/web/src/lib/services/supabase.ts | 42 -- apps/maerchenzauber/apps/backend/package.json | 1 + .../src/character/character.service.ts | 15 +- .../services/image-optimization.service.ts | 2 +- .../story/services/story-creation.service.ts | 20 +- .../maerchenzauber/apps/backend/tsconfig.json | 3 + apps/nutriphi/CLAUDE.md | 83 ++- apps/nutriphi/apps/backend/src/app.module.ts | 2 + .../decorators/current-user.decorator.ts | 15 + .../src/common/guards/jwt-auth.guard.ts | 66 ++ .../backend/src/meals/dto/analyze-meal.dto.ts | 6 - .../backend/src/meals/meals.controller.ts | 54 +- .../apps/backend/src/meals/meals.service.ts | 28 +- .../apps/backend/src/sync/dto/sync.dto.ts | 146 +++++ .../apps/backend/src/sync/sync.controller.ts | 50 ++ .../apps/backend/src/sync/sync.module.ts | 10 + .../apps/backend/src/sync/sync.service.ts | 251 ++++++++ apps/nutriphi/apps/mobile/package.json | 4 +- .../mobile/services/DataClearingService.ts | 25 +- .../apps/mobile/services/auth/authService.ts | 439 +++++++++++++ .../apps/mobile/services/auth/tokenManager.ts | 120 ++++ .../mobile/services/database/SQLiteService.ts | 175 +++++ .../apps/mobile/services/sync/SyncService.ts | 347 ++++++++++ apps/nutriphi/apps/mobile/store/AuthStore.ts | 300 +++++++++ apps/nutriphi/apps/mobile/utils/supabase.ts | 14 - apps/picture/apps/backend/.env.example | 19 + apps/picture/apps/backend/drizzle.config.ts | 13 + apps/picture/apps/backend/nest-cli.json | 8 + apps/picture/apps/backend/package.json | 58 ++ apps/picture/apps/backend/src/app.module.ts | 32 + .../src/board-item/board-item.controller.ts | 114 ++++ .../src/board-item/board-item.module.ts | 10 + .../src/board-item/board-item.service.ts | 515 +++++++++++++++ .../src/board-item/dto/board-item.dto.ts | 134 ++++ .../backend/src/board/board.controller.ts | 102 +++ .../apps/backend/src/board/board.module.ts | 10 + .../apps/backend/src/board/board.service.ts | 403 ++++++++++++ .../apps/backend/src/board/dto/board.dto.ts | 80 +++ .../decorators/current-user.decorator.ts | 15 + .../src/common/guards/jwt-auth.guard.ts | 66 ++ .../picture/apps/backend/src/db/connection.ts | 38 ++ .../apps/backend/src/db/database.module.ts | 28 + apps/picture/apps/backend/src/db/migrate.ts | 26 + .../src/db/schema/board-items.schema.ts | 50 ++ .../backend/src/db/schema/boards.schema.ts | 33 + .../src/db/schema/image-generations.schema.ts | 53 ++ .../backend/src/db/schema/images.schema.ts | 46 ++ .../apps/backend/src/db/schema/index.ts | 6 + .../backend/src/db/schema/models.schema.ts | 51 ++ .../apps/backend/src/db/schema/tags.schema.ts | 23 + apps/picture/apps/backend/src/db/seed.ts | 90 +++ .../backend/src/explore/dto/explore.dto.ts | 34 + .../backend/src/explore/explore.controller.ts | 20 + .../backend/src/explore/explore.module.ts | 9 + .../backend/src/explore/explore.service.ts | 83 +++ .../backend/src/generate/dto/generate.dto.ts | 41 ++ .../src/generate/generate.controller.ts | 54 ++ .../backend/src/generate/generate.module.ts | 13 + .../backend/src/generate/generate.service.ts | 382 +++++++++++ .../backend/src/generate/replicate.service.ts | 124 ++++ .../backend/src/health/health.controller.ts | 13 + .../apps/backend/src/health/health.module.ts | 7 + .../apps/backend/src/image/dto/image.dto.ts | 38 ++ .../backend/src/image/image.controller.ts | 88 +++ .../apps/backend/src/image/image.module.ts | 10 + .../apps/backend/src/image/image.service.ts | 286 ++++++++ apps/picture/apps/backend/src/main.ts | 38 ++ .../backend/src/model/model.controller.ts | 19 + .../apps/backend/src/model/model.module.ts | 10 + .../apps/backend/src/model/model.service.ts | 49 ++ .../apps/backend/src/tag/dto/tag.dto.ts | 20 + .../apps/backend/src/tag/tag.controller.ts | 60 ++ .../apps/backend/src/tag/tag.module.ts | 10 + .../apps/backend/src/tag/tag.service.ts | 157 +++++ .../backend/src/upload/storage.service.ts | 126 ++++ .../backend/src/upload/upload.controller.ts | 93 +++ .../apps/backend/src/upload/upload.module.ts | 11 + .../apps/backend/src/upload/upload.service.ts | 123 ++++ apps/picture/apps/backend/tsconfig.json | 23 + .../apps/web/src/lib/api/boardItems.ts | 609 ++++++++---------- apps/picture/apps/web/src/lib/api/boards.ts | 289 ++++----- apps/picture/apps/web/src/lib/api/client.ts | 162 +++++ apps/picture/apps/web/src/lib/api/explore.ts | 100 ++- .../apps/web/src/lib/api/generate-async.ts | 261 ++++---- apps/picture/apps/web/src/lib/api/generate.ts | 185 +++--- apps/picture/apps/web/src/lib/api/images.ts | 264 ++++---- apps/picture/apps/web/src/lib/api/models.ts | 52 +- apps/picture/apps/web/src/lib/api/tags.ts | 110 ++-- apps/picture/apps/web/src/lib/api/upload.ts | 218 +++---- .../apps/web/src/lib/stores/archive.svelte.ts | 70 ++ .../apps/web/src/lib/stores/auth.svelte.ts | 190 ++++++ .../apps/web/src/lib/stores/boards.svelte.ts | 147 +++++ .../apps/web/src/lib/stores/canvas.svelte.ts | 342 ++++++++++ .../web/src/lib/stores/contextMenu.svelte.ts | 110 ++++ .../apps/web/src/lib/stores/explore.svelte.ts | 105 +++ .../web/src/lib/stores/generate.svelte.ts | 73 +++ .../apps/web/src/lib/stores/images.svelte.ts | 102 +++ .../apps/web/src/lib/stores/models.svelte.ts | 61 ++ .../apps/web/src/lib/stores/sidebar.svelte.ts | 65 ++ .../apps/web/src/lib/stores/tags.svelte.ts | 84 +++ .../apps/web/src/lib/stores/toast.svelte.ts | 80 +++ .../apps/web/src/lib/stores/ui.svelte.ts | 73 +++ .../apps/web/src/lib/stores/view.svelte.ts | 67 ++ .../apps/web/src/routes/+layout.svelte | 49 +- .../apps/web/src/routes/app/+layout.svelte | 18 +- .../mana-core-nestjs-integration/package.json | 42 ++ .../src/decorators/current-user.decorator.ts | 23 + .../insufficient-credits.exception.ts | 22 + .../src/guards/auth.guard.ts | 73 +++ .../mana-core-nestjs-integration/src/index.ts | 28 + .../interfaces/mana-core-options.interface.ts | 20 + .../src/mana-core.module.ts | 87 +++ .../src/services/credit-client.service.ts | 182 ++++++ .../tsconfig.json | 21 + packages/shared-errors/package.json | 5 + pnpm-lock.yaml | 58 +- scripts/generate-env.mjs | 2 +- turbo.json | 3 +- 134 files changed, 9459 insertions(+), 1904 deletions(-) delete mode 100644 apps/chat/apps/mobile/utils/supabase.ts delete mode 100644 apps/chat/apps/web/src/lib/services/supabase.ts create mode 100644 apps/nutriphi/apps/backend/src/common/decorators/current-user.decorator.ts create mode 100644 apps/nutriphi/apps/backend/src/common/guards/jwt-auth.guard.ts create mode 100644 apps/nutriphi/apps/backend/src/sync/dto/sync.dto.ts create mode 100644 apps/nutriphi/apps/backend/src/sync/sync.controller.ts create mode 100644 apps/nutriphi/apps/backend/src/sync/sync.module.ts create mode 100644 apps/nutriphi/apps/backend/src/sync/sync.service.ts create mode 100644 apps/nutriphi/apps/mobile/services/auth/authService.ts create mode 100644 apps/nutriphi/apps/mobile/services/auth/tokenManager.ts create mode 100644 apps/nutriphi/apps/mobile/services/sync/SyncService.ts create mode 100644 apps/nutriphi/apps/mobile/store/AuthStore.ts delete mode 100644 apps/nutriphi/apps/mobile/utils/supabase.ts create mode 100644 apps/picture/apps/backend/.env.example create mode 100644 apps/picture/apps/backend/drizzle.config.ts create mode 100644 apps/picture/apps/backend/nest-cli.json create mode 100644 apps/picture/apps/backend/package.json create mode 100644 apps/picture/apps/backend/src/app.module.ts create mode 100644 apps/picture/apps/backend/src/board-item/board-item.controller.ts create mode 100644 apps/picture/apps/backend/src/board-item/board-item.module.ts create mode 100644 apps/picture/apps/backend/src/board-item/board-item.service.ts create mode 100644 apps/picture/apps/backend/src/board-item/dto/board-item.dto.ts create mode 100644 apps/picture/apps/backend/src/board/board.controller.ts create mode 100644 apps/picture/apps/backend/src/board/board.module.ts create mode 100644 apps/picture/apps/backend/src/board/board.service.ts create mode 100644 apps/picture/apps/backend/src/board/dto/board.dto.ts create mode 100644 apps/picture/apps/backend/src/common/decorators/current-user.decorator.ts create mode 100644 apps/picture/apps/backend/src/common/guards/jwt-auth.guard.ts create mode 100644 apps/picture/apps/backend/src/db/connection.ts create mode 100644 apps/picture/apps/backend/src/db/database.module.ts create mode 100644 apps/picture/apps/backend/src/db/migrate.ts create mode 100644 apps/picture/apps/backend/src/db/schema/board-items.schema.ts create mode 100644 apps/picture/apps/backend/src/db/schema/boards.schema.ts create mode 100644 apps/picture/apps/backend/src/db/schema/image-generations.schema.ts create mode 100644 apps/picture/apps/backend/src/db/schema/images.schema.ts create mode 100644 apps/picture/apps/backend/src/db/schema/index.ts create mode 100644 apps/picture/apps/backend/src/db/schema/models.schema.ts create mode 100644 apps/picture/apps/backend/src/db/schema/tags.schema.ts create mode 100644 apps/picture/apps/backend/src/db/seed.ts create mode 100644 apps/picture/apps/backend/src/explore/dto/explore.dto.ts create mode 100644 apps/picture/apps/backend/src/explore/explore.controller.ts create mode 100644 apps/picture/apps/backend/src/explore/explore.module.ts create mode 100644 apps/picture/apps/backend/src/explore/explore.service.ts create mode 100644 apps/picture/apps/backend/src/generate/dto/generate.dto.ts create mode 100644 apps/picture/apps/backend/src/generate/generate.controller.ts create mode 100644 apps/picture/apps/backend/src/generate/generate.module.ts create mode 100644 apps/picture/apps/backend/src/generate/generate.service.ts create mode 100644 apps/picture/apps/backend/src/generate/replicate.service.ts create mode 100644 apps/picture/apps/backend/src/health/health.controller.ts create mode 100644 apps/picture/apps/backend/src/health/health.module.ts create mode 100644 apps/picture/apps/backend/src/image/dto/image.dto.ts create mode 100644 apps/picture/apps/backend/src/image/image.controller.ts create mode 100644 apps/picture/apps/backend/src/image/image.module.ts create mode 100644 apps/picture/apps/backend/src/image/image.service.ts create mode 100644 apps/picture/apps/backend/src/main.ts create mode 100644 apps/picture/apps/backend/src/model/model.controller.ts create mode 100644 apps/picture/apps/backend/src/model/model.module.ts create mode 100644 apps/picture/apps/backend/src/model/model.service.ts create mode 100644 apps/picture/apps/backend/src/tag/dto/tag.dto.ts create mode 100644 apps/picture/apps/backend/src/tag/tag.controller.ts create mode 100644 apps/picture/apps/backend/src/tag/tag.module.ts create mode 100644 apps/picture/apps/backend/src/tag/tag.service.ts create mode 100644 apps/picture/apps/backend/src/upload/storage.service.ts create mode 100644 apps/picture/apps/backend/src/upload/upload.controller.ts create mode 100644 apps/picture/apps/backend/src/upload/upload.module.ts create mode 100644 apps/picture/apps/backend/src/upload/upload.service.ts create mode 100644 apps/picture/apps/backend/tsconfig.json create mode 100644 apps/picture/apps/web/src/lib/api/client.ts create mode 100644 apps/picture/apps/web/src/lib/stores/archive.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/auth.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/boards.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/canvas.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/contextMenu.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/explore.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/generate.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/images.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/models.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/sidebar.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/tags.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/toast.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/ui.svelte.ts create mode 100644 apps/picture/apps/web/src/lib/stores/view.svelte.ts create mode 100644 packages/mana-core-nestjs-integration/package.json create mode 100644 packages/mana-core-nestjs-integration/src/decorators/current-user.decorator.ts create mode 100644 packages/mana-core-nestjs-integration/src/exceptions/insufficient-credits.exception.ts create mode 100644 packages/mana-core-nestjs-integration/src/guards/auth.guard.ts create mode 100644 packages/mana-core-nestjs-integration/src/index.ts create mode 100644 packages/mana-core-nestjs-integration/src/interfaces/mana-core-options.interface.ts create mode 100644 packages/mana-core-nestjs-integration/src/mana-core.module.ts create mode 100644 packages/mana-core-nestjs-integration/src/services/credit-client.service.ts create mode 100644 packages/mana-core-nestjs-integration/tsconfig.json diff --git a/apps/chat/apps/mobile/.env.example b/apps/chat/apps/mobile/.env.example index 3fab7c005..2c8420893 100644 --- a/apps/chat/apps/mobile/.env.example +++ b/apps/chat/apps/mobile/.env.example @@ -1,10 +1,6 @@ # Mana Core Auth Configuration EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 -# Supabase Configuration (for database only, not auth) -EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key - # Chat Backend API # The backend handles AI API calls securely - no API keys needed in the mobile app EXPO_PUBLIC_BACKEND_URL=http://localhost:3002 diff --git a/apps/chat/apps/mobile/app/api/models+api.ts b/apps/chat/apps/mobile/app/api/models+api.ts index ffd01c640..f88c75ea3 100644 --- a/apps/chat/apps/mobile/app/api/models+api.ts +++ b/apps/chat/apps/mobile/app/api/models+api.ts @@ -1,4 +1,4 @@ -import { supabase } from '../../utils/supabase'; +const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3001'; // Definiere den Typ für ein Modell export type Model = { @@ -10,7 +10,7 @@ export type Model = { updated_at?: string; }; -// Fallback-Modelle, falls keine aus der Datenbank geladen werden können +// Fallback-Modelle, falls keine aus dem Backend geladen werden können const FALLBACK_MODELS: Model[] = [ { id: '550e8400-e29b-41d4-a716-446655440000', @@ -56,28 +56,25 @@ const FALLBACK_MODELS: Model[] = [ // GET-Handler für Modelle export async function GET(request: Request) { try { - // Versuche, Modelle aus der Supabase-Datenbank zu laden + // Versuche, Modelle vom Backend zu laden let models: Model[] = FALLBACK_MODELS; - - // Wenn Supabase konfiguriert ist, versuche die Modelle von dort zu laden + try { - if (supabase) { - const { data, error } = await supabase - .from('models') - .select('*'); - // Entfernt: .order('created_at', { ascending: false }) - - if (error) { - console.error('Fehler beim Laden der Modelle aus Supabase:', error); - } else if (data && data.length > 0) { + const response = await fetch(`${BACKEND_URL}/api/chat/models`); + + if (response.ok) { + const data = await response.json(); + if (data && data.length > 0) { models = data as Model[]; } + } else { + console.error('Fehler beim Laden der Modelle vom Backend:', response.status); } } catch (e) { - console.error('Fehler bei der Supabase-Verbindung:', e); + console.error('Fehler bei der Backend-Verbindung:', e); // Fallback zu den vordefinierten Modellen } - + return Response.json(models); } catch (error) { console.error('Fehler beim Verarbeiten der Anfrage:', error); @@ -90,59 +87,12 @@ export async function GET(request: Request) { } } -// POST-Handler zum Erstellen eines neuen Modells +// POST-Handler zum Erstellen eines neuen Modells (nicht unterstützt ohne Backend-Endpoint) export async function POST(request: Request) { - try { - const body = await request.json(); - - // Validiere die Eingabedaten - if (!body.name || !body.description) { - return new Response(JSON.stringify({ error: 'Name und Beschreibung sind erforderlich' }), { - status: 400, - headers: { - 'Content-Type': 'application/json', - }, - }); - } - - // Erstelle ein neues Modell in der Datenbank - if (supabase) { - const { data, error } = await supabase - .from('models') - .insert([{ - name: body.name, - description: body.description, - parameters: body.parameters || {}, - }]) - .select(); - - if (error) { - console.error('Fehler beim Erstellen des Modells:', error); - return new Response(JSON.stringify({ error: 'Fehler beim Erstellen des Modells' }), { - status: 500, - headers: { - 'Content-Type': 'application/json', - }, - }); - } - - return Response.json(data[0]); - } else { - // Wenn Supabase nicht verfügbar ist, gib einen Fehler zurück - return new Response(JSON.stringify({ error: 'Datenbank nicht verfügbar' }), { - status: 503, - headers: { - 'Content-Type': 'application/json', - }, - }); - } - } catch (error) { - console.error('Fehler beim Verarbeiten der Anfrage:', error); - return new Response(JSON.stringify({ error: 'Interner Serverfehler' }), { - status: 500, - headers: { - 'Content-Type': 'application/json', - }, - }); - } + return new Response(JSON.stringify({ error: 'Modell-Erstellung wird über das Backend nicht unterstützt' }), { + status: 501, + headers: { + 'Content-Type': 'application/json', + }, + }); } diff --git a/apps/chat/apps/mobile/app/api/usage+api.ts b/apps/chat/apps/mobile/app/api/usage+api.ts index 759fb2249..8eec42de2 100644 --- a/apps/chat/apps/mobile/app/api/usage+api.ts +++ b/apps/chat/apps/mobile/app/api/usage+api.ts @@ -1,4 +1,5 @@ -import { supabase } from '../../utils/supabase'; +// TODO: Implement usage statistics via Backend API +// The Backend needs endpoints for user usage statistics // Typ für die Token-Nutzung pro Modell export type ModelUsage = { @@ -28,57 +29,31 @@ export type ConversationUsage = { }; // Handler für GET /api/usage +// TODO: Backend-Endpoints für Usage-Statistiken implementieren export async function GET(request: Request) { try { const url = new URL(request.url); const userId = url.searchParams.get('userId'); - const period = url.searchParams.get('period') || 'month'; - + if (!userId) { return new Response(JSON.stringify({ error: 'User ID ist erforderlich' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } - - // Lade die Tokennutzung nach Modell - const { data: modelUsage, error: modelError } = await supabase - .rpc('get_user_model_usage', { user_id: userId }); - - if (modelError) { - console.error('Fehler beim Laden der Modellnutzung:', modelError); - return new Response(JSON.stringify({ error: 'Fehler beim Laden der Modellnutzung' }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } - - // Lade die Tokennutzung nach Zeitraum - const { data: periodUsage, error: periodError } = await supabase - .rpc('get_user_usage_by_period', { - user_id: userId, - period: period - }); - - if (periodError) { - console.error('Fehler beim Laden der Zeitraumnutzung:', periodError); - return new Response(JSON.stringify({ error: 'Fehler beim Laden der Zeitraumnutzung' }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } - - // Berechne Gesamtkosten und Token - const totalCost = (modelUsage as ModelUsage[]).reduce((sum, model) => sum + model.total_cost, 0); - const totalTokens = (modelUsage as ModelUsage[]).reduce((sum, model) => sum + model.total_tokens, 0); - + + // Usage-Statistiken sind noch nicht über die Backend-API verfügbar + // Gebe leere Daten zurück + console.log('Usage-Statistiken: Backend-Endpoints noch nicht implementiert'); + return Response.json({ - modelUsage, - periodUsage, + modelUsage: [], + periodUsage: [], summary: { - totalCost, - totalTokens - } + totalCost: 0, + totalTokens: 0 + }, + message: 'Usage-Statistiken sind derzeit nicht verfügbar' }); } catch (error) { console.error('Fehler beim Verarbeiten der Anfrage:', error); @@ -90,42 +65,31 @@ export async function GET(request: Request) { } // Handler für GET /api/usage/conversation +// TODO: Backend-Endpoints für Conversation-Usage implementieren export async function GET_conversation(request: Request) { try { const url = new URL(request.url); const conversationId = url.searchParams.get('conversationId'); - + if (!conversationId) { return new Response(JSON.stringify({ error: 'Conversation ID ist erforderlich' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } - - // Lade die Tokennutzung für die Konversation - const { data: conversationUsage, error } = await supabase - .rpc('get_conversation_usage', { conversation_id: conversationId }); - - if (error) { - console.error('Fehler beim Laden der Konversationsnutzung:', error); - return new Response(JSON.stringify({ error: 'Fehler beim Laden der Konversationsnutzung' }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } - - // Berechne Gesamtkosten und Token für diese Konversation - const usage = conversationUsage as ConversationUsage[]; - const totalCost = usage.reduce((sum, item) => sum + item.estimated_cost, 0); - const totalTokens = usage.reduce((sum, item) => sum + item.total_tokens, 0); - + + // Usage-Statistiken sind noch nicht über die Backend-API verfügbar + // Gebe leere Daten zurück + console.log('Conversation-Usage: Backend-Endpoints noch nicht implementiert'); + return Response.json({ - conversationUsage, + conversationUsage: [], summary: { - totalCost, - totalTokens, - messageCount: usage.length - } + totalCost: 0, + totalTokens: 0, + messageCount: 0 + }, + message: 'Usage-Statistiken sind derzeit nicht verfügbar' }); } catch (error) { console.error('Fehler beim Verarbeiten der Anfrage:', error); @@ -134,4 +98,4 @@ export async function GET_conversation(request: Request) { headers: { 'Content-Type': 'application/json' } }); } -} \ No newline at end of file +} diff --git a/apps/chat/apps/mobile/app/archive.tsx b/apps/chat/apps/mobile/app/archive.tsx index 6699142c3..4f75dcc4c 100644 --- a/apps/chat/apps/mobile/app/archive.tsx +++ b/apps/chat/apps/mobile/app/archive.tsx @@ -15,13 +15,13 @@ import { Ionicons } from '@expo/vector-icons'; import { useAuth } from '../context/AuthProvider'; import { useAppTheme } from '../theme/ThemeProvider'; import CustomDrawer from '../components/CustomDrawer'; -import { - getArchivedConversations, - getMessages, - deleteConversation, +import { + getArchivedConversations, + getMessages, + deleteConversation, unarchiveConversation } from '../services/conversation'; -import { supabase } from '../utils/supabase'; +import { modelApi } from '../services/api'; // Typendefinitionen für Konversationen type ConversationItem = { @@ -69,18 +69,14 @@ export default function ArchiveScreen() { try { // Lade die Nachrichten der Konversation const messages = await getMessages(conv.id); - // Lade das Modell aus der Datenbank - const { data: modelData } = await supabase - .from('models') - .select('name') - .eq('id', conv.model_id) - .single(); - + // Lade das Modell über die Backend API + const modelData = await modelApi.getModel(conv.model_id); + // Finde die letzte Nachricht (die nicht vom System ist) const lastMessage = messages .filter(msg => msg.sender !== 'system') .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]; - + if (lastMessage) { conversationItems.push({ id: conv.id, @@ -88,7 +84,7 @@ export default function ArchiveScreen() { title: conv.title || 'Unbenannte Konversation', lastMessage: lastMessage.message_text, timestamp: new Date(conv.updated_at), - mode: conv.conversation_mode === 'free' ? 'frei' : + mode: conv.conversation_mode === 'free' ? 'frei' : conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage' }); } diff --git a/apps/chat/apps/mobile/app/auth/login.tsx b/apps/chat/apps/mobile/app/auth/login.tsx index cfcfeda3f..f654b3912 100644 --- a/apps/chat/apps/mobile/app/auth/login.tsx +++ b/apps/chat/apps/mobile/app/auth/login.tsx @@ -4,7 +4,6 @@ import { useTheme } from '@react-navigation/native'; import { useRouter, Link } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useAuth } from '../../context/AuthProvider'; -import { supabase } from '../../utils/supabase'; import { useAppTheme } from '../../theme/ThemeProvider'; export default function LoginScreen() { @@ -24,24 +23,13 @@ export default function LoginScreen() { Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse und dein Passwort ein.'); return; } - + try { setLoading(true); const { error } = await signIn(email, password); - + if (error) { - console.log('Anmeldung mit Passwort fehlgeschlagen, versuche direkte Anmeldung...'); - // Wenn die normale Anmeldung fehlschlägt, versuche eine direkte Anmeldung - const { error: directError } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (directError) { - Alert.alert('Anmeldung fehlgeschlagen', directError.message); - } else { - router.replace('/'); - } + Alert.alert('Anmeldung fehlgeschlagen', error.message || 'Unbekannter Fehler'); } else { // Erfolgreich angemeldet, navigiere zur Hauptseite router.replace('/'); @@ -54,37 +42,12 @@ export default function LoginScreen() { } }; + // Magic Link ist derzeit nicht verfügbar (mana-core-auth unterstützt dies nicht) const handleMagicLink = async () => { - if (!email) { - Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse ein.'); - return; - } - - try { - setLoading(true); - - const { error } = await supabase.auth.signInWithOtp({ - email, - options: { - emailRedirectTo: 'exp://localhost:8081/', - }, - }); - - if (error) { - Alert.alert('Fehler', error.message); - } else { - setIsMagicLinkSent(true); - Alert.alert( - 'Magic Link gesendet', - 'Wir haben dir einen Magic Link an deine E-Mail-Adresse gesendet. Bitte öffne den Link, um dich anzumelden.' - ); - } - } catch (error) { - console.error('Fehler beim Senden des Magic Links:', error); - Alert.alert('Fehler', 'Beim Senden des Magic Links ist ein Fehler aufgetreten. Bitte versuche es später erneut.'); - } finally { - setLoading(false); - } + Alert.alert( + 'Nicht verfügbar', + 'Magic Link Anmeldung ist derzeit nicht verfügbar. Bitte nutze E-Mail und Passwort.' + ); }; return ( diff --git a/apps/chat/apps/mobile/app/conversation/[id].tsx b/apps/chat/apps/mobile/app/conversation/[id].tsx index db195609b..6ba74813f 100644 --- a/apps/chat/apps/mobile/app/conversation/[id].tsx +++ b/apps/chat/apps/mobile/app/conversation/[id].tsx @@ -13,8 +13,9 @@ import DocumentVersions from '../../components/DocumentVersions'; // Import der Konversations- und OpenAI-Services import { createConversation, addMessage, getMessages, sendMessageAndGetResponse, Message as DbMessage } from '../../services/conversation'; -import { supabase } from '../../utils/supabase'; +import { conversationApi } from '../../services/api'; import { Document, createDocument, createDocumentVersion, getLatestDocument, getAllDocumentVersions, hasDocument, deleteDocumentVersion } from '../../services/document'; +import { useAuth } from '../../context/AuthProvider'; // Typdefinition für die Nachrichten in der UI type UIMessage = { @@ -38,10 +39,11 @@ function convertDbToUiMessages(dbMessages: DbMessage[]): UIMessage[] { export default function ConversationScreen() { const { colors } = useTheme(); const router = useRouter(); + const { user } = useAuth(); // Hole Parameter aus URL und Query-String const params = useLocalSearchParams(); const { id } = params; - + // Drawer (Seitenmenü) Status const [isDrawerOpen, setIsDrawerOpen] = useState(false); @@ -79,20 +81,15 @@ export default function ConversationScreen() { const [isDocumentLoading, setIsDocumentLoading] = useState(false); const [isVersionsModalVisible, setIsVersionsModalVisible] = useState(false); - // Überprüfe den aktuellen Benutzer + // Setze userId vom AuthProvider useEffect(() => { - const checkUser = async () => { - const { data } = await supabase.auth.getUser(); - if (data?.user) { - setUserId(data.user.id); - } else { - // In einer echten App würden wir hier zur Login-Seite weiterleiten - // Für jetzt verwenden wir eine Test-ID - setUserId('test-user-id'); - } - }; - checkUser(); - }, []); + if (user?.id) { + setUserId(user.id); + } else { + // Fallback für Test-Zwecke + setUserId('test-user-id'); + } + }, [user]); // Lade das Modell useEffect(() => { @@ -117,32 +114,28 @@ export default function ConversationScreen() { } } - // Wenn kein URL-Modell gefunden wurde oder keins angegeben war, + // Wenn kein URL-Modell gefunden wurde oder keins angegeben war, // hole die Konversation, um die Model-ID zu bekommen if (!isNewConversation && conversationId) { console.log("Hole Modell-ID aus Konversation:", conversationId); - const { data: conversationData, error: conversationError } = await supabase - .from('conversations') - .select('model_id, title') - .eq('id', conversationId) - .single(); - - if (conversationData && conversationData.model_id) { - console.log("✓ Model-ID aus der Konversation geladen:", conversationData.model_id); + const conversationData = await conversationApi.getConversation(conversationId); + + if (conversationData && conversationData.modelId) { + console.log("✓ Model-ID aus der Konversation geladen:", conversationData.modelId); // Setze das modelId, wenn wir es aus der Konversation bekommen haben - const fetchedModelId = conversationData.model_id; - + const fetchedModelId = conversationData.modelId; + // Setze den Titel aus der Konversation if (conversationData.title) { console.log("✓ Titel aus der Konversation geladen:", conversationData.title); setConversationTitle(conversationData.title); } - + // Hole jetzt das Modell mit der ID const response = await fetch(`/api/models`); const models = await response.json(); const model = models.find((m: any) => m.id === fetchedModelId); - + if (model) { console.log("✓ Model-Daten aus Konversation geladen:", model.name); setModelName(model.name); @@ -150,8 +143,8 @@ export default function ConversationScreen() { } else { console.warn("Modell mit ID aus Konversation nicht gefunden:", fetchedModelId); } - } else if (conversationError) { - console.error('Fehler beim Laden der Konversation:', conversationError); + } else { + console.error('Fehler beim Laden der Konversation oder keine Model-ID gefunden'); } } } catch (error) { @@ -174,13 +167,9 @@ export default function ConversationScreen() { // Prüfe, ob es eine bestehende Konversation mit Dokumentmodus ist if (conversationId) { - const { data: convData, error: convError } = await supabase - .from('conversations') - .select('document_mode') - .eq('id', conversationId) - .single(); - - if (convData && convData.document_mode) { + const convData = await conversationApi.getConversation(conversationId); + + if (convData && convData.documentMode) { setIsDocumentMode(true); await loadDocumentData(conversationId); } @@ -203,45 +192,21 @@ export default function ConversationScreen() { try { console.log(`[loadDocumentData] Lade Dokumentdaten für Konversation ${convId}`); setIsDocumentLoading(true); - - // Längere Verzögerung zur Sicherstellung, dass Datenbanktransaktionen Zeit haben - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Direkter Supabase-Aufruf mit Cache-Umgehung - console.log('Lade alle Dokumentversionen direkt...'); - - // Generiere einen zufälligen String, um Caching zu verhindern - const timestamp = new Date().getTime(); - const randomString = Math.random().toString(36).substring(2, 8); - const noCache = `${timestamp}-${randomString}`; - - const { data: freshVersions, error } = await supabase - .from('documents') - .select(`*,noCacheKey:conversation_id(id)`) - .eq('conversation_id', convId) - .order('version', { ascending: false }) - .filter('noCacheKey.id', 'not.is', null) - .limit(100); - - if (error) { - console.error('Fehler beim direkten Laden der Dokumentversionen:', error); - setDocumentVersions([]); - return; - } - - // Entferne noCacheKey-Feld - const cleanVersions = freshVersions.map(v => { - const { noCacheKey, ...rest } = v; - return rest; - }); - - console.log(`${cleanVersions.length} Dokumentversionen direkt geladen`); - setDocumentVersions(cleanVersions); - + + // Kurze Verzögerung zur Sicherstellung, dass DB-Transaktionen abgeschlossen sind + await new Promise(resolve => setTimeout(resolve, 500)); + + // Lade alle Dokumentversionen über den Service + console.log('Lade alle Dokumentversionen über Backend API...'); + const versions = await getAllDocumentVersions(convId); + + console.log(`${versions.length} Dokumentversionen geladen`); + setDocumentVersions(versions); + // Wenn Versionen existieren, nehme die neueste - if (cleanVersions.length > 0) { - console.log('Setze neuestes Dokument aus Liste', cleanVersions[0]); - setCurrentDocument(cleanVersions[0]); + if (versions.length > 0) { + console.log('Setze neuestes Dokument aus Liste', versions[0]); + setCurrentDocument(versions[0]); } else { // Wenn keine Versionen existieren, setze alles zurück console.log('Keine Dokumentversionen vorhanden, setze null'); @@ -322,53 +287,26 @@ export default function ConversationScreen() { console.error('Keine aktuelle Konversations-ID verfügbar'); return; } - + try { console.log(`[handleDeleteVersion] Versuche Dokumentversion ${document.version} (ID: ${document.id}) zu löschen`); setIsDocumentLoading(true); - + // Debug-Informationen console.log('Aktuelle Konversation:', actualConversationId); console.log('Aktuelles Dokument:', currentDocument?.id); console.log('Zu löschendes Dokument:', document.id); - + // Sicherstellen, dass die zu löschende Version nicht aktuell angezeigt wird const isCurrentlyDisplayed = currentDocument?.id === document.id; - - // Direkter Zugriff auf Supabase - console.log('Versuche direkte Löschung mit Supabase via delete'); - - // Wir verwenden einen speziellen Trick, um sicherzustellen, dass die Löschung - // Zeit hat, vollständig durchgeführt zu werden, bevor wir weitermachen - await new Promise(resolve => setTimeout(resolve, 500)); - - try { - // Direktes Löschen über die normale DELETE-Methode - const { error } = await supabase - .from('documents') - .delete() - .eq('id', document.id); - - if (error) { - console.error('Fehler beim direkten Löschen:', error); - throw error; - } - - console.log('Dokument erfolgreich gelöscht'); - - // Warten, damit die Datenbank Zeit hat, sich zu aktualisieren - await new Promise(resolve => setTimeout(resolve, 800)); - } catch (deleteError) { - console.error('Fehler beim Löschen:', deleteError); - throw deleteError; - } - - const success = true; // Wenn wir bis hierher kommen, war es erfolgreich - console.log('Löschvorgang Ergebnis: Erfolgreich'); - + + // Löschen über den Service + console.log('Lösche Dokument über Backend API...'); + const success = await deleteDocumentVersion(document.id); + if (success) { console.log(`Dokumentversion ${document.version} erfolgreich gelöscht`); - + // Systemische Nachricht hinzufügen const messageId = await addMessage( actualConversationId, @@ -376,44 +314,33 @@ export default function ConversationScreen() { `Dokumentversion ${document.version} wurde gelöscht.` ); console.log('System-Nachricht hinzugefügt:', messageId); - + // Nachrichten neu laden const dbMessages = await getMessages(actualConversationId); setMessages(convertDbToUiMessages(dbMessages)); - - // Dokumentversionen neu laden mit forcierter Aktualisierung - // Zuerst kurz warten, damit die DB-Änderungen sich vollständig auswirken können - await new Promise(resolve => setTimeout(resolve, 1000)); - await loadDocumentData(actualConversationId); - + + // Dokumentversionen neu laden + await new Promise(resolve => setTimeout(resolve, 500)); + await loadDocumentData(actualConversationId); + // Wenn die gerade angezeigte Version gelöscht wurde, zur neuesten wechseln if (isCurrentlyDisplayed) { console.log('Aktuell angezeigte Version wurde gelöscht, wechsle zur neuesten'); - - // Direkter Supabase-Aufruf für die aktuellste Version - const { data: latestData, error: latestError } = await supabase - .from('documents') - .select('*') - .eq('conversation_id', actualConversationId) - .order('version', { ascending: false }) - .limit(1) - .maybeSingle(); - - if (latestError) { - console.error('Fehler beim Laden des neuesten Dokuments:', latestError); - } else if (latestData) { - console.log('Setze neues aktuelles Dokument:', latestData.id); - setCurrentDocument(latestData); + const latestDoc = await getLatestDocument(actualConversationId); + + if (latestDoc) { + console.log('Setze neues aktuelles Dokument:', latestDoc.id); + setCurrentDocument(latestDoc); } else { console.log('Kein neuestes Dokument gefunden, setze null'); setCurrentDocument(null); } } - + // Kurze Pause für bessere Benutzererfahrung setTimeout(() => { setIsVersionsModalVisible(false); - + // Erfolgsmeldung anzeigen Alert.alert( "Version gelöscht", @@ -611,27 +538,23 @@ export default function ConversationScreen() { if ((!modelId || modelId === 'undefined') && !modelData && actualConversationId) { try { console.log('Hole Modell aus der Konversation:', actualConversationId); - const { data, error } = await supabase - .from('conversations') - .select('model_id') - .eq('id', actualConversationId) - .single(); - - if (error) { - console.error('Fehler beim Laden des Modells aus der Konversation:', error); + const convData = await conversationApi.getConversation(actualConversationId); + + if (!convData) { + console.error('Fehler beim Laden der Konversation'); Alert.alert('Fehler', 'Modell konnte nicht geladen werden.'); return; } - - if (data && data.model_id) { - console.log('Modell-ID aus der Konversation geladen:', data.model_id); - const fetchedModelId = data.model_id; - + + if (convData.modelId) { + console.log('Modell-ID aus der Konversation geladen:', convData.modelId); + const fetchedModelId = convData.modelId; + // Setze das Modell für die nächsten API-Aufrufe const response = await fetch(`/api/models`); const models = await response.json(); const model = models.find((m: any) => m.id === fetchedModelId); - + if (model) { setModelName(model.name); setModelData(model); diff --git a/apps/chat/apps/mobile/app/conversations.tsx b/apps/chat/apps/mobile/app/conversations.tsx index 77e23fb92..c9e1476b1 100644 --- a/apps/chat/apps/mobile/app/conversations.tsx +++ b/apps/chat/apps/mobile/app/conversations.tsx @@ -17,13 +17,13 @@ import { Ionicons } from '@expo/vector-icons'; import { useAuth } from '../context/AuthProvider'; import { useAppTheme } from '../theme/ThemeProvider'; import CustomDrawer from '../components/CustomDrawer'; -import { - getConversations, - getMessages, - deleteConversation, - archiveConversation +import { + getConversations, + getMessages, + deleteConversation, + archiveConversation } from '../services/conversation'; -import { supabase } from '../utils/supabase'; +import { modelApi } from '../services/api'; // Typendefinitionen für Konversationen type ConversationItem = { @@ -71,18 +71,14 @@ export default function ConversationsScreen() { try { // Lade die Nachrichten der Konversation const messages = await getMessages(conv.id); - // Lade das Modell aus der Datenbank - const { data: modelData } = await supabase - .from('models') - .select('name') - .eq('id', conv.model_id) - .single(); - + // Lade das Modell über die Backend API + const modelData = await modelApi.getModel(conv.model_id); + // Finde die letzte Nachricht (die nicht vom System ist) const lastMessage = messages .filter(msg => msg.sender !== 'system') .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]; - + if (lastMessage) { conversationItems.push({ id: conv.id, @@ -90,7 +86,7 @@ export default function ConversationsScreen() { title: conv.title || 'Unbenannte Konversation', lastMessage: lastMessage.message_text, timestamp: new Date(conv.updated_at), - mode: conv.conversation_mode === 'free' ? 'frei' : + mode: conv.conversation_mode === 'free' ? 'frei' : conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage' }); } diff --git a/apps/chat/apps/mobile/app/documents.tsx b/apps/chat/apps/mobile/app/documents.tsx index b86bf5167..b6d1c7e9e 100644 --- a/apps/chat/apps/mobile/app/documents.tsx +++ b/apps/chat/apps/mobile/app/documents.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect, useMemo } from 'react'; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, ActivityIndicator, useWindowDimensions, Platform @@ -12,8 +12,9 @@ import { import { useTheme } from '@react-navigation/native'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; -import { Document } from '../services/document'; -import { supabase } from '../utils/supabase'; +import { Document, getLatestDocument } from '../services/document'; +import { conversationApi } from '../services/api'; +import { useAuth } from '../context/AuthProvider'; import Markdown from 'react-native-markdown-display'; type DocumentWithTitle = Document & { @@ -23,11 +24,11 @@ type DocumentWithTitle = Document & { export default function DocumentsScreen() { const { colors } = useTheme(); const router = useRouter(); + const { user } = useAuth(); const { width } = useWindowDimensions(); const [documents, setDocuments] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [userId, setUserId] = useState(null); - + // Berechne die Anzahl der Spalten basierend auf der Bildschirmbreite const columnsCount = useMemo(() => { // Mobile (schmaler Bildschirm) @@ -41,7 +42,7 @@ export default function DocumentsScreen() { // Desktop oder großes Tablet return 3; }, [width]); - + // Berechne die Breite jeder Karte basierend auf der Spaltenanzahl const cardWidth = useMemo(() => { const padding = 16; // Container-Padding rechts und links @@ -49,85 +50,55 @@ export default function DocumentsScreen() { const contentWidth = width - (padding * 2); const gapTotal = gap * (columnsCount - 1); const availableWidth = contentWidth - gapTotal; - + // Verhältnis für schmalere Karten, je nach Spaltenanzahl anpassen const widthRatio = columnsCount === 1 ? 0.95 : // Fast volle Breite bei 1 Spalte columnsCount === 2 ? 0.48 : // Etwas schmaler bei 2 Spalten 0.31; // Noch schmaler bei 3 Spalten - + return (availableWidth * widthRatio); }, [width, columnsCount]); useEffect(() => { - const checkUser = async () => { - const { data } = await supabase.auth.getUser(); - if (data?.user) { - setUserId(data.user.id); - } else { - // In einer echten App würden wir hier zur Login-Seite weiterleiten - // Für jetzt verwenden wir eine Test-ID - setUserId('test-user-id'); - } - }; - checkUser(); - }, []); - - useEffect(() => { - if (userId) { + if (user?.id) { loadDocuments(); } - }, [userId]); + }, [user]); const loadDocuments = async () => { try { setIsLoading(true); - - // Lade alle Konversationen des Benutzers, die im Dokumentmodus sind - const { data: conversations, error: convError } = await supabase - .from('conversations') - .select('id, title, document_mode') - .eq('user_id', userId) - .eq('document_mode', true); - - if (convError) { - console.error('Fehler beim Laden der Konversationen:', convError); - setIsLoading(false); - return; - } - - if (!conversations || conversations.length === 0) { + + // Lade alle Konversationen des Benutzers über die Backend-API + const conversations = await conversationApi.getConversations(); + + // Filtere nur Konversationen im Dokumentmodus + const documentConversations = conversations.filter(conv => conv.documentMode); + + if (documentConversations.length === 0) { setDocuments([]); setIsLoading(false); return; } - + // Für jede Konversation den neuesten Dokumentstand laden const latestDocuments: DocumentWithTitle[] = []; - - for (const conv of conversations) { - const { data: docData, error: docError } = await supabase - .from('documents') - .select('*') - .eq('conversation_id', conv.id) - .order('version', { ascending: false }) - .limit(1) - .single(); - - if (docError) { - if (docError.code !== 'PGRST116') { // Ignore "No rows found" error - console.error(`Fehler beim Laden des Dokuments für Konversation ${conv.id}:`, docError); + + for (const conv of documentConversations) { + try { + const docData = await getLatestDocument(conv.id); + + if (docData) { + latestDocuments.push({ + ...docData, + conversation_title: conv.title || 'Unbenannte Konversation' + }); } - continue; - } - - if (docData) { - latestDocuments.push({ - ...docData, - conversation_title: conv.title || 'Unbenannte Konversation' - }); + } catch (docError) { + console.error(`Fehler beim Laden des Dokuments für Konversation ${conv.id}:`, docError); } } - + setDocuments(latestDocuments); } catch (error) { console.error('Fehler beim Laden der Dokumente:', error); diff --git a/apps/chat/apps/mobile/app/index.tsx b/apps/chat/apps/mobile/app/index.tsx index c04eaa7a3..1d27d274d 100644 --- a/apps/chat/apps/mobile/app/index.tsx +++ b/apps/chat/apps/mobile/app/index.tsx @@ -11,7 +11,7 @@ import CustomDrawer from '../components/CustomDrawer'; import { useAppTheme } from '../theme/ThemeProvider'; import { getConversations, getMessages, deleteConversation, archiveConversation } from '../services/conversation'; import { getUserSpaces, Space } from '../services/space'; -import { supabase } from '../utils/supabase'; +import { modelApi } from '../services/api'; // Typendefinitionen für Konversationen type ConversationItem = { @@ -75,18 +75,14 @@ export default function HomeScreen() { try { // Lade die Nachrichten der Konversation const messages = await getMessages(conv.id); - // Lade das Modell aus der Datenbank - const { data: modelData } = await supabase - .from('models') - .select('name') - .eq('id', conv.model_id) - .single(); - + // Lade das Modell über die Backend API + const modelData = await modelApi.getModel(conv.model_id); + // Finde die letzte Nachricht (die nicht vom System ist) const lastMessage = messages .filter(msg => msg.sender !== 'system') .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]; - + if (lastMessage) { conversationItems.push({ id: conv.id, @@ -94,7 +90,7 @@ export default function HomeScreen() { title: conv.title || 'Unbenannte Konversation', lastMessage: lastMessage.message_text, timestamp: new Date(conv.updated_at), - mode: conv.conversation_mode === 'free' ? 'frei' : + mode: conv.conversation_mode === 'free' ? 'frei' : conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage' }); } diff --git a/apps/chat/apps/mobile/app/profile.tsx b/apps/chat/apps/mobile/app/profile.tsx index d994c5f21..fee1c4736 100644 --- a/apps/chat/apps/mobile/app/profile.tsx +++ b/apps/chat/apps/mobile/app/profile.tsx @@ -5,7 +5,6 @@ import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useAuth } from '../context/AuthProvider'; import { useAppTheme } from '../theme/ThemeProvider'; -import { supabase } from '../utils/supabase'; // Typendefinitionen für die Token-Nutzung type ModelUsage = { @@ -44,46 +43,18 @@ export default function ProfileScreen() { const [selectedPeriod, setSelectedPeriod] = useState<'day' | 'month' | 'year'>('month'); // Funktion zum Laden der Token-Nutzungsdaten + // TODO: Backend-Endpoints für Usage-Statistiken implementieren const loadUsageData = async () => { if (!user) return; - + setIsLoading(true); try { - // Lade die Token-Nutzung nach Modell - const { data: modelData, error: modelError } = await supabase - .rpc('get_user_model_usage', { user_id: user.id }); - - if (modelError) { - console.error('Fehler beim Laden der Modellnutzung:', modelError); - } else if (modelData) { - setModelUsage(modelData as ModelUsage[]); - } - - // Lade die Token-Nutzung nach Zeitraum - const { data: periodData, error: periodError } = await supabase - .rpc('get_user_usage_by_period', { - user_id: user.id, - period: selectedPeriod - }); - - if (periodError) { - console.error('Fehler beim Laden der Zeitraumnutzung:', periodError); - } else if (periodData) { - setPeriodUsage(periodData as UsageByPeriod[]); - } - - // Berechne die Zusammenfassung - if (modelData) { - const totalCost = (modelData as ModelUsage[]).reduce((sum, model) => sum + model.total_cost, 0); - const totalTokens = (modelData as ModelUsage[]).reduce((sum, model) => sum + model.total_tokens, 0); - - setSummary({ - totalCost, - totalTokens, - modelCount: (modelData as ModelUsage[]).length, - periodCount: periodData ? (periodData as UsageByPeriod[]).length : 0 - }); - } + // Usage-Statistiken sind noch nicht über die Backend-API verfügbar + // Setze leere Daten und zeige Info-Text an + console.log('Usage-Statistiken: Backend-Endpoints noch nicht implementiert'); + setModelUsage([]); + setPeriodUsage([]); + setSummary(null); } catch (error) { console.error('Fehler beim Laden der Nutzungsdaten:', error); } finally { diff --git a/apps/chat/apps/mobile/components/ChatPromptInput.tsx b/apps/chat/apps/mobile/components/ChatPromptInput.tsx index a828ec94a..4c0f41df8 100644 --- a/apps/chat/apps/mobile/components/ChatPromptInput.tsx +++ b/apps/chat/apps/mobile/components/ChatPromptInput.tsx @@ -6,7 +6,6 @@ import { useAppTheme } from '../theme/ThemeProvider'; import ModelDropdown from './ModelDropdown'; import { useRouter } from 'expo-router'; import { createConversation, addMessage } from '../services/conversation'; -import { supabase } from '../utils/supabase'; import { useAuth } from '../context/AuthProvider'; import { Template, getTemplates } from '../services/template'; diff --git a/apps/chat/apps/mobile/components/ConversationStarter.tsx b/apps/chat/apps/mobile/components/ConversationStarter.tsx index 558672248..070e5fe4b 100644 --- a/apps/chat/apps/mobile/components/ConversationStarter.tsx +++ b/apps/chat/apps/mobile/components/ConversationStarter.tsx @@ -6,7 +6,6 @@ import { useAppTheme } from '../theme/ThemeProvider'; import ModelDropdown from './ModelDropdown'; import { useRouter } from 'expo-router'; import { createConversation, addMessage } from '../services/conversation'; -import { supabase } from '../utils/supabase'; import { useAuth } from '../context/AuthProvider'; import { Template, getTemplates } from '../services/template'; import { Space, getUserSpaces } from '../services/space'; diff --git a/apps/chat/apps/mobile/package.json b/apps/chat/apps/mobile/package.json index 157e28317..5dc86d555 100644 --- a/apps/chat/apps/mobile/package.json +++ b/apps/chat/apps/mobile/package.json @@ -14,11 +14,7 @@ "prebuild": "expo prebuild", "lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"", "format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write", - "web": "expo start --web", - "supabase:cli": "node --experimental-json-modules scripts/supabase-cli.js", - "supabase:update-models": "node --experimental-json-modules scripts/update_models.js", - "supabase:setup": "node --experimental-json-modules scripts/setup_supabase.js", - "supabase:setup-spaces": "node --experimental-json-modules scripts/spaces/setup_spaces.js" + "web": "expo start --web" }, "dependencies": { "@expo/vector-icons": "^14.0.0", @@ -26,7 +22,6 @@ "@react-navigation/bottom-tabs": "^7.0.5", "@react-navigation/drawer": "^7.0.0", "@react-navigation/native": "^7.0.3", - "@supabase/supabase-js": "^2.38.4", "expo": "^52.0.39", "expo-constants": "~17.0.8", "expo-dev-client": "~5.0.4", diff --git a/apps/chat/apps/mobile/utils/supabase.ts b/apps/chat/apps/mobile/utils/supabase.ts deleted file mode 100644 index f3ae10836..000000000 --- a/apps/chat/apps/mobile/utils/supabase.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createClient } from '@supabase/supabase-js'; - -const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || ''; -const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || ''; - -// Überprüfe, ob wir in einer Browser-Umgebung sind -const isBrowser = typeof window !== 'undefined'; - -// Importiere AsyncStorage nur, wenn wir in einer Browser-Umgebung sind -let AsyncStorage; -if (isBrowser) { - AsyncStorage = require('@react-native-async-storage/async-storage').default; -} - -// Erstelle Supabase-Client mit unterschiedlichen Konfigurationen je nach Umgebung -export const supabase = createClient(supabaseUrl, supabaseAnonKey, { - auth: isBrowser - ? { - storage: AsyncStorage, - autoRefreshToken: true, - persistSession: true, - detectSessionInUrl: false, - } - : { - // Dummy-Storage für serverseitiges Rendering - storage: { - getItem: () => Promise.resolve(null), - setItem: () => Promise.resolve(), - removeItem: () => Promise.resolve(), - }, - autoRefreshToken: false, - persistSession: false, - detectSessionInUrl: false, - }, -}); diff --git a/apps/chat/apps/web/.env.example b/apps/chat/apps/web/.env.example index 1c5b3325f..d683ba963 100644 --- a/apps/chat/apps/web/.env.example +++ b/apps/chat/apps/web/.env.example @@ -1,9 +1,5 @@ # Mana Core Auth Configuration PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 -# Supabase Configuration (for database only, not auth) -PUBLIC_SUPABASE_URL=https://your-project.supabase.co -PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key - # Chat Backend API PUBLIC_BACKEND_URL=http://localhost:3002 diff --git a/apps/chat/apps/web/package.json b/apps/chat/apps/web/package.json index 06cb49ec9..289fcf678 100644 --- a/apps/chat/apps/web/package.json +++ b/apps/chat/apps/web/package.json @@ -33,14 +33,11 @@ "@manacore/shared-branding": "workspace:*", "@manacore/shared-i18n": "workspace:*", "@manacore/shared-icons": "workspace:*", - "@manacore/shared-supabase": "workspace:*", "@manacore/shared-tailwind": "workspace:*", "@manacore/shared-theme": "workspace:*", "@manacore/shared-theme-ui": "workspace:*", "@manacore/shared-ui": "workspace:*", "@manacore/shared-utils": "workspace:*", - "@supabase/ssr": "^0.6.1", - "@supabase/supabase-js": "^2.81.1", "marked": "^17.0.0" } } diff --git a/apps/chat/apps/web/src/lib/services/supabase.ts b/apps/chat/apps/web/src/lib/services/supabase.ts deleted file mode 100644 index 3db9c50a3..000000000 --- a/apps/chat/apps/web/src/lib/services/supabase.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Supabase Client for Chat Web App - * Uses the same Supabase instance as the mobile app - */ - -import { createClient } from '@supabase/supabase-js'; -import { createBrowserClient, createServerClient } from '@supabase/ssr'; -import { env } from '$env/dynamic/public'; -import type { Cookies } from '@sveltejs/kit'; - -const supabaseUrl = env.PUBLIC_SUPABASE_URL || ''; -const supabaseAnonKey = env.PUBLIC_SUPABASE_ANON_KEY || ''; - -/** - * Browser client for client-side operations - */ -export function createSupabaseBrowserClient() { - return createBrowserClient(supabaseUrl, supabaseAnonKey); -} - -/** - * Server client for SSR operations - */ -export function createSupabaseServerClient(cookies: Cookies) { - return createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { - return cookies.getAll(); - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value, options }) => { - cookies.set(name, value, { ...options, path: '/' }); - }); - }, - }, - }); -} - -/** - * Simple client for basic operations (no SSR) - */ -export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/apps/maerchenzauber/apps/backend/package.json b/apps/maerchenzauber/apps/backend/package.json index c2a858c11..fdeccd36e 100644 --- a/apps/maerchenzauber/apps/backend/package.json +++ b/apps/maerchenzauber/apps/backend/package.json @@ -23,6 +23,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@mana-core/nestjs-integration": "workspace:*", "@manacore/shared-errors": "workspace:*", "@google-cloud/aiplatform": "^3.34.0", "@google-cloud/storage": "^7.15.0", diff --git a/apps/maerchenzauber/apps/backend/src/character/character.service.ts b/apps/maerchenzauber/apps/backend/src/character/character.service.ts index c71f585f5..c6191f086 100644 --- a/apps/maerchenzauber/apps/backend/src/character/character.service.ts +++ b/apps/maerchenzauber/apps/backend/src/character/character.service.ts @@ -77,8 +77,7 @@ export class CharacterService { console.error('Error creating character:', error); return err( DatabaseError.queryFailed( - 'create_character', - error instanceof Error ? error.message : 'Unknown error', + `Failed to create character: ${error instanceof Error ? error.message : 'Unknown error'}`, error instanceof Error ? error : undefined, ), ); @@ -107,8 +106,7 @@ export class CharacterService { console.error('Error getting character:', error); return err( DatabaseError.queryFailed( - 'get_character', - error instanceof Error ? error.message : 'Unknown error', + `Failed to get character: ${error instanceof Error ? error.message : 'Unknown error'}`, error instanceof Error ? error : undefined, ), ); @@ -153,8 +151,7 @@ export class CharacterService { console.error('Error updating character:', error); return err( DatabaseError.queryFailed( - 'update_character', - error instanceof Error ? error.message : 'Unknown error', + `Failed to update character: ${error instanceof Error ? error.message : 'Unknown error'}`, error instanceof Error ? error : undefined, ), ); @@ -183,8 +180,7 @@ export class CharacterService { console.error('Error deleting character:', error); return err( DatabaseError.queryFailed( - 'delete_character', - error instanceof Error ? error.message : 'Unknown error', + `Failed to delete character: ${error instanceof Error ? error.message : 'Unknown error'}`, error instanceof Error ? error : undefined, ), ); @@ -206,8 +202,7 @@ export class CharacterService { console.error('Error listing characters:', error); return err( DatabaseError.queryFailed( - 'list_characters', - error instanceof Error ? error.message : 'Unknown error', + `Failed to list characters: ${error instanceof Error ? error.message : 'Unknown error'}`, error instanceof Error ? error : undefined, ), ); diff --git a/apps/maerchenzauber/apps/backend/src/core/services/image-optimization.service.ts b/apps/maerchenzauber/apps/backend/src/core/services/image-optimization.service.ts index 737a36977..a858abc91 100644 --- a/apps/maerchenzauber/apps/backend/src/core/services/image-optimization.service.ts +++ b/apps/maerchenzauber/apps/backend/src/core/services/image-optimization.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import * as sharp from 'sharp'; +import sharp from 'sharp'; import { encode } from 'blurhash'; export interface OptimizedImages { diff --git a/apps/maerchenzauber/apps/backend/src/story/services/story-creation.service.ts b/apps/maerchenzauber/apps/backend/src/story/services/story-creation.service.ts index 6bd0ce94d..d984cd434 100644 --- a/apps/maerchenzauber/apps/backend/src/story/services/story-creation.service.ts +++ b/apps/maerchenzauber/apps/backend/src/story/services/story-creation.service.ts @@ -10,7 +10,7 @@ import { ImageSupabaseService } from '../../core/services/image-supabase.service import { CoreService } from '../../core/services/core.service'; import { StoryCharacter, StoryResponse } from '../../core/models/story'; import { StoryError } from '../../core/consts/errors.const'; -import { Result } from '../../core/models/error'; +import { type Result, isOk } from '@manacore/shared-errors'; import { StoryLogbookService } from '../../core/services/story-logbook.service'; export interface StoryCreationParams { @@ -115,7 +115,7 @@ export class StoryCreationService { isAnimalStory, ); - if (story.error || !story.data) { + if (!isOk(story)) { this.logbookService.logError( storyId, 'generate_story', @@ -124,7 +124,7 @@ export class StoryCreationService { await this.logStoryError( userId, storyId, - story.data, + null, StoryError.CREATE_STORYLINE, isAnimalStory, ); @@ -134,12 +134,12 @@ export class StoryCreationService { // Update logbook with page count this.logbookService.updateMetadata(storyId, { - pageCount: story.data.pages.length, + pageCount: story.value.pages.length, }); // 4. Create character descriptions const storyCharacters = await this.createCharacterDescriptions( - story.data.pages, + story.value.pages, character, userId, storyId, @@ -148,7 +148,7 @@ export class StoryCreationService { // 5. Generate illustrations const { illustrationPrompts, images } = await this.generateIllustrations( - story.data.pages, + story.value.pages, storyCharacters, finalIllustratorId || '', userId, @@ -160,7 +160,7 @@ export class StoryCreationService { // 6. Generate title const title = await this.generateTitle( - story.data.pages, + story.value.pages, userId, storyId, isAnimalStory, @@ -168,7 +168,7 @@ export class StoryCreationService { // 7. Translate story const translatedPages = await this.translateStory( - story.data.pages, + story.value.pages, illustrationPrompts, images, userId, @@ -575,7 +575,7 @@ export class StoryCreationService { ): Promise { const titleResult = await this.storyService.generateStoryTitle(pages); - if (titleResult.error) { + if (!isOk(titleResult)) { this.logger.error('Error generating story title'); await this.logStoryError( userId, @@ -587,7 +587,7 @@ export class StoryCreationService { return ''; } - return titleResult.data || ''; + return titleResult.value; } private async translateStory( diff --git a/apps/maerchenzauber/apps/backend/tsconfig.json b/apps/maerchenzauber/apps/backend/tsconfig.json index acf51ba68..04be8edb6 100644 --- a/apps/maerchenzauber/apps/backend/tsconfig.json +++ b/apps/maerchenzauber/apps/backend/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "module": "commonjs", + "moduleResolution": "node", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, @@ -11,6 +12,8 @@ "baseUrl": "./", "rootDir": "./", "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, "paths": { "@storyteller/shared-types": ["./src/shared-types/src"], "@/*": ["./src/*"] diff --git a/apps/nutriphi/CLAUDE.md b/apps/nutriphi/CLAUDE.md index 3cd81e2e0..153a716af 100644 --- a/apps/nutriphi/CLAUDE.md +++ b/apps/nutriphi/CLAUDE.md @@ -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 ` 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 diff --git a/apps/nutriphi/apps/backend/src/app.module.ts b/apps/nutriphi/apps/backend/src/app.module.ts index e70543a6c..7256daf94 100644 --- a/apps/nutriphi/apps/backend/src/app.module.ts +++ b/apps/nutriphi/apps/backend/src/app.module.ts @@ -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 {} diff --git a/apps/nutriphi/apps/backend/src/common/decorators/current-user.decorator.ts b/apps/nutriphi/apps/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 000000000..29f1fff1b --- /dev/null +++ b/apps/nutriphi/apps/backend/src/common/decorators/current-user.decorator.ts @@ -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; + }, +); diff --git a/apps/nutriphi/apps/backend/src/common/guards/jwt-auth.guard.ts b/apps/nutriphi/apps/backend/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 000000000..37bf176fe --- /dev/null +++ b/apps/nutriphi/apps/backend/src/common/guards/jwt-auth.guard.ts @@ -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 { + 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('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; + } +} diff --git a/apps/nutriphi/apps/backend/src/meals/dto/analyze-meal.dto.ts b/apps/nutriphi/apps/backend/src/meals/dto/analyze-meal.dto.ts index 44de81b08..ba2add462 100644 --- a/apps/nutriphi/apps/backend/src/meals/dto/analyze-meal.dto.ts +++ b/apps/nutriphi/apps/backend/src/meals/dto/analyze-meal.dto.ts @@ -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'; diff --git a/apps/nutriphi/apps/backend/src/meals/meals.controller.ts b/apps/nutriphi/apps/backend/src/meals/meals.controller.ts index 3580548f0..131301b17 100644 --- a/apps/nutriphi/apps/backend/src/meals/meals.controller.ts +++ b/apps/nutriphi/apps/backend/src/meals/meals.controller.ts @@ -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); } } diff --git a/apps/nutriphi/apps/backend/src/meals/meals.service.ts b/apps/nutriphi/apps/backend/src/meals/meals.service.ts index d1da12117..a2bd3ec4b 100644 --- a/apps/nutriphi/apps/backend/src/meals/meals.service.ts +++ b/apps/nutriphi/apps/backend/src/meals/meals.service.ts @@ -87,8 +87,8 @@ export class MealsService { /** * Upload an image to storage, analyze it, and create a meal */ - async uploadAndAnalyzeMeal(dto: UploadMealDto): Promise { - this.logger.log(`Uploading and analyzing meal for user: ${dto.userId}`); + async uploadAndAnalyzeMeal(dto: UploadMealDto, userId: string): Promise { + 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 { - this.logger.log(`Creating meal for user: ${dto.userId}`); + async createMeal(dto: CreateMealDto, userId: string): Promise { + 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 { + async getMealById(id: string, userId: string): Promise { 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 { - this.logger.log(`Updating meal: ${id}`); + async updateMeal(id: string, dto: UpdateMealDto, userId: string): Promise { + this.logger.log(`Updating meal: ${id} for user: ${userId}`); const updateData: Partial = { 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 { - this.logger.log(`Deleting meal: ${id}`); + async deleteMeal(id: string, userId: string): Promise { + 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) { diff --git a/apps/nutriphi/apps/backend/src/sync/dto/sync.dto.ts b/apps/nutriphi/apps/backend/src/sync/dto/sync.dto.ts new file mode 100644 index 000000000..08aa881f5 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/sync/dto/sync.dto.ts @@ -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; +} diff --git a/apps/nutriphi/apps/backend/src/sync/sync.controller.ts b/apps/nutriphi/apps/backend/src/sync/sync.controller.ts new file mode 100644 index 000000000..e8a80c1cf --- /dev/null +++ b/apps/nutriphi/apps/backend/src/sync/sync.controller.ts @@ -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 { + 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 { + return this.syncService.pullChanges(user.userId, query.since); + } + + /** + * Get sync status + * GET /api/sync/status + */ + @Get('status') + async getStatus(@CurrentUser() user: CurrentUserData): Promise { + return this.syncService.getStatus(user.userId); + } +} diff --git a/apps/nutriphi/apps/backend/src/sync/sync.module.ts b/apps/nutriphi/apps/backend/src/sync/sync.module.ts new file mode 100644 index 000000000..75a13e8e7 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/sync/sync.module.ts @@ -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 {} diff --git a/apps/nutriphi/apps/backend/src/sync/sync.service.ts b/apps/nutriphi/apps/backend/src/sync/sync.service.ts new file mode 100644 index 000000000..e0d515700 --- /dev/null +++ b/apps/nutriphi/apps/backend/src/sync/sync.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/nutriphi/apps/mobile/package.json b/apps/nutriphi/apps/mobile/package.json index 7f1edd65a..59817c812 100644 --- a/apps/nutriphi/apps/mobile/package.json +++ b/apps/nutriphi/apps/mobile/package.json @@ -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", diff --git a/apps/nutriphi/apps/mobile/services/DataClearingService.ts b/apps/nutriphi/apps/mobile/services/DataClearingService.ts index ef4360720..ebf0d984d 100644 --- a/apps/nutriphi/apps/mobile/services/DataClearingService.ts +++ b/apps/nutriphi/apps/mobile/services/DataClearingService.ts @@ -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 { + // Sign out from auth store + await useAuthStore.getState().signOut(); + // Clear all tokens + await tokenManager.clearTokens(); + } + private async clearDatabase(): Promise { const db = SQLiteService.getInstance(); @@ -120,14 +133,6 @@ export class DataClearingService { } } - // TODO: Implement when Supabase is configured - // private async signOutSupabase(): Promise { - // 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(); diff --git a/apps/nutriphi/apps/mobile/services/auth/authService.ts b/apps/nutriphi/apps/mobile/services/auth/authService.ts new file mode 100644 index 000000000..4b0e109e8 --- /dev/null +++ b/apps/nutriphi/apps/mobile/services/auth/authService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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); + }, +}; diff --git a/apps/nutriphi/apps/mobile/services/auth/tokenManager.ts b/apps/nutriphi/apps/mobile/services/auth/tokenManager.ts new file mode 100644 index 000000000..0d6f25b71 --- /dev/null +++ b/apps/nutriphi/apps/mobile/services/auth/tokenManager.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + const token = await this.getAppToken(); + if (token) { + return { Authorization: `Bearer ${token}` }; + } + return {}; + }, + + /** + * Check if user has tokens stored + */ + async hasTokens(): Promise { + const token = await this.getAppToken(); + return !!token; + }, +}; diff --git a/apps/nutriphi/apps/mobile/services/database/SQLiteService.ts b/apps/nutriphi/apps/mobile/services/database/SQLiteService.ts index 7d73bbe1c..05305cbb7 100644 --- a/apps/nutriphi/apps/mobile/services/database/SQLiteService.ts +++ b/apps/nutriphi/apps/mobile/services/database/SQLiteService.ts @@ -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 { + if (!this.db) throw new Error('Database not initialized'); + + return await this.db.getAllAsync( + `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 { + if (!this.db) throw new Error('Database not initialized'); + + const result = await this.db.getFirstAsync( + '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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (!this.db) throw new Error('Database not initialized'); + + return await this.db.getAllAsync( + `SELECT * FROM meals WHERE updated_at > ? ORDER BY updated_at ASC`, + [since] + ); + } } diff --git a/apps/nutriphi/apps/mobile/services/sync/SyncService.ts b/apps/nutriphi/apps/mobile/services/sync/SyncService.ts new file mode 100644 index 000000000..dd32e6dd1 --- /dev/null +++ b/apps/nutriphi/apps/mobile/services/sync/SyncService.ts @@ -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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/nutriphi/apps/mobile/store/AuthStore.ts b/apps/nutriphi/apps/mobile/store/AuthStore.ts new file mode 100644 index 000000000..552cddb40 --- /dev/null +++ b/apps/nutriphi/apps/mobile/store/AuthStore.ts @@ -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; + 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; + forgotPassword: (email: string) => Promise<{ success: boolean; error?: string }>; + refreshAuth: () => Promise; + clearError: () => void; +} + +export const useAuthStore = create((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 }), +})); diff --git a/apps/nutriphi/apps/mobile/utils/supabase.ts b/apps/nutriphi/apps/mobile/utils/supabase.ts deleted file mode 100644 index 54f1bf240..000000000 --- a/apps/nutriphi/apps/mobile/utils/supabase.ts +++ /dev/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, - }, -}); diff --git a/apps/picture/apps/backend/.env.example b/apps/picture/apps/backend/.env.example new file mode 100644 index 000000000..dc16356f9 --- /dev/null +++ b/apps/picture/apps/backend/.env.example @@ -0,0 +1,19 @@ +# Server +PORT=3003 +NODE_ENV=development + +# Database +DATABASE_URL=postgresql://picture:password@localhost:5432/picture + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 + +# Supabase Storage (Service Role for Backend) +SUPABASE_URL=https://xxx.supabase.co +SUPABASE_SERVICE_ROLE_KEY=eyJ... + +# Replicate API +REPLICATE_API_TOKEN=r8_xxx + +# Webhook (for Replicate Callbacks in Production) +WEBHOOK_BASE_URL=http://localhost:3003 diff --git a/apps/picture/apps/backend/drizzle.config.ts b/apps/picture/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..4480b7fcc --- /dev/null +++ b/apps/picture/apps/backend/drizzle.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'drizzle-kit'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export default defineConfig({ + schema: './src/db/schema/index.ts', + out: './src/db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || 'postgresql://picture:password@localhost:5432/picture', + }, +}); diff --git a/apps/picture/apps/backend/nest-cli.json b/apps/picture/apps/backend/nest-cli.json new file mode 100644 index 000000000..f9aa683b1 --- /dev/null +++ b/apps/picture/apps/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/picture/apps/backend/package.json b/apps/picture/apps/backend/package.json new file mode 100644 index 000000000..7b1187177 --- /dev/null +++ b/apps/picture/apps/backend/package.json @@ -0,0 +1,58 @@ +{ + "name": "@picture/backend", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "dev": "nest start --watch", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "migration:generate": "drizzle-kit generate", + "migration:run": "tsx src/db/migrate.ts", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "db:seed": "tsx src/db/seed.ts" + }, + "dependencies": { + "@manacore/shared-errors": "workspace:*", + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "@supabase/supabase-js": "^2.45.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^16.4.7", + "drizzle-kit": "^0.30.2", + "drizzle-orm": "^0.38.3", + "multer": "^1.4.5-lts.1", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "replicate": "^0.32.0", + "rxjs": "^7.8.1", + "sharp": "^0.33.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/express": "^5.0.0", + "@types/multer": "^1.4.11", + "@types/node": "^22.10.2", + "@typescript-eslint/eslint-plugin": "^8.18.1", + "@typescript-eslint/parser": "^8.18.1", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/picture/apps/backend/src/app.module.ts b/apps/picture/apps/backend/src/app.module.ts new file mode 100644 index 000000000..bc557a04e --- /dev/null +++ b/apps/picture/apps/backend/src/app.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from './db/database.module'; +import { HealthModule } from './health/health.module'; +import { ModelModule } from './model/model.module'; +import { TagModule } from './tag/tag.module'; +import { ImageModule } from './image/image.module'; +import { BoardModule } from './board/board.module'; +import { BoardItemModule } from './board-item/board-item.module'; +import { UploadModule } from './upload/upload.module'; +import { GenerateModule } from './generate/generate.module'; +import { ExploreModule } from './explore/explore.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + }), + DatabaseModule, + HealthModule, + ModelModule, + TagModule, + ImageModule, + BoardModule, + BoardItemModule, + UploadModule, + GenerateModule, + ExploreModule, + ], +}) +export class AppModule {} diff --git a/apps/picture/apps/backend/src/board-item/board-item.controller.ts b/apps/picture/apps/backend/src/board-item/board-item.controller.ts new file mode 100644 index 000000000..336991a51 --- /dev/null +++ b/apps/picture/apps/backend/src/board-item/board-item.controller.ts @@ -0,0 +1,114 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + UseGuards, +} from '@nestjs/common'; +import { BoardItemService } from './board-item.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { + CurrentUser, + CurrentUserData, +} from '../common/decorators/current-user.decorator'; +import { + AddImageToBoardDto, + AddTextToBoardDto, + UpdateBoardItemDto, + UpdateBoardItemsDto, + RemoveBoardItemsDto, + ChangeZIndexDto, +} from './dto/board-item.dto'; + +@Controller('board-items') +@UseGuards(JwtAuthGuard) +export class BoardItemController { + constructor(private readonly boardItemService: BoardItemService) {} + + @Get('board/:boardId') + async getBoardItems( + @CurrentUser() user: CurrentUserData, + @Param('boardId') boardId: string, + ) { + return this.boardItemService.getBoardItems(boardId, user.userId); + } + + @Get(':id') + async getBoardItemById( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.boardItemService.getBoardItemById(id, user.userId); + } + + @Post('image') + async addImageToBoard( + @CurrentUser() user: CurrentUserData, + @Body() dto: AddImageToBoardDto, + ) { + return this.boardItemService.addImageToBoard(user.userId, dto); + } + + @Post('text') + async addTextToBoard( + @CurrentUser() user: CurrentUserData, + @Body() dto: AddTextToBoardDto, + ) { + return this.boardItemService.addTextToBoard(user.userId, dto); + } + + @Patch(':id') + async updateBoardItem( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateBoardItemDto, + ) { + return this.boardItemService.updateBoardItem(id, user.userId, dto); + } + + @Patch('batch') + async updateBoardItems( + @CurrentUser() user: CurrentUserData, + @Body() dto: UpdateBoardItemsDto, + ) { + return this.boardItemService.updateBoardItems(user.userId, dto.items); + } + + @Delete(':id') + async removeBoardItem( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.boardItemService.removeBoardItem(id, user.userId); + } + + @Delete('batch') + async removeBoardItems( + @CurrentUser() user: CurrentUserData, + @Body() dto: RemoveBoardItemsDto, + ) { + return this.boardItemService.removeBoardItems(user.userId, dto.ids); + } + + @Patch(':id/z-index') + async changeZIndex( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: ChangeZIndexDto, + ) { + return this.boardItemService.changeZIndex(id, user.userId, dto.direction); + } + + @Get('check/:boardId/:imageId') + async isImageOnBoard( + @CurrentUser() user: CurrentUserData, + @Param('boardId') boardId: string, + @Param('imageId') imageId: string, + ) { + const result = await this.boardItemService.isImageOnBoard(boardId, imageId); + return { isOnBoard: result }; + } +} diff --git a/apps/picture/apps/backend/src/board-item/board-item.module.ts b/apps/picture/apps/backend/src/board-item/board-item.module.ts new file mode 100644 index 000000000..9b17c19cf --- /dev/null +++ b/apps/picture/apps/backend/src/board-item/board-item.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BoardItemController } from './board-item.controller'; +import { BoardItemService } from './board-item.service'; + +@Module({ + controllers: [BoardItemController], + providers: [BoardItemService], + exports: [BoardItemService], +}) +export class BoardItemModule {} diff --git a/apps/picture/apps/backend/src/board-item/board-item.service.ts b/apps/picture/apps/backend/src/board-item/board-item.service.ts new file mode 100644 index 000000000..3154aa37f --- /dev/null +++ b/apps/picture/apps/backend/src/board-item/board-item.service.ts @@ -0,0 +1,515 @@ +import { + Injectable, + Inject, + NotFoundException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { eq, and, max, inArray, gt, lt } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { boards, boardItems, images, type BoardItem } from '../db/schema'; +import { + AddImageToBoardDto, + AddTextToBoardDto, + UpdateBoardItemDto, +} from './dto/board-item.dto'; + +export interface BoardItemWithImage extends BoardItem { + image?: { + id: string; + publicUrl: string | null; + width: number | null; + height: number | null; + prompt: string; + blurhash: string | null; + }; +} + +@Injectable() +export class BoardItemService { + private readonly logger = new Logger(BoardItemService.name); + + constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {} + + async getBoardItems( + boardId: string, + userId: string, + ): Promise { + try { + await this.verifyBoardAccess(boardId, userId); + + const result = await this.db + .select({ + id: boardItems.id, + boardId: boardItems.boardId, + imageId: boardItems.imageId, + itemType: boardItems.itemType, + positionX: boardItems.positionX, + positionY: boardItems.positionY, + scaleX: boardItems.scaleX, + scaleY: boardItems.scaleY, + rotation: boardItems.rotation, + zIndex: boardItems.zIndex, + opacity: boardItems.opacity, + width: boardItems.width, + height: boardItems.height, + textContent: boardItems.textContent, + fontSize: boardItems.fontSize, + color: boardItems.color, + properties: boardItems.properties, + createdAt: boardItems.createdAt, + image: { + id: images.id, + publicUrl: images.publicUrl, + width: images.width, + height: images.height, + prompt: images.prompt, + blurhash: images.blurhash, + }, + }) + .from(boardItems) + .leftJoin(images, eq(boardItems.imageId, images.id)) + .where(eq(boardItems.boardId, boardId)) + .orderBy(boardItems.zIndex); + + return result as BoardItemWithImage[]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error fetching board items for board ${boardId}`, error); + throw error; + } + } + + async getBoardItemById( + id: string, + userId: string, + ): Promise { + try { + const result = await this.db + .select({ + id: boardItems.id, + boardId: boardItems.boardId, + imageId: boardItems.imageId, + itemType: boardItems.itemType, + positionX: boardItems.positionX, + positionY: boardItems.positionY, + scaleX: boardItems.scaleX, + scaleY: boardItems.scaleY, + rotation: boardItems.rotation, + zIndex: boardItems.zIndex, + opacity: boardItems.opacity, + width: boardItems.width, + height: boardItems.height, + textContent: boardItems.textContent, + fontSize: boardItems.fontSize, + color: boardItems.color, + properties: boardItems.properties, + createdAt: boardItems.createdAt, + image: { + id: images.id, + publicUrl: images.publicUrl, + width: images.width, + height: images.height, + prompt: images.prompt, + blurhash: images.blurhash, + }, + }) + .from(boardItems) + .leftJoin(images, eq(boardItems.imageId, images.id)) + .where(eq(boardItems.id, id)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Board item with id ${id} not found`); + } + + const item = result[0]; + await this.verifyBoardAccess(item.boardId, userId); + + return item as BoardItemWithImage; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error fetching board item ${id}`, error); + throw error; + } + } + + async addImageToBoard( + userId: string, + dto: AddImageToBoardDto, + ): Promise { + try { + await this.verifyBoardOwnership(dto.boardId, userId); + + const nextZIndex = await this.getNextZIndex(dto.boardId); + + const result = await this.db + .insert(boardItems) + .values({ + boardId: dto.boardId, + imageId: dto.imageId, + itemType: 'image', + positionX: dto.positionX || 100, + positionY: dto.positionY || 100, + zIndex: nextZIndex, + }) + .returning(); + + // Update board's updatedAt + await this.db + .update(boards) + .set({ updatedAt: new Date() }) + .where(eq(boards.id, dto.boardId)); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error('Error adding image to board', error); + throw error; + } + } + + async addTextToBoard( + userId: string, + dto: AddTextToBoardDto, + ): Promise { + try { + await this.verifyBoardOwnership(dto.boardId, userId); + + const nextZIndex = await this.getNextZIndex(dto.boardId); + + const result = await this.db + .insert(boardItems) + .values({ + boardId: dto.boardId, + itemType: 'text', + positionX: dto.positionX || 100, + positionY: dto.positionY || 100, + textContent: dto.content || 'New Text', + fontSize: dto.fontSize || 24, + color: dto.color || '#000000', + properties: dto.properties, + zIndex: nextZIndex, + }) + .returning(); + + // Update board's updatedAt + await this.db + .update(boards) + .set({ updatedAt: new Date() }) + .where(eq(boards.id, dto.boardId)); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error('Error adding text to board', error); + throw error; + } + } + + async updateBoardItem( + id: string, + userId: string, + dto: UpdateBoardItemDto, + ): Promise { + try { + const item = await this.db + .select() + .from(boardItems) + .where(eq(boardItems.id, id)) + .limit(1); + + if (item.length === 0) { + throw new NotFoundException(`Board item with id ${id} not found`); + } + + await this.verifyBoardOwnership(item[0].boardId, userId); + + const result = await this.db + .update(boardItems) + .set({ + ...(dto.positionX !== undefined && { positionX: dto.positionX }), + ...(dto.positionY !== undefined && { positionY: dto.positionY }), + ...(dto.scaleX !== undefined && { scaleX: dto.scaleX }), + ...(dto.scaleY !== undefined && { scaleY: dto.scaleY }), + ...(dto.rotation !== undefined && { rotation: dto.rotation }), + ...(dto.zIndex !== undefined && { zIndex: dto.zIndex }), + ...(dto.opacity !== undefined && { opacity: dto.opacity }), + ...(dto.width !== undefined && { width: dto.width }), + ...(dto.height !== undefined && { height: dto.height }), + ...(dto.textContent !== undefined && { textContent: dto.textContent }), + ...(dto.fontSize !== undefined && { fontSize: dto.fontSize }), + ...(dto.color !== undefined && { color: dto.color }), + ...(dto.properties !== undefined && { properties: dto.properties }), + }) + .where(eq(boardItems.id, id)) + .returning(); + + // Update board's updatedAt + await this.db + .update(boards) + .set({ updatedAt: new Date() }) + .where(eq(boards.id, item[0].boardId)); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error updating board item ${id}`, error); + throw error; + } + } + + async updateBoardItems( + userId: string, + items: Array<{ id: string } & UpdateBoardItemDto>, + ): Promise { + try { + for (const item of items) { + await this.updateBoardItem(item.id, userId, item); + } + } catch (error) { + this.logger.error('Error batch updating board items', error); + throw error; + } + } + + async removeBoardItem(id: string, userId: string): Promise { + try { + const item = await this.db + .select() + .from(boardItems) + .where(eq(boardItems.id, id)) + .limit(1); + + if (item.length === 0) { + throw new NotFoundException(`Board item with id ${id} not found`); + } + + await this.verifyBoardOwnership(item[0].boardId, userId); + + await this.db.delete(boardItems).where(eq(boardItems.id, id)); + + // Update board's updatedAt + await this.db + .update(boards) + .set({ updatedAt: new Date() }) + .where(eq(boards.id, item[0].boardId)); + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error removing board item ${id}`, error); + throw error; + } + } + + async removeBoardItems(userId: string, ids: string[]): Promise { + try { + for (const id of ids) { + await this.removeBoardItem(id, userId); + } + } catch (error) { + this.logger.error('Error batch removing board items', error); + throw error; + } + } + + async changeZIndex( + id: string, + userId: string, + direction: 'up' | 'down' | 'top' | 'bottom', + ): Promise { + try { + const item = await this.db + .select() + .from(boardItems) + .where(eq(boardItems.id, id)) + .limit(1); + + if (item.length === 0) { + throw new NotFoundException(`Board item with id ${id} not found`); + } + + await this.verifyBoardOwnership(item[0].boardId, userId); + + const currentZIndex = item[0].zIndex; + let newZIndex: number; + + if (direction === 'top') { + const maxResult = await this.db + .select({ maxZ: max(boardItems.zIndex) }) + .from(boardItems) + .where(eq(boardItems.boardId, item[0].boardId)); + newZIndex = (maxResult[0]?.maxZ || 0) + 1; + } else if (direction === 'bottom') { + newZIndex = 0; + // Shift all other items up + await this.db + .update(boardItems) + .set({ zIndex: boardItems.zIndex + 1 } as any) + .where(eq(boardItems.boardId, item[0].boardId)); + } else if (direction === 'up') { + // Find the next item above + const above = await this.db + .select() + .from(boardItems) + .where( + and( + eq(boardItems.boardId, item[0].boardId), + gt(boardItems.zIndex, currentZIndex), + ), + ) + .orderBy(boardItems.zIndex) + .limit(1); + + if (above.length > 0) { + // Swap z-indices + await this.db + .update(boardItems) + .set({ zIndex: currentZIndex }) + .where(eq(boardItems.id, above[0].id)); + newZIndex = above[0].zIndex; + } else { + newZIndex = currentZIndex; + } + } else { + // down + const below = await this.db + .select() + .from(boardItems) + .where( + and( + eq(boardItems.boardId, item[0].boardId), + lt(boardItems.zIndex, currentZIndex), + ), + ) + .orderBy(boardItems.zIndex) + .limit(1); + + if (below.length > 0) { + // Swap z-indices + await this.db + .update(boardItems) + .set({ zIndex: currentZIndex }) + .where(eq(boardItems.id, below[0].id)); + newZIndex = below[0].zIndex; + } else { + newZIndex = currentZIndex; + } + } + + const result = await this.db + .update(boardItems) + .set({ zIndex: newZIndex }) + .where(eq(boardItems.id, id)) + .returning(); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error changing z-index for board item ${id}`, error); + throw error; + } + } + + async isImageOnBoard(boardId: string, imageId: string): Promise { + try { + const result = await this.db + .select() + .from(boardItems) + .where( + and(eq(boardItems.boardId, boardId), eq(boardItems.imageId, imageId)), + ) + .limit(1); + + return result.length > 0; + } catch (error) { + this.logger.error( + `Error checking if image ${imageId} is on board ${boardId}`, + error, + ); + throw error; + } + } + + private async getNextZIndex(boardId: string): Promise { + const result = await this.db + .select({ maxZ: max(boardItems.zIndex) }) + .from(boardItems) + .where(eq(boardItems.boardId, boardId)); + + return (result[0]?.maxZ || 0) + 1; + } + + private async verifyBoardAccess( + boardId: string, + userId: string, + ): Promise { + const result = await this.db + .select({ userId: boards.userId, isPublic: boards.isPublic }) + .from(boards) + .where(eq(boards.id, boardId)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Board with id ${boardId} not found`); + } + + if (result[0].userId !== userId && !result[0].isPublic) { + throw new ForbiddenException('Access denied'); + } + } + + private async verifyBoardOwnership( + boardId: string, + userId: string, + ): Promise { + const result = await this.db + .select({ userId: boards.userId }) + .from(boards) + .where(eq(boards.id, boardId)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Board with id ${boardId} not found`); + } + + if (result[0].userId !== userId) { + throw new ForbiddenException('Access denied'); + } + } +} diff --git a/apps/picture/apps/backend/src/board-item/dto/board-item.dto.ts b/apps/picture/apps/backend/src/board-item/dto/board-item.dto.ts new file mode 100644 index 000000000..53c92c2d1 --- /dev/null +++ b/apps/picture/apps/backend/src/board-item/dto/board-item.dto.ts @@ -0,0 +1,134 @@ +import { + IsString, + IsOptional, + IsNumber, + IsArray, + ValidateNested, + IsObject, + IsIn, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { TextProperties } from '../../db/schema/board-items.schema'; + +export class AddImageToBoardDto { + @IsString() + boardId: string; + + @IsString() + imageId: string; + + @IsNumber() + @IsOptional() + positionX?: number; + + @IsNumber() + @IsOptional() + positionY?: number; +} + +export class AddTextToBoardDto { + @IsString() + boardId: string; + + @IsString() + @IsOptional() + content?: string; + + @IsNumber() + @IsOptional() + positionX?: number; + + @IsNumber() + @IsOptional() + positionY?: number; + + @IsNumber() + @IsOptional() + fontSize?: number; + + @IsString() + @IsOptional() + color?: string; + + @IsObject() + @IsOptional() + properties?: TextProperties; +} + +export class UpdateBoardItemDto { + @IsNumber() + @IsOptional() + positionX?: number; + + @IsNumber() + @IsOptional() + positionY?: number; + + @IsNumber() + @IsOptional() + scaleX?: number; + + @IsNumber() + @IsOptional() + scaleY?: number; + + @IsNumber() + @IsOptional() + rotation?: number; + + @IsNumber() + @IsOptional() + zIndex?: number; + + @IsNumber() + @IsOptional() + opacity?: number; + + @IsNumber() + @IsOptional() + width?: number; + + @IsNumber() + @IsOptional() + height?: number; + + @IsString() + @IsOptional() + textContent?: string; + + @IsNumber() + @IsOptional() + fontSize?: number; + + @IsString() + @IsOptional() + color?: string; + + @IsObject() + @IsOptional() + properties?: TextProperties; +} + +export class UpdateBoardItemWithIdDto extends UpdateBoardItemDto { + @IsString() + id: string; +} + +export class UpdateBoardItemsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UpdateBoardItemWithIdDto) + items: UpdateBoardItemWithIdDto[]; +} + +export class RemoveBoardItemsDto { + @IsArray() + @IsString({ each: true }) + ids: string[]; +} + +export class ChangeZIndexDto { + @IsString() + @IsIn(['up', 'down', 'top', 'bottom']) + direction: 'up' | 'down' | 'top' | 'bottom'; +} diff --git a/apps/picture/apps/backend/src/board/board.controller.ts b/apps/picture/apps/backend/src/board/board.controller.ts new file mode 100644 index 000000000..4043cfd10 --- /dev/null +++ b/apps/picture/apps/backend/src/board/board.controller.ts @@ -0,0 +1,102 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Query, + Body, + UseGuards, +} from '@nestjs/common'; +import { BoardService } from './board.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { + CurrentUser, + CurrentUserData, +} from '../common/decorators/current-user.decorator'; +import { + CreateBoardDto, + UpdateBoardDto, + GetBoardsQueryDto, + GenerateThumbnailDto, + ToggleVisibilityDto, +} from './dto/board.dto'; + +@Controller('boards') +@UseGuards(JwtAuthGuard) +export class BoardController { + constructor(private readonly boardService: BoardService) {} + + @Get() + async getBoards( + @CurrentUser() user: CurrentUserData, + @Query() query: GetBoardsQueryDto, + ) { + return this.boardService.getBoards(user.userId, query); + } + + @Get('public') + async getPublicBoards(@Query() query: GetBoardsQueryDto) { + return this.boardService.getPublicBoards(query); + } + + @Get(':id') + async getBoardById( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.boardService.getBoardById(id, user.userId); + } + + @Post() + async createBoard( + @CurrentUser() user: CurrentUserData, + @Body() dto: CreateBoardDto, + ) { + return this.boardService.createBoard(user.userId, dto); + } + + @Patch(':id') + async updateBoard( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateBoardDto, + ) { + return this.boardService.updateBoard(id, user.userId, dto); + } + + @Delete(':id') + async deleteBoard( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.boardService.deleteBoard(id, user.userId); + } + + @Post(':id/duplicate') + async duplicateBoard( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.boardService.duplicateBoard(id, user.userId); + } + + @Post(':id/thumbnail') + async generateThumbnail( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: GenerateThumbnailDto, + ) { + return this.boardService.generateThumbnail(id, user.userId, dto.dataUrl); + } + + @Patch(':id/visibility') + async toggleVisibility( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: ToggleVisibilityDto, + ) { + return this.boardService.toggleVisibility(id, user.userId, dto.isPublic); + } +} diff --git a/apps/picture/apps/backend/src/board/board.module.ts b/apps/picture/apps/backend/src/board/board.module.ts new file mode 100644 index 000000000..7a3eb7d97 --- /dev/null +++ b/apps/picture/apps/backend/src/board/board.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BoardController } from './board.controller'; +import { BoardService } from './board.service'; + +@Module({ + controllers: [BoardController], + providers: [BoardService], + exports: [BoardService], +}) +export class BoardModule {} diff --git a/apps/picture/apps/backend/src/board/board.service.ts b/apps/picture/apps/backend/src/board/board.service.ts new file mode 100644 index 000000000..994d636e9 --- /dev/null +++ b/apps/picture/apps/backend/src/board/board.service.ts @@ -0,0 +1,403 @@ +import { + Injectable, + Inject, + NotFoundException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { eq, and, or, desc, sql } from 'drizzle-orm'; +import { ConfigService } from '@nestjs/config'; +import { createClient } from '@supabase/supabase-js'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { boards, boardItems, type Board } from '../db/schema'; +import { CreateBoardDto, UpdateBoardDto, GetBoardsQueryDto } from './dto/board.dto'; + +export interface BoardWithCount extends Board { + itemCount: number; +} + +@Injectable() +export class BoardService { + private readonly logger = new Logger(BoardService.name); + private supabase: ReturnType; + + constructor( + @Inject(DATABASE_CONNECTION) private readonly db: Database, + private configService: ConfigService, + ) { + const supabaseUrl = this.configService.get('SUPABASE_URL'); + const supabaseKey = this.configService.get('SUPABASE_SERVICE_ROLE_KEY'); + if (supabaseUrl && supabaseKey) { + this.supabase = createClient(supabaseUrl, supabaseKey); + } + } + + async getBoards( + userId: string, + query: GetBoardsQueryDto, + ): Promise { + try { + const { page = 1, limit = 20, includePublic = false } = query; + const offset = (page - 1) * limit; + + const conditions = includePublic + ? or(eq(boards.userId, userId), eq(boards.isPublic, true)) + : eq(boards.userId, userId); + + const result = await this.db + .select({ + id: boards.id, + userId: boards.userId, + name: boards.name, + description: boards.description, + thumbnailUrl: boards.thumbnailUrl, + canvasWidth: boards.canvasWidth, + canvasHeight: boards.canvasHeight, + backgroundColor: boards.backgroundColor, + isPublic: boards.isPublic, + createdAt: boards.createdAt, + updatedAt: boards.updatedAt, + itemCount: sql`( + SELECT COUNT(*)::int FROM ${boardItems} + WHERE ${boardItems.boardId} = ${boards.id} + )`, + }) + .from(boards) + .where(conditions) + .orderBy(desc(boards.updatedAt)) + .limit(limit) + .offset(offset); + + return result as BoardWithCount[]; + } catch (error) { + this.logger.error('Error fetching boards', error); + throw error; + } + } + + async getPublicBoards(query: GetBoardsQueryDto): Promise { + try { + const { page = 1, limit = 20 } = query; + const offset = (page - 1) * limit; + + const result = await this.db + .select({ + id: boards.id, + userId: boards.userId, + name: boards.name, + description: boards.description, + thumbnailUrl: boards.thumbnailUrl, + canvasWidth: boards.canvasWidth, + canvasHeight: boards.canvasHeight, + backgroundColor: boards.backgroundColor, + isPublic: boards.isPublic, + createdAt: boards.createdAt, + updatedAt: boards.updatedAt, + itemCount: sql`( + SELECT COUNT(*)::int FROM ${boardItems} + WHERE ${boardItems.boardId} = ${boards.id} + )`, + }) + .from(boards) + .where(eq(boards.isPublic, true)) + .orderBy(desc(boards.updatedAt)) + .limit(limit) + .offset(offset); + + return result as BoardWithCount[]; + } catch (error) { + this.logger.error('Error fetching public boards', error); + throw error; + } + } + + async getBoardById(id: string, userId: string): Promise { + try { + const result = await this.db + .select() + .from(boards) + .where(eq(boards.id, id)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Board with id ${id} not found`); + } + + const board = result[0]; + + // Check access + if (board.userId !== userId && !board.isPublic) { + throw new ForbiddenException('Access denied'); + } + + return board; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error fetching board ${id}`, error); + throw error; + } + } + + async createBoard(userId: string, dto: CreateBoardDto): Promise { + try { + const result = await this.db + .insert(boards) + .values({ + userId, + name: dto.name, + description: dto.description, + canvasWidth: dto.canvasWidth || 2000, + canvasHeight: dto.canvasHeight || 1500, + backgroundColor: dto.backgroundColor || '#ffffff', + isPublic: dto.isPublic || false, + }) + .returning(); + + return result[0]; + } catch (error) { + this.logger.error('Error creating board', error); + throw error; + } + } + + async updateBoard( + id: string, + userId: string, + dto: UpdateBoardDto, + ): Promise { + try { + await this.verifyOwnership(id, userId); + + const result = await this.db + .update(boards) + .set({ + ...(dto.name && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.canvasWidth && { canvasWidth: dto.canvasWidth }), + ...(dto.canvasHeight && { canvasHeight: dto.canvasHeight }), + ...(dto.backgroundColor && { backgroundColor: dto.backgroundColor }), + ...(dto.isPublic !== undefined && { isPublic: dto.isPublic }), + updatedAt: new Date(), + }) + .where(eq(boards.id, id)) + .returning(); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error updating board ${id}`, error); + throw error; + } + } + + async deleteBoard(id: string, userId: string): Promise { + try { + await this.verifyOwnership(id, userId); + + // Delete board items first + await this.db.delete(boardItems).where(eq(boardItems.boardId, id)); + + // Delete the board + await this.db.delete(boards).where(eq(boards.id, id)); + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error deleting board ${id}`, error); + throw error; + } + } + + async duplicateBoard(id: string, userId: string): Promise { + try { + // Get original board (user can duplicate public boards too) + const original = await this.db + .select() + .from(boards) + .where(eq(boards.id, id)) + .limit(1); + + if (original.length === 0) { + throw new NotFoundException(`Board with id ${id} not found`); + } + + const board = original[0]; + + // Check access + if (board.userId !== userId && !board.isPublic) { + throw new ForbiddenException('Access denied'); + } + + // Create new board + const newBoard = await this.db + .insert(boards) + .values({ + userId, + name: `${board.name} (Copy)`, + description: board.description, + canvasWidth: board.canvasWidth, + canvasHeight: board.canvasHeight, + backgroundColor: board.backgroundColor, + isPublic: false, + }) + .returning(); + + // Copy board items + const items = await this.db + .select() + .from(boardItems) + .where(eq(boardItems.boardId, id)); + + if (items.length > 0) { + await this.db.insert(boardItems).values( + items.map((item) => ({ + boardId: newBoard[0].id, + imageId: item.imageId, + itemType: item.itemType, + positionX: item.positionX, + positionY: item.positionY, + scaleX: item.scaleX, + scaleY: item.scaleY, + rotation: item.rotation, + zIndex: item.zIndex, + opacity: item.opacity, + width: item.width, + height: item.height, + textContent: item.textContent, + fontSize: item.fontSize, + color: item.color, + properties: item.properties, + })), + ); + } + + return newBoard[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error duplicating board ${id}`, error); + throw error; + } + } + + async generateThumbnail( + id: string, + userId: string, + dataUrl: string, + ): Promise { + try { + await this.verifyOwnership(id, userId); + + if (!this.supabase) { + throw new Error('Supabase not configured'); + } + + // Convert data URL to buffer + const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, ''); + const buffer = Buffer.from(base64Data, 'base64'); + + // Upload to Supabase Storage + const filename = `boards/${id}/thumbnail-${Date.now()}.png`; + const { error: uploadError } = await this.supabase.storage + .from('user-uploads') + .upload(filename, buffer, { + contentType: 'image/png', + upsert: true, + }); + + if (uploadError) { + throw uploadError; + } + + // Get public URL + const { data: urlData } = this.supabase.storage + .from('user-uploads') + .getPublicUrl(filename); + + // Update board with thumbnail URL + const result = await this.db + .update(boards) + .set({ + thumbnailUrl: urlData.publicUrl, + updatedAt: new Date(), + }) + .where(eq(boards.id, id)) + .returning(); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error generating thumbnail for board ${id}`, error); + throw error; + } + } + + async toggleVisibility( + id: string, + userId: string, + isPublic: boolean, + ): Promise { + try { + await this.verifyOwnership(id, userId); + + const result = await this.db + .update(boards) + .set({ + isPublic, + updatedAt: new Date(), + }) + .where(eq(boards.id, id)) + .returning(); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error toggling visibility for board ${id}`, error); + throw error; + } + } + + private async verifyOwnership(id: string, userId: string): Promise { + const result = await this.db + .select({ userId: boards.userId }) + .from(boards) + .where(eq(boards.id, id)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Board with id ${id} not found`); + } + + if (result[0].userId !== userId) { + throw new ForbiddenException('Access denied'); + } + } +} diff --git a/apps/picture/apps/backend/src/board/dto/board.dto.ts b/apps/picture/apps/backend/src/board/dto/board.dto.ts new file mode 100644 index 000000000..f39de12b8 --- /dev/null +++ b/apps/picture/apps/backend/src/board/dto/board.dto.ts @@ -0,0 +1,80 @@ +import { IsString, IsOptional, IsBoolean, IsNumber } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +export class GetBoardsQueryDto { + @IsNumber() + @IsOptional() + @Type(() => Number) + page?: number = 1; + + @IsNumber() + @IsOptional() + @Type(() => Number) + limit?: number = 20; + + @IsBoolean() + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + includePublic?: boolean = false; +} + +export class CreateBoardDto { + @IsString() + name: string; + + @IsString() + @IsOptional() + description?: string; + + @IsNumber() + @IsOptional() + canvasWidth?: number; + + @IsNumber() + @IsOptional() + canvasHeight?: number; + + @IsString() + @IsOptional() + backgroundColor?: string; + + @IsBoolean() + @IsOptional() + isPublic?: boolean; +} + +export class UpdateBoardDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsNumber() + @IsOptional() + canvasWidth?: number; + + @IsNumber() + @IsOptional() + canvasHeight?: number; + + @IsString() + @IsOptional() + backgroundColor?: string; + + @IsBoolean() + @IsOptional() + isPublic?: boolean; +} + +export class GenerateThumbnailDto { + @IsString() + dataUrl: string; +} + +export class ToggleVisibilityDto { + @IsBoolean() + isPublic: boolean; +} diff --git a/apps/picture/apps/backend/src/common/decorators/current-user.decorator.ts b/apps/picture/apps/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 000000000..29f1fff1b --- /dev/null +++ b/apps/picture/apps/backend/src/common/decorators/current-user.decorator.ts @@ -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; + }, +); diff --git a/apps/picture/apps/backend/src/common/guards/jwt-auth.guard.ts b/apps/picture/apps/backend/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 000000000..37bf176fe --- /dev/null +++ b/apps/picture/apps/backend/src/common/guards/jwt-auth.guard.ts @@ -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 { + 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('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; + } +} diff --git a/apps/picture/apps/backend/src/db/connection.ts b/apps/picture/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..ad2e67bfe --- /dev/null +++ b/apps/picture/apps/backend/src/db/connection.ts @@ -0,0 +1,38 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import * as schema from './schema'; + +// Use require for postgres to avoid ESM/CommonJS interop issues +// eslint-disable-next-line @typescript-eslint/no-var-requires +const postgres = require('postgres'); + +let connection: ReturnType | null = null; +let db: ReturnType | null = null; + +export function getConnection(databaseUrl: string) { + if (!connection) { + connection = postgres(databaseUrl, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); + } + return connection; +} + +export function getDb(databaseUrl: string) { + if (!db) { + const conn = getConnection(databaseUrl); + db = drizzle(conn, { schema }); + } + return db; +} + +export async function closeConnection() { + if (connection) { + await connection.end(); + connection = null; + db = null; + } +} + +export type Database = ReturnType; diff --git a/apps/picture/apps/backend/src/db/database.module.ts b/apps/picture/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..ab834ba6c --- /dev/null +++ b/apps/picture/apps/backend/src/db/database.module.ts @@ -0,0 +1,28 @@ +import { Module, Global, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getDb, closeConnection, type Database } from './connection'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + useFactory: (configService: ConfigService): Database => { + const databaseUrl = configService.get('DATABASE_URL'); + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + return getDb(databaseUrl); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule implements OnModuleDestroy { + async onModuleDestroy() { + await closeConnection(); + } +} diff --git a/apps/picture/apps/backend/src/db/migrate.ts b/apps/picture/apps/backend/src/db/migrate.ts new file mode 100644 index 000000000..99aa8320a --- /dev/null +++ b/apps/picture/apps/backend/src/db/migrate.ts @@ -0,0 +1,26 @@ +import * as dotenv from 'dotenv'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import { getDb, closeConnection } from './connection'; + +dotenv.config(); + +async function runMigrations() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('DATABASE_URL is not set'); + } + + const db = getDb(databaseUrl); + + console.log('Running migrations...'); + + await migrate(db, { migrationsFolder: './src/db/migrations' }); + + console.log('Migrations complete!'); + await closeConnection(); +} + +runMigrations().catch((error) => { + console.error('Migration failed:', error); + process.exit(1); +}); diff --git a/apps/picture/apps/backend/src/db/schema/board-items.schema.ts b/apps/picture/apps/backend/src/db/schema/board-items.schema.ts new file mode 100644 index 000000000..9d890d799 --- /dev/null +++ b/apps/picture/apps/backend/src/db/schema/board-items.schema.ts @@ -0,0 +1,50 @@ +import { + pgTable, + uuid, + text, + timestamp, + integer, + real, + jsonb, + pgEnum, +} from 'drizzle-orm/pg-core'; + +export const itemTypeEnum = pgEnum('item_type', ['image', 'text']); + +export interface TextProperties { + fontFamily?: string; + fontWeight?: 'normal' | 'bold'; + fontStyle?: 'normal' | 'italic'; + textAlign?: 'left' | 'center' | 'right'; + lineHeight?: number; +} + +export const boardItems = pgTable('board_items', { + id: uuid('id').primaryKey().defaultRandom(), + boardId: uuid('board_id').notNull(), + imageId: uuid('image_id'), + + itemType: itemTypeEnum('item_type').default('image').notNull(), + + positionX: real('position_x').default(0).notNull(), + positionY: real('position_y').default(0).notNull(), + scaleX: real('scale_x').default(1).notNull(), + scaleY: real('scale_y').default(1).notNull(), + rotation: real('rotation').default(0).notNull(), + zIndex: integer('z_index').default(0).notNull(), + opacity: real('opacity').default(1).notNull(), + width: integer('width'), + height: integer('height'), + + textContent: text('text_content'), + fontSize: integer('font_size'), + color: text('color'), + properties: jsonb('properties').$type(), + + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +export type BoardItem = typeof boardItems.$inferSelect; +export type NewBoardItem = typeof boardItems.$inferInsert; diff --git a/apps/picture/apps/backend/src/db/schema/boards.schema.ts b/apps/picture/apps/backend/src/db/schema/boards.schema.ts new file mode 100644 index 000000000..8c5337b38 --- /dev/null +++ b/apps/picture/apps/backend/src/db/schema/boards.schema.ts @@ -0,0 +1,33 @@ +import { + pgTable, + uuid, + text, + timestamp, + boolean, + integer, +} from 'drizzle-orm/pg-core'; + +export const boards = pgTable('boards', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + + name: text('name').notNull(), + description: text('description'), + thumbnailUrl: text('thumbnail_url'), + + canvasWidth: integer('canvas_width').default(2000).notNull(), + canvasHeight: integer('canvas_height').default(1500).notNull(), + backgroundColor: text('background_color').default('#ffffff').notNull(), + + isPublic: boolean('is_public').default(false).notNull(), + + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +export type Board = typeof boards.$inferSelect; +export type NewBoard = typeof boards.$inferInsert; diff --git a/apps/picture/apps/backend/src/db/schema/image-generations.schema.ts b/apps/picture/apps/backend/src/db/schema/image-generations.schema.ts new file mode 100644 index 000000000..e0ba42f6c --- /dev/null +++ b/apps/picture/apps/backend/src/db/schema/image-generations.schema.ts @@ -0,0 +1,53 @@ +import { + pgTable, + uuid, + text, + timestamp, + integer, + real, + pgEnum, +} from 'drizzle-orm/pg-core'; + +export const generationStatusEnum = pgEnum('generation_status', [ + 'pending', + 'queued', + 'processing', + 'completed', + 'failed', + 'cancelled', +]); + +export const imageGenerations = pgTable('image_generations', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + modelId: uuid('model_id'), + batchId: uuid('batch_id'), + + prompt: text('prompt').notNull(), + negativePrompt: text('negative_prompt'), + model: text('model'), + style: text('style'), + sourceImageUrl: text('source_image_url'), + + width: integer('width'), + height: integer('height'), + steps: integer('steps'), + guidanceScale: real('guidance_scale'), + seed: integer('seed'), + generationStrength: real('generation_strength'), + + status: generationStatusEnum('status').default('pending').notNull(), + replicatePredictionId: text('replicate_prediction_id'), + errorMessage: text('error_message'), + generationTimeSeconds: integer('generation_time_seconds'), + retryCount: integer('retry_count').default(0).notNull(), + priority: integer('priority').default(0).notNull(), + + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + completedAt: timestamp('completed_at', { withTimezone: true }), +}); + +export type ImageGeneration = typeof imageGenerations.$inferSelect; +export type NewImageGeneration = typeof imageGenerations.$inferInsert; diff --git a/apps/picture/apps/backend/src/db/schema/images.schema.ts b/apps/picture/apps/backend/src/db/schema/images.schema.ts new file mode 100644 index 000000000..756202720 --- /dev/null +++ b/apps/picture/apps/backend/src/db/schema/images.schema.ts @@ -0,0 +1,46 @@ +import { + pgTable, + uuid, + text, + timestamp, + boolean, + integer, +} from 'drizzle-orm/pg-core'; + +export const images = pgTable('images', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + generationId: uuid('generation_id'), + sourceImageId: uuid('source_image_id'), + + prompt: text('prompt').notNull(), + negativePrompt: text('negative_prompt'), + model: text('model'), + style: text('style'), + + publicUrl: text('public_url'), + storagePath: text('storage_path').notNull(), + filename: text('filename').notNull(), + format: text('format'), + + width: integer('width'), + height: integer('height'), + fileSize: integer('file_size'), + blurhash: text('blurhash'), + + isPublic: boolean('is_public').default(false).notNull(), + isFavorite: boolean('is_favorite').default(false).notNull(), + downloadCount: integer('download_count').default(0).notNull(), + rating: integer('rating'), + + archivedAt: timestamp('archived_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +export type Image = typeof images.$inferSelect; +export type NewImage = typeof images.$inferInsert; diff --git a/apps/picture/apps/backend/src/db/schema/index.ts b/apps/picture/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..4de4c222e --- /dev/null +++ b/apps/picture/apps/backend/src/db/schema/index.ts @@ -0,0 +1,6 @@ +export * from './images.schema'; +export * from './image-generations.schema'; +export * from './boards.schema'; +export * from './board-items.schema'; +export * from './tags.schema'; +export * from './models.schema'; diff --git a/apps/picture/apps/backend/src/db/schema/models.schema.ts b/apps/picture/apps/backend/src/db/schema/models.schema.ts new file mode 100644 index 000000000..923272cb9 --- /dev/null +++ b/apps/picture/apps/backend/src/db/schema/models.schema.ts @@ -0,0 +1,51 @@ +import { + pgTable, + uuid, + text, + timestamp, + boolean, + integer, + real, +} from 'drizzle-orm/pg-core'; + +export const models = pgTable('models', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + displayName: text('display_name').notNull(), + description: text('description'), + replicateId: text('replicate_id').notNull(), + version: text('version'), + + defaultWidth: integer('default_width').default(1024), + defaultHeight: integer('default_height').default(1024), + defaultSteps: integer('default_steps').default(25), + defaultGuidanceScale: real('default_guidance_scale').default(7.5), + + minWidth: integer('min_width').default(512), + minHeight: integer('min_height').default(512), + maxWidth: integer('max_width').default(2048), + maxHeight: integer('max_height').default(2048), + maxSteps: integer('max_steps').default(50), + + supportsNegativePrompt: boolean('supports_negative_prompt') + .default(true) + .notNull(), + supportsImg2Img: boolean('supports_img2img').default(false).notNull(), + supportsSeed: boolean('supports_seed').default(true).notNull(), + + isActive: boolean('is_active').default(true).notNull(), + isDefault: boolean('is_default').default(false).notNull(), + sortOrder: integer('sort_order').default(0).notNull(), + costPerGeneration: real('cost_per_generation'), + estimatedTimeSeconds: integer('estimated_time_seconds'), + + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +export type Model = typeof models.$inferSelect; +export type NewModel = typeof models.$inferInsert; diff --git a/apps/picture/apps/backend/src/db/schema/tags.schema.ts b/apps/picture/apps/backend/src/db/schema/tags.schema.ts new file mode 100644 index 000000000..e41d7481c --- /dev/null +++ b/apps/picture/apps/backend/src/db/schema/tags.schema.ts @@ -0,0 +1,23 @@ +import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'; + +export const tags = pgTable('tags', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull().unique(), + color: text('color'), + createdAt: timestamp('created_at', { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +export type Tag = typeof tags.$inferSelect; +export type NewTag = typeof tags.$inferInsert; + +export const imageTags = pgTable('image_tags', { + id: uuid('id').primaryKey().defaultRandom(), + imageId: uuid('image_id').notNull(), + tagId: uuid('tag_id').notNull(), + addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export type ImageTag = typeof imageTags.$inferSelect; +export type NewImageTag = typeof imageTags.$inferInsert; diff --git a/apps/picture/apps/backend/src/db/seed.ts b/apps/picture/apps/backend/src/db/seed.ts new file mode 100644 index 000000000..2017fe9af --- /dev/null +++ b/apps/picture/apps/backend/src/db/seed.ts @@ -0,0 +1,90 @@ +import * as dotenv from 'dotenv'; +import { getDb, closeConnection } from './connection'; +import { models } from './schema'; + +dotenv.config(); + +const defaultModels = [ + { + name: 'sdxl', + displayName: 'Stable Diffusion XL', + description: 'High-quality image generation with excellent prompt adherence', + replicateId: 'stability-ai/sdxl', + version: '39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b', + defaultWidth: 1024, + defaultHeight: 1024, + defaultSteps: 25, + defaultGuidanceScale: 7.5, + supportsNegativePrompt: true, + supportsImg2Img: true, + supportsSeed: true, + isActive: true, + isDefault: true, + sortOrder: 0, + estimatedTimeSeconds: 15, + }, + { + name: 'flux-schnell', + displayName: 'FLUX Schnell', + description: 'Fast image generation with good quality', + replicateId: 'black-forest-labs/flux-schnell', + version: 'f2ab8a5bfe79f02f0789a146cf5e73d2a4ff2684a98c2b303d1e1ff3814271db', + defaultWidth: 1024, + defaultHeight: 1024, + defaultSteps: 4, + defaultGuidanceScale: 0, + supportsNegativePrompt: false, + supportsImg2Img: false, + supportsSeed: true, + isActive: true, + isDefault: false, + sortOrder: 1, + estimatedTimeSeconds: 5, + }, + { + name: 'flux-pro', + displayName: 'FLUX Pro', + description: 'Professional quality image generation', + replicateId: 'black-forest-labs/flux-pro', + version: '7d6fbcd3da3f4e1c1c08d8ab0e7a4c2e0e5e3c9e8f8e8e8e8e8e8e8e8e8e8e8e', + defaultWidth: 1024, + defaultHeight: 1024, + defaultSteps: 25, + defaultGuidanceScale: 3.5, + supportsNegativePrompt: false, + supportsImg2Img: false, + supportsSeed: true, + isActive: true, + isDefault: false, + sortOrder: 2, + estimatedTimeSeconds: 20, + }, +]; + +async function seed() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('DATABASE_URL is not set'); + } + + const db = getDb(databaseUrl); + + console.log('Seeding models...'); + + for (const model of defaultModels) { + try { + await db.insert(models).values(model).onConflictDoNothing(); + console.log(` - ${model.displayName}`); + } catch (error) { + console.error(` - Error seeding ${model.displayName}:`, error); + } + } + + console.log('Seeding complete!'); + await closeConnection(); +} + +seed().catch((error) => { + console.error('Seed failed:', error); + process.exit(1); +}); diff --git a/apps/picture/apps/backend/src/explore/dto/explore.dto.ts b/apps/picture/apps/backend/src/explore/dto/explore.dto.ts new file mode 100644 index 000000000..cdfc8433e --- /dev/null +++ b/apps/picture/apps/backend/src/explore/dto/explore.dto.ts @@ -0,0 +1,34 @@ +import { IsString, IsOptional, IsNumber, IsIn } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class GetPublicImagesDto { + @IsNumber() + @IsOptional() + @Type(() => Number) + page?: number = 1; + + @IsNumber() + @IsOptional() + @Type(() => Number) + limit?: number = 20; + + @IsString() + @IsOptional() + @IsIn(['recent', 'popular', 'trending']) + sortBy?: 'recent' | 'popular' | 'trending' = 'recent'; +} + +export class SearchPublicImagesDto { + @IsString() + searchTerm: string; + + @IsNumber() + @IsOptional() + @Type(() => Number) + page?: number = 1; + + @IsNumber() + @IsOptional() + @Type(() => Number) + limit?: number = 20; +} diff --git a/apps/picture/apps/backend/src/explore/explore.controller.ts b/apps/picture/apps/backend/src/explore/explore.controller.ts new file mode 100644 index 000000000..d99e42bc2 --- /dev/null +++ b/apps/picture/apps/backend/src/explore/explore.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ExploreService } from './explore.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { GetPublicImagesDto, SearchPublicImagesDto } from './dto/explore.dto'; + +@Controller('explore') +@UseGuards(JwtAuthGuard) +export class ExploreController { + constructor(private readonly exploreService: ExploreService) {} + + @Get() + async getPublicImages(@Query() query: GetPublicImagesDto) { + return this.exploreService.getPublicImages(query); + } + + @Get('search') + async searchPublicImages(@Query() query: SearchPublicImagesDto) { + return this.exploreService.searchPublicImages(query); + } +} diff --git a/apps/picture/apps/backend/src/explore/explore.module.ts b/apps/picture/apps/backend/src/explore/explore.module.ts new file mode 100644 index 000000000..deec8d14b --- /dev/null +++ b/apps/picture/apps/backend/src/explore/explore.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ExploreController } from './explore.controller'; +import { ExploreService } from './explore.service'; + +@Module({ + controllers: [ExploreController], + providers: [ExploreService], +}) +export class ExploreModule {} diff --git a/apps/picture/apps/backend/src/explore/explore.service.ts b/apps/picture/apps/backend/src/explore/explore.service.ts new file mode 100644 index 000000000..3b5f1f837 --- /dev/null +++ b/apps/picture/apps/backend/src/explore/explore.service.ts @@ -0,0 +1,83 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { eq, and, isNull, desc, ilike } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { images, type Image } from '../db/schema'; +import { GetPublicImagesDto, SearchPublicImagesDto } from './dto/explore.dto'; + +@Injectable() +export class ExploreService { + private readonly logger = new Logger(ExploreService.name); + + constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {} + + async getPublicImages(query: GetPublicImagesDto): Promise { + try { + const { page = 1, limit = 20, sortBy = 'recent' } = query; + const offset = (page - 1) * limit; + + const conditions = [ + eq(images.isPublic, true), + isNull(images.archivedAt), + ]; + + let orderBy; + switch (sortBy) { + case 'popular': + orderBy = desc(images.downloadCount); + break; + case 'trending': + // For trending, we could implement a more complex algorithm + // For now, just use recent with some weight on downloads + orderBy = desc(images.createdAt); + break; + case 'recent': + default: + orderBy = desc(images.createdAt); + } + + const result = await this.db + .select() + .from(images) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(limit) + .offset(offset); + + return result; + } catch (error) { + this.logger.error('Error fetching public images', error); + throw error; + } + } + + async searchPublicImages(query: SearchPublicImagesDto): Promise { + try { + const { searchTerm, page = 1, limit = 20 } = query; + const offset = (page - 1) * limit; + + if (!searchTerm || searchTerm.trim().length === 0) { + return this.getPublicImages({ page, limit }); + } + + const conditions = [ + eq(images.isPublic, true), + isNull(images.archivedAt), + ilike(images.prompt, `%${searchTerm}%`), + ]; + + const result = await this.db + .select() + .from(images) + .where(and(...conditions)) + .orderBy(desc(images.createdAt)) + .limit(limit) + .offset(offset); + + return result; + } catch (error) { + this.logger.error('Error searching public images', error); + throw error; + } + } +} diff --git a/apps/picture/apps/backend/src/generate/dto/generate.dto.ts b/apps/picture/apps/backend/src/generate/dto/generate.dto.ts new file mode 100644 index 000000000..b59f6ae7b --- /dev/null +++ b/apps/picture/apps/backend/src/generate/dto/generate.dto.ts @@ -0,0 +1,41 @@ +import { IsString, IsOptional, IsNumber } from 'class-validator'; + +export class GenerateImageDto { + @IsString() + prompt: string; + + @IsString() + modelId: string; + + @IsString() + @IsOptional() + negativePrompt?: string; + + @IsNumber() + @IsOptional() + width?: number; + + @IsNumber() + @IsOptional() + height?: number; + + @IsNumber() + @IsOptional() + steps?: number; + + @IsNumber() + @IsOptional() + guidanceScale?: number; + + @IsNumber() + @IsOptional() + seed?: number; + + @IsString() + @IsOptional() + sourceImageUrl?: string; + + @IsNumber() + @IsOptional() + generationStrength?: number; +} diff --git a/apps/picture/apps/backend/src/generate/generate.controller.ts b/apps/picture/apps/backend/src/generate/generate.controller.ts new file mode 100644 index 000000000..29dc22d64 --- /dev/null +++ b/apps/picture/apps/backend/src/generate/generate.controller.ts @@ -0,0 +1,54 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + UseGuards, +} from '@nestjs/common'; +import { GenerateService } from './generate.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { + CurrentUser, + CurrentUserData, +} from '../common/decorators/current-user.decorator'; +import { GenerateImageDto } from './dto/generate.dto'; + +@Controller('generate') +export class GenerateController { + constructor(private readonly generateService: GenerateService) {} + + @Post() + @UseGuards(JwtAuthGuard) + async generateImage( + @CurrentUser() user: CurrentUserData, + @Body() dto: GenerateImageDto, + ) { + return this.generateService.generateImage(user.userId, dto); + } + + @Get(':id/status') + @UseGuards(JwtAuthGuard) + async checkStatus( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.generateService.checkStatus(id, user.userId); + } + + @Delete(':id') + @UseGuards(JwtAuthGuard) + async cancelGeneration( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.generateService.cancelGeneration(id, user.userId); + } + + // Webhook endpoint for Replicate - no auth required + @Post('webhook') + async handleWebhook(@Body() body: any) { + return this.generateService.handleWebhook(body); + } +} diff --git a/apps/picture/apps/backend/src/generate/generate.module.ts b/apps/picture/apps/backend/src/generate/generate.module.ts new file mode 100644 index 000000000..2f8b1af1a --- /dev/null +++ b/apps/picture/apps/backend/src/generate/generate.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { GenerateController } from './generate.controller'; +import { GenerateService } from './generate.service'; +import { ReplicateService } from './replicate.service'; +import { UploadModule } from '../upload/upload.module'; + +@Module({ + imports: [UploadModule], + controllers: [GenerateController], + providers: [GenerateService, ReplicateService], + exports: [GenerateService], +}) +export class GenerateModule {} diff --git a/apps/picture/apps/backend/src/generate/generate.service.ts b/apps/picture/apps/backend/src/generate/generate.service.ts new file mode 100644 index 000000000..b486c69a1 --- /dev/null +++ b/apps/picture/apps/backend/src/generate/generate.service.ts @@ -0,0 +1,382 @@ +import { + Injectable, + Inject, + NotFoundException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { eq } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { + imageGenerations, + images, + models, + type ImageGeneration, + type Image, +} from '../db/schema'; +import { ReplicateService } from './replicate.service'; +import { StorageService } from '../upload/storage.service'; +import { GenerateImageDto } from './dto/generate.dto'; + +@Injectable() +export class GenerateService { + private readonly logger = new Logger(GenerateService.name); + private readonly webhookBaseUrl: string; + + constructor( + @Inject(DATABASE_CONNECTION) private readonly db: Database, + private readonly replicateService: ReplicateService, + private readonly storageService: StorageService, + private configService: ConfigService, + ) { + this.webhookBaseUrl = + this.configService.get('WEBHOOK_BASE_URL') || + 'http://localhost:3003'; + } + + async generateImage( + userId: string, + dto: GenerateImageDto, + ): Promise<{ generationId: string; status: string }> { + try { + // Get model info + const modelResult = await this.db + .select() + .from(models) + .where(eq(models.id, dto.modelId)) + .limit(1); + + if (modelResult.length === 0) { + throw new NotFoundException(`Model with id ${dto.modelId} not found`); + } + + const model = modelResult[0]; + + // Create generation record + const generationResult = await this.db + .insert(imageGenerations) + .values({ + userId, + modelId: dto.modelId, + prompt: dto.prompt, + negativePrompt: dto.negativePrompt, + model: model.name, + width: dto.width || model.defaultWidth || 1024, + height: dto.height || model.defaultHeight || 1024, + steps: dto.steps || model.defaultSteps || 25, + guidanceScale: dto.guidanceScale || model.defaultGuidanceScale || 7.5, + seed: dto.seed, + sourceImageUrl: dto.sourceImageUrl, + generationStrength: dto.generationStrength, + status: 'pending', + }) + .returning(); + + const generation = generationResult[0]; + + // Start the prediction + try { + const webhookUrl = `${this.webhookBaseUrl}/api/generate/webhook`; + + const prediction = await this.replicateService.createPrediction( + model.replicateId, + model.version || '', + { + prompt: dto.prompt, + negative_prompt: dto.negativePrompt, + width: dto.width || model.defaultWidth || 1024, + height: dto.height || model.defaultHeight || 1024, + num_inference_steps: dto.steps || model.defaultSteps || 25, + guidance_scale: dto.guidanceScale || model.defaultGuidanceScale || 7.5, + seed: dto.seed, + image: dto.sourceImageUrl, + prompt_strength: dto.generationStrength, + }, + webhookUrl, + ); + + // Update generation with prediction ID + await this.db + .update(imageGenerations) + .set({ + replicatePredictionId: prediction.id, + status: 'processing', + }) + .where(eq(imageGenerations.id, generation.id)); + + return { + generationId: generation.id, + status: 'processing', + }; + } catch (error) { + // Update generation as failed + await this.db + .update(imageGenerations) + .set({ + status: 'failed', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }) + .where(eq(imageGenerations.id, generation.id)); + + throw error; + } + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error('Error generating image', error); + throw error; + } + } + + async checkStatus( + generationId: string, + userId: string, + ): Promise { + try { + const result = await this.db + .select() + .from(imageGenerations) + .where(eq(imageGenerations.id, generationId)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Generation with id ${generationId} not found`); + } + + const generation = result[0]; + + if (generation.userId !== userId) { + throw new ForbiddenException('Access denied'); + } + + // If still processing, check Replicate status + if ( + generation.status === 'processing' && + generation.replicatePredictionId + ) { + const prediction = await this.replicateService.getPrediction( + generation.replicatePredictionId, + ); + + if (prediction.status === 'succeeded' && prediction.output) { + // Process the completed generation + await this.processCompletedGeneration(generation, prediction.output); + + // Refetch the updated generation + const updatedResult = await this.db + .select() + .from(imageGenerations) + .where(eq(imageGenerations.id, generationId)) + .limit(1); + + const updated = updatedResult[0]; + + // Get the created image + const imageResult = await this.db + .select() + .from(images) + .where(eq(images.generationId, generationId)) + .limit(1); + + return { + ...updated, + image: imageResult[0], + }; + } else if (prediction.status === 'failed') { + await this.db + .update(imageGenerations) + .set({ + status: 'failed', + errorMessage: prediction.error || 'Generation failed', + }) + .where(eq(imageGenerations.id, generationId)); + + return { + ...generation, + status: 'failed', + errorMessage: prediction.error || 'Generation failed', + }; + } + } + + // Get associated image if completed + if (generation.status === 'completed') { + const imageResult = await this.db + .select() + .from(images) + .where(eq(images.generationId, generationId)) + .limit(1); + + return { + ...generation, + image: imageResult[0], + }; + } + + return generation; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error checking status for generation ${generationId}`, error); + throw error; + } + } + + async cancelGeneration(generationId: string, userId: string): Promise { + try { + const result = await this.db + .select() + .from(imageGenerations) + .where(eq(imageGenerations.id, generationId)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Generation with id ${generationId} not found`); + } + + const generation = result[0]; + + if (generation.userId !== userId) { + throw new ForbiddenException('Access denied'); + } + + if (generation.status !== 'pending' && generation.status !== 'processing') { + return; // Already completed or failed + } + + // Cancel on Replicate + if (generation.replicatePredictionId) { + try { + await this.replicateService.cancelPrediction( + generation.replicatePredictionId, + ); + } catch (error) { + this.logger.warn('Failed to cancel prediction on Replicate', error); + } + } + + // Update status + await this.db + .update(imageGenerations) + .set({ + status: 'cancelled', + errorMessage: 'Cancelled by user', + }) + .where(eq(imageGenerations.id, generationId)); + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error cancelling generation ${generationId}`, error); + throw error; + } + } + + async handleWebhook(body: any): Promise<{ received: boolean }> { + try { + const { id, status, output, error, metrics } = body; + + if (!id) { + return { received: false }; + } + + // Find the generation by prediction ID + const result = await this.db + .select() + .from(imageGenerations) + .where(eq(imageGenerations.replicatePredictionId, id)) + .limit(1); + + if (result.length === 0) { + this.logger.warn(`No generation found for prediction ${id}`); + return { received: false }; + } + + const generation = result[0]; + + if (status === 'succeeded' && output) { + await this.processCompletedGeneration(generation, output); + } else if (status === 'failed') { + await this.db + .update(imageGenerations) + .set({ + status: 'failed', + errorMessage: error || 'Generation failed', + }) + .where(eq(imageGenerations.id, generation.id)); + } + + return { received: true }; + } catch (error) { + this.logger.error('Error handling webhook', error); + return { received: false }; + } + } + + private async processCompletedGeneration( + generation: ImageGeneration, + output: string[] | string, + ): Promise { + try { + const imageUrl = Array.isArray(output) ? output[0] : output; + + if (!imageUrl) { + throw new Error('No output URL from generation'); + } + + // Download and upload to storage + const { storagePath, publicUrl } = await this.storageService.uploadFromUrl( + imageUrl, + generation.userId, + `generated-${generation.id}.png`, + ); + + // Create image record + await this.db.insert(images).values({ + userId: generation.userId, + generationId: generation.id, + prompt: generation.prompt, + negativePrompt: generation.negativePrompt, + model: generation.model, + storagePath, + publicUrl, + filename: `generated-${generation.id}.png`, + width: generation.width, + height: generation.height, + format: 'png', + }); + + // Update generation as completed + await this.db + .update(imageGenerations) + .set({ + status: 'completed', + completedAt: new Date(), + }) + .where(eq(imageGenerations.id, generation.id)); + } catch (error) { + this.logger.error( + `Error processing completed generation ${generation.id}`, + error, + ); + + await this.db + .update(imageGenerations) + .set({ + status: 'failed', + errorMessage: error instanceof Error ? error.message : 'Processing failed', + }) + .where(eq(imageGenerations.id, generation.id)); + } + } +} diff --git a/apps/picture/apps/backend/src/generate/replicate.service.ts b/apps/picture/apps/backend/src/generate/replicate.service.ts new file mode 100644 index 000000000..d97a19faa --- /dev/null +++ b/apps/picture/apps/backend/src/generate/replicate.service.ts @@ -0,0 +1,124 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Replicate from 'replicate'; + +export interface PredictionInput { + prompt: string; + negative_prompt?: string; + width?: number; + height?: number; + num_inference_steps?: number; + guidance_scale?: number; + seed?: number; + image?: string; // For img2img + prompt_strength?: number; +} + +export interface Prediction { + id: string; + status: 'starting' | 'processing' | 'succeeded' | 'failed' | 'canceled'; + output?: string[] | string; + error?: string; + metrics?: { + predict_time?: number; + }; +} + +@Injectable() +export class ReplicateService { + private readonly logger = new Logger(ReplicateService.name); + private replicate: Replicate | null = null; + + constructor(private configService: ConfigService) { + const apiToken = this.configService.get('REPLICATE_API_TOKEN'); + if (apiToken) { + this.replicate = new Replicate({ auth: apiToken }); + } else { + this.logger.warn('REPLICATE_API_TOKEN not configured'); + } + } + + async createPrediction( + modelId: string, + version: string, + input: PredictionInput, + webhookUrl?: string, + ): Promise { + if (!this.replicate) { + throw new Error('Replicate not configured'); + } + + try { + const prediction = await this.replicate.predictions.create({ + version, + input, + webhook: webhookUrl, + webhook_events_filter: ['completed'], + }); + + return { + id: prediction.id, + status: prediction.status as Prediction['status'], + output: prediction.output as string[] | string | undefined, + error: prediction.error as string | undefined, + }; + } catch (error) { + this.logger.error('Error creating prediction', error); + throw error; + } + } + + async getPrediction(predictionId: string): Promise { + if (!this.replicate) { + throw new Error('Replicate not configured'); + } + + try { + const prediction = await this.replicate.predictions.get(predictionId); + + return { + id: prediction.id, + status: prediction.status as Prediction['status'], + output: prediction.output as string[] | string | undefined, + error: prediction.error as string | undefined, + metrics: prediction.metrics as Prediction['metrics'], + }; + } catch (error) { + this.logger.error(`Error getting prediction ${predictionId}`, error); + throw error; + } + } + + async cancelPrediction(predictionId: string): Promise { + if (!this.replicate) { + throw new Error('Replicate not configured'); + } + + try { + await this.replicate.predictions.cancel(predictionId); + } catch (error) { + this.logger.error(`Error canceling prediction ${predictionId}`, error); + throw error; + } + } + + async waitForPrediction( + predictionId: string, + timeoutMs: number = 300000, // 5 minutes + pollIntervalMs: number = 2000, + ): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const prediction = await this.getPrediction(predictionId); + + if (prediction.status === 'succeeded' || prediction.status === 'failed' || prediction.status === 'canceled') { + return prediction; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error('Prediction timed out'); + } +} diff --git a/apps/picture/apps/backend/src/health/health.controller.ts b/apps/picture/apps/backend/src/health/health.controller.ts new file mode 100644 index 000000000..2eb173031 --- /dev/null +++ b/apps/picture/apps/backend/src/health/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + service: 'picture-backend', + }; + } +} diff --git a/apps/picture/apps/backend/src/health/health.module.ts b/apps/picture/apps/backend/src/health/health.module.ts new file mode 100644 index 000000000..7476abedd --- /dev/null +++ b/apps/picture/apps/backend/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/picture/apps/backend/src/image/dto/image.dto.ts b/apps/picture/apps/backend/src/image/dto/image.dto.ts new file mode 100644 index 000000000..191961444 --- /dev/null +++ b/apps/picture/apps/backend/src/image/dto/image.dto.ts @@ -0,0 +1,38 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +export class GetImagesQueryDto { + @IsNumber() + @IsOptional() + @Type(() => Number) + page?: number = 1; + + @IsNumber() + @IsOptional() + @Type(() => Number) + limit?: number = 20; + + @IsBoolean() + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + archived?: boolean = false; + + @IsOptional() + tagIds?: string | string[]; + + @IsBoolean() + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + favoritesOnly?: boolean = false; +} + +export class ToggleFavoriteDto { + @IsBoolean() + isFavorite: boolean; +} diff --git a/apps/picture/apps/backend/src/image/image.controller.ts b/apps/picture/apps/backend/src/image/image.controller.ts new file mode 100644 index 000000000..42de1f7d9 --- /dev/null +++ b/apps/picture/apps/backend/src/image/image.controller.ts @@ -0,0 +1,88 @@ +import { + Controller, + Get, + Patch, + Delete, + Param, + Query, + Body, + UseGuards, +} from '@nestjs/common'; +import { ImageService } from './image.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { + CurrentUser, + CurrentUserData, +} from '../common/decorators/current-user.decorator'; +import { GetImagesQueryDto, ToggleFavoriteDto } from './dto/image.dto'; + +@Controller('images') +@UseGuards(JwtAuthGuard) +export class ImageController { + constructor(private readonly imageService: ImageService) {} + + @Get() + async getImages( + @CurrentUser() user: CurrentUserData, + @Query() query: GetImagesQueryDto, + ) { + return this.imageService.getImages(user.userId, query); + } + + @Get(':id') + async getImageById( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.imageService.getImageById(id, user.userId); + } + + @Patch(':id/archive') + async archiveImage( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.imageService.archiveImage(id, user.userId); + } + + @Patch(':id/unarchive') + async unarchiveImage( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.imageService.unarchiveImage(id, user.userId); + } + + @Delete(':id') + async deleteImage( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.imageService.deleteImage(id, user.userId); + } + + @Patch(':id/publish') + async publishImage( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.imageService.publishImage(id, user.userId); + } + + @Patch(':id/unpublish') + async unpublishImage( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.imageService.unpublishImage(id, user.userId); + } + + @Patch(':id/favorite') + async toggleFavorite( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: ToggleFavoriteDto, + ) { + return this.imageService.toggleFavorite(id, user.userId, dto.isFavorite); + } +} diff --git a/apps/picture/apps/backend/src/image/image.module.ts b/apps/picture/apps/backend/src/image/image.module.ts new file mode 100644 index 000000000..beefa9459 --- /dev/null +++ b/apps/picture/apps/backend/src/image/image.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ImageController } from './image.controller'; +import { ImageService } from './image.service'; + +@Module({ + controllers: [ImageController], + providers: [ImageService], + exports: [ImageService], +}) +export class ImageModule {} diff --git a/apps/picture/apps/backend/src/image/image.service.ts b/apps/picture/apps/backend/src/image/image.service.ts new file mode 100644 index 000000000..ce468cecd --- /dev/null +++ b/apps/picture/apps/backend/src/image/image.service.ts @@ -0,0 +1,286 @@ +import { + Injectable, + Inject, + NotFoundException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { eq, and, isNull, isNotNull, desc, inArray, sql } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { images, imageTags, type Image } from '../db/schema'; +import { GetImagesQueryDto } from './dto/image.dto'; + +@Injectable() +export class ImageService { + private readonly logger = new Logger(ImageService.name); + + constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {} + + async getImages( + userId: string, + query: GetImagesQueryDto, + ): Promise { + try { + const { + page = 1, + limit = 20, + archived = false, + tagIds, + favoritesOnly = false, + } = query; + + const offset = (page - 1) * limit; + + // Build base conditions + const conditions = [eq(images.userId, userId)]; + + if (archived) { + conditions.push(isNotNull(images.archivedAt)); + } else { + conditions.push(isNull(images.archivedAt)); + } + + if (favoritesOnly) { + conditions.push(eq(images.isFavorite, true)); + } + + // If tag filtering is needed + if (tagIds && tagIds.length > 0) { + const tagIdArray = Array.isArray(tagIds) ? tagIds : tagIds.split(','); + + // Get image IDs that have ALL specified tags + const imageIdsWithTags = await this.db + .select({ imageId: imageTags.imageId }) + .from(imageTags) + .where(inArray(imageTags.tagId, tagIdArray)) + .groupBy(imageTags.imageId) + .having(sql`count(distinct ${imageTags.tagId}) = ${tagIdArray.length}`); + + const validImageIds = imageIdsWithTags.map((r) => r.imageId); + + if (validImageIds.length === 0) { + return []; + } + + conditions.push(inArray(images.id, validImageIds)); + } + + const result = await this.db + .select() + .from(images) + .where(and(...conditions)) + .orderBy(desc(images.createdAt)) + .limit(limit) + .offset(offset); + + return result; + } catch (error) { + this.logger.error('Error fetching images', error); + throw error; + } + } + + async getImageById(id: string, userId: string): Promise { + try { + const result = await this.db + .select() + .from(images) + .where(eq(images.id, id)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Image with id ${id} not found`); + } + + const image = result[0]; + + // Check ownership (allow if public or owned by user) + if (image.userId !== userId && !image.isPublic) { + throw new ForbiddenException('Access denied'); + } + + return image; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error fetching image ${id}`, error); + throw error; + } + } + + async archiveImage(id: string, userId: string): Promise { + try { + await this.verifyOwnership(id, userId); + + const result = await this.db + .update(images) + .set({ + archivedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(images.id, id)) + .returning(); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error archiving image ${id}`, error); + throw error; + } + } + + async unarchiveImage(id: string, userId: string): Promise { + try { + await this.verifyOwnership(id, userId); + + const result = await this.db + .update(images) + .set({ + archivedAt: null, + updatedAt: new Date(), + }) + .where(eq(images.id, id)) + .returning(); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error unarchiving image ${id}`, error); + throw error; + } + } + + async deleteImage(id: string, userId: string): Promise { + try { + await this.verifyOwnership(id, userId); + + // Delete image-tag relations first + await this.db.delete(imageTags).where(eq(imageTags.imageId, id)); + + // Delete the image + await this.db.delete(images).where(eq(images.id, id)); + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error deleting image ${id}`, error); + throw error; + } + } + + async publishImage(id: string, userId: string): Promise { + try { + await this.verifyOwnership(id, userId); + + const result = await this.db + .update(images) + .set({ + isPublic: true, + updatedAt: new Date(), + }) + .where(eq(images.id, id)) + .returning(); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error publishing image ${id}`, error); + throw error; + } + } + + async unpublishImage(id: string, userId: string): Promise { + try { + await this.verifyOwnership(id, userId); + + const result = await this.db + .update(images) + .set({ + isPublic: false, + updatedAt: new Date(), + }) + .where(eq(images.id, id)) + .returning(); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error unpublishing image ${id}`, error); + throw error; + } + } + + async toggleFavorite( + id: string, + userId: string, + isFavorite: boolean, + ): Promise { + try { + await this.verifyOwnership(id, userId); + + const result = await this.db + .update(images) + .set({ + isFavorite, + updatedAt: new Date(), + }) + .where(eq(images.id, id)) + .returning(); + + return result[0]; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error toggling favorite for image ${id}`, error); + throw error; + } + } + + private async verifyOwnership(id: string, userId: string): Promise { + const result = await this.db + .select({ userId: images.userId }) + .from(images) + .where(eq(images.id, id)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Image with id ${id} not found`); + } + + if (result[0].userId !== userId) { + throw new ForbiddenException('Access denied'); + } + } +} diff --git a/apps/picture/apps/backend/src/main.ts b/apps/picture/apps/backend/src/main.ts new file mode 100644 index 000000000..5961ebaf7 --- /dev/null +++ b/apps/picture/apps/backend/src/main.ts @@ -0,0 +1,38 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Enable CORS for mobile and web apps + app.enableCors({ + origin: [ + 'http://localhost:3000', + 'http://localhost:5173', + 'http://localhost:5174', + 'http://localhost:8081', + 'exp://localhost:8081', + 'http://localhost:3001', // Mana Core Auth + ], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + credentials: true, + }); + + // Enable validation + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + // Set global prefix for API routes + app.setGlobalPrefix('api'); + + const port = process.env.PORT || 3003; + await app.listen(port); + console.log(`Picture backend running on http://localhost:${port}`); +} +bootstrap(); diff --git a/apps/picture/apps/backend/src/model/model.controller.ts b/apps/picture/apps/backend/src/model/model.controller.ts new file mode 100644 index 000000000..63f1f14db --- /dev/null +++ b/apps/picture/apps/backend/src/model/model.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { ModelService } from './model.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; + +@Controller('models') +@UseGuards(JwtAuthGuard) +export class ModelController { + constructor(private readonly modelService: ModelService) {} + + @Get() + async getActiveModels() { + return this.modelService.getActiveModels(); + } + + @Get(':id') + async getModelById(@Param('id') id: string) { + return this.modelService.getModelById(id); + } +} diff --git a/apps/picture/apps/backend/src/model/model.module.ts b/apps/picture/apps/backend/src/model/model.module.ts new file mode 100644 index 000000000..6a6785e1e --- /dev/null +++ b/apps/picture/apps/backend/src/model/model.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ModelController } from './model.controller'; +import { ModelService } from './model.service'; + +@Module({ + controllers: [ModelController], + providers: [ModelService], + exports: [ModelService], +}) +export class ModelModule {} diff --git a/apps/picture/apps/backend/src/model/model.service.ts b/apps/picture/apps/backend/src/model/model.service.ts new file mode 100644 index 000000000..b56a9197f --- /dev/null +++ b/apps/picture/apps/backend/src/model/model.service.ts @@ -0,0 +1,49 @@ +import { Injectable, Inject, NotFoundException, Logger } from '@nestjs/common'; +import { eq, desc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { models, type Model } from '../db/schema'; + +@Injectable() +export class ModelService { + private readonly logger = new Logger(ModelService.name); + + constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {} + + async getActiveModels(): Promise { + try { + const result = await this.db + .select() + .from(models) + .where(eq(models.isActive, true)) + .orderBy(desc(models.isDefault), models.sortOrder); + + return result; + } catch (error) { + this.logger.error('Error fetching active models', error); + throw error; + } + } + + async getModelById(id: string): Promise { + try { + const result = await this.db + .select() + .from(models) + .where(eq(models.id, id)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Model with id ${id} not found`); + } + + return result[0]; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Error fetching model ${id}`, error); + throw error; + } + } +} diff --git a/apps/picture/apps/backend/src/tag/dto/tag.dto.ts b/apps/picture/apps/backend/src/tag/dto/tag.dto.ts new file mode 100644 index 000000000..fa4c1b60c --- /dev/null +++ b/apps/picture/apps/backend/src/tag/dto/tag.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class CreateTagDto { + @IsString() + name: string; + + @IsString() + @IsOptional() + color?: string; +} + +export class UpdateTagDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + color?: string; +} diff --git a/apps/picture/apps/backend/src/tag/tag.controller.ts b/apps/picture/apps/backend/src/tag/tag.controller.ts new file mode 100644 index 000000000..34171075e --- /dev/null +++ b/apps/picture/apps/backend/src/tag/tag.controller.ts @@ -0,0 +1,60 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + UseGuards, +} from '@nestjs/common'; +import { TagService } from './tag.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CreateTagDto, UpdateTagDto } from './dto/tag.dto'; + +@Controller('tags') +@UseGuards(JwtAuthGuard) +export class TagController { + constructor(private readonly tagService: TagService) {} + + @Get() + async getAllTags() { + return this.tagService.getAllTags(); + } + + @Post() + async createTag(@Body() dto: CreateTagDto) { + return this.tagService.createTag(dto); + } + + @Patch(':id') + async updateTag(@Param('id') id: string, @Body() dto: UpdateTagDto) { + return this.tagService.updateTag(id, dto); + } + + @Delete(':id') + async deleteTag(@Param('id') id: string) { + return this.tagService.deleteTag(id); + } + + @Get('image/:imageId') + async getImageTags(@Param('imageId') imageId: string) { + return this.tagService.getImageTags(imageId); + } + + @Post('image/:imageId/:tagId') + async addTagToImage( + @Param('imageId') imageId: string, + @Param('tagId') tagId: string, + ) { + return this.tagService.addTagToImage(imageId, tagId); + } + + @Delete('image/:imageId/:tagId') + async removeTagFromImage( + @Param('imageId') imageId: string, + @Param('tagId') tagId: string, + ) { + return this.tagService.removeTagFromImage(imageId, tagId); + } +} diff --git a/apps/picture/apps/backend/src/tag/tag.module.ts b/apps/picture/apps/backend/src/tag/tag.module.ts new file mode 100644 index 000000000..bb712598c --- /dev/null +++ b/apps/picture/apps/backend/src/tag/tag.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TagController } from './tag.controller'; +import { TagService } from './tag.service'; + +@Module({ + controllers: [TagController], + providers: [TagService], + exports: [TagService], +}) +export class TagModule {} diff --git a/apps/picture/apps/backend/src/tag/tag.service.ts b/apps/picture/apps/backend/src/tag/tag.service.ts new file mode 100644 index 000000000..4fc5b9a33 --- /dev/null +++ b/apps/picture/apps/backend/src/tag/tag.service.ts @@ -0,0 +1,157 @@ +import { Injectable, Inject, NotFoundException, Logger } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { tags, imageTags, images, type Tag } from '../db/schema'; +import { CreateTagDto, UpdateTagDto } from './dto/tag.dto'; + +@Injectable() +export class TagService { + private readonly logger = new Logger(TagService.name); + + constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {} + + async getAllTags(): Promise { + try { + const result = await this.db + .select() + .from(tags) + .orderBy(tags.name); + + return result; + } catch (error) { + this.logger.error('Error fetching tags', error); + throw error; + } + } + + async createTag(dto: CreateTagDto): Promise { + try { + const result = await this.db + .insert(tags) + .values({ + name: dto.name, + color: dto.color, + }) + .returning(); + + return result[0]; + } catch (error) { + this.logger.error('Error creating tag', error); + throw error; + } + } + + async updateTag(id: string, dto: UpdateTagDto): Promise { + try { + const result = await this.db + .update(tags) + .set({ + ...(dto.name && { name: dto.name }), + ...(dto.color !== undefined && { color: dto.color }), + }) + .where(eq(tags.id, id)) + .returning(); + + if (result.length === 0) { + throw new NotFoundException(`Tag with id ${id} not found`); + } + + return result[0]; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Error updating tag ${id}`, error); + throw error; + } + } + + async deleteTag(id: string): Promise { + try { + // Delete image-tag relations first + await this.db.delete(imageTags).where(eq(imageTags.tagId, id)); + + // Delete the tag + const result = await this.db + .delete(tags) + .where(eq(tags.id, id)) + .returning(); + + if (result.length === 0) { + throw new NotFoundException(`Tag with id ${id} not found`); + } + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Error deleting tag ${id}`, error); + throw error; + } + } + + async getImageTags(imageId: string): Promise { + try { + const result = await this.db + .select({ + id: tags.id, + name: tags.name, + color: tags.color, + createdAt: tags.createdAt, + }) + .from(imageTags) + .innerJoin(tags, eq(imageTags.tagId, tags.id)) + .where(eq(imageTags.imageId, imageId)) + .orderBy(tags.name); + + return result; + } catch (error) { + this.logger.error(`Error fetching tags for image ${imageId}`, error); + throw error; + } + } + + async addTagToImage(imageId: string, tagId: string): Promise { + try { + // Check if relation already exists + const existing = await this.db + .select() + .from(imageTags) + .where( + and(eq(imageTags.imageId, imageId), eq(imageTags.tagId, tagId)), + ) + .limit(1); + + if (existing.length > 0) { + return; // Already exists + } + + await this.db.insert(imageTags).values({ + imageId, + tagId, + }); + } catch (error) { + this.logger.error( + `Error adding tag ${tagId} to image ${imageId}`, + error, + ); + throw error; + } + } + + async removeTagFromImage(imageId: string, tagId: string): Promise { + try { + await this.db + .delete(imageTags) + .where( + and(eq(imageTags.imageId, imageId), eq(imageTags.tagId, tagId)), + ); + } catch (error) { + this.logger.error( + `Error removing tag ${tagId} from image ${imageId}`, + error, + ); + throw error; + } + } +} diff --git a/apps/picture/apps/backend/src/upload/storage.service.ts b/apps/picture/apps/backend/src/upload/storage.service.ts new file mode 100644 index 000000000..0c061fb03 --- /dev/null +++ b/apps/picture/apps/backend/src/upload/storage.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createClient, SupabaseClient } from '@supabase/supabase-js'; + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private supabase: SupabaseClient | null = null; + private readonly bucket = 'user-uploads'; + + constructor(private configService: ConfigService) { + const supabaseUrl = this.configService.get('SUPABASE_URL'); + const supabaseKey = this.configService.get('SUPABASE_SERVICE_ROLE_KEY'); + + if (supabaseUrl && supabaseKey) { + this.supabase = createClient(supabaseUrl, supabaseKey); + } else { + this.logger.warn('Supabase credentials not configured'); + } + } + + async uploadFile( + buffer: Buffer, + userId: string, + filename: string, + contentType: string, + ): Promise<{ storagePath: string; publicUrl: string }> { + if (!this.supabase) { + throw new Error('Supabase not configured'); + } + + const timestamp = Date.now(); + const randomId = Math.random().toString(36).substring(2, 10); + const ext = filename.split('.').pop() || 'jpg'; + const storagePath = `${userId}/${timestamp}-${randomId}.${ext}`; + + const { error } = await this.supabase.storage + .from(this.bucket) + .upload(storagePath, buffer, { + contentType, + upsert: false, + }); + + if (error) { + this.logger.error('Error uploading file to storage', error); + throw error; + } + + const { data: urlData } = this.supabase.storage + .from(this.bucket) + .getPublicUrl(storagePath); + + return { + storagePath, + publicUrl: urlData.publicUrl, + }; + } + + async uploadFromUrl( + url: string, + userId: string, + filename: string, + ): Promise<{ storagePath: string; publicUrl: string }> { + if (!this.supabase) { + throw new Error('Supabase not configured'); + } + + // Download the file + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download file from ${url}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const contentType = response.headers.get('content-type') || 'image/jpeg'; + + return this.uploadFile(buffer, userId, filename, contentType); + } + + async deleteFile(storagePath: string): Promise { + if (!this.supabase) { + throw new Error('Supabase not configured'); + } + + const { error } = await this.supabase.storage + .from(this.bucket) + .remove([storagePath]); + + if (error) { + this.logger.error(`Error deleting file ${storagePath}`, error); + throw error; + } + } + + async uploadBoardThumbnail( + boardId: string, + dataUrl: string, + ): Promise { + if (!this.supabase) { + throw new Error('Supabase not configured'); + } + + const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, ''); + const buffer = Buffer.from(base64Data, 'base64'); + + const filename = `boards/${boardId}/thumbnail-${Date.now()}.png`; + + const { error } = await this.supabase.storage + .from(this.bucket) + .upload(filename, buffer, { + contentType: 'image/png', + upsert: true, + }); + + if (error) { + this.logger.error('Error uploading board thumbnail', error); + throw error; + } + + const { data: urlData } = this.supabase.storage + .from(this.bucket) + .getPublicUrl(filename); + + return urlData.publicUrl; + } +} diff --git a/apps/picture/apps/backend/src/upload/upload.controller.ts b/apps/picture/apps/backend/src/upload/upload.controller.ts new file mode 100644 index 000000000..d3e7af84f --- /dev/null +++ b/apps/picture/apps/backend/src/upload/upload.controller.ts @@ -0,0 +1,93 @@ +import { + Controller, + Post, + Delete, + Param, + UseGuards, + UseInterceptors, + UploadedFile, + UploadedFiles, + BadRequestException, +} from '@nestjs/common'; +import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; +import { UploadService } from './upload.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { + CurrentUser, + CurrentUserData, +} from '../common/decorators/current-user.decorator'; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + +@Controller('upload') +@UseGuards(JwtAuthGuard) +export class UploadController { + constructor(private readonly uploadService: UploadService) {} + + @Post() + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: MAX_FILE_SIZE }, + fileFilter: (req, file, callback) => { + if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { + callback( + new BadRequestException( + 'Invalid file type. Only JPEG, PNG, and WebP are allowed.', + ), + false, + ); + return; + } + callback(null, true); + }, + }), + ) + async uploadImage( + @CurrentUser() user: CurrentUserData, + @UploadedFile() file: Express.Multer.File, + ) { + if (!file) { + throw new BadRequestException('No file uploaded'); + } + + return this.uploadService.uploadImage(user.userId, file); + } + + @Post('multiple') + @UseInterceptors( + FilesInterceptor('files', 10, { + limits: { fileSize: MAX_FILE_SIZE }, + fileFilter: (req, file, callback) => { + if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { + callback( + new BadRequestException( + 'Invalid file type. Only JPEG, PNG, and WebP are allowed.', + ), + false, + ); + return; + } + callback(null, true); + }, + }), + ) + async uploadMultiple( + @CurrentUser() user: CurrentUserData, + @UploadedFiles() files: Express.Multer.File[], + ) { + if (!files || files.length === 0) { + throw new BadRequestException('No files uploaded'); + } + + return this.uploadService.uploadMultiple(user.userId, files); + } + + @Delete(':id') + async deleteUploadedImage( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + ) { + return this.uploadService.deleteUploadedImage(id, user.userId); + } +} diff --git a/apps/picture/apps/backend/src/upload/upload.module.ts b/apps/picture/apps/backend/src/upload/upload.module.ts new file mode 100644 index 000000000..dfa5d881f --- /dev/null +++ b/apps/picture/apps/backend/src/upload/upload.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { UploadController } from './upload.controller'; +import { UploadService } from './upload.service'; +import { StorageService } from './storage.service'; + +@Module({ + controllers: [UploadController], + providers: [UploadService, StorageService], + exports: [UploadService, StorageService], +}) +export class UploadModule {} diff --git a/apps/picture/apps/backend/src/upload/upload.service.ts b/apps/picture/apps/backend/src/upload/upload.service.ts new file mode 100644 index 000000000..dfd5fd0c3 --- /dev/null +++ b/apps/picture/apps/backend/src/upload/upload.service.ts @@ -0,0 +1,123 @@ +import { + Injectable, + Inject, + NotFoundException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { type Database } from '../db/connection'; +import { images, imageTags, type Image } from '../db/schema'; +import { StorageService } from './storage.service'; + +@Injectable() +export class UploadService { + private readonly logger = new Logger(UploadService.name); + + constructor( + @Inject(DATABASE_CONNECTION) private readonly db: Database, + private readonly storageService: StorageService, + ) {} + + async uploadImage( + userId: string, + file: Express.Multer.File, + ): Promise { + try { + // Upload to storage + const { storagePath, publicUrl } = await this.storageService.uploadFile( + file.buffer, + userId, + file.originalname, + file.mimetype, + ); + + // Get image dimensions (would need sharp for this) + // For now, we'll skip dimensions + + // Create database record + const result = await this.db + .insert(images) + .values({ + userId, + prompt: file.originalname, // Use filename as prompt for uploaded images + storagePath, + publicUrl, + filename: file.originalname, + format: file.mimetype.split('/')[1], + fileSize: file.size, + }) + .returning(); + + return result[0]; + } catch (error) { + this.logger.error('Error uploading image', error); + throw error; + } + } + + async uploadMultiple( + userId: string, + files: Express.Multer.File[], + ): Promise { + const results: Image[] = []; + + for (const file of files) { + try { + const image = await this.uploadImage(userId, file); + results.push(image); + } catch (error) { + this.logger.error(`Error uploading file ${file.originalname}`, error); + // Continue with other files + } + } + + return results; + } + + async deleteUploadedImage(id: string, userId: string): Promise { + try { + // Get the image + const result = await this.db + .select() + .from(images) + .where(eq(images.id, id)) + .limit(1); + + if (result.length === 0) { + throw new NotFoundException(`Image with id ${id} not found`); + } + + const image = result[0]; + + // Verify ownership + if (image.userId !== userId) { + throw new ForbiddenException('Access denied'); + } + + // Delete from storage + try { + await this.storageService.deleteFile(image.storagePath); + } catch (error) { + this.logger.warn(`Failed to delete file from storage: ${image.storagePath}`); + // Continue with database deletion + } + + // Delete image-tag relations + await this.db.delete(imageTags).where(eq(imageTags.imageId, id)); + + // Delete the database record + await this.db.delete(images).where(eq(images.id, id)); + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + this.logger.error(`Error deleting uploaded image ${id}`, error); + throw error; + } + } +} diff --git a/apps/picture/apps/backend/tsconfig.json b/apps/picture/apps/backend/tsconfig.json new file mode 100644 index 000000000..5c48f6334 --- /dev/null +++ b/apps/picture/apps/backend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true + } +} diff --git a/apps/picture/apps/web/src/lib/api/boardItems.ts b/apps/picture/apps/web/src/lib/api/boardItems.ts index 6e26ba786..85042e1d4 100644 --- a/apps/picture/apps/web/src/lib/api/boardItems.ts +++ b/apps/picture/apps/web/src/lib/api/boardItems.ts @@ -1,67 +1,66 @@ -import { supabase } from '$lib/supabase'; -import type { Database } from '@picture/shared/types'; +/** + * Board Items API - Now using Backend API instead of direct Supabase calls + */ -type BoardItemRow = Database['public']['Tables']['board_items']['Row']; -type BoardItemInsert = Database['public']['Tables']['board_items']['Insert']; -type BoardItemUpdate = Database['public']['Tables']['board_items']['Update']; +import { fetchApi } from './client'; // ===== BASE TYPES ===== interface BoardItemBase { - id: string; - board_id: string; - item_type: 'image' | 'text'; - position_x: number; - position_y: number; - scale_x: number; - scale_y: number; - rotation: number; - z_index: number; - opacity: number; - width: number | null; - height: number | null; - properties: Record; - created_at: string; + id: string; + boardId: string; + itemType: 'image' | 'text'; + positionX: number; + positionY: number; + scaleX: number; + scaleY: number; + rotation: number; + zIndex: number; + opacity: number; + width: number | null; + height: number | null; + properties: Record; + createdAt: string; } // ===== IMAGE ITEM ===== export interface BoardImageItem extends BoardItemBase { - item_type: 'image'; - image_id: string; - text_content: null; - font_size: null; - color: null; - image?: { - id: string; - public_url: string; - width: number | null; - height: number | null; - prompt: string | null; - blurhash: string | null; - }; + itemType: 'image'; + imageId: string; + textContent: null; + fontSize: null; + color: null; + image?: { + id: string; + publicUrl: string; + width: number | null; + height: number | null; + prompt: string | null; + blurhash: string | null; + }; } // ===== TEXT ITEM ===== export interface TextProperties { - fontFamily?: string; - fontWeight?: 'normal' | 'bold'; - fontStyle?: 'normal' | 'italic'; - textAlign?: 'left' | 'center' | 'right'; - lineHeight?: number; - letterSpacing?: number; - backgroundColor?: string; - padding?: number; + fontFamily?: string; + fontWeight?: 'normal' | 'bold'; + fontStyle?: 'normal' | 'italic'; + textAlign?: 'left' | 'center' | 'right'; + lineHeight?: number; + letterSpacing?: number; + backgroundColor?: string; + padding?: number; } export interface BoardTextItem extends BoardItemBase { - item_type: 'text'; - image_id: null; - text_content: string; - font_size: number; - color: string; - properties: TextProperties; + itemType: 'text'; + imageId: null; + textContent: string; + fontSize: number; + color: string; + properties: TextProperties; } // ===== DISCRIMINATED UNION ===== @@ -71,341 +70,245 @@ export type BoardItem = BoardImageItem | BoardTextItem; // ===== TYPE GUARDS ===== export function isImageItem(item: BoardItem): item is BoardImageItem { - return item.item_type === 'image'; + return item.itemType === 'image'; } export function isTextItem(item: BoardItem): item is BoardTextItem { - return item.item_type === 'text'; + return item.itemType === 'text'; } // ===== LEGACY (for backwards compatibility) ===== export interface BoardItemWithImage extends BoardImageItem { - image: { - id: string; - public_url: string; - width: number | null; - height: number | null; - prompt: string | null; - blurhash: string | null; - }; + image: { + id: string; + publicUrl: string; + width: number | null; + height: number | null; + prompt: string | null; + blurhash: string | null; + }; } -// ===== HELPER FUNCTIONS ===== +// ===== INPUT TYPES ===== -async function getNextZIndex(boardId: string): Promise { - const { data: maxZIndex, error } = await supabase - .from('board_items') - .select('z_index') - .eq('board_id', boardId) - .order('z_index', { ascending: false }) - .limit(1) - .maybeSingle(); - - if (error) throw error; - return (maxZIndex?.z_index ?? -1) + 1; +export interface AddImageToBoardInput { + imageId: string; + position?: { x: number; y: number }; } +export interface AddTextToBoardInput { + content?: string; + position?: { x: number; y: number }; + fontSize?: number; + color?: string; + fontFamily?: string; +} + +export interface UpdateBoardItemInput { + positionX?: number; + positionY?: number; + scaleX?: number; + scaleY?: number; + rotation?: number; + zIndex?: number; + opacity?: number; + width?: number; + height?: number; + textContent?: string; + fontSize?: number; + color?: string; + properties?: Record; +} + +// ===== API FUNCTIONS ===== + /** * Get all items for a board (images and texts) */ export async function getBoardItems(boardId: string): Promise { - const { data, error } = await supabase - .from('board_items') - .select(` - *, - image:images( - id, - public_url, - width, - height, - prompt, - blurhash - ) - `) - .eq('board_id', boardId) - .order('z_index', { ascending: true }); - - if (error) throw error; - return data as BoardItem[]; -} - -/** - * Add an image to a board - */ -export async function addImageToBoard(params: { - boardId: string; - imageId: string; - position?: { x: number; y: number }; -}): Promise { - const { boardId, imageId, position = { x: 100, y: 100 } } = params; - - const item: BoardItemInsert = { - board_id: boardId, - item_type: 'image', - image_id: imageId, - position_x: position.x, - position_y: position.y, - z_index: await getNextZIndex(boardId) - }; - - const { data, error } = await supabase - .from('board_items') - .insert(item) - .select(` - *, - image:images( - id, - public_url, - width, - height, - prompt, - blurhash - ) - `) - .single(); - - if (error) throw error; - return data as BoardImageItem; -} - -/** - * Add text to a board - */ -export async function addTextToBoard(params: { - boardId: string; - content?: string; - position?: { x: number; y: number }; - fontSize?: number; - color?: string; - fontFamily?: string; -}): Promise { - const { - boardId, - content = 'Doppelklick zum Bearbeiten', - position = { x: 100, y: 100 }, - fontSize = 24, - color = '#000000', - fontFamily = 'Arial' - } = params; - - const item: BoardItemInsert = { - board_id: boardId, - item_type: 'text', - text_content: content, - font_size: fontSize, - color: color, - position_x: position.x, - position_y: position.y, - width: 300, // Default text box width - z_index: await getNextZIndex(boardId), - properties: { - fontFamily, - fontWeight: 'normal', - fontStyle: 'normal', - textAlign: 'left', - lineHeight: 1.2 - } - }; - - const { data, error } = await supabase - .from('board_items') - .insert(item) - .select() - .single(); - - if (error) throw error; - return data as BoardTextItem; -} - -/** - * Legacy function for backwards compatibility - */ -export async function addBoardItem(item: BoardItemInsert) { - const nextZIndex = await getNextZIndex(item.board_id); - - const { data, error } = await supabase - .from('board_items') - .insert({ - ...item, - z_index: nextZIndex - }) - .select(` - *, - image:images( - id, - public_url, - width, - height, - prompt, - blurhash - ) - `) - .single(); - - if (error) throw error; - return data as BoardItem; -} - -/** - * Update a board item (position, scale, rotation, text content, etc.) - */ -export async function updateBoardItem(id: string, updates: BoardItemUpdate): Promise { - const { data, error } = await supabase - .from('board_items') - .update(updates) - .eq('id', id) - .select(` - *, - image:images( - id, - public_url, - width, - height, - prompt, - blurhash - ) - `) - .single(); - - if (error) throw error; - return data as BoardItem; -} - -/** - * Update multiple board items at once (for batch operations) - */ -export async function updateBoardItems(items: Array<{ id: string } & BoardItemUpdate>) { - const promises = items.map(({ id, ...updates }) => - supabase - .from('board_items') - .update(updates) - .eq('id', id) - ); - - const results = await Promise.all(promises); - const errors = results.filter(r => r.error).map(r => r.error); - - if (errors.length > 0) throw errors[0]; -} - -/** - * Remove an item from a board - */ -export async function removeBoardItem(id: string) { - const { error } = await supabase - .from('board_items') - .delete() - .eq('id', id); - - if (error) throw error; -} - -/** - * Remove multiple items from a board - */ -export async function removeBoardItems(ids: string[]) { - const { error } = await supabase - .from('board_items') - .delete() - .in('id', ids); - - if (error) throw error; -} - -/** - * Change z-index (layer order) of an item - */ -export async function changeBoardItemZIndex(id: string, direction: 'up' | 'down' | 'top' | 'bottom') { - // Get current item - const { data: currentItem, error: currentError } = await supabase - .from('board_items') - .select('*') - .eq('id', id) - .single(); - - if (currentError) throw currentError; - - // Get all items in the same board - const { data: allItems, error: allError } = await supabase - .from('board_items') - .select('id, z_index') - .eq('board_id', currentItem.board_id) - .order('z_index', { ascending: true }); - - if (allError) throw allError; - - const currentIndex = allItems.findIndex(item => item.id === id); - let newZIndex = currentItem.z_index; - - switch (direction) { - case 'up': - if (currentIndex < allItems.length - 1) { - newZIndex = allItems[currentIndex + 1].z_index; - // Swap z-indexes - await supabase - .from('board_items') - .update({ z_index: currentItem.z_index }) - .eq('id', allItems[currentIndex + 1].id); - } - break; - case 'down': - if (currentIndex > 0) { - newZIndex = allItems[currentIndex - 1].z_index; - // Swap z-indexes - await supabase - .from('board_items') - .update({ z_index: currentItem.z_index }) - .eq('id', allItems[currentIndex - 1].id); - } - break; - case 'top': - newZIndex = allItems[allItems.length - 1].z_index + 1; - break; - case 'bottom': - newZIndex = allItems[0].z_index - 1; - break; - } - - // Update current item - return updateBoardItem(id, { z_index: newZIndex }); + const { data, error } = await fetchApi(`/board-items/board/${boardId}`); + if (error) throw error; + return data || []; } /** * Get a single board item by ID */ export async function getBoardItemById(id: string): Promise { - const { data, error } = await supabase - .from('board_items') - .select(` - *, - image:images( - id, - public_url, - width, - height, - prompt, - blurhash - ) - `) - .eq('id', id) - .single(); + const { data, error } = await fetchApi(`/board-items/${id}`); + if (error) throw error; + if (!data) throw new Error('Board item not found'); + return data; +} - if (error) throw error; - return data as BoardItem; +/** + * Add an image to a board + */ +export async function addImageToBoard(params: { + boardId: string; + imageId: string; + position?: { x: number; y: number }; +}): Promise { + const { boardId, imageId, position = { x: 100, y: 100 } } = params; + + const { data, error } = await fetchApi(`/board-items/board/${boardId}/image`, { + method: 'POST', + body: { + imageId, + positionX: position.x, + positionY: position.y, + }, + }); + + if (error) throw error; + if (!data) throw new Error('Failed to add image to board'); + return data; +} + +/** + * Add text to a board + */ +export async function addTextToBoard(params: { + boardId: string; + content?: string; + position?: { x: number; y: number }; + fontSize?: number; + color?: string; + fontFamily?: string; +}): Promise { + const { + boardId, + content = 'Doppelklick zum Bearbeiten', + position = { x: 100, y: 100 }, + fontSize = 24, + color = '#000000', + fontFamily = 'Arial', + } = params; + + const { data, error } = await fetchApi(`/board-items/board/${boardId}/text`, { + method: 'POST', + body: { + textContent: content, + positionX: position.x, + positionY: position.y, + fontSize, + color, + properties: { + fontFamily, + fontWeight: 'normal', + fontStyle: 'normal', + textAlign: 'left', + lineHeight: 1.2, + }, + }, + }); + + if (error) throw error; + if (!data) throw new Error('Failed to add text to board'); + return data; +} + +/** + * Legacy function for backwards compatibility + */ +export async function addBoardItem(item: { + boardId: string; + itemType: 'image' | 'text'; + imageId?: string; + textContent?: string; + positionX?: number; + positionY?: number; + fontSize?: number; + color?: string; + properties?: Record; +}): Promise { + const { data, error } = await fetchApi(`/board-items/board/${item.boardId}`, { + method: 'POST', + body: item, + }); + + if (error) throw error; + if (!data) throw new Error('Failed to add board item'); + return data; +} + +/** + * Update a board item (position, scale, rotation, text content, etc.) + */ +export async function updateBoardItem(id: string, updates: UpdateBoardItemInput): Promise { + const { data, error } = await fetchApi(`/board-items/${id}`, { + method: 'PATCH', + body: updates, + }); + + if (error) throw error; + if (!data) throw new Error('Failed to update board item'); + return data; +} + +/** + * Update multiple board items at once (for batch operations) + */ +export async function updateBoardItems( + items: Array<{ id: string } & UpdateBoardItemInput>, +): Promise { + const { error } = await fetchApi('/board-items/batch', { + method: 'PATCH', + body: { items }, + }); + + if (error) throw error; +} + +/** + * Remove an item from a board + */ +export async function removeBoardItem(id: string): Promise { + const { error } = await fetchApi(`/board-items/${id}`, { + method: 'DELETE', + }); + + if (error) throw error; +} + +/** + * Remove multiple items from a board + */ +export async function removeBoardItems(ids: string[]): Promise { + const { error } = await fetchApi('/board-items/batch', { + method: 'DELETE', + body: { ids }, + }); + + if (error) throw error; +} + +/** + * Change z-index (layer order) of an item + */ +export async function changeBoardItemZIndex( + id: string, + direction: 'up' | 'down' | 'top' | 'bottom', +): Promise { + const { data, error } = await fetchApi(`/board-items/${id}/z-index`, { + method: 'PATCH', + body: { direction }, + }); + + if (error) throw error; + if (!data) throw new Error('Failed to change z-index'); + return data; } /** * Check if an image is already on a board */ -export async function isImageOnBoard(boardId: string, imageId: string) { - const { data, error } = await supabase - .from('board_items') - .select('id') - .eq('board_id', boardId) - .eq('image_id', imageId) - .maybeSingle(); +export async function isImageOnBoard(boardId: string, imageId: string): Promise { + const { data, error } = await fetchApi<{ exists: boolean }>( + `/board-items/board/${boardId}/image/${imageId}/exists`, + ); - if (error) throw error; - return !!data; + if (error) throw error; + return data?.exists || false; } diff --git a/apps/picture/apps/web/src/lib/api/boards.ts b/apps/picture/apps/web/src/lib/api/boards.ts index bab7e3471..909666a70 100644 --- a/apps/picture/apps/web/src/lib/api/boards.ts +++ b/apps/picture/apps/web/src/lib/api/boards.ts @@ -1,226 +1,173 @@ -import { supabase } from '$lib/supabase'; -import type { Database } from '@picture/shared/types'; +/** + * Boards API - Now using Backend API instead of direct Supabase calls + */ -type Board = Database['public']['Tables']['boards']['Row']; -type BoardInsert = Database['public']['Tables']['boards']['Insert']; -type BoardUpdate = Database['public']['Tables']['boards']['Update']; +import { fetchApi } from './client'; + +export interface Board { + id: string; + userId: string; + name: string; + description?: string; + thumbnailUrl?: string; + canvasWidth: number; + canvasHeight: number; + backgroundColor: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; +} export interface BoardWithCount extends Board { - item_count: number; + itemCount: number; } export interface GetBoardsParams { - userId: string; - page?: number; - limit?: number; - includePublic?: boolean; + page?: number; + limit?: number; + includePublic?: boolean; +} + +export interface CreateBoardInput { + name: string; + description?: string; + canvasWidth?: number; + canvasHeight?: number; + backgroundColor?: string; + isPublic?: boolean; +} + +export interface UpdateBoardInput { + name?: string; + description?: string; + canvasWidth?: number; + canvasHeight?: number; + backgroundColor?: string; + isPublic?: boolean; + thumbnailUrl?: string; } /** - * Get all boards for a user with item counts + * Get all boards for the current user with item counts */ -export async function getBoards({ userId, page = 1, limit = 20, includePublic = false }: GetBoardsParams) { - const start = (page - 1) * limit; - const end = start + limit - 1; +export async function getBoards({ + page = 1, + limit = 20, + includePublic = false, +}: GetBoardsParams = {}): Promise { + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + includePublic: String(includePublic), + }); - let query = supabase - .from('boards') - .select(` - *, - board_items(count) - `) - .eq('user_id', userId) - .order('updated_at', { ascending: false }) - .range(start, end); - - if (includePublic) { - query = query.or(`user_id.eq.${userId},is_public.eq.true`); - } - - const { data, error } = await query; - - if (error) throw error; - - // Transform the data to include item_count - const boards = data?.map((board: any) => ({ - ...board, - item_count: board.board_items?.[0]?.count || 0, - board_items: undefined // Remove the nested object - })) as BoardWithCount[]; - - return boards; + const { data, error } = await fetchApi(`/boards?${params}`); + if (error) throw error; + return data || []; } /** * Get a single board by ID */ -export async function getBoardById(id: string) { - const { data, error } = await supabase - .from('boards') - .select('*') - .eq('id', id) - .single(); - - if (error) throw error; - return data as Board; +export async function getBoardById(id: string): Promise { + const { data, error } = await fetchApi(`/boards/${id}`); + if (error) throw error; + if (!data) throw new Error('Board not found'); + return data; } /** * Create a new board */ -export async function createBoard(board: BoardInsert) { - const { data, error } = await supabase - .from('boards') - .insert(board) - .select() - .single(); - - if (error) throw error; - return data as Board; +export async function createBoard(board: CreateBoardInput): Promise { + const { data, error } = await fetchApi('/boards', { + method: 'POST', + body: board, + }); + if (error) throw error; + if (!data) throw new Error('Failed to create board'); + return data; } /** * Update an existing board */ -export async function updateBoard(id: string, updates: BoardUpdate) { - const { data, error } = await supabase - .from('boards') - .update(updates) - .eq('id', id) - .select() - .single(); - - if (error) throw error; - return data as Board; +export async function updateBoard(id: string, updates: UpdateBoardInput): Promise { + const { data, error } = await fetchApi(`/boards/${id}`, { + method: 'PATCH', + body: updates, + }); + if (error) throw error; + if (!data) throw new Error('Failed to update board'); + return data; } /** * Delete a board (cascade deletes all board_items) */ -export async function deleteBoard(id: string) { - const { error } = await supabase - .from('boards') - .delete() - .eq('id', id); - - if (error) throw error; +export async function deleteBoard(id: string): Promise { + const { error } = await fetchApi(`/boards/${id}`, { + method: 'DELETE', + }); + if (error) throw error; } /** * Duplicate a board with all its items */ -export async function duplicateBoard(boardId: string, userId: string) { - // Get the original board - const board = await getBoardById(boardId); - - // Create new board with same settings - const newBoard = await createBoard({ - user_id: userId, - name: `${board.name} (Copy)`, - description: board.description, - canvas_width: board.canvas_width, - canvas_height: board.canvas_height, - background_color: board.background_color, - is_public: false - }); - - // Get all items from original board - const { data: items, error } = await supabase - .from('board_items') - .select('*') - .eq('board_id', boardId); - - if (error) throw error; - - // Copy items to new board - if (items && items.length > 0) { - const newItems = items.map(item => ({ - board_id: newBoard.id, - image_id: item.image_id, - position_x: item.position_x, - position_y: item.position_y, - scale_x: item.scale_x, - scale_y: item.scale_y, - rotation: item.rotation, - z_index: item.z_index, - opacity: item.opacity, - width: item.width, - height: item.height - })); - - const { error: insertError } = await supabase - .from('board_items') - .insert(newItems); - - if (insertError) throw insertError; - } - - return newBoard; +export async function duplicateBoard(boardId: string): Promise { + const { data, error } = await fetchApi(`/boards/${boardId}/duplicate`, { + method: 'POST', + }); + if (error) throw error; + if (!data) throw new Error('Failed to duplicate board'); + return data; } /** - * Generate thumbnail for board (exports to storage) + * Generate thumbnail for board (uploads to storage) */ -export async function generateBoardThumbnail(boardId: string, dataUrl: string) { - // Convert data URL to blob - const response = await fetch(dataUrl); - const blob = await response.blob(); +export async function generateBoardThumbnail(boardId: string, dataUrl: string): Promise { + // Convert data URL to blob + const response = await fetch(dataUrl); + const blob = await response.blob(); - // Upload to Supabase Storage - const fileName = `board-thumbnails/${boardId}.png`; - const { data, error } = await supabase.storage - .from('images') - .upload(fileName, blob, { - upsert: true, - contentType: 'image/png' - }); + // Create form data + const formData = new FormData(); + formData.append('thumbnail', blob, 'thumbnail.png'); - if (error) throw error; + // Upload via backend API + const { data, error } = await fetchApi<{ thumbnailUrl: string }>( + `/boards/${boardId}/thumbnail`, + { + method: 'POST', + body: formData, + isFormData: true, + }, + ); - // Get public URL - const { data: urlData } = supabase.storage - .from('images') - .getPublicUrl(fileName); - - // Update board with thumbnail URL - await updateBoard(boardId, { - thumbnail_url: urlData.publicUrl - }); - - return urlData.publicUrl; + if (error) throw error; + if (!data) throw new Error('Failed to generate thumbnail'); + return data.thumbnailUrl; } /** * Get public boards for explore/sharing */ -export async function getPublicBoards(page = 1, limit = 20) { - const start = (page - 1) * limit; - const end = start + limit - 1; +export async function getPublicBoards(page = 1, limit = 20): Promise { + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + }); - const { data, error } = await supabase - .from('boards') - .select(` - *, - board_items(count) - `) - .eq('is_public', true) - .order('updated_at', { ascending: false }) - .range(start, end); - - if (error) throw error; - - const boards = data?.map((board: any) => ({ - ...board, - item_count: board.board_items?.[0]?.count || 0, - board_items: undefined - })) as BoardWithCount[]; - - return boards; + const { data, error } = await fetchApi(`/boards/public?${params}`); + if (error) throw error; + return data || []; } /** * Toggle board visibility (public/private) */ -export async function toggleBoardVisibility(id: string, isPublic: boolean) { - return updateBoard(id, { is_public: isPublic }); +export async function toggleBoardVisibility(id: string, isPublic: boolean): Promise { + return updateBoard(id, { isPublic }); } diff --git a/apps/picture/apps/web/src/lib/api/client.ts b/apps/picture/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..91ebf5f1a --- /dev/null +++ b/apps/picture/apps/web/src/lib/api/client.ts @@ -0,0 +1,162 @@ +/** + * API Client for Picture Backend + * Replaces direct Supabase calls with backend API calls. + */ + +import { browser } from '$app/environment'; +import { env } from '$env/dynamic/public'; + +const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3003'; + +type FetchOptions = { + method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + body?: unknown; + token?: string; + isFormData?: boolean; +}; + +export async function fetchApi( + endpoint: string, + options: FetchOptions = {}, +): Promise<{ data: T | null; error: Error | null }> { + const { method = 'GET', body, token, isFormData = false } = options; + + let authToken = token; + if (!authToken && browser) { + authToken = localStorage.getItem('@auth/appToken') || undefined; + } + + try { + const headers: Record = {}; + + // Don't set Content-Type for FormData - browser sets it automatically with boundary + if (!isFormData) { + headers['Content-Type'] = 'application/json'; + } + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const response = await fetch(`${API_BASE}/api${endpoint}`, { + method, + headers, + body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + data: null, + error: new Error(errorData.message || `API error: ${response.status}`), + }; + } + + // Handle empty responses (204 No Content) + if (response.status === 204) { + return { data: null, error: null }; + } + + const data = await response.json(); + return { data, error: null }; + } catch (error) { + return { + data: null, + error: error instanceof Error ? error : new Error('Unknown error'), + }; + } +} + +/** + * Upload a file to the backend + */ +export async function uploadFile( + endpoint: string, + file: File, + token?: string, +): Promise<{ data: any; error: Error | null }> { + let authToken = token; + if (!authToken && browser) { + authToken = localStorage.getItem('@auth/appToken') || undefined; + } + + try { + const formData = new FormData(); + formData.append('file', file); + + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const response = await fetch(`${API_BASE}/api${endpoint}`, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + data: null, + error: new Error(errorData.message || `Upload error: ${response.status}`), + }; + } + + const data = await response.json(); + return { data, error: null }; + } catch (error) { + return { + data: null, + error: error instanceof Error ? error : new Error('Upload failed'), + }; + } +} + +/** + * Upload multiple files to the backend + */ +export async function uploadFiles( + endpoint: string, + files: File[], + token?: string, +): Promise<{ data: any; error: Error | null }> { + let authToken = token; + if (!authToken && browser) { + authToken = localStorage.getItem('@auth/appToken') || undefined; + } + + try { + const formData = new FormData(); + files.forEach((file) => { + formData.append('files', file); + }); + + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const response = await fetch(`${API_BASE}/api${endpoint}`, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + data: null, + error: new Error(errorData.message || `Upload error: ${response.status}`), + }; + } + + const data = await response.json(); + return { data, error: null }; + } catch (error) { + return { + data: null, + error: error instanceof Error ? error : new Error('Upload failed'), + }; + } +} diff --git a/apps/picture/apps/web/src/lib/api/explore.ts b/apps/picture/apps/web/src/lib/api/explore.ts index ff81903b5..a8d66e36a 100644 --- a/apps/picture/apps/web/src/lib/api/explore.ts +++ b/apps/picture/apps/web/src/lib/api/explore.ts @@ -1,73 +1,49 @@ -import { supabase } from '$lib/supabase'; -import type { Database } from '@picture/shared/types'; +/** + * Explore API - Now using Backend API instead of direct Supabase calls + */ -type Image = Database['public']['Tables']['images']['Row']; +import { fetchApi } from './client'; +import type { Image } from './images'; export interface GetPublicImagesParams { - page?: number; - limit?: number; - sortBy?: 'recent' | 'popular' | 'trending'; - favoritesOnly?: boolean; + page?: number; + limit?: number; + sortBy?: 'recent' | 'popular' | 'trending'; + favoritesOnly?: boolean; } export async function getPublicImages({ - page = 1, - limit = 20, - sortBy = 'recent', - favoritesOnly = false -}: GetPublicImagesParams) { - const start = (page - 1) * limit; - const end = start + limit - 1; + page = 1, + limit = 20, + sortBy = 'recent', + favoritesOnly = false, +}: GetPublicImagesParams = {}): Promise { + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + sortBy, + favoritesOnly: String(favoritesOnly), + }); - let query = supabase - .from('images') - .select('*') - .eq('is_public', true) - .is('archived_at', null); - - // Filter by favorites - if (favoritesOnly) { - query = query.eq('is_favorite', true); - } - - query = query.range(start, end); - - // Sort by different criteria - if (sortBy === 'recent') { - query = query.order('created_at', { ascending: false }); - } else if (sortBy === 'popular') { - query = query.order('download_count', { ascending: false }); - } else if (sortBy === 'trending') { - // Combine recency and popularity for trending - query = query.order('created_at', { ascending: false }); - } - - const { data, error } = await query; - - if (error) throw error; - return data as Image[]; + const { data, error } = await fetchApi(`/explore?${params}`); + if (error) throw error; + return data || []; } -export async function searchPublicImages(searchTerm: string, page = 1, limit = 20, favoritesOnly = false) { - const start = (page - 1) * limit; - const end = start + limit - 1; +export async function searchPublicImages( + searchTerm: string, + page = 1, + limit = 20, + favoritesOnly = false, +): Promise { + const params = new URLSearchParams({ + q: searchTerm, + page: String(page), + limit: String(limit), + favoritesOnly: String(favoritesOnly), + }); - let query = supabase - .from('images') - .select('*') - .eq('is_public', true) - .is('archived_at', null) - .ilike('prompt', `%${searchTerm}%`); - - // Filter by favorites - if (favoritesOnly) { - query = query.eq('is_favorite', true); - } - - const { data, error } = await query - .order('created_at', { ascending: false }) - .range(start, end); - - if (error) throw error; - return data as Image[]; + const { data, error } = await fetchApi(`/explore/search?${params}`); + if (error) throw error; + return data || []; } diff --git a/apps/picture/apps/web/src/lib/api/generate-async.ts b/apps/picture/apps/web/src/lib/api/generate-async.ts index 70560c849..33439f1ff 100644 --- a/apps/picture/apps/web/src/lib/api/generate-async.ts +++ b/apps/picture/apps/web/src/lib/api/generate-async.ts @@ -1,17 +1,11 @@ /** - * Async Image Generation API (New Queue-Based System) + * Async Image Generation API (Using Backend API) * - * This replaces the old synchronous generate.ts with an async, non-blocking approach. - * Uses the job queue system for better scalability and user experience. + * Provides async generation with polling for status updates. */ -import { supabase } from '$lib/supabase'; -import { - startImageGeneration, - subscribeToGeneration, - generateImageWithUpdates, - type GenerateImageJobParams -} from '@picture/shared'; +import { fetchApi } from './client'; +import type { Image } from './images'; // ============================================================================ // TYPES @@ -27,6 +21,23 @@ export interface GenerationProgress { export type GenerationCallback = (progress: GenerationProgress) => void; +export interface GenerateImageJobParams { + prompt: string; + modelId: string; + negativePrompt?: string; + width?: number; + height?: number; + numInferenceSteps?: number; + guidanceScale?: number; +} + +interface GenerationStatusResponse { + id: string; + status: 'queued' | 'pending' | 'processing' | 'completed' | 'failed'; + errorMessage?: string; + image?: Image; +} + // ============================================================================ // MAIN API FUNCTIONS // ============================================================================ @@ -35,148 +46,130 @@ export type GenerationCallback = (progress: GenerationProgress) => void; * Generate an image (async, non-blocking) * * Returns immediately with a generation ID. - * Use subscribeToGenerationUpdates() to monitor progress. - * - * @example - * ```typescript - * // Start generation - * const { generationId } = await generateImageAsync({ - * prompt: 'A beautiful sunset', - * model_id: 'black-forest-labs/flux-dev' - * }); - * - * // Subscribe to updates - * const unsubscribe = subscribeToGenerationUpdates(generationId, (progress) => { - * console.log('Status:', progress.status); - * if (progress.status === 'completed') { - * console.log('Image URL:', progress.imageUrl); - * unsubscribe(); - * } - * }); - * ``` + * Use pollGenerationUpdates() to monitor progress. */ export async function generateImageAsync( - params: GenerateImageJobParams -): Promise<{ generationId: string; jobId: string }> { - try { - const result = await startImageGeneration(supabase, params); - return result; - } catch (error: any) { + params: GenerateImageJobParams, +): Promise<{ generationId: string }> { + const { data, error } = await fetchApi<{ generationId: string; status: string }>('/generate', { + method: 'POST', + body: params, + }); + + if (error) { console.error('Failed to start image generation:', error); throw new Error(error.message || 'Failed to start image generation'); } + + if (!data) { + throw new Error('No data returned from generation endpoint'); + } + + return { generationId: data.generationId }; } /** - * Subscribe to generation progress updates via Realtime + * Poll for generation status updates * * @example * ```typescript - * const unsubscribe = subscribeToGenerationUpdates(generationId, (progress) => { + * const stopPolling = pollGenerationUpdates(generationId, (progress) => { * console.log(`${progress.status}: ${progress.progress}%`); * * if (progress.status === 'completed') { * displayImage(progress.imageUrl); - * unsubscribe(); + * stopPolling(); * } * }); * ``` */ -export function subscribeToGenerationUpdates( +export function pollGenerationUpdates( generationId: string, - callback: GenerationCallback + callback: GenerationCallback, + pollInterval = 2000, ): () => void { - return subscribeToGeneration(supabase, generationId, (generation) => { - // Map database status to progress object - const progress: GenerationProgress = { - generationId: generation.id, - status: generation.status, - progress: getProgressPercentage(generation.status), - error: generation.error_message - }; + let isPolling = true; - // If completed, fetch the image record - if (generation.status === 'completed') { - fetchGeneratedImage(generationId).then(image => { - if (image) { - progress.imageUrl = image.public_url; + const poll = async () => { + while (isPolling) { + try { + const { data, error } = await fetchApi( + `/generate/${generationId}/status`, + ); + + if (error) { + callback({ + generationId, + status: 'failed', + error: error.message, + }); + break; } - callback(progress); - }); - } else { - callback(progress); + + if (data) { + const progress: GenerationProgress = { + generationId: data.id, + status: data.status, + progress: getProgressPercentage(data.status), + error: data.errorMessage, + imageUrl: data.image?.publicUrl, + }; + + callback(progress); + + if (data.status === 'completed' || data.status === 'failed') { + break; + } + } + } catch (err) { + callback({ + generationId, + status: 'failed', + error: err instanceof Error ? err.message : 'Unknown error', + }); + break; + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); } - }); + }; + + poll(); + + return () => { + isPolling = false; + }; } /** - * All-in-one: Generate image and subscribe to updates - * - * Convenience function that combines generateImageAsync + subscribeToGenerationUpdates. - * - * @example - * ```typescript - * const { generationId, unsubscribe } = await generateWithRealtime( - * { prompt: 'Sunset', model_id: 'flux-dev' }, - * (progress) => { - * updateUI(progress); - * if (progress.status === 'completed') { - * showImage(progress.imageUrl); - * unsubscribe(); - * } - * } - * ); - * ``` + * Subscribe to generation progress updates (alias for pollGenerationUpdates) + * Kept for backwards compatibility + */ +export function subscribeToGenerationUpdates( + generationId: string, + callback: GenerationCallback, +): () => void { + return pollGenerationUpdates(generationId, callback); +} + +/** + * All-in-one: Generate image and poll for updates */ export async function generateWithRealtime( params: GenerateImageJobParams, - onUpdate: GenerationCallback -): Promise<{ generationId: string; jobId: string; unsubscribe: () => void }> { - const result = await generateImageWithUpdates(supabase, params, (generation) => { - const progress: GenerationProgress = { - generationId: generation.id, - status: generation.status, - progress: getProgressPercentage(generation.status), - error: generation.error_message - }; + onUpdate: GenerationCallback, +): Promise<{ generationId: string; unsubscribe: () => void }> { + const { generationId } = await generateImageAsync(params); - if (generation.status === 'completed') { - fetchGeneratedImage(generation.id).then(image => { - if (image) { - progress.imageUrl = image.public_url; - } - onUpdate(progress); - }); - } else { - onUpdate(progress); - } - }); + const unsubscribe = pollGenerationUpdates(generationId, onUpdate); - return result; + return { generationId, unsubscribe }; } // ============================================================================ // HELPER FUNCTIONS // ============================================================================ -/** - * Fetch the generated image record - */ -async function fetchGeneratedImage(generationId: string) { - const { data, error } = await supabase - .from('images') - .select('*') - .eq('generation_id', generationId) - .single(); - - if (error) { - console.error('Failed to fetch generated image:', error); - return null; - } - - return data; -} - /** * Convert status to progress percentage (for UI) */ @@ -198,53 +191,41 @@ function getProgressPercentage(status: string): number { } /** - * Get generation status (one-time check, no subscription) + * Get generation status (one-time check, no polling) */ export async function getGenerationStatus(generationId: string): Promise { - const { data, error } = await supabase - .from('image_generations') - .select('*') - .eq('id', generationId) - .single(); + const { data, error } = await fetchApi( + `/generate/${generationId}/status`, + ); if (error) { console.error('Failed to get generation status:', error); return null; } - const progress: GenerationProgress = { + if (!data) { + return null; + } + + return { generationId: data.id, status: data.status, progress: getProgressPercentage(data.status), - error: data.error_message + error: data.errorMessage, + imageUrl: data.image?.publicUrl, }; - - if (data.status === 'completed') { - const image = await fetchGeneratedImage(generationId); - if (image) { - progress.imageUrl = image.public_url; - } - } - - return progress; } /** * Cancel a pending generation */ export async function cancelGeneration(generationId: string): Promise { - // Update generation status - const { error } = await supabase - .from('image_generations') - .update({ status: 'failed', error_message: 'Cancelled by user' }) - .eq('id', generationId) - .eq('status', 'pending'); // Only cancel if still pending + const { error } = await fetchApi(`/generate/${generationId}/cancel`, { + method: 'POST', + }); if (error) { console.error('Failed to cancel generation:', error); throw new Error('Failed to cancel generation'); } - - // Note: The job will still be in queue but will fail when processed - // Could also mark the job as cancelled in job_queue table } diff --git a/apps/picture/apps/web/src/lib/api/generate.ts b/apps/picture/apps/web/src/lib/api/generate.ts index e7e8697a8..a09b34ac5 100644 --- a/apps/picture/apps/web/src/lib/api/generate.ts +++ b/apps/picture/apps/web/src/lib/api/generate.ts @@ -1,113 +1,102 @@ -import { supabase } from '$lib/supabase'; +/** + * Generate API - Now using Backend API instead of Edge Functions + */ + +import { fetchApi } from './client'; +import type { Image } from './images'; export interface GenerateImageParams { - prompt: string; - model_id: string; - negative_prompt?: string; - width?: number; - height?: number; - num_inference_steps?: number; - guidance_scale?: number; + prompt: string; + modelId: string; + negativePrompt?: string; + width?: number; + height?: number; + numInferenceSteps?: number; + guidanceScale?: number; } export interface GenerateImageResponse { - image_id: string; - status: 'pending' | 'processing' | 'completed' | 'failed'; + generationId: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; } +export interface GenerationStatus { + id: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress?: number; + errorMessage?: string; + image?: Image; +} + +/** + * Start image generation (async) + */ export async function generateImage(params: GenerateImageParams): Promise { - // Get current user - const { - data: { user }, - error: userError - } = await supabase.auth.getUser(); + const { data, error } = await fetchApi('/generate', { + method: 'POST', + body: params, + }); - if (userError || !user) { - throw new Error('User not authenticated'); - } + if (error) { + console.error('Generate Image Error:', error); + throw error; + } - // Get model info - const { data: model, error: modelError } = await supabase - .from('models') - .select('*') - .eq('id', params.model_id) - .single(); + if (!data) { + throw new Error('Failed to start image generation'); + } - if (modelError || !model) { - throw new Error('Invalid model selected'); - } - - // Create generation record first - const { data: generation, error: generationError } = await supabase - .from('image_generations') - .insert({ - user_id: user.id, - prompt: params.prompt, - negative_prompt: params.negative_prompt || null, - model: model.name, - width: params.width || model.default_width, - height: params.height || model.default_height, - steps: params.num_inference_steps || model.default_steps, - guidance_scale: params.guidance_scale || model.default_guidance_scale, - status: 'pending' - }) - .select() - .single(); - - if (generationError) { - throw generationError; - } - - // Call Edge Function with generation_id - const { data, error } = await supabase.functions.invoke('generate-image', { - body: { - prompt: params.prompt, - negative_prompt: params.negative_prompt, - model_id: model.replicate_id, - model_version: model.version, - width: params.width || model.default_width, - height: params.height || model.default_height, - num_inference_steps: params.num_inference_steps || model.default_steps, - guidance_scale: params.guidance_scale || model.default_guidance_scale, - generation_id: generation.id - } - }); - - if (error) { - // Log detailed error for debugging - console.error('Edge Function Error:', error); - console.error('Error details:', { - message: error.message, - context: error.context, - details: error - }); - - // Update generation status to failed - await supabase - .from('image_generations') - .update({ - status: 'failed', - error_message: error.message || JSON.stringify(error), - completed_at: new Date().toISOString() - }) - .eq('id', generation.id); - - throw new Error(error.message || 'Edge Function failed'); - } - - return { - image_id: generation.id, - status: 'processing' - }; + return data; } -export async function checkGenerationStatus(imageId: string) { - const { data, error } = await supabase - .from('images') - .select('*') - .eq('id', imageId) - .single(); +/** + * Check generation status + */ +export async function checkGenerationStatus(generationId: string): Promise { + const { data, error } = await fetchApi(`/generate/${generationId}/status`); - if (error) throw error; - return data; + if (error) throw error; + if (!data) throw new Error('Generation not found'); + return data; +} + +/** + * Poll for generation completion + */ +export async function waitForGeneration( + generationId: string, + onProgress?: (status: GenerationStatus) => void, + pollInterval = 2000, + maxAttempts = 60, +): Promise { + let attempts = 0; + + while (attempts < maxAttempts) { + const status = await checkGenerationStatus(generationId); + + if (onProgress) { + onProgress(status); + } + + if (status.status === 'completed' || status.status === 'failed') { + return status; + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + attempts++; + } + + throw new Error('Generation timed out'); +} + +/** + * Generate image and wait for completion + */ +export async function generateAndWait( + params: GenerateImageParams, + onProgress?: (status: GenerationStatus) => void, +): Promise { + const { generationId } = await generateImage(params); + + return waitForGeneration(generationId, onProgress); } diff --git a/apps/picture/apps/web/src/lib/api/images.ts b/apps/picture/apps/web/src/lib/api/images.ts index e93042035..892421403 100644 --- a/apps/picture/apps/web/src/lib/api/images.ts +++ b/apps/picture/apps/web/src/lib/api/images.ts @@ -1,177 +1,135 @@ -import { supabase } from '$lib/supabase'; -import type { Database } from '@picture/shared/types'; +/** + * Images API - Now using Backend API instead of direct Supabase calls + */ -type Image = Database['public']['Tables']['images']['Row']; +import { fetchApi } from './client'; + +export interface Image { + id: string; + userId: string; + generationId?: string; + sourceImageId?: string; + prompt: string; + negativePrompt?: string; + model?: string; + style?: string; + publicUrl?: string; + storagePath: string; + filename: string; + format?: string; + width?: number; + height?: number; + fileSize?: number; + blurhash?: string; + isPublic: boolean; + isFavorite: boolean; + downloadCount: number; + rating?: number; + archivedAt?: string; + createdAt: string; + updatedAt: string; +} export interface GetImagesParams { - userId: string; - page?: number; - limit?: number; - archived?: boolean; - tagIds?: string[]; - favoritesOnly?: boolean; + page?: number; + limit?: number; + archived?: boolean; + tagIds?: string[]; + favoritesOnly?: boolean; } -export async function getImages({ userId, page = 1, limit = 20, archived = false, tagIds, favoritesOnly = false }: GetImagesParams) { - const start = (page - 1) * limit; - const end = start + limit - 1; +export async function getImages({ + page = 1, + limit = 20, + archived = false, + tagIds, + favoritesOnly = false, +}: GetImagesParams = {}): Promise { + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + archived: String(archived), + favoritesOnly: String(favoritesOnly), + }); - let query = supabase - .from('images') - .select('*') - .eq('user_id', userId); + if (tagIds && tagIds.length > 0) { + params.append('tagIds', tagIds.join(',')); + } - // Filter by archived_at: NULL = active, NOT NULL = archived - if (archived) { - query = query.not('archived_at', 'is', null); - } else { - query = query.is('archived_at', null); - } - - // Filter by favorites - if (favoritesOnly) { - query = query.eq('is_favorite', true); - } - - // Filter by tags if provided - if (tagIds && tagIds.length > 0) { - // Get image IDs that have ALL selected tags - const { data: imageTagsData, error: imageTagsError } = await supabase - .from('image_tags') - .select('image_id') - .in('tag_id', tagIds); - - if (imageTagsError) throw imageTagsError; - - // Count occurrences of each image_id - const imageIdCounts = imageTagsData?.reduce((acc: Record, item) => { - acc[item.image_id] = (acc[item.image_id] || 0) + 1; - return acc; - }, {}); - - // Filter to only images that have all selected tags - const imageIds = Object.entries(imageIdCounts || {}) - .filter(([_, count]) => count === tagIds.length) - .map(([imageId, _]) => imageId); - - if (imageIds.length === 0) { - return []; // No images match all tags - } - - query = query.in('id', imageIds); - } - - const { data, error } = await query - .order('created_at', { ascending: false }) - .range(start, end); - - if (error) throw error; - return data as Image[]; + const { data, error } = await fetchApi(`/images?${params}`); + if (error) throw error; + return data || []; } -export async function getImageById(id: string) { - const { data, error } = await supabase - .from('images') - .select('*') - .eq('id', id) - .single(); - - if (error) throw error; - return data as Image; +export async function getImageById(id: string): Promise { + const { data, error } = await fetchApi(`/images/${id}`); + if (error) throw error; + if (!data) throw new Error('Image not found'); + return data; } -export async function archiveImage(id: string) { - console.log('[archiveImage] Archiving image:', id); - - const { data, error } = await supabase - .from('images') - .update({ archived_at: new Date().toISOString() }) - .eq('id', id) - .select() - .single(); - - if (error) { - console.error('[archiveImage] Error:', error); - throw error; - } - - console.log('[archiveImage] Success:', data); - return data as Image; +export async function archiveImage(id: string): Promise { + const { data, error } = await fetchApi(`/images/${id}/archive`, { + method: 'PATCH', + }); + if (error) throw error; + if (!data) throw new Error('Failed to archive image'); + return data; } -export async function unarchiveImage(id: string) { - const { data, error } = await supabase - .from('images') - .update({ archived_at: null }) - .eq('id', id) - .select() - .single(); - - if (error) throw error; - return data as Image; +export async function unarchiveImage(id: string): Promise { + const { data, error } = await fetchApi(`/images/${id}/unarchive`, { + method: 'PATCH', + }); + if (error) throw error; + if (!data) throw new Error('Failed to unarchive image'); + return data; } -export async function deleteImage(id: string) { - const { error } = await supabase - .from('images') - .delete() - .eq('id', id); - - if (error) throw error; +export async function deleteImage(id: string): Promise { + const { error } = await fetchApi(`/images/${id}`, { + method: 'DELETE', + }); + if (error) throw error; } -export async function downloadImage(url: string, filename: string) { - const response = await fetch(url); - const blob = await response.blob(); - const downloadUrl = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = downloadUrl; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(downloadUrl); +export async function downloadImage(url: string, filename: string): Promise { + const response = await fetch(url); + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); } -export async function publishImage(id: string) { - const { data, error } = await supabase - .from('images') - .update({ is_public: true }) - .eq('id', id) - .select() - .single(); - - if (error) throw error; - return data as Image; +export async function publishImage(id: string): Promise { + const { data, error } = await fetchApi(`/images/${id}/publish`, { + method: 'PATCH', + }); + if (error) throw error; + if (!data) throw new Error('Failed to publish image'); + return data; } -export async function unpublishImage(id: string) { - const { data, error } = await supabase - .from('images') - .update({ is_public: false }) - .eq('id', id) - .select() - .single(); - - if (error) throw error; - return data as Image; +export async function unpublishImage(id: string): Promise { + const { data, error } = await fetchApi(`/images/${id}/unpublish`, { + method: 'PATCH', + }); + if (error) throw error; + if (!data) throw new Error('Failed to unpublish image'); + return data; } -export async function toggleFavorite(id: string, isFavorite: boolean) { - console.log('[toggleFavorite] Toggling favorite:', id, 'to', isFavorite); - - const { data, error } = await supabase - .from('images') - .update({ is_favorite: isFavorite }) - .eq('id', id) - .select() - .single(); - - if (error) { - console.error('[toggleFavorite] Error:', error); - throw error; - } - - console.log('[toggleFavorite] Success:', data); - return data as Image; +export async function toggleFavorite(id: string, isFavorite: boolean): Promise { + const { data, error } = await fetchApi(`/images/${id}/favorite`, { + method: 'PATCH', + body: { isFavorite }, + }); + if (error) throw error; + if (!data) throw new Error('Failed to toggle favorite'); + return data; } diff --git a/apps/picture/apps/web/src/lib/api/models.ts b/apps/picture/apps/web/src/lib/api/models.ts index a53981e16..7c307e2e6 100644 --- a/apps/picture/apps/web/src/lib/api/models.ts +++ b/apps/picture/apps/web/src/lib/api/models.ts @@ -1,22 +1,40 @@ -import { supabase } from '$lib/supabase'; -import type { Database } from '@picture/shared/types'; +/** + * Models API - Now using Backend API instead of direct Supabase calls + */ -type Model = Database['public']['Tables']['models']['Row']; +import { fetchApi } from './client'; -export async function getActiveModels() { - const { data, error } = await supabase - .from('models') - .select('*') - .eq('is_active', true) - .order('is_default', { ascending: false }); - - if (error) throw error; - return data as Model[]; +export interface Model { + id: string; + name: string; + displayName: string; + replicateId: string; + version?: string; + description?: string; + category?: string; + defaultWidth: number; + defaultHeight: number; + defaultSteps: number; + defaultGuidanceScale: number; + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + supportsNegativePrompt: boolean; + isDefault: boolean; + isActive: boolean; + createdAt: string; } -export async function getModelById(id: string) { - const { data, error } = await supabase.from('models').select('*').eq('id', id).single(); - - if (error) throw error; - return data as Model; +export async function getActiveModels(): Promise { + const { data, error } = await fetchApi('/models'); + if (error) throw error; + return data || []; +} + +export async function getModelById(id: string): Promise { + const { data, error } = await fetchApi(`/models/${id}`); + if (error) throw error; + if (!data) throw new Error('Model not found'); + return data; } diff --git a/apps/picture/apps/web/src/lib/api/tags.ts b/apps/picture/apps/web/src/lib/api/tags.ts index 2fcca215d..7aea74c45 100644 --- a/apps/picture/apps/web/src/lib/api/tags.ts +++ b/apps/picture/apps/web/src/lib/api/tags.ts @@ -1,85 +1,65 @@ -import { supabase } from '$lib/supabase'; -import type { Database } from '@picture/shared/types'; +/** + * Tags API - Now using Backend API instead of direct Supabase calls + */ -type Tag = Database['public']['Tables']['tags']['Row']; -type TagInsert = Database['public']['Tables']['tags']['Insert']; +import { fetchApi } from './client'; + +export interface Tag { + id: string; + name: string; + color?: string; + createdAt: string; +} export async function getAllTags(): Promise { - const { data, error } = await supabase - .from('tags') - .select('*') - .order('name', { ascending: true }); - - if (error) throw error; - return data || []; + const { data, error } = await fetchApi('/tags'); + if (error) throw error; + return data || []; } -export async function createTag(tag: Omit): Promise { - const { data, error } = await supabase - .from('tags') - .insert(tag) - .select() - .single(); - - if (error) throw error; - return data; +export async function createTag(tag: { name: string; color?: string }): Promise { + const { data, error } = await fetchApi('/tags', { + method: 'POST', + body: tag, + }); + if (error) throw error; + if (!data) throw new Error('Failed to create tag'); + return data; } -export async function updateTag(id: string, updates: Partial): Promise { - const { data, error } = await supabase - .from('tags') - .update(updates) - .eq('id', id) - .select() - .single(); - - if (error) throw error; - return data; +export async function updateTag(id: string, updates: { name?: string; color?: string }): Promise { + const { data, error } = await fetchApi(`/tags/${id}`, { + method: 'PATCH', + body: updates, + }); + if (error) throw error; + if (!data) throw new Error('Failed to update tag'); + return data; } export async function deleteTag(id: string): Promise { - const { error } = await supabase - .from('tags') - .delete() - .eq('id', id); - - if (error) throw error; + const { error } = await fetchApi(`/tags/${id}`, { + method: 'DELETE', + }); + if (error) throw error; } export async function getImageTags(imageId: string): Promise { - const { data, error } = await supabase - .from('image_tags') - .select('tag:tags(*)') - .eq('image_id', imageId); - - if (error) throw error; - return data?.map((item: any) => item.tag).filter(Boolean) || []; + const { data, error } = await fetchApi(`/tags/image/${imageId}`); + if (error) throw error; + return data || []; } export async function addTagToImage(imageId: string, tagId: string): Promise { - const { error } = await supabase - .from('image_tags') - .insert({ image_id: imageId, tag_id: tagId }); - - if (error) throw error; + const { error } = await fetchApi(`/tags/image/${imageId}/${tagId}`, { + method: 'POST', + }); + if (error) throw error; } export async function removeTagFromImage(imageId: string, tagId: string): Promise { - const { error } = await supabase - .from('image_tags') - .delete() - .eq('image_id', imageId) - .eq('tag_id', tagId); - - if (error) throw error; -} - -export async function getImagesByTag(tagId: string) { - const { data, error } = await supabase - .from('image_tags') - .select('image:images(*)') - .eq('tag_id', tagId); - - if (error) throw error; - return data?.map((item: any) => item.image).filter(Boolean) || []; + const { error } = await fetchApi(`/tags/image/${imageId}/${tagId}`, { + method: 'DELETE', + }); + if (error) throw error; } diff --git a/apps/picture/apps/web/src/lib/api/upload.ts b/apps/picture/apps/web/src/lib/api/upload.ts index d18e6c3f0..d7be440d1 100644 --- a/apps/picture/apps/web/src/lib/api/upload.ts +++ b/apps/picture/apps/web/src/lib/api/upload.ts @@ -1,160 +1,134 @@ -import { supabase } from '$lib/supabase'; -import type { Database } from '@picture/shared/types'; +/** + * Upload API - Now using Backend API instead of direct Supabase calls + */ -type Image = Database['public']['Tables']['images']['Row']; +import { fetchApi } from './client'; +import type { Image } from './images'; export interface UploadProgress { - filename: string; - progress: number; - status: 'pending' | 'uploading' | 'success' | 'error'; - error?: string; + filename: string; + progress: number; + status: 'pending' | 'uploading' | 'success' | 'error'; + error?: string; } -const STORAGE_BUCKET = 'user-uploads'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; export function validateImage(file: File): { valid: boolean; error?: string } { - if (!ALLOWED_TYPES.includes(file.type)) { - return { - valid: false, - error: 'Nur JPG, PNG und WebP Bilder sind erlaubt' - }; - } + if (!ALLOWED_TYPES.includes(file.type)) { + return { + valid: false, + error: 'Nur JPG, PNG und WebP Bilder sind erlaubt', + }; + } - if (file.size > MAX_FILE_SIZE) { - return { - valid: false, - error: `Datei ist zu groß. Maximale Größe: ${MAX_FILE_SIZE / 1024 / 1024}MB` - }; - } + if (file.size > MAX_FILE_SIZE) { + return { + valid: false, + error: `Datei ist zu groß. Maximale Größe: ${MAX_FILE_SIZE / 1024 / 1024}MB`, + }; + } - return { valid: true }; + return { valid: true }; } export async function uploadImage( - file: File, - userId: string, - onProgress?: (progress: number) => void + file: File, + onProgress?: (progress: number) => void, ): Promise { - // Validate file - const validation = validateImage(file); - if (!validation.valid) { - throw new Error(validation.error); - } + // Validate file + const validation = validateImage(file); + if (!validation.valid) { + throw new Error(validation.error); + } - // Generate unique filename - const fileExt = file.name.split('.').pop(); - const fileName = `${userId}/${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`; + // Create form data + const formData = new FormData(); + formData.append('file', file); - // Upload to Supabase Storage - const { data: uploadData, error: uploadError } = await supabase.storage - .from(STORAGE_BUCKET) - .upload(fileName, file, { - cacheControl: '3600', - upsert: false - }); + // Upload via backend API + const { data, error } = await fetchApi('/upload', { + method: 'POST', + body: formData, + isFormData: true, + }); - if (uploadError) { - console.error('Upload error:', uploadError); - throw new Error('Fehler beim Hochladen des Bildes'); - } + if (error) { + console.error('Upload error:', error); + throw new Error('Fehler beim Hochladen des Bildes'); + } - // Get public URL - const { - data: { publicUrl } - } = supabase.storage.from(STORAGE_BUCKET).getPublicUrl(fileName); + if (!data) { + throw new Error('Keine Daten vom Server erhalten'); + } - // Create database entry - const { data: imageData, error: dbError } = await supabase - .from('images') - .insert({ - user_id: userId, - public_url: publicUrl, - storage_path: fileName, - filename: file.name, - prompt: `Uploaded: ${file.name}` - }) - .select() - .single(); + // Call progress callback with 100% when done + if (onProgress) { + onProgress(100); + } - if (dbError) { - // Cleanup: delete uploaded file if DB insert fails - await supabase.storage.from(STORAGE_BUCKET).remove([fileName]); - console.error('Database error:', dbError); - console.error('Error details:', JSON.stringify(dbError, null, 2)); - throw new Error(`Fehler beim Speichern des Bildes: ${dbError.message || JSON.stringify(dbError)}`); - } - - return imageData as Image; + return data; } export async function uploadMultipleImages( - files: File[], - userId: string, - onProgressUpdate?: (progress: UploadProgress[]) => void + files: File[], + onProgressUpdate?: (progress: UploadProgress[]) => void, ): Promise { - const progressMap: Map = new Map(); + const progressMap: Map = new Map(); - // Initialize progress for all files - files.forEach((file) => { - progressMap.set(file.name, { - filename: file.name, - progress: 0, - status: 'pending' - }); - }); + // Initialize progress for all files + files.forEach((file) => { + progressMap.set(file.name, { + filename: file.name, + progress: 0, + status: 'pending', + }); + }); - // Update progress callback - const updateProgress = () => { - if (onProgressUpdate) { - onProgressUpdate(Array.from(progressMap.values())); - } - }; + // Update progress callback + const updateProgress = () => { + if (onProgressUpdate) { + onProgressUpdate(Array.from(progressMap.values())); + } + }; - updateProgress(); + updateProgress(); - // Upload files sequentially (can be parallelized if needed) - const results: Image[] = []; + // Upload files sequentially + const results: Image[] = []; - for (const file of files) { - const progress = progressMap.get(file.name)!; - progress.status = 'uploading'; - updateProgress(); + for (const file of files) { + const progress = progressMap.get(file.name)!; + progress.status = 'uploading'; + updateProgress(); - try { - const image = await uploadImage(file, userId, (percent) => { - progress.progress = percent; - updateProgress(); - }); + try { + const image = await uploadImage(file, (percent) => { + progress.progress = percent; + updateProgress(); + }); - progress.status = 'success'; - progress.progress = 100; - results.push(image); - } catch (error) { - progress.status = 'error'; - progress.error = error instanceof Error ? error.message : 'Upload fehlgeschlagen'; - } + progress.status = 'success'; + progress.progress = 100; + results.push(image); + } catch (error) { + progress.status = 'error'; + progress.error = error instanceof Error ? error.message : 'Upload fehlgeschlagen'; + } - updateProgress(); - } + updateProgress(); + } - return results; + return results; } -export async function deleteUploadedImage(imageId: string, filePath: string): Promise { - // Delete from database - const { error: dbError } = await supabase.from('images').delete().eq('id', imageId); +export async function deleteUploadedImage(imageId: string): Promise { + const { error } = await fetchApi(`/images/${imageId}`, { + method: 'DELETE', + }); - if (dbError) { - throw new Error('Fehler beim Löschen des Bildes aus der Datenbank'); - } - - // Delete from storage - const { error: storageError } = await supabase.storage.from(STORAGE_BUCKET).remove([filePath]); - - if (storageError) { - console.error('Storage deletion error:', storageError); - // Don't throw here as DB entry is already deleted - } + if (error) { + throw new Error('Fehler beim Löschen des Bildes'); + } } diff --git a/apps/picture/apps/web/src/lib/stores/archive.svelte.ts b/apps/picture/apps/web/src/lib/stores/archive.svelte.ts new file mode 100644 index 000000000..0982a6c66 --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/archive.svelte.ts @@ -0,0 +1,70 @@ +/** + * Archive Store - Svelte 5 Runes Version + */ + +import type { Image } from '$lib/api/images'; + +// State using Svelte 5 runes +let archivedImages = $state([]); +let isLoadingArchive = $state(false); +let hasMoreArchive = $state(true); +let currentArchivePage = $state(1); + +export const archiveStore = { + get images() { + return archivedImages; + }, + get isLoading() { + return isLoadingArchive; + }, + get hasMore() { + return hasMoreArchive; + }, + get currentPage() { + return currentArchivePage; + }, + + setImages(images: Image[]) { + archivedImages = images; + }, + + appendImages(images: Image[]) { + archivedImages = [...archivedImages, ...images]; + }, + + addImage(image: Image) { + archivedImages = [image, ...archivedImages]; + }, + + removeImage(id: string) { + archivedImages = archivedImages.filter((img) => img.id !== id); + }, + + setLoading(loading: boolean) { + isLoadingArchive = loading; + }, + + setHasMore(more: boolean) { + hasMoreArchive = more; + }, + + setCurrentPage(page: number) { + currentArchivePage = page; + }, + + incrementPage() { + currentArchivePage++; + }, + + reset() { + archivedImages = []; + isLoadingArchive = false; + hasMoreArchive = true; + currentArchivePage = 1; + }, +}; + +// Export individual getters for backwards compatibility +export function getArchivedImages() { + return archivedImages; +} diff --git a/apps/picture/apps/web/src/lib/stores/auth.svelte.ts b/apps/picture/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..bf34c0fdc --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,190 @@ +/** + * Auth Store - Manages authentication state using Svelte 5 runes + * Now using Mana Core Auth instead of Supabase Auth + */ + +import { browser } from '$app/environment'; +import { env } from '$env/dynamic/public'; + +const MANA_AUTH_URL = env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; + +export interface UserData { + id: string; + email: string; + role?: string; +} + +interface AuthResult { + success: boolean; + error?: string; +} + +// Internal auth service reference +let _authService: any = null; +let _tokenManager: any = null; + +async function getAuthService() { + if (!browser) return null; + if (!_authService) { + try { + const { initializeWebAuth } = await import('@manacore/shared-auth'); + const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL }); + _authService = auth.authService; + _tokenManager = auth.tokenManager; + } catch (error) { + console.error('Failed to initialize auth service:', error); + return null; + } + } + return _authService; +} + +// State using Svelte 5 runes +let user = $state(null); +let loading = $state(true); +let initialized = $state(false); + +export const authStore = { + get user() { + return user; + }, + get loading() { + return loading; + }, + get isAuthenticated() { + return !!user; + }, + get initialized() { + return initialized; + }, + + async initialize() { + if (!browser || initialized) return; + + loading = true; + + try { + const authService = await getAuthService(); + if (authService) { + const userData = await authService.getUserFromToken(); + user = userData; + } + } catch (error) { + console.error('Auth initialization error:', error); + user = null; + } finally { + loading = false; + initialized = true; + } + }, + + async signIn(email: string, password: string): Promise { + const authService = await getAuthService(); + if (!authService) { + return { success: false, error: 'Auth service not available' }; + } + + try { + loading = true; + const result = await authService.signIn(email, password); + + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + return { success: true }; + } + + return { success: false, error: result.error || 'Login failed' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Login failed', + }; + } finally { + loading = false; + } + }, + + async signUp(email: string, password: string): Promise { + const authService = await getAuthService(); + if (!authService) { + return { success: false, error: 'Auth service not available' }; + } + + try { + loading = true; + const result = await authService.signUp(email, password); + + if (result.success) { + // Auto-login after signup + const signInResult = await this.signIn(email, password); + return signInResult; + } + + return { success: false, error: result.error || 'Signup failed' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Signup failed', + }; + } finally { + loading = false; + } + }, + + async signOut(): Promise { + const authService = await getAuthService(); + if (authService) { + try { + await authService.signOut(); + } catch (error) { + console.error('Sign out error:', error); + } + } + user = null; + }, + + async resetPassword(email: string): Promise { + const authService = await getAuthService(); + if (!authService) { + return { success: false, error: 'Auth service not available' }; + } + + try { + const result = await authService.forgotPassword(email); + return { + success: result.success, + error: result.error, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Password reset failed', + }; + } + }, + + async getAccessToken(): Promise { + const authService = await getAuthService(); + if (!authService) return null; + return authService.getAppToken(); + }, + + // For compatibility with old code that reads user store directly + setUser(userData: UserData | null) { + user = userData; + }, +}; + +// Export individual reactive getters for backward compatibility +export function getUser() { + return user; +} + +export function getLoading() { + return loading; +} + +export function getIsAuthenticated() { + return !!user; +} diff --git a/apps/picture/apps/web/src/lib/stores/boards.svelte.ts b/apps/picture/apps/web/src/lib/stores/boards.svelte.ts new file mode 100644 index 000000000..cb55ae3a7 --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/boards.svelte.ts @@ -0,0 +1,147 @@ +/** + * Boards Store - Svelte 5 Runes Version + */ + +import type { Board, BoardWithCount } from '$lib/api/boards'; + +// State using Svelte 5 runes +let boards = $state([]); +let currentBoard = $state(null); +let isLoadingBoards = $state(false); +let isLoadingBoard = $state(false); +let currentBoardsPage = $state(1); +let hasBoardsMore = $state(true); +let selectedBoard = $state(null); +let showCreateBoardModal = $state(false); +let showShareBoardModal = $state(false); +let shareBoardId = $state(null); + +// Derived state +const boardSettings = $derived({ + width: currentBoard?.canvasWidth || 2000, + height: currentBoard?.canvasHeight || 1500, + backgroundColor: currentBoard?.backgroundColor || '#ffffff', +}); + +export const boardsStore = { + get boards() { + return boards; + }, + get currentBoard() { + return currentBoard; + }, + get isLoadingBoards() { + return isLoadingBoards; + }, + get isLoadingBoard() { + return isLoadingBoard; + }, + get currentBoardsPage() { + return currentBoardsPage; + }, + get hasBoardsMore() { + return hasBoardsMore; + }, + get selectedBoard() { + return selectedBoard; + }, + get showCreateBoardModal() { + return showCreateBoardModal; + }, + get showShareBoardModal() { + return showShareBoardModal; + }, + get shareBoardId() { + return shareBoardId; + }, + get boardSettings() { + return boardSettings; + }, + + setBoards(newBoards: BoardWithCount[]) { + boards = newBoards; + }, + + appendBoards(newBoards: BoardWithCount[]) { + boards = [...boards, ...newBoards]; + }, + + addBoard(board: BoardWithCount) { + boards = [board, ...boards]; + }, + + updateBoardInList(boardId: string, updates: Partial) { + boards = boards.map((board) => (board.id === boardId ? { ...board, ...updates } : board)); + }, + + removeBoardFromList(boardId: string) { + boards = boards.filter((board) => board.id !== boardId); + }, + + incrementBoardItemCount(boardId: string) { + boards = boards.map((board) => + board.id === boardId ? { ...board, itemCount: board.itemCount + 1 } : board, + ); + }, + + decrementBoardItemCount(boardId: string) { + boards = boards.map((board) => + board.id === boardId ? { ...board, itemCount: Math.max(0, board.itemCount - 1) } : board, + ); + }, + + setCurrentBoard(board: Board | null) { + currentBoard = board; + }, + + setLoadingBoards(loading: boolean) { + isLoadingBoards = loading; + }, + + setLoadingBoard(loading: boolean) { + isLoadingBoard = loading; + }, + + setCurrentBoardsPage(page: number) { + currentBoardsPage = page; + }, + + setHasBoardsMore(more: boolean) { + hasBoardsMore = more; + }, + + setSelectedBoard(board: Board | null) { + selectedBoard = board; + }, + + setShowCreateBoardModal(show: boolean) { + showCreateBoardModal = show; + }, + + setShowShareBoardModal(show: boolean) { + showShareBoardModal = show; + }, + + setShareBoardId(id: string | null) { + shareBoardId = id; + }, + + resetBoardsState() { + boards = []; + currentBoardsPage = 1; + hasBoardsMore = true; + }, +}; + +// Export individual getters for backwards compatibility +export function getBoards() { + return boards; +} + +export function getCurrentBoard() { + return currentBoard; +} + +export function getBoardSettings() { + return boardSettings; +} diff --git a/apps/picture/apps/web/src/lib/stores/canvas.svelte.ts b/apps/picture/apps/web/src/lib/stores/canvas.svelte.ts new file mode 100644 index 000000000..5e56d4680 --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/canvas.svelte.ts @@ -0,0 +1,342 @@ +/** + * Canvas Store - Svelte 5 Runes Version + */ + +import type { BoardItem, BoardImageItem, BoardTextItem } from '$lib/api/boardItems'; +import { isImageItem, isTextItem } from '$lib/api/boardItems'; + +// Canvas items (images and texts on the board) +let canvasItems = $state([]); + +// Selected items on canvas +let selectedItemIds = $state([]); + +// Canvas view state +let canvasZoom = $state(1); +let canvasPan = $state({ x: 0, y: 0 }); + +// Canvas interaction mode +export type CanvasMode = 'select' | 'pan' | 'draw'; +let canvasMode = $state('select'); + +// Canvas tools +let showGrid = $state(true); +let snapToGrid = $state(false); +let gridSize = $state(20); + +// UI state +let showPropertiesPanel = $state(false); + +// Text editing state +let editingTextId = $state(null); + +// Loading state +let isLoadingCanvasItems = $state(false); + +// History for undo/redo +interface HistoryState { + items: BoardItem[]; + timestamp: number; +} + +let canvasHistory = $state([]); +let canvasHistoryIndex = $state(-1); + +// Derived states +const selectedItems = $derived(canvasItems.filter((item) => selectedItemIds.includes(item.id))); + +const selectedTextItems = $derived(selectedItems.filter(isTextItem)); + +const selectedImageItems = $derived(selectedItems.filter(isImageItem)); + +const hasMixedSelection = $derived(selectedTextItems.length > 0 && selectedImageItems.length > 0); + +const hasSelection = $derived(selectedItemIds.length > 0); + +const isEditingText = $derived(editingTextId !== null); + +const canUndo = $derived(canvasHistoryIndex > 0); + +const canRedo = $derived(canvasHistoryIndex < canvasHistory.length - 1); + +export const canvasStore = { + // Getters + get items() { + return canvasItems; + }, + get selectedItemIds() { + return selectedItemIds; + }, + get selectedItems() { + return selectedItems; + }, + get selectedTextItems() { + return selectedTextItems; + }, + get selectedImageItems() { + return selectedImageItems; + }, + get hasMixedSelection() { + return hasMixedSelection; + }, + get hasSelection() { + return hasSelection; + }, + get zoom() { + return canvasZoom; + }, + get pan() { + return canvasPan; + }, + get mode() { + return canvasMode; + }, + get showGrid() { + return showGrid; + }, + get snapToGrid() { + return snapToGrid; + }, + get gridSize() { + return gridSize; + }, + get showPropertiesPanel() { + return showPropertiesPanel; + }, + get editingTextId() { + return editingTextId; + }, + get isEditingText() { + return isEditingText; + }, + get isLoading() { + return isLoadingCanvasItems; + }, + get canUndo() { + return canUndo; + }, + get canRedo() { + return canRedo; + }, + + // Setters + setItems(items: BoardItem[]) { + canvasItems = items; + }, + + setLoading(loading: boolean) { + isLoadingCanvasItems = loading; + }, + + setMode(mode: CanvasMode) { + canvasMode = mode; + }, + + setShowGrid(show: boolean) { + showGrid = show; + }, + + setSnapToGrid(snap: boolean) { + snapToGrid = snap; + }, + + setGridSize(size: number) { + gridSize = size; + }, + + setShowPropertiesPanel(show: boolean) { + showPropertiesPanel = show; + }, + + // Item management + addItem(item: BoardItem) { + canvasItems = [...canvasItems, item]; + saveToHistory(); + }, + + updateItem(id: string, updates: Partial) { + canvasItems = canvasItems.map((item) => (item.id === id ? { ...item, ...updates } : item)); + saveToHistory(); + }, + + removeItem(id: string) { + canvasItems = canvasItems.filter((item) => item.id !== id); + selectedItemIds = selectedItemIds.filter((itemId) => itemId !== id); + saveToHistory(); + }, + + removeSelectedItems() { + const ids = selectedItemIds; + canvasItems = canvasItems.filter((item) => !ids.includes(item.id)); + selectedItemIds = []; + saveToHistory(); + }, + + // Selection management + selectItem(id: string, multi = false) { + if (multi) { + if (selectedItemIds.includes(id)) { + selectedItemIds = selectedItemIds.filter((itemId) => itemId !== id); + } else { + selectedItemIds = [...selectedItemIds, id]; + } + } else { + selectedItemIds = [id]; + } + }, + + selectAll() { + selectedItemIds = canvasItems.map((item) => item.id); + }, + + deselectAll() { + selectedItemIds = []; + }, + + // Text editing + startEditingText(id: string) { + editingTextId = id; + }, + + stopEditingText() { + editingTextId = null; + }, + + // Z-index management + bringToFront(id: string) { + const maxZIndex = Math.max(...canvasItems.map((item) => item.zIndex)); + canvasStore.updateItem(id, { zIndex: maxZIndex + 1 }); + }, + + sendToBack(id: string) { + const minZIndex = Math.min(...canvasItems.map((item) => item.zIndex)); + canvasStore.updateItem(id, { zIndex: minZIndex - 1 }); + }, + + moveForward(id: string) { + const item = canvasItems.find((i) => i.id === id); + if (!item) return; + + const itemsAbove = canvasItems.filter((i) => i.zIndex > item.zIndex); + if (itemsAbove.length === 0) return; + + const nextZIndex = Math.min(...itemsAbove.map((i) => i.zIndex)); + canvasStore.updateItem(id, { zIndex: nextZIndex + 0.5 }); + }, + + moveBackward(id: string) { + const item = canvasItems.find((i) => i.id === id); + if (!item) return; + + const itemsBelow = canvasItems.filter((i) => i.zIndex < item.zIndex); + if (itemsBelow.length === 0) return; + + const prevZIndex = Math.max(...itemsBelow.map((i) => i.zIndex)); + canvasStore.updateItem(id, { zIndex: prevZIndex - 0.5 }); + }, + + // Zoom functions + zoomIn() { + canvasZoom = Math.min(canvasZoom * 1.2, 5); + }, + + zoomOut() { + canvasZoom = Math.max(canvasZoom / 1.2, 0.1); + }, + + setZoom(zoom: number) { + canvasZoom = zoom; + }, + + setPan(pan: { x: number; y: number }) { + canvasPan = pan; + }, + + zoomToFit(containerWidth: number, containerHeight: number, boardWidth: number, boardHeight: number) { + const scaleX = containerWidth / boardWidth; + const scaleY = containerHeight / boardHeight; + const scale = Math.min(scaleX, scaleY) * 0.9; + canvasZoom = scale; + canvasPan = { x: 0, y: 0 }; + }, + + resetZoom() { + canvasZoom = 1; + canvasPan = { x: 0, y: 0 }; + }, + + // History management + undo() { + if (canvasHistoryIndex <= 0) return; + + const prevState = canvasHistory[canvasHistoryIndex - 1]; + canvasItems = JSON.parse(JSON.stringify(prevState.items)); + canvasHistoryIndex--; + }, + + redo() { + if (canvasHistoryIndex >= canvasHistory.length - 1) return; + + const nextState = canvasHistory[canvasHistoryIndex + 1]; + canvasItems = JSON.parse(JSON.stringify(nextState.items)); + canvasHistoryIndex++; + }, + + clearHistory() { + canvasHistory = []; + canvasHistoryIndex = -1; + }, + + // Reset + reset() { + canvasItems = []; + selectedItemIds = []; + canvasZoom = 1; + canvasPan = { x: 0, y: 0 }; + canvasMode = 'select'; + editingTextId = null; + canvasStore.clearHistory(); + }, + + // Grid snapping + snapPositionToGrid(x: number, y: number): { x: number; y: number } { + if (!snapToGrid) return { x, y }; + return { + x: Math.round(x / gridSize) * gridSize, + y: Math.round(y / gridSize) * gridSize, + }; + }, +}; + +// Internal helper +function saveToHistory() { + // Remove any history after current index + const newHistory = canvasHistory.slice(0, canvasHistoryIndex + 1); + + // Add current state + newHistory.push({ + items: JSON.parse(JSON.stringify(canvasItems)), + timestamp: Date.now(), + }); + + // Limit history to 50 states + if (newHistory.length > 50) { + newHistory.shift(); + } + + canvasHistory = newHistory; + canvasHistoryIndex = newHistory.length - 1; +} + +// Export for backwards compatibility +export function getCanvasItems() { + return canvasItems; +} + +export function getSelectedItemIds() { + return selectedItemIds; +} + +export function getCanvasZoom() { + return canvasZoom; +} diff --git a/apps/picture/apps/web/src/lib/stores/contextMenu.svelte.ts b/apps/picture/apps/web/src/lib/stores/contextMenu.svelte.ts new file mode 100644 index 000000000..d952ae671 --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/contextMenu.svelte.ts @@ -0,0 +1,110 @@ +/** + * Context Menu Store - Svelte 5 Runes Version + */ + +import type { Image } from '$lib/api/images'; + +interface ContextMenuState { + visible: boolean; + x: number; + y: number; + image: Image | null; + showTagSubmenu: boolean; + submenuX: number; + submenuY: number; +} + +const initialState: ContextMenuState = { + visible: false, + x: 0, + y: 0, + image: null, + showTagSubmenu: false, + submenuX: 0, + submenuY: 0, +}; + +let contextMenuState = $state({ ...initialState }); + +export const contextMenuStore = { + get state() { + return contextMenuState; + }, + get visible() { + return contextMenuState.visible; + }, + get x() { + return contextMenuState.x; + }, + get y() { + return contextMenuState.y; + }, + get image() { + return contextMenuState.image; + }, + get showTagSubmenu() { + return contextMenuState.showTagSubmenu; + }, + get submenuX() { + return contextMenuState.submenuX; + }, + get submenuY() { + return contextMenuState.submenuY; + }, + + show(x: number, y: number, image: Image) { + contextMenuState = { + visible: true, + x, + y, + image, + showTagSubmenu: false, + submenuX: 0, + submenuY: 0, + }; + }, + + hide() { + contextMenuState = { ...initialState }; + }, + + showTagSubmenu(x: number, y: number) { + contextMenuState = { + ...contextMenuState, + showTagSubmenu: true, + submenuX: x, + submenuY: y, + }; + }, + + hideTagSubmenu() { + contextMenuState = { + ...contextMenuState, + showTagSubmenu: false, + }; + }, +}; + +// Export for backwards compatibility +export function showContextMenu(x: number, y: number, image: Image) { + contextMenuStore.show(x, y, image); +} + +export function hideContextMenu() { + contextMenuStore.hide(); +} + +export function showTagSubmenu(x: number, y: number) { + contextMenuStore.showTagSubmenu(x, y); +} + +export function hideTagSubmenu() { + contextMenuStore.hideTagSubmenu(); +} + +export function getContextMenu() { + return contextMenuState; +} + +// Re-export for compatibility +export { contextMenuState as contextMenu }; diff --git a/apps/picture/apps/web/src/lib/stores/explore.svelte.ts b/apps/picture/apps/web/src/lib/stores/explore.svelte.ts new file mode 100644 index 000000000..ede2928d6 --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/explore.svelte.ts @@ -0,0 +1,105 @@ +/** + * Explore Store - Svelte 5 Runes Version + */ + +import type { Image } from '$lib/api/images'; + +// State using Svelte 5 runes +let exploreImages = $state([]); +let isLoadingExplore = $state(false); +let hasMoreExplore = $state(true); +let currentExplorePage = $state(1); +let exploreSortBy = $state<'recent' | 'popular' | 'trending'>('recent'); +let exploreSearchQuery = $state(''); +let showExploreFavoritesOnly = $state(false); + +export const exploreStore = { + get images() { + return exploreImages; + }, + get isLoading() { + return isLoadingExplore; + }, + get hasMore() { + return hasMoreExplore; + }, + get currentPage() { + return currentExplorePage; + }, + get sortBy() { + return exploreSortBy; + }, + get searchQuery() { + return exploreSearchQuery; + }, + get showFavoritesOnly() { + return showExploreFavoritesOnly; + }, + + setImages(images: Image[]) { + exploreImages = images; + }, + + appendImages(images: Image[]) { + exploreImages = [...exploreImages, ...images]; + }, + + setLoading(loading: boolean) { + isLoadingExplore = loading; + }, + + setHasMore(more: boolean) { + hasMoreExplore = more; + }, + + setCurrentPage(page: number) { + currentExplorePage = page; + }, + + incrementPage() { + currentExplorePage++; + }, + + setSortBy(sort: 'recent' | 'popular' | 'trending') { + exploreSortBy = sort; + // Reset when changing sort + exploreImages = []; + currentExplorePage = 1; + hasMoreExplore = true; + }, + + setSearchQuery(query: string) { + exploreSearchQuery = query; + // Reset when changing search + exploreImages = []; + currentExplorePage = 1; + hasMoreExplore = true; + }, + + setShowFavoritesOnly(favoritesOnly: boolean) { + showExploreFavoritesOnly = favoritesOnly; + // Reset when changing filter + exploreImages = []; + currentExplorePage = 1; + hasMoreExplore = true; + }, + + reset() { + exploreImages = []; + isLoadingExplore = false; + hasMoreExplore = true; + currentExplorePage = 1; + exploreSortBy = 'recent'; + exploreSearchQuery = ''; + showExploreFavoritesOnly = false; + }, +}; + +// Export individual getters for backwards compatibility +export function getExploreImages() { + return exploreImages; +} + +export function getExploreSortBy() { + return exploreSortBy; +} diff --git a/apps/picture/apps/web/src/lib/stores/generate.svelte.ts b/apps/picture/apps/web/src/lib/stores/generate.svelte.ts new file mode 100644 index 000000000..d6c37e326 --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/generate.svelte.ts @@ -0,0 +1,73 @@ +/** + * Generate Store - Svelte 5 Runes Version + */ + +// State using Svelte 5 runes +let isGenerating = $state(false); +let generationProgress = $state(''); +let generationError = $state(''); +let currentGenerationId = $state(null); + +export const generateStore = { + get isGenerating() { + return isGenerating; + }, + get generationProgress() { + return generationProgress; + }, + get generationError() { + return generationError; + }, + get currentGenerationId() { + return currentGenerationId; + }, + + startGeneration(generationId?: string) { + isGenerating = true; + generationProgress = 'Starting...'; + generationError = ''; + currentGenerationId = generationId || null; + }, + + updateProgress(progress: string) { + generationProgress = progress; + }, + + setError(error: string) { + generationError = error; + isGenerating = false; + }, + + completeGeneration() { + isGenerating = false; + generationProgress = 'Complete!'; + currentGenerationId = null; + }, + + cancelGeneration() { + isGenerating = false; + generationProgress = ''; + generationError = ''; + currentGenerationId = null; + }, + + reset() { + isGenerating = false; + generationProgress = ''; + generationError = ''; + currentGenerationId = null; + }, +}; + +// Export individual getters for backwards compatibility +export function getIsGenerating() { + return isGenerating; +} + +export function getGenerationProgress() { + return generationProgress; +} + +export function getGenerationError() { + return generationError; +} diff --git a/apps/picture/apps/web/src/lib/stores/images.svelte.ts b/apps/picture/apps/web/src/lib/stores/images.svelte.ts new file mode 100644 index 000000000..12f0cb3c0 --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/images.svelte.ts @@ -0,0 +1,102 @@ +/** + * Images Store - Svelte 5 Runes Version + */ + +import type { Image } from '$lib/api/images'; + +// State using Svelte 5 runes +let images = $state([]); +let selectedImage = $state(null); +let isLoading = $state(false); +let hasMore = $state(true); +let currentPage = $state(1); +let showFavoritesOnly = $state(false); + +export const imagesStore = { + get images() { + return images; + }, + get selectedImage() { + return selectedImage; + }, + get isLoading() { + return isLoading; + }, + get hasMore() { + return hasMore; + }, + get currentPage() { + return currentPage; + }, + get showFavoritesOnly() { + return showFavoritesOnly; + }, + + setImages(newImages: Image[]) { + images = newImages; + }, + + appendImages(newImages: Image[]) { + images = [...images, ...newImages]; + }, + + addImage(image: Image) { + images = [image, ...images]; + }, + + updateImage(id: string, updates: Partial) { + images = images.map((img) => (img.id === id ? { ...img, ...updates } : img)); + }, + + removeImage(id: string) { + images = images.filter((img) => img.id !== id); + if (selectedImage?.id === id) { + selectedImage = null; + } + }, + + selectImage(image: Image | null) { + selectedImage = image; + }, + + setLoading(loading: boolean) { + isLoading = loading; + }, + + setHasMore(more: boolean) { + hasMore = more; + }, + + setCurrentPage(page: number) { + currentPage = page; + }, + + incrementPage() { + currentPage++; + }, + + setShowFavoritesOnly(favoritesOnly: boolean) { + showFavoritesOnly = favoritesOnly; + }, + + reset() { + images = []; + selectedImage = null; + isLoading = false; + hasMore = true; + currentPage = 1; + }, +}; + +// Export individual getters for backwards compatibility +export function getImages() { + return images; +} + +export function getSelectedImage() { + return selectedImage; +} + +export function getIsLoading() { + return isLoading; +} diff --git a/apps/picture/apps/web/src/lib/stores/models.svelte.ts b/apps/picture/apps/web/src/lib/stores/models.svelte.ts new file mode 100644 index 000000000..3a325a5c7 --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/models.svelte.ts @@ -0,0 +1,61 @@ +/** + * Models Store - Svelte 5 Runes Version + */ + +import type { Model } from '$lib/api/models'; + +// State using Svelte 5 runes +let models = $state([]); +let selectedModel = $state(null); +let isLoadingModels = $state(false); + +export const modelsStore = { + get models() { + return models; + }, + get selectedModel() { + return selectedModel; + }, + get isLoadingModels() { + return isLoadingModels; + }, + + setModels(newModels: Model[]) { + models = newModels; + // Auto-select default model if no model selected + if (!selectedModel && newModels.length > 0) { + const defaultModel = newModels.find((m) => m.isDefault) || newModels[0]; + selectedModel = defaultModel; + } + }, + + selectModel(model: Model | null) { + selectedModel = model; + }, + + selectModelById(id: string) { + const model = models.find((m) => m.id === id); + if (model) { + selectedModel = model; + } + }, + + setLoading(loading: boolean) { + isLoadingModels = loading; + }, + + reset() { + models = []; + selectedModel = null; + isLoadingModels = false; + }, +}; + +// Export individual getters for backwards compatibility +export function getModels() { + return models; +} + +export function getSelectedModel() { + return selectedModel; +} diff --git a/apps/picture/apps/web/src/lib/stores/sidebar.svelte.ts b/apps/picture/apps/web/src/lib/stores/sidebar.svelte.ts new file mode 100644 index 000000000..aa1aa7f2a --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/sidebar.svelte.ts @@ -0,0 +1,65 @@ +/** + * Sidebar Store - Svelte 5 Runes Version + */ + +import { browser } from '$app/environment'; + +const SIDEBAR_KEY = 'picture_sidebar_collapsed'; + +function loadInitialState(): boolean { + if (!browser) return false; + const saved = localStorage.getItem(SIDEBAR_KEY); + return saved === 'true'; +} + +let isSidebarCollapsed = $state(loadInitialState()); + +export const sidebarStore = { + get isCollapsed() { + return isSidebarCollapsed; + }, + + toggle() { + isSidebarCollapsed = !isSidebarCollapsed; + if (browser) { + localStorage.setItem(SIDEBAR_KEY, String(isSidebarCollapsed)); + } + }, + + setCollapsed(collapsed: boolean) { + isSidebarCollapsed = collapsed; + if (browser) { + localStorage.setItem(SIDEBAR_KEY, String(collapsed)); + } + }, + + expand() { + isSidebarCollapsed = false; + if (browser) { + localStorage.setItem(SIDEBAR_KEY, 'false'); + } + }, + + collapse() { + isSidebarCollapsed = true; + if (browser) { + localStorage.setItem(SIDEBAR_KEY, 'true'); + } + }, +}; + +// Export for backwards compatibility +export function getIsSidebarCollapsed() { + return isSidebarCollapsed; +} + +export function toggleSidebar() { + sidebarStore.toggle(); +} + +export function setSidebarCollapsed(collapsed: boolean) { + sidebarStore.setCollapsed(collapsed); +} + +// Re-export the writable-like interface for backward compatibility +export { isSidebarCollapsed }; diff --git a/apps/picture/apps/web/src/lib/stores/tags.svelte.ts b/apps/picture/apps/web/src/lib/stores/tags.svelte.ts new file mode 100644 index 000000000..2b571525d --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/tags.svelte.ts @@ -0,0 +1,84 @@ +/** + * Tags Store - Svelte 5 Runes Version + */ + +import type { Tag } from '$lib/api/tags'; + +// State using Svelte 5 runes +let tags = $state([]); +let selectedTags = $state([]); +let isLoadingTags = $state(false); + +export const tagsStore = { + get tags() { + return tags; + }, + get selectedTags() { + return selectedTags; + }, + get isLoadingTags() { + return isLoadingTags; + }, + + setTags(newTags: Tag[]) { + tags = newTags; + }, + + addTag(tag: Tag) { + tags = [...tags, tag]; + }, + + updateTag(id: string, updates: Partial) { + tags = tags.map((tag) => (tag.id === id ? { ...tag, ...updates } : tag)); + }, + + removeTag(id: string) { + tags = tags.filter((tag) => tag.id !== id); + selectedTags = selectedTags.filter((tagId) => tagId !== id); + }, + + selectTag(tagId: string) { + if (!selectedTags.includes(tagId)) { + selectedTags = [...selectedTags, tagId]; + } + }, + + deselectTag(tagId: string) { + selectedTags = selectedTags.filter((id) => id !== tagId); + }, + + toggleTag(tagId: string) { + if (selectedTags.includes(tagId)) { + selectedTags = selectedTags.filter((id) => id !== tagId); + } else { + selectedTags = [...selectedTags, tagId]; + } + }, + + setSelectedTags(tagIds: string[]) { + selectedTags = tagIds; + }, + + clearSelectedTags() { + selectedTags = []; + }, + + setLoading(loading: boolean) { + isLoadingTags = loading; + }, + + reset() { + tags = []; + selectedTags = []; + isLoadingTags = false; + }, +}; + +// Export individual getters for backwards compatibility +export function getTags() { + return tags; +} + +export function getSelectedTags() { + return selectedTags; +} diff --git a/apps/picture/apps/web/src/lib/stores/toast.svelte.ts b/apps/picture/apps/web/src/lib/stores/toast.svelte.ts new file mode 100644 index 000000000..cfd0af312 --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/toast.svelte.ts @@ -0,0 +1,80 @@ +/** + * Toast Store - Svelte 5 Runes Version + */ + +export type ToastType = 'success' | 'error' | 'info' | 'warning'; + +export interface Toast { + id: string; + message: string; + type: ToastType; + duration?: number; +} + +let toasts = $state([]); +let toastId = 0; + +export const toastStore = { + get toasts() { + return toasts; + }, + + show(message: string, type: ToastType = 'info', duration = 5000): string { + const id = `toast-${toastId++}`; + const toast: Toast = { id, message, type, duration }; + + toasts = [...toasts, toast]; + + if (duration > 0) { + setTimeout(() => { + toastStore.dismiss(id); + }, duration); + } + + return id; + }, + + dismiss(id: string) { + toasts = toasts.filter((toast) => toast.id !== id); + }, + + clear() { + toasts = []; + }, + + success(message: string, duration = 5000) { + return toastStore.show(message, 'success', duration); + }, + + error(message: string, duration = 5000) { + return toastStore.show(message, 'error', duration); + }, + + warning(message: string, duration = 5000) { + return toastStore.show(message, 'warning', duration); + }, + + info(message: string, duration = 5000) { + return toastStore.show(message, 'info', duration); + }, +}; + +// Export for backwards compatibility +export function showToast(message: string, type: ToastType = 'info', duration = 5000) { + return toastStore.show(message, type, duration); +} + +export function dismissToast(id: string) { + toastStore.dismiss(id); +} + +export function clearToasts() { + toastStore.clear(); +} + +export function getToasts() { + return toasts; +} + +// Re-export for compatibility +export { toasts }; diff --git a/apps/picture/apps/web/src/lib/stores/ui.svelte.ts b/apps/picture/apps/web/src/lib/stores/ui.svelte.ts new file mode 100644 index 000000000..8483c6311 --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/ui.svelte.ts @@ -0,0 +1,73 @@ +/** + * UI Store - Svelte 5 Runes Version + */ + +import { browser } from '$app/environment'; + +const UI_VISIBLE_KEY = 'picture_ui_visible'; + +function loadInitialState(): boolean { + if (!browser) return true; + const saved = localStorage.getItem(UI_VISIBLE_KEY); + return saved !== 'false'; // Default to true +} + +let isUIVisible = $state(loadInitialState()); +let showKeyboardShortcuts = $state(false); + +export const uiStore = { + get isVisible() { + return isUIVisible; + }, + get showKeyboardShortcuts() { + return showKeyboardShortcuts; + }, + + toggle() { + isUIVisible = !isUIVisible; + if (browser) { + localStorage.setItem(UI_VISIBLE_KEY, String(isUIVisible)); + } + }, + + setVisible(visible: boolean) { + isUIVisible = visible; + if (browser) { + localStorage.setItem(UI_VISIBLE_KEY, String(visible)); + } + }, + + show() { + isUIVisible = true; + if (browser) { + localStorage.setItem(UI_VISIBLE_KEY, 'true'); + } + }, + + hide() { + isUIVisible = false; + if (browser) { + localStorage.setItem(UI_VISIBLE_KEY, 'false'); + } + }, + + setShowKeyboardShortcuts(show: boolean) { + showKeyboardShortcuts = show; + }, + + toggleKeyboardShortcuts() { + showKeyboardShortcuts = !showKeyboardShortcuts; + }, +}; + +// Export for backwards compatibility +export function toggleUI() { + uiStore.toggle(); +} + +export function getIsUIVisible() { + return isUIVisible; +} + +// Re-export for compatibility +export { isUIVisible, showKeyboardShortcuts }; diff --git a/apps/picture/apps/web/src/lib/stores/view.svelte.ts b/apps/picture/apps/web/src/lib/stores/view.svelte.ts new file mode 100644 index 000000000..649775cbc --- /dev/null +++ b/apps/picture/apps/web/src/lib/stores/view.svelte.ts @@ -0,0 +1,67 @@ +/** + * View Store - Svelte 5 Runes Version + */ + +import { browser } from '$app/environment'; + +export type ViewMode = 'single' | 'grid3' | 'grid5'; + +const VIEW_MODE_KEY = 'picture_view_mode'; + +function loadInitialViewMode(): ViewMode { + if (!browser) { + return 'grid3'; + } + const saved = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null; + return saved || 'grid3'; +} + +let viewMode = $state(loadInitialViewMode()); + +export const viewStore = { + get mode() { + return viewMode; + }, + + set(mode: ViewMode) { + viewMode = mode; + if (browser) { + localStorage.setItem(VIEW_MODE_KEY, mode); + } + }, + + cycle() { + const modes: ViewMode[] = ['single', 'grid3', 'grid5']; + const currentIndex = modes.indexOf(viewMode); + const nextMode = modes[(currentIndex + 1) % modes.length]; + viewStore.set(nextMode); + }, + + setSingle() { + viewStore.set('single'); + }, + + setGrid3() { + viewStore.set('grid3'); + }, + + setGrid5() { + viewStore.set('grid5'); + }, +}; + +// Export for backwards compatibility +export function setViewMode(mode: ViewMode) { + viewStore.set(mode); +} + +export function cycleViewMode() { + viewStore.cycle(); +} + +export function getViewMode() { + return viewMode; +} + +// Re-export for compatibility +export { viewMode }; diff --git a/apps/picture/apps/web/src/routes/+layout.svelte b/apps/picture/apps/web/src/routes/+layout.svelte index c6347e49d..61f0ae4d7 100644 --- a/apps/picture/apps/web/src/routes/+layout.svelte +++ b/apps/picture/apps/web/src/routes/+layout.svelte @@ -1,8 +1,7 @@ diff --git a/apps/picture/apps/web/src/routes/app/+layout.svelte b/apps/picture/apps/web/src/routes/app/+layout.svelte index aaf62170b..9eca5fa97 100644 --- a/apps/picture/apps/web/src/routes/app/+layout.svelte +++ b/apps/picture/apps/web/src/routes/app/+layout.svelte @@ -1,5 +1,5 @@