From 21ca704abcf10e061a753e00cb1ec8563c8273bf Mon Sep 17 00:00:00 2001 From: robin Date: Sun, 4 Jan 2026 23:50:35 +0100 Subject: [PATCH] Fix Storage RLS, refactor AI analysis to Base64, and improve ScanAndTaste save flow --- src/app/auth/callback/route.ts | 5 +- src/components/BottomNavigation.tsx | 80 ++++++++++++++++-- src/components/ScanAndTasteFlow.tsx | 122 ++++++++++++++++++---------- src/services/bulk-scan.ts | 67 +++++---------- supa_schema.sql | 30 +++++++ 5 files changed, 207 insertions(+), 97 deletions(-) diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 73a0049..cd96cbe 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -11,6 +11,9 @@ export async function GET(request: Request) { await supabase.auth.exchangeCodeForSession(code); } + // Prefer SITE_URL from env, fall back to request origin (local dev) + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || requestUrl.origin; + // URL to redirect to after sign in process completes - return NextResponse.redirect(requestUrl.origin); + return NextResponse.redirect(`${baseUrl}/`); } diff --git a/src/components/BottomNavigation.tsx b/src/components/BottomNavigation.tsx index e06b3d6..9637d4f 100644 --- a/src/components/BottomNavigation.tsx +++ b/src/components/BottomNavigation.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Home, Library, Camera, UserRound, GlassWater } from 'lucide-react'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { usePathname } from 'next/navigation'; import { useI18n } from '@/i18n/I18nContext'; @@ -39,16 +39,26 @@ const NavButton = ({ onClick, icon, label, ariaLabel, active }: NavButtonProps) export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onScan }: BottomNavigationProps) { const { t } = useI18n(); const pathname = usePathname(); - const fileInputRef = React.useRef(null); + const cameraInputRef = React.useRef(null); + const galleryInputRef = React.useRef(null); + const [showSourcePicker, setShowSourcePicker] = React.useState(false); - const handleScanClick = () => { - fileInputRef.current?.click(); + const handleCameraClick = () => { + cameraInputRef.current?.click(); + setShowSourcePicker(false); + }; + + const handleGalleryClick = () => { + galleryInputRef.current?.click(); + setShowSourcePicker(false); }; const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { onScan(file); + // Reset inputs + e.target.value = ''; } }; @@ -59,15 +69,67 @@ export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onSca return (
- {/* Hidden Input for Scanning */} + {/* Hidden Inputs for Scanning */} + + + {showSourcePicker && ( + <> + {/* Backdrop to close */} + setShowSourcePicker(false)} + /> + + {/* Source Picker Menu */} + + +
+ + + + )} + +
{/* Left Items */}
@@ -91,12 +153,12 @@ export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onSca {/* Center FAB */}
diff --git a/src/components/ScanAndTasteFlow.tsx b/src/components/ScanAndTasteFlow.tsx index 885f2d4..08aac5f 100644 --- a/src/components/ScanAndTasteFlow.tsx +++ b/src/components/ScanAndTasteFlow.tsx @@ -39,6 +39,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS const [isOffline, setIsOffline] = useState(typeof navigator !== 'undefined' ? !navigator.onLine : false); const [isEnriching, setIsEnriching] = useState(false); const [aiFallbackActive, setAiFallbackActive] = useState(false); + const [pendingTastingData, setPendingTastingData] = useState(null); // Use the Gemini-only scanner hook const scanner = useScanner({ @@ -174,18 +175,77 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS scanner.handleScan(file); }; + const performSave = async (formData: any, currentMetadata: BottleMetadata) => { + try { + // ONLINE: Normal save to Supabase + let user; + try { + const { data: { user: authUser } = {} } = await supabase.auth.getUser(); + if (!authUser) throw new Error('Nicht autorisiert'); + user = authUser; + } catch (authError: any) { + if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) { + console.log('[ScanFlow] Auth failed due to network - switching to offline mode'); + setIsOffline(true); + // Re-route back to original handleSaveTasting to trigger offline flow + return handleSaveTasting(formData); + } + throw authError; + } + + const bottleDataToSave = formData.bottleMetadata || currentMetadata; + const bottleResult = await saveBottle(bottleDataToSave, scanner.processedImage!.base64, user.id); + + if (!bottleResult.success || !bottleResult.data) { + throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche'); + } + + const bottleId = bottleResult.data.id; + + const tastingNote = { + ...formData, + bottle_id: bottleId, + }; + + const tastingResult = await saveTasting(tastingNote); + if (!tastingResult.success) { + throw new Error(tastingResult.error || 'Fehler beim Speichern des Tastings'); + } + + setTastingData(tastingNote); + setState('RESULT'); + + if (onBottleSaved) { + onBottleSaved(bottleId); + } + } catch (err: any) { + setError(err.message); + setState('ERROR'); + } finally { + setIsSaving(false); + setPendingTastingData(null); + } + }; + const handleSaveTasting = async (formData: any) => { - if (!bottleMetadata || !scanner.processedImage) return; + if (!scanner.mergedResult || !scanner.processedImage) return; setIsSaving(true); setError(null); + // If AI is still analyzing, put in "pending" status + if (scanner.isAnalyzing) { + console.log('[ScanFlow] AI still analyzing - queuing save'); + setPendingTastingData(formData); + return; + } + try { // OFFLINE: Save to IndexedDB queue if (isOffline) { console.log('[ScanFlow] Offline mode - queuing for upload'); const tempId = `temp_${Date.now()}`; - const bottleDataToSave = formData.bottleMetadata || bottleMetadata; + const bottleDataToSave = formData.bottleMetadata || scanner.mergedResult; const existingScan = await db.pending_scans .filter(s => s.imageBase64 === scanner.processedImage!.base64) @@ -227,53 +287,24 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS return; } - // ONLINE: Normal save to Supabase - let user; - try { - const { data: { user: authUser } = {} } = await supabase.auth.getUser(); - if (!authUser) throw new Error('Nicht autorisiert'); - user = authUser; - } catch (authError: any) { - if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) { - console.log('[ScanFlow] Auth failed due to network - switching to offline mode'); - setIsOffline(true); - return handleSaveTasting(formData); - } - throw authError; - } + // Normal online save + await performSave(formData, scanner.mergedResult); - const bottleDataToSave = formData.bottleMetadata || bottleMetadata; - const bottleResult = await saveBottle(bottleDataToSave, scanner.processedImage.base64, user.id); - if (!bottleResult.success || !bottleResult.data) { - throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche'); - } - - const bottleId = bottleResult.data.id; - - const tastingNote = { - ...formData, - bottle_id: bottleId, - }; - - const tastingResult = await saveTasting(tastingNote); - if (!tastingResult.success) { - throw new Error(tastingResult.error || 'Fehler beim Speichern des Tastings'); - } - - setTastingData(tastingNote); - setState('RESULT'); - - if (onBottleSaved) { - onBottleSaved(bottleId); - } } catch (err: any) { setError(err.message); setState('ERROR'); - } finally { setIsSaving(false); } }; + // New Effect: Watch for AI completion if we have a pending save + useEffect(() => { + if (!scanner.isAnalyzing && pendingTastingData && scanner.mergedResult) { + console.log('[ScanFlow] AI finished, triggering pending save'); + performSave(pendingTastingData, scanner.mergedResult); + } + }, [scanner.isAnalyzing, pendingTastingData, scanner.mergedResult]); + const handleShare = async () => { if (navigator.share) { try { @@ -479,7 +510,14 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS className="absolute inset-0 z-[80] bg-zinc-950/80 backdrop-blur-sm flex flex-col items-center justify-center gap-6" > -

Speichere Tasting...

+

+ {scanner.isAnalyzing ? 'Warte auf Analyse...' : 'Speichere Tasting...'} +

+ {scanner.isAnalyzing && ( +

+ KI verarbeitet Etikett-Details +

+ )} )} diff --git a/src/services/bulk-scan.ts b/src/services/bulk-scan.ts index 1b80e3c..896d106 100644 --- a/src/services/bulk-scan.ts +++ b/src/services/bulk-scan.ts @@ -91,8 +91,8 @@ export async function processBulkScan( } // 4. Trigger background analysis for all bottles (fire & forget) - // This won't block the response - triggerBackgroundAnalysis(bottleIds, user.id).catch(err => { + // Pass BOTH the bottle IDs and the original base64 images + triggerBackgroundAnalysis(bottleIds, imageDataUrls, user.id).catch(err => { console.error('Background analysis error:', err); }); @@ -166,10 +166,17 @@ async function uploadImage( * Trigger background AI analysis for bottles. * This runs asynchronously and updates bottles with results. */ -async function triggerBackgroundAnalysis(bottleIds: string[], userId: string): Promise { +async function triggerBackgroundAnalysis( + bottleIds: string[], + imageDataUrls: string[], + userId: string +): Promise { const supabase = await createClient(); - for (const bottleId of bottleIds) { + for (let i = 0; i < bottleIds.length; i++) { + const bottleId = bottleIds[i]; + const imageDataUrl = imageDataUrls[i]; + try { // Update status to analyzing await supabase @@ -177,20 +184,8 @@ async function triggerBackgroundAnalysis(bottleIds: string[], userId: string): P .update({ processing_status: 'analyzing' }) .eq('id', bottleId); - // Get bottle image - const { data: bottle } = await supabase - .from('bottles') - .select('image_url') - .eq('id', bottleId) - .single(); - - if (!bottle?.image_url) { - await markBottleError(supabase, bottleId, 'Kein Bild gefunden'); - continue; - } - - // Call Gemini analysis - const analysisResult = await analyzeBottleImage(bottle.image_url); + // Call AI analysis with the base64 data directly + const analysisResult = await analyzeBottleImage(imageDataUrl); if (analysisResult.success && analysisResult.data) { // Update bottle with AI results @@ -237,33 +232,18 @@ async function markBottleError( * Analyze bottle image using configured AI provider * Uses OpenRouter by default, falls back to Gemini */ -async function analyzeBottleImage(imageUrl: string): Promise<{ +async function analyzeBottleImage(dataUrl: string): Promise<{ success: boolean; - data?: { - name: string; - distillery?: string; - category?: string; - abv?: number; - age?: number; - is_whisky?: boolean; - confidence?: number; - }; + data?: any; error?: string; }> { const { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } = await import('@/lib/openrouter'); const provider = getAIProvider(); try { - // Fetch image and convert to base64 - const response = await fetch(imageUrl); - if (!response.ok) { - return { success: false, error: 'Bild konnte nicht geladen werden' }; - } - - const blob = await response.blob(); - const buffer = await blob.arrayBuffer(); - const base64 = Buffer.from(buffer).toString('base64'); - const mimeType = blob.type || 'image/webp'; + // Extract base64 and mime type from data URL + const base64Data = dataUrl.split(',')[1]; + const mimeType = dataUrl.match(/data:(.*?);/)?.[1] || 'image/webp'; const prompt = `Analyze this whisky bottle image. Extract: - name: Full product name @@ -277,14 +257,13 @@ async function analyzeBottleImage(imageUrl: string): Promise<{ Respond ONLY with valid JSON, no markdown.`; if (provider === 'openrouter') { - // OpenRouter path const client = getOpenRouterClient(); const openRouterResponse = await client.chat.completions.create({ model: 'google/gemma-3-27b-it', messages: [{ role: 'user', content: [ - { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64}` } }, + { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } }, { type: 'text', text: prompt }, ], }], @@ -296,13 +275,12 @@ Respond ONLY with valid JSON, no markdown.`; const content = openRouterResponse.choices[0]?.message?.content || '{}'; let jsonStr = content; - const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/); - if (jsonMatch) jsonStr = jsonMatch[1].trim(); + const jsonMatch = content.match(/\{[\s\S]*\}/); + if (jsonMatch) jsonStr = jsonMatch[0].trim(); const parsed = JSON.parse(jsonStr); return { success: true, data: parsed }; } else { - // Gemini path const apiKey = process.env.GEMINI_API_KEY; if (!apiKey) { return { success: false, error: 'GEMINI_API_KEY nicht konfiguriert' }; @@ -317,7 +295,7 @@ Respond ONLY with valid JSON, no markdown.`; contents: [{ parts: [ { text: prompt }, - { inline_data: { mime_type: mimeType, data: base64 } } + { inline_data: { mime_type: mimeType, data: base64Data } } ] }], generationConfig: { temperature: 0.1, maxOutputTokens: 500 } @@ -344,7 +322,6 @@ Respond ONLY with valid JSON, no markdown.`; const parsed = JSON.parse(jsonMatch[0]); return { success: true, data: parsed }; } - } catch (error) { console.error(`[BulkScan] ${provider} analysis error:`, error); return { success: false, error: 'Analysefehler' }; diff --git a/supa_schema.sql b/supa_schema.sql index 5856496..a0ec1cb 100644 --- a/supa_schema.sql +++ b/supa_schema.sql @@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS public.profiles ( id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY, username TEXT UNIQUE, avatar_url TEXT, + deletion_requested_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) ); @@ -42,6 +43,7 @@ CREATE TABLE IF NOT EXISTS public.bottles ( bottled_at TEXT, batch_info TEXT, suggested_tags TEXT[], + suggested_custom_tags TEXT[], created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()), updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) ); @@ -85,6 +87,7 @@ CREATE TABLE IF NOT EXISTS public.tastings ( palate_notes TEXT, finish_notes TEXT, audio_transcript_url TEXT, + is_sample BOOLEAN DEFAULT false, tasted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()), created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) ); @@ -325,6 +328,7 @@ ALTER TABLE public.tags ENABLE ROW LEVEL SECURITY; -- Policies CREATE POLICY "profiles_select_policy" ON public.profiles FOR SELECT USING (auth.uid() = id OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid())); +CREATE POLICY "profiles_insert_policy" ON public.profiles FOR INSERT WITH CHECK (auth.uid() = id); CREATE POLICY "profiles_update_policy" ON public.profiles FOR UPDATE USING (auth.uid() = id OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid())); CREATE POLICY "bottles_select_policy" ON public.bottles FOR SELECT USING ( @@ -385,3 +389,29 @@ INSERT INTO public.subscription_plans (name, display_name, monthly_credits, pric ('silver', 'Silver', 100, 8.99, 'Best value for power users', 3), ('gold', 'Gold', 250, 19.99, 'Unlimited searches for professionals', 4) ON CONFLICT (name) DO NOTHING; + +-- ============================================ +-- 8. STORAGE POLICIES +-- ============================================ + +-- Policies for 'bottles' bucket +-- These policies use a folder structure where the first part is the user's ID: auth.uid() +INSERT INTO storage.buckets (id, name, public) +VALUES ('bottles', 'bottles', false) +ON CONFLICT (id) DO NOTHING; + +CREATE POLICY "Allow authenticated uploads" ON storage.objects +FOR INSERT TO authenticated +WITH CHECK (bucket_id = 'bottles' AND (storage.foldername(name))[1] = auth.uid()::text); + +CREATE POLICY "Allow authenticated selects" ON storage.objects +FOR SELECT TO authenticated +USING (bucket_id = 'bottles'); + +CREATE POLICY "Allow authenticated updates" ON storage.objects +FOR UPDATE TO authenticated +USING (bucket_id = 'bottles' AND (storage.foldername(name))[1] = auth.uid()::text); + +CREATE POLICY "Allow authenticated deletes" ON storage.objects +FOR DELETE TO authenticated +USING (bucket_id = 'bottles' AND (storage.foldername(name))[1] = auth.uid()::text);