Fix Storage RLS, refactor AI analysis to Base64, and improve ScanAndTaste save flow
This commit is contained in:
@@ -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}/`);
|
||||
}
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const cameraInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = React.useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<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
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
ref={cameraInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={galleryInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
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">
|
||||
{/* Left Items */}
|
||||
<div className="flex-1 flex justify-around">
|
||||
@@ -91,12 +153,12 @@ export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onSca
|
||||
{/* Center FAB */}
|
||||
<div className="px-2">
|
||||
<button
|
||||
onClick={handleScanClick}
|
||||
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"
|
||||
onClick={() => setShowSourcePicker(!showSourcePicker)}
|
||||
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')}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<any>(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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
async function triggerBackgroundAnalysis(
|
||||
bottleIds: string[],
|
||||
imageDataUrls: string[],
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
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' };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user