Fix Storage RLS, refactor AI analysis to Base64, and improve ScanAndTaste save flow

This commit is contained in:
2026-01-04 23:50:35 +01:00
parent 71586fd6a8
commit 21ca704abc
5 changed files with 207 additions and 97 deletions

View File

@@ -11,6 +11,9 @@ export async function GET(request: Request) {
await supabase.auth.exchangeCodeForSession(code); 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 // URL to redirect to after sign in process completes
return NextResponse.redirect(requestUrl.origin); return NextResponse.redirect(`${baseUrl}/`);
} }

View File

@@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import { Home, Library, Camera, UserRound, GlassWater } from 'lucide-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 { usePathname } from 'next/navigation';
import { useI18n } from '@/i18n/I18nContext'; 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) { export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onScan }: BottomNavigationProps) {
const { t } = useI18n(); const { t } = useI18n();
const pathname = usePathname(); const pathname = usePathname();
const fileInputRef = React.useRef<HTMLInputElement>(null); const cameraInputRef = React.useRef<HTMLInputElement>(null);
const galleryInputRef = React.useRef<HTMLInputElement>(null);
const [showSourcePicker, setShowSourcePicker] = React.useState(false);
const handleScanClick = () => { const handleCameraClick = () => {
fileInputRef.current?.click(); cameraInputRef.current?.click();
setShowSourcePicker(false);
};
const handleGalleryClick = () => {
galleryInputRef.current?.click();
setShowSourcePicker(false);
}; };
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
onScan(file); onScan(file);
// Reset inputs
e.target.value = '';
} }
}; };
@@ -59,15 +69,67 @@ export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onSca
return ( return (
<div className="fixed bottom-0 left-0 right-0 p-6 pb-10 z-50 pointer-events-none"> <div className="fixed bottom-0 left-0 right-0 p-6 pb-10 z-50 pointer-events-none">
{/* Hidden Input for Scanning */} {/* Hidden Inputs for Scanning */}
<input <input
type="file" type="file"
ref={fileInputRef} ref={cameraInputRef}
onChange={handleFileChange}
accept="image/*"
capture="environment"
className="hidden"
/>
<input
type="file"
ref={galleryInputRef}
onChange={handleFileChange} onChange={handleFileChange}
accept="image/*" accept="image/*"
className="hidden" className="hidden"
/> />
<AnimatePresence>
{showSourcePicker && (
<>
{/* Backdrop to close */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-40 pointer-events-auto"
onClick={() => setShowSourcePicker(false)}
/>
{/* Source Picker Menu */}
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.9 }}
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-50 pointer-events-auto w-48 bg-zinc-900/95 backdrop-blur-2xl border border-white/10 rounded-3xl p-2 shadow-2xl"
>
<button
onClick={handleCameraClick}
className="w-full flex items-center justify-between p-4 hover:bg-white/5 rounded-2xl transition-colors text-zinc-50"
>
<div className="flex items-center gap-3 font-bold text-xs uppercase tracking-widest">
<Camera size={18} className="text-orange-500" />
<span>Kamera</span>
</div>
<div className="w-2 h-2 rounded-full bg-orange-500 shadow-[0_0_8px_rgba(249,115,22,0.6)]" />
</button>
<div className="h-px bg-white/5 mx-2" />
<button
onClick={handleGalleryClick}
className="w-full flex items-center justify-between p-4 hover:bg-white/5 rounded-2xl transition-colors text-zinc-50"
>
<div className="flex items-center gap-3 font-bold text-xs uppercase tracking-widest">
<Library size={18} className="text-zinc-400" />
<span>Galerie</span>
</div>
</button>
</motion.div>
</>
)}
</AnimatePresence>
<div className="max-w-md mx-auto bg-[#09090b]/90 backdrop-blur-xl border border-white/10 rounded-[40px] p-2 flex items-center shadow-2xl pointer-events-auto"> <div className="max-w-md mx-auto bg-[#09090b]/90 backdrop-blur-xl border border-white/10 rounded-[40px] p-2 flex items-center shadow-2xl pointer-events-auto">
{/* Left Items */} {/* Left Items */}
<div className="flex-1 flex justify-around"> <div className="flex-1 flex justify-around">
@@ -91,12 +153,12 @@ export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onSca
{/* Center FAB */} {/* Center FAB */}
<div className="px-2"> <div className="px-2">
<button <button
onClick={handleScanClick} onClick={() => setShowSourcePicker(!showSourcePicker)}
className="w-16 h-16 bg-orange-600 rounded-[30px] flex items-center justify-center text-white shadow-lg shadow-orange-950/40 border border-white/20 active:scale-90 transition-all hover:bg-orange-500 hover:rotate-2 group relative" className={`w-16 h-16 rounded-[30px] flex items-center justify-center text-white shadow-xl border active:scale-90 transition-all group relative ${showSourcePicker ? 'bg-zinc-800 border-white/20' : 'bg-orange-600 border-white/20 shadow-orange-950/40 hover:bg-orange-500 hover:rotate-2'}`}
aria-label={t('camera.scanBottle')} aria-label={t('camera.scanBottle')}
> >
<div className="absolute inset-0 bg-white/20 rounded-[30px] opacity-0 group-hover:opacity-100 transition-opacity" /> <div className="absolute inset-0 bg-white/20 rounded-[30px] opacity-0 group-hover:opacity-100 transition-opacity" />
<Camera size={28} strokeWidth={2.5} /> <Camera size={28} strokeWidth={2.5} className={`transition-transform duration-300 ${showSourcePicker ? 'rotate-90 text-orange-500 scale-90' : ''}`} />
</button> </button>
</div> </div>

View File

@@ -39,6 +39,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
const [isOffline, setIsOffline] = useState(typeof navigator !== 'undefined' ? !navigator.onLine : false); const [isOffline, setIsOffline] = useState(typeof navigator !== 'undefined' ? !navigator.onLine : false);
const [isEnriching, setIsEnriching] = useState(false); const [isEnriching, setIsEnriching] = useState(false);
const [aiFallbackActive, setAiFallbackActive] = useState(false); const [aiFallbackActive, setAiFallbackActive] = useState(false);
const [pendingTastingData, setPendingTastingData] = useState<any>(null);
// Use the Gemini-only scanner hook // Use the Gemini-only scanner hook
const scanner = useScanner({ const scanner = useScanner({
@@ -174,18 +175,77 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
scanner.handleScan(file); 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) => { const handleSaveTasting = async (formData: any) => {
if (!bottleMetadata || !scanner.processedImage) return; if (!scanner.mergedResult || !scanner.processedImage) return;
setIsSaving(true); setIsSaving(true);
setError(null); 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 { try {
// OFFLINE: Save to IndexedDB queue // OFFLINE: Save to IndexedDB queue
if (isOffline) { if (isOffline) {
console.log('[ScanFlow] Offline mode - queuing for upload'); console.log('[ScanFlow] Offline mode - queuing for upload');
const tempId = `temp_${Date.now()}`; const tempId = `temp_${Date.now()}`;
const bottleDataToSave = formData.bottleMetadata || bottleMetadata; const bottleDataToSave = formData.bottleMetadata || scanner.mergedResult;
const existingScan = await db.pending_scans const existingScan = await db.pending_scans
.filter(s => s.imageBase64 === scanner.processedImage!.base64) .filter(s => s.imageBase64 === scanner.processedImage!.base64)
@@ -227,53 +287,24 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
return; return;
} }
// ONLINE: Normal save to Supabase // Normal online save
let user; await performSave(formData, scanner.mergedResult);
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;
}
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) { } catch (err: any) {
setError(err.message); setError(err.message);
setState('ERROR'); setState('ERROR');
} finally {
setIsSaving(false); 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 () => { const handleShare = async () => {
if (navigator.share) { if (navigator.share) {
try { 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" className="absolute inset-0 z-[80] bg-zinc-950/80 backdrop-blur-sm flex flex-col items-center justify-center gap-6"
> >
<Loader2 size={48} className="animate-spin text-orange-600" /> <Loader2 size={48} className="animate-spin text-orange-600" />
<h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight">Speichere Tasting...</h2> <h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight">
{scanner.isAnalyzing ? 'Warte auf Analyse...' : 'Speichere Tasting...'}
</h2>
{scanner.isAnalyzing && (
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-[0.2em] animate-pulse">
KI verarbeitet Etikett-Details
</p>
)}
</motion.div> </motion.div>
)} )}

View File

@@ -91,8 +91,8 @@ export async function processBulkScan(
} }
// 4. Trigger background analysis for all bottles (fire & forget) // 4. Trigger background analysis for all bottles (fire & forget)
// This won't block the response // Pass BOTH the bottle IDs and the original base64 images
triggerBackgroundAnalysis(bottleIds, user.id).catch(err => { triggerBackgroundAnalysis(bottleIds, imageDataUrls, user.id).catch(err => {
console.error('Background analysis error:', err); console.error('Background analysis error:', err);
}); });
@@ -166,10 +166,17 @@ async function uploadImage(
* Trigger background AI analysis for bottles. * Trigger background AI analysis for bottles.
* This runs asynchronously and updates bottles with results. * This runs asynchronously and updates bottles with results.
*/ */
async function triggerBackgroundAnalysis(bottleIds: string[], userId: string): Promise<void> { async function triggerBackgroundAnalysis(
bottleIds: string[],
imageDataUrls: string[],
userId: string
): Promise<void> {
const supabase = await createClient(); 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 { try {
// Update status to analyzing // Update status to analyzing
await supabase await supabase
@@ -177,20 +184,8 @@ async function triggerBackgroundAnalysis(bottleIds: string[], userId: string): P
.update({ processing_status: 'analyzing' }) .update({ processing_status: 'analyzing' })
.eq('id', bottleId); .eq('id', bottleId);
// Get bottle image // Call AI analysis with the base64 data directly
const { data: bottle } = await supabase const analysisResult = await analyzeBottleImage(imageDataUrl);
.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);
if (analysisResult.success && analysisResult.data) { if (analysisResult.success && analysisResult.data) {
// Update bottle with AI results // Update bottle with AI results
@@ -237,33 +232,18 @@ async function markBottleError(
* Analyze bottle image using configured AI provider * Analyze bottle image using configured AI provider
* Uses OpenRouter by default, falls back to Gemini * Uses OpenRouter by default, falls back to Gemini
*/ */
async function analyzeBottleImage(imageUrl: string): Promise<{ async function analyzeBottleImage(dataUrl: string): Promise<{
success: boolean; success: boolean;
data?: { data?: any;
name: string;
distillery?: string;
category?: string;
abv?: number;
age?: number;
is_whisky?: boolean;
confidence?: number;
};
error?: string; error?: string;
}> { }> {
const { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } = await import('@/lib/openrouter'); const { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } = await import('@/lib/openrouter');
const provider = getAIProvider(); const provider = getAIProvider();
try { try {
// Fetch image and convert to base64 // Extract base64 and mime type from data URL
const response = await fetch(imageUrl); const base64Data = dataUrl.split(',')[1];
if (!response.ok) { const mimeType = dataUrl.match(/data:(.*?);/)?.[1] || 'image/webp';
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';
const prompt = `Analyze this whisky bottle image. Extract: const prompt = `Analyze this whisky bottle image. Extract:
- name: Full product name - name: Full product name
@@ -277,14 +257,13 @@ async function analyzeBottleImage(imageUrl: string): Promise<{
Respond ONLY with valid JSON, no markdown.`; Respond ONLY with valid JSON, no markdown.`;
if (provider === 'openrouter') { if (provider === 'openrouter') {
// OpenRouter path
const client = getOpenRouterClient(); const client = getOpenRouterClient();
const openRouterResponse = await client.chat.completions.create({ const openRouterResponse = await client.chat.completions.create({
model: 'google/gemma-3-27b-it', model: 'google/gemma-3-27b-it',
messages: [{ messages: [{
role: 'user', role: 'user',
content: [ 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 }, { type: 'text', text: prompt },
], ],
}], }],
@@ -296,13 +275,12 @@ Respond ONLY with valid JSON, no markdown.`;
const content = openRouterResponse.choices[0]?.message?.content || '{}'; const content = openRouterResponse.choices[0]?.message?.content || '{}';
let jsonStr = content; let jsonStr = content;
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/); const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) jsonStr = jsonMatch[1].trim(); if (jsonMatch) jsonStr = jsonMatch[0].trim();
const parsed = JSON.parse(jsonStr); const parsed = JSON.parse(jsonStr);
return { success: true, data: parsed }; return { success: true, data: parsed };
} else { } else {
// Gemini path
const apiKey = process.env.GEMINI_API_KEY; const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) { if (!apiKey) {
return { success: false, error: 'GEMINI_API_KEY nicht konfiguriert' }; return { success: false, error: 'GEMINI_API_KEY nicht konfiguriert' };
@@ -317,7 +295,7 @@ Respond ONLY with valid JSON, no markdown.`;
contents: [{ contents: [{
parts: [ parts: [
{ text: prompt }, { text: prompt },
{ inline_data: { mime_type: mimeType, data: base64 } } { inline_data: { mime_type: mimeType, data: base64Data } }
] ]
}], }],
generationConfig: { temperature: 0.1, maxOutputTokens: 500 } generationConfig: { temperature: 0.1, maxOutputTokens: 500 }
@@ -344,7 +322,6 @@ Respond ONLY with valid JSON, no markdown.`;
const parsed = JSON.parse(jsonMatch[0]); const parsed = JSON.parse(jsonMatch[0]);
return { success: true, data: parsed }; return { success: true, data: parsed };
} }
} catch (error) { } catch (error) {
console.error(`[BulkScan] ${provider} analysis error:`, error); console.error(`[BulkScan] ${provider} analysis error:`, error);
return { success: false, error: 'Analysefehler' }; return { success: false, error: 'Analysefehler' };

View File

@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS public.profiles (
id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY, id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
username TEXT UNIQUE, username TEXT UNIQUE,
avatar_url TEXT, avatar_url TEXT,
deletion_requested_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) 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, bottled_at TEXT,
batch_info TEXT, batch_info TEXT,
suggested_tags TEXT[], suggested_tags TEXT[],
suggested_custom_tags TEXT[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()), created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
updated_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, palate_notes TEXT,
finish_notes TEXT, finish_notes TEXT,
audio_transcript_url TEXT, audio_transcript_url TEXT,
is_sample BOOLEAN DEFAULT false,
tasted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()), tasted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
created_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 -- 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_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 "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 ( 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), ('silver', 'Silver', 100, 8.99, 'Best value for power users', 3),
('gold', 'Gold', 250, 19.99, 'Unlimited searches for professionals', 4) ('gold', 'Gold', 250, 19.99, 'Unlimited searches for professionals', 4)
ON CONFLICT (name) DO NOTHING; 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);