From d07af05b666869e243c1d53685a43e18a3b40b21 Mon Sep 17 00:00:00 2001 From: robin Date: Thu, 18 Dec 2025 10:56:41 +0100 Subject: [PATCH] feat: implement buddies and tasting sessions features --- src/app/bottles/[id]/page.tsx | 23 ++- src/app/page.tsx | 12 +- src/app/sessions/[id]/page.tsx | 275 +++++++++++++++++++++++++++++ src/components/BuddyList.tsx | 138 +++++++++++++++ src/components/SessionList.tsx | 131 ++++++++++++++ src/components/TastingList.tsx | 22 ++- src/components/TastingNoteForm.tsx | 71 +++++++- src/services/save-tasting.ts | 20 +++ supa_schema.sql | 93 +++++++++- 9 files changed, 771 insertions(+), 14 deletions(-) create mode 100644 src/app/sessions/[id]/page.tsx create mode 100644 src/components/BuddyList.tsx create mode 100644 src/components/SessionList.tsx diff --git a/src/app/bottles/[id]/page.tsx b/src/app/bottles/[id]/page.tsx index 8d81200..0ac0770 100644 --- a/src/app/bottles/[id]/page.tsx +++ b/src/app/bottles/[id]/page.tsx @@ -9,7 +9,14 @@ import StatusSwitcher from '@/components/StatusSwitcher'; import TastingList from '@/components/TastingList'; import DeleteBottleButton from '@/components/DeleteBottleButton'; -export default async function BottlePage({ params }: { params: { id: string } }) { +export default async function BottlePage({ + params, + searchParams +}: { + params: { id: string }, + searchParams: { session_id?: string } +}) { + const sessionId = searchParams.session_id; const supabase = createServerComponentClient({ cookies }); const { data: bottle } = await supabase @@ -24,7 +31,15 @@ export default async function BottlePage({ params }: { params: { id: string } }) const { data: tastings } = await supabase .from('tastings') - .select('*') + .select(` + *, + tasting_tags ( + buddies ( + id, + name + ) + ) + `) .eq('bottle_id', params.id) .order('created_at', { ascending: false }); @@ -127,9 +142,9 @@ export default async function BottlePage({ params }: { params: { id: string } }) {/* Form */}

- Neu Verkosten + {sessionId ? 'Session-Notiz' : 'Neu Verkosten'}

- +
{/* List */} diff --git a/src/app/page.tsx b/src/app/page.tsx index e212bec..b4c3437 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,8 @@ import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; import CameraCapture from "@/components/CameraCapture"; import BottleGrid from "@/components/BottleGrid"; import AuthForm from "@/components/AuthForm"; +import BuddyList from "@/components/BuddyList"; +import SessionList from "@/components/SessionList"; export default function Home() { const supabase = createClientComponentClient(); @@ -116,7 +118,15 @@ export default function Home() { - +
+
+ + +
+
+ +
+

diff --git a/src/app/sessions/[id]/page.tsx b/src/app/sessions/[id]/page.tsx new file mode 100644 index 0000000..3b38ed6 --- /dev/null +++ b/src/app/sessions/[id]/page.tsx @@ -0,0 +1,275 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; +import { ChevronLeft, Users, Calendar, GlassWater, Plus, Trash2, Loader2, Sparkles, ChevronRight } from 'lucide-react'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; + +interface Buddy { + id: string; + name: string; +} + +interface Participant { + buddy_id: string; + buddies: { + name: string; + }; +} + +interface Session { + id: string; + name: string; + scheduled_at: string; +} + +interface SessionTasting { + id: string; + rating: number; + bottles: { + id: string; + name: string; + distillery: string; + }; +} + +export default function SessionDetailPage() { + const { id } = useParams(); + const router = useRouter(); + const supabase = createClientComponentClient(); + const [session, setSession] = useState(null); + const [participants, setParticipants] = useState([]); + const [tastings, setTastings] = useState([]); + const [allBuddies, setAllBuddies] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isAddingParticipant, setIsAddingParticipant] = useState(false); + + useEffect(() => { + fetchSessionData(); + }, [id]); + + const fetchSessionData = async () => { + setIsLoading(true); + + // Fetch Session + const { data: sessionData } = await supabase + .from('tasting_sessions') + .select('*') + .eq('id', id) + .single(); + + if (sessionData) { + setSession(sessionData); + + // Fetch Participants + const { data: partData } = await supabase + .from('session_participants') + .select('buddy_id, buddies(name)') + .eq('session_id', id); + + setParticipants((partData as any)?.map((p: any) => ({ + buddy_id: p.buddy_id, + buddies: p.buddies + })) || []); + + // Fetch Tastings in this session + const { data: tastingData } = await supabase + .from('tastings') + .select('id, rating, bottles(id, name, distillery)') + .eq('session_id', id); + + setTastings((tastingData as any)?.map((t: any) => ({ + id: t.id, + rating: t.rating, + bottles: t.bottles + })) || []); + + // Fetch all buddies for the picker + const { data: buddies } = await supabase + .from('buddies') + .select('id, name') + .order('name'); + setAllBuddies(buddies || []); + } + + setIsLoading(false); + }; + + const handleAddParticipant = async (buddyId: string) => { + if (participants.some(p => p.buddy_id === buddyId)) return; + + const { error } = await supabase + .from('session_participants') + .insert([{ session_id: id, buddy_id: buddyId }]); + + if (!error) { + fetchSessionData(); + } + }; + + const handleRemoveParticipant = async (buddyId: string) => { + const { error } = await supabase + .from('session_participants') + .delete() + .eq('session_id', id) + .eq('buddy_id', buddyId); + + if (!error) { + fetchSessionData(); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!session) { + return ( +
+

Session nicht gefunden

+ Zurück zum Start +
+ ); + } + + return ( +
+
+ {/* Back Button */} + + + Alle Sessions + + + {/* Hero */} +
+
+ +
+
+
+
+ + Tasting Session +
+

+ {session.name} +

+
+ + + {new Date(session.scheduled_at).toLocaleDateString('de-DE')} + +
+
+
+
+ +
+ {/* Sidebar: Participants */} + + + {/* Main Content: Bottle List */} +
+
+
+

+ + Verkostete Flaschen +

+ + + Flasche hinzufügen + +
+ + {tastings.length === 0 ? ( +
+ Noch keine Flaschen in dieser Session verkostet. 🥃 +
+ ) : ( +
+ {tastings.map((t) => ( +
+
+
{t.bottles.distillery}
+
{t.bottles.name}
+
+
+
+ {t.rating}/100 +
+ + + +
+
+ ))} +
+ )} +
+
+
+
+
+ ); +} + diff --git a/src/components/BuddyList.tsx b/src/components/BuddyList.tsx new file mode 100644 index 0000000..175eaf3 --- /dev/null +++ b/src/components/BuddyList.tsx @@ -0,0 +1,138 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; +import { Users, UserPlus, Trash2, User, Loader2 } from 'lucide-react'; + +interface Buddy { + id: string; + name: string; + buddy_profile_id: string | null; +} + +export default function BuddyList() { + const supabase = createClientComponentClient(); + const [buddies, setBuddies] = useState([]); + const [newName, setNewName] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isAdding, setIsAdding] = useState(false); + + useEffect(() => { + fetchBuddies(); + }, []); + + const fetchBuddies = async () => { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return; + + const { data, error } = await supabase + .from('buddies') + .select('*') + .order('name'); + + if (error) { + console.error('Error fetching buddies:', error); + } else { + setBuddies(data || []); + } + setIsLoading(false); + }; + + const handleAddBuddy = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newName.trim()) return; + + setIsAdding(true); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return; + + const { data, error } = await supabase + .from('buddies') + .insert([{ name: newName.trim(), user_id: user.id }]) + .select(); + + if (error) { + console.error('Error adding buddy:', error); + } else { + setBuddies(prev => [...(data || []), ...prev].sort((a, b) => a.name.localeCompare(b.name))); + setNewName(''); + } + setIsAdding(false); + }; + + const handleDeleteBuddy = async (id: string) => { + const { error } = await supabase + .from('buddies') + .delete() + .eq('id', id); + + if (error) { + console.error('Error deleting buddy:', error); + } else { + setBuddies(prev => prev.filter(b => b.id !== id)); + } + }; + + return ( +
+

+ + Deine Buddies +

+ +
+ setNewName(e.target.value)} + placeholder="Buddy Name..." + className="flex-1 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50" + /> + +
+ + {isLoading ? ( +
+ +
+ ) : buddies.length === 0 ? ( +
+ Noch keine Buddies hinzugefügt. +
+ ) : ( +
+ {buddies.map((buddy) => ( +
+
+
+ +
+
+ {buddy.name} + {buddy.buddy_profile_id && ( + verknüpft + )} +
+
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx new file mode 100644 index 0000000..76702aa --- /dev/null +++ b/src/components/SessionList.tsx @@ -0,0 +1,131 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; +import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users } from 'lucide-react'; +import Link from 'next/link'; + +interface Session { + id: string; + name: string; + scheduled_at: string; + participant_count?: number; +} + +export default function SessionList() { + const supabase = createClientComponentClient(); + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [newName, setNewName] = useState(''); + + useEffect(() => { + fetchSessions(); + }, []); + + const fetchSessions = async () => { + const { data, error } = await supabase + .from('tasting_sessions') + .select(` + *, + session_participants (count) + `) + .order('scheduled_at', { ascending: false }); + + if (error) { + console.error('Error fetching sessions:', error); + } else { + setSessions(data.map(s => ({ + ...s, + participant_count: s.session_participants[0]?.count || 0 + })) || []); + } + setIsLoading(false); + }; + + const handleCreateSession = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newName.trim()) return; + + setIsCreating(true); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return; + + const { data, error } = await supabase + .from('tasting_sessions') + .insert([{ name: newName.trim(), user_id: user.id }]) + .select() + .single(); + + if (error) { + console.error('Error creating session:', error); + } else { + setSessions(prev => [data, ...prev]); + setNewName(''); + } + setIsCreating(false); + }; + + return ( +
+

+ + Tasting Sessions +

+ +
+ setNewName(e.target.value)} + placeholder="Event Name (z.B. Islay Night)..." + className="flex-1 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50" + /> + +
+ + {isLoading ? ( +
+ +
+ ) : sessions.length === 0 ? ( +
+ Noch keine Sessions geplant. +
+ ) : ( +
+ {sessions.map((session) => ( + +
+
{session.name}
+
+ + + {new Date(session.scheduled_at).toLocaleDateString('de-DE')} + + {session.participant_count! > 0 && ( + + + {session.participant_count} Teilnehmer + + )} +
+
+ + + ))} +
+ )} +
+ ); +} diff --git a/src/components/TastingList.tsx b/src/components/TastingList.tsx index 677efff..ddebe66 100644 --- a/src/components/TastingList.tsx +++ b/src/components/TastingList.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useMemo } from 'react'; -import { Calendar, Star, ArrowUpDown, Clock, Trash2, Loader2 } from 'lucide-react'; +import { Calendar, Star, ArrowUpDown, Clock, Trash2, Loader2, Users } from 'lucide-react'; import { deleteTasting } from '@/services/delete-tasting'; interface Tasting { @@ -13,6 +13,12 @@ interface Tasting { is_sample?: boolean; bottle_id: string; created_at: string; + tasting_tags?: { + buddies: { + id: string; + name: string; + } + }[]; } interface TastingListProps { @@ -156,6 +162,20 @@ export default function TastingList({ initialTastings }: TastingListProps) {

)} + + {note.tasting_tags && note.tasting_tags.length > 0 && ( +
+ + + Gekostet mit: + + {note.tasting_tags.map((tag) => ( + + {tag.buddies.name} + + ))} +
+ )} ))} diff --git a/src/components/TastingNoteForm.tsx b/src/components/TastingNoteForm.tsx index f76fa3d..e278408 100644 --- a/src/components/TastingNoteForm.tsx +++ b/src/components/TastingNoteForm.tsx @@ -1,14 +1,22 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { saveTasting } from '@/services/save-tasting'; -import { Loader2, Send, Star } from 'lucide-react'; +import { Loader2, Send, Star, Users, Check } from 'lucide-react'; +import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; + +interface Buddy { + id: string; + name: string; +} interface TastingNoteFormProps { bottleId: string; + sessionId?: string; } -export default function TastingNoteForm({ bottleId }: TastingNoteFormProps) { +export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteFormProps) { + const supabase = createClientComponentClient(); const [rating, setRating] = useState(85); const [nose, setNose] = useState(''); const [palate, setPalate] = useState(''); @@ -16,6 +24,35 @@ export default function TastingNoteForm({ bottleId }: TastingNoteFormProps) { const [isSample, setIsSample] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [buddies, setBuddies] = useState([]); + const [selectedBuddyIds, setSelectedBuddyIds] = useState([]); + + useEffect(() => { + const fetchData = async () => { + // Fetch All Buddies + const { data: buddiesData } = await supabase.from('buddies').select('id, name').order('name'); + setBuddies(buddiesData || []); + + // If Session ID, fetch session participants and pre-select them + if (sessionId) { + const { data: participants } = await supabase + .from('session_participants') + .select('buddy_id') + .eq('session_id', sessionId); + + if (participants) { + setSelectedBuddyIds(participants.map(p => p.buddy_id)); + } + } + }; + fetchData(); + }, [sessionId]); + + const toggleBuddy = (id: string) => { + setSelectedBuddyIds(prev => + prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id] + ); + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -25,17 +62,20 @@ export default function TastingNoteForm({ bottleId }: TastingNoteFormProps) { try { const result = await saveTasting({ bottle_id: bottleId, + session_id: sessionId, rating, nose_notes: nose, palate_notes: palate, finish_notes: finish, is_sample: isSample, + buddy_ids: selectedBuddyIds, }); if (result.success) { setNose(''); setPalate(''); setFinish(''); + setSelectedBuddyIds([]); // We don't need to manually refresh because of revalidatePath in the server action } else { setError(result.error || 'Fehler beim Speichern'); @@ -131,6 +171,31 @@ export default function TastingNoteForm({ bottleId }: TastingNoteFormProps) { /> + {buddies.length > 0 && ( +
+ +
+ {buddies.map((buddy) => ( + + ))} +
+
+ )} + {error && (
{error} diff --git a/src/services/save-tasting.ts b/src/services/save-tasting.ts index a96fddf..6768fe7 100644 --- a/src/services/save-tasting.ts +++ b/src/services/save-tasting.ts @@ -6,11 +6,13 @@ import { revalidatePath } from 'next/cache'; export async function saveTasting(data: { bottle_id: string; + session_id?: string; rating: number; nose_notes?: string; palate_notes?: string; finish_notes?: string; is_sample?: boolean; + buddy_ids?: string[]; }) { const supabase = createServerActionClient({ cookies }); @@ -23,6 +25,7 @@ export async function saveTasting(data: { .insert({ bottle_id: data.bottle_id, user_id: session.user.id, + session_id: data.session_id, rating: data.rating, nose_notes: data.nose_notes, palate_notes: data.palate_notes, @@ -34,6 +37,23 @@ export async function saveTasting(data: { if (error) throw error; + // Add buddy tags if any + if (data.buddy_ids && data.buddy_ids.length > 0) { + const tags = data.buddy_ids.map(buddyId => ({ + tasting_id: tasting.id, + buddy_id: buddyId + })); + const { error: tagError } = await supabase + .from('tasting_tags') + .insert(tags); + + if (tagError) { + console.error('Error adding tasting tags:', tagError); + // We don't throw here to not fail the whole tasting save, + // but in a real app we might want more robust error handling + } + } + revalidatePath(`/bottles/${data.bottle_id}`); return { success: true, data: tasting }; diff --git a/supa_schema.sql b/supa_schema.sql index bc67ce1..84f5bdd 100644 --- a/supa_schema.sql +++ b/supa_schema.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS profiles ( id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY, username TEXT UNIQUE, avatar_url TEXT, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) + updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) ); -- Function to handle new user signup @@ -42,21 +42,54 @@ CREATE TABLE IF NOT EXISTS bottles ( image_url TEXT, is_whisky BOOLEAN DEFAULT true, confidence INTEGER DEFAULT 100, - created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::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()) ); --- Tastings table +-- Buddies table +CREATE TABLE IF NOT EXISTS buddies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL, + name TEXT NOT NULL, + buddy_profile_id UUID REFERENCES profiles(id) ON DELETE SET NULL, -- Link to real account + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) +); + +-- Tasting Sessions table +CREATE TABLE IF NOT EXISTS tasting_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL, + name TEXT NOT NULL, + scheduled_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()), + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) +); + +-- Session Participants junction +CREATE TABLE IF NOT EXISTS session_participants ( + session_id UUID REFERENCES tasting_sessions(id) ON DELETE CASCADE NOT NULL, + buddy_id UUID REFERENCES buddies(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (session_id, buddy_id) +); + +-- Tastings table (updated with session and buddy tagging) CREATE TABLE IF NOT EXISTS tastings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), bottle_id UUID REFERENCES bottles(id) ON DELETE CASCADE NOT NULL, user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL, + session_id UUID REFERENCES tasting_sessions(id) ON DELETE SET NULL, rating INTEGER CHECK (rating >= 0 AND rating <= 100), nose_notes TEXT, palate_notes TEXT, finish_notes TEXT, audio_transcript_url TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) +); + +-- Tasting Tagging (to tag buddies in a tasting) +CREATE TABLE IF NOT EXISTS tasting_tags ( + tasting_id UUID REFERENCES tastings(id) ON DELETE CASCADE NOT NULL, + buddy_id UUID REFERENCES buddies(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (tasting_id, buddy_id) ); -- Enable Row Level Security (RLS) @@ -88,6 +121,15 @@ CREATE POLICY "Users can delete their own bottles" ON bottles CREATE POLICY "Users can view their own tastings" ON tastings FOR SELECT USING (auth.uid() = user_id); +-- Geteilte Tastings für Buddies sichtbar machen (wenn verknüpft) +CREATE POLICY "Users can view tastings they are tagged in" ON tastings + FOR SELECT USING ( + id IN ( + SELECT tasting_id FROM tasting_tags + WHERE buddy_id IN (SELECT id FROM buddies WHERE buddy_profile_id = auth.uid()) + ) + ); + CREATE POLICY "Users can insert their own tastings" ON tastings FOR INSERT WITH CHECK (auth.uid() = user_id); @@ -97,6 +139,47 @@ CREATE POLICY "Users can update their own tastings" ON tastings CREATE POLICY "Users can delete their own tastings" ON tastings FOR DELETE USING (auth.uid() = user_id); +-- Policies for Buddies +ALTER TABLE buddies ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Users can manage their own buddies" ON buddies + FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "Users can see buddies linked to their profile" ON buddies + FOR SELECT USING (buddy_profile_id = auth.uid()); + +-- Policies for Tasting Sessions +ALTER TABLE tasting_sessions ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Users can manage their own sessions" ON tasting_sessions + FOR ALL USING (auth.uid() = user_id); +CREATE POLICY "Users can see sessions they participate in" ON tasting_sessions + FOR SELECT USING ( + id IN ( + SELECT session_id FROM session_participants + WHERE buddy_id IN (SELECT id FROM buddies WHERE buddy_profile_id = auth.uid()) + ) + ); + +-- Policies for Session Participants +ALTER TABLE session_participants ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Users can manage participants of their sessions" ON session_participants + FOR ALL USING ( + session_id IN (SELECT id FROM tasting_sessions WHERE user_id = auth.uid()) + ); +CREATE POLICY "Participants can see session membership" ON session_participants + FOR SELECT USING ( + buddy_id IN (SELECT id FROM buddies WHERE buddy_profile_id = auth.uid()) + ); + +-- Policies for Tasting Tags +ALTER TABLE tasting_tags ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Users can manage tags on their tastings" ON tasting_tags + FOR ALL USING ( + tasting_id IN (SELECT id FROM tastings WHERE user_id = auth.uid()) + ); +CREATE POLICY "Tagged users can see the tags" ON tasting_tags + FOR SELECT USING ( + buddy_id IN (SELECT id FROM buddies WHERE buddy_profile_id = auth.uid()) + ); + -- STORAGE SETUP -- Create 'bottles' bucket if it doesn't exist INSERT INTO storage.buckets (id, name, public)