diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index a398990..ab7efd3 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -4,15 +4,19 @@ import type { SupabaseClient } from '@supabase/supabase-js'; let supabaseClient: SupabaseClient | null = null; export function createClient() { - if (supabaseClient) return supabaseClient; - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl || !supabaseAnonKey) { - throw new Error('Supabase URL and Anon Key must be defined'); + if (typeof window === 'undefined') { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + return createBrowserClient(supabaseUrl, supabaseAnonKey); } - supabaseClient = createBrowserClient(supabaseUrl, supabaseAnonKey); - return supabaseClient; + // Singleton for client-side to prevent multiple instances + // Use window object to persist across module reloads in dev + if (!(window as any).supabase) { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + (window as any).supabase = createBrowserClient(supabaseUrl, supabaseAnonKey); + } + + return (window as any).supabase as SupabaseClient; } diff --git a/src/middleware.ts b/src/middleware.ts index 19dd388..332b344 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -52,8 +52,6 @@ export async function middleware(request: NextRequest) { path.startsWith("/_next"); // Static assets // 2. Specialized Logic for /splits - // - Public: /splits/[slug] - // - Protected: /splits/create, /splits/manage const isSplitsPublic = path.startsWith("/splits/") && !path.startsWith("/splits/create") && @@ -75,11 +73,6 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(redirectUrl); } - // 4. Admin Protection (Optional Layer in Middleware, but Page also checks) - // We rely on Page component for Admin role check to avoid DB hit in middleware if possible, - // OR we can trust getUser() + Page logic. - // Middleware mainly ensures *Authentication*. Authorization is Page level. - return response; } diff --git a/src/proxy.ts b/src/proxy.ts deleted file mode 100644 index 11c1e39..0000000 --- a/src/proxy.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createServerClient, type CookieOptions } from '@supabase/ssr'; -import { NextResponse, type NextRequest } from 'next/server'; - -export async function proxy(request: NextRequest) { - let response = NextResponse.next({ - request: { - headers: request.headers, - }, - }); - - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return request.cookies.getAll(); - }, - setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) { - cookiesToSet.forEach(({ name, value, options }) => - request.cookies.set(name, value) - ); - response = NextResponse.next({ - request: { - headers: request.headers, - }, - }); - cookiesToSet.forEach(({ name, value, options }) => - response.cookies.set(name, value, options) - ); - }, - }, - } - ); - - const { data: { user } } = await supabase.auth.getUser(); - - const url = new URL(request.url); - const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico'; - - if (!isStatic) { - const status = user ? `User:${user.id.slice(0, 8)}` : 'No Session'; - console.log(`[Proxy] ${request.method} ${url.pathname} | ${status}`); - } - - return response; -} - -export const config = { - matcher: [ - '/((?!_next/static|_next/image|favicon.ico).*)', - ], -}; diff --git a/src/services/analyze-bottle-mistral.ts b/src/services/analyze-bottle-mistral.ts new file mode 100644 index 0000000..e0a3c21 --- /dev/null +++ b/src/services/analyze-bottle-mistral.ts @@ -0,0 +1,191 @@ +'use server'; + +import { Mistral } from '@mistralai/mistralai'; +import { getSystemPrompt } from '@/lib/ai-prompts'; +import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky'; +import { createClient } from '@/lib/supabase/server'; +import { createHash } from 'crypto'; +import { trackApiUsage } from './track-api-usage'; +import { checkCreditBalance, deductCredits } from './credit-service'; + +// WICHTIG: Wir akzeptieren jetzt FormData statt Strings +export async function analyzeBottleMistral(input: any): Promise { + if (!process.env.MISTRAL_API_KEY) { + return { success: false, error: 'MISTRAL_API_KEY is not configured.' }; + } + + let supabase; + try { + // Helper to get value from either FormData or POJO + const getValue = (obj: any, key: string): any => { + if (obj && typeof obj.get === 'function') return obj.get(key); + if (obj && typeof obj[key] !== 'undefined') return obj[key]; + return null; + }; + + // 1. Daten extrahieren + const file = getValue(input, 'file') as File; + const tagsString = getValue(input, 'tags') as string; + const locale = getValue(input, 'locale') || 'de'; + + if (!file) { + return { success: false, error: 'Kein Bild empfangen.' }; + } + + const tags = tagsString ? (typeof tagsString === 'string' ? JSON.parse(tagsString) : tagsString) : []; + + // 2. Auth & Credits + supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' }; + } + + const userId = user.id; + const creditCheck = await checkCreditBalance(userId, 'gemini_ai'); + if (!creditCheck.allowed) { + return { + success: false, + error: `Nicht genügend Credits. Benötigt: ${creditCheck.cost}, Verfügbar: ${creditCheck.balance}.` + }; + } + + // 3. Datei in Buffer umwandeln + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // 4. Hash für Cache erstellen + const imageHash = createHash('sha256').update(buffer).digest('hex'); + + // Cache Check + const { data: cachedResult } = await supabase + .from('vision_cache') + .select('result') + .eq('hash', imageHash) + .maybeSingle(); + + if (cachedResult) { + return { + success: true, + data: cachedResult.result as any, + perf: { + apiDuration: 0, + parseDuration: 0, + uploadSize: buffer.length + } + }; + } + + // 5. Für Mistral vorbereiten + const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY }); + const base64Data = buffer.toString('base64'); + const mimeType = file.type || 'image/webp'; + const dataUrl = `data:${mimeType};base64,${base64Data}`; + const uploadSize = buffer.length; + + const prompt = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale); + + try { + const startApi = performance.now(); + const chatResponse = await client.chat.complete({ + model: 'mistral-large-latest', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'image_url', imageUrl: dataUrl }H + ] + } + ], + responseFormat: { type: 'json_object' }, + temperature: 0.1 + }); + const endApi = performance.now(); + + const startParse = performance.now(); + const rawContent = chatResponse.choices?.[0].message.content; + if (!rawContent) throw new Error("Keine Antwort von Mistral"); + + let jsonData; + try { + jsonData = JSON.parse(rawContent as string); + } catch (e) { + const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, ''); + jsonData = JSON.parse(cleanedText); + } + + if (Array.isArray(jsonData)) jsonData = jsonData[0]; + console.log('[Mistral AI] JSON Response:', jsonData); + + const searchString = jsonData.search_string; + delete jsonData.search_string; + + if (typeof jsonData.abv === 'string') { + // Fix: Global replace to remove all % signs + jsonData.abv = parseFloat(jsonData.abv.replace(/%/g, '').trim()); + } + + if (jsonData.age) jsonData.age = parseInt(jsonData.age); + if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage); + + const validatedData = BottleMetadataSchema.parse(jsonData); + const endParse = performance.now(); + + await trackApiUsage({ + userId: userId, + apiType: 'gemini_ai', + endpoint: 'mistral/mistral-large', + success: true, + provider: 'mistral', + model: 'mistral-large-latest', + responseText: rawContent as string + }); + + await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis'); + + await supabase + .from('vision_cache') + .insert({ hash: imageHash, result: validatedData }); + + return { + success: true, + data: validatedData, + search_string: searchString, + perf: { + apiDuration: endApi - startApi, + parseDuration: endParse - startParse, + uploadSize: uploadSize + }, + raw: jsonData + }; + + } catch (aiError: any) { + console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message); + + await trackApiUsage({ + userId: userId, + apiType: 'gemini_ai', + endpoint: 'mistral/mistral-large', + success: false, + errorMessage: aiError.message, + provider: 'mistral', + model: 'mistral-large-latest' + }); + + return { + success: false, + isAiError: true, + error: aiError.message, + imageHash: imageHash + } as any; + } + + } catch (error) { + console.error('Mistral Analysis Global Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Mistral AI analysis failed.', + }; + } +}