Refactor: Centralized Supabase Auth and implemented Auth Guards to prevent 401 errors

This commit is contained in:
2026-01-04 23:00:18 +01:00
parent 9d6a8b358f
commit 71586fd6a8
15 changed files with 18678 additions and 576 deletions

18001
distillery_tags_results.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
import fs from 'fs';
import path from 'path';
import { createClient } from '@supabase/supabase-js';
// --- CONFIGURATION ---
const OPENROUTER_MODEL = 'xiaomi/mimo-v2-flash:free';
const BATCH_SIZE = 5; // How many distilleries to process before saving checkpoint
const CONCURRENCY = 2; // Maximum concurrent requests to OpenRouter
const DELAY_MS = 1000; // Small delay between batches to avoid rate limits
const CHECKPOINT_FILE = 'distillery_tags_checkpoint.json';
const TAGS_OUTPUT_FILE = 'distillery_tags_results.json';
// --- ENVIRONMENT SETUP ---
function loadEnv() {
const envPath = '.env.local';
if (!fs.existsSync(envPath)) {
console.error('❌ .env.local not found');
process.exit(1);
}
const content = fs.readFileSync(envPath, 'utf8');
content.split('\n').forEach(line => {
const parts = line.split('=');
if (parts.length >= 2) {
const key = parts[0].trim();
const value = parts.slice(1).join('=').trim();
process.env[key] = value;
}
});
}
loadEnv();
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // Use Service Role to bypass RLS for bulk import
);
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
if (!OPENROUTER_API_KEY) {
console.error('❌ OPENROUTER_API_KEY not found in .env.local');
process.exit(1);
}
console.log('🎬 Script file loaded. Starting execution...');
// --- UTILS ---
async function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchTagsWithRetry(name: string, region: string, retries = 3, backoff = 2000): Promise<any> {
for (let attempt = 1; attempt <= retries; attempt++) {
const result = await fetchTagsForDistillery(name, region);
if (result) return result;
if (attempt < retries) {
console.log(`⏳ Rate limited or error for ${name}. Retrying in ${backoff / 1000}s (Attempt ${attempt}/${retries})...`);
await sleep(backoff);
backoff *= 2; // Exponential backoff
}
}
return null;
}
async function fetchTagsForDistillery(name: string, region: string) {
console.log(`🔍 Processing: ${name} (${region})...`);
const prompt = `
Analyze the whisky distillery "${name}" from the "${region}" region.
Provide a comprehensive list of characteristic tasting tags (aroma and flavor notes) that are typical for this distillery's core range.
Break them down into these four categories: 'nose', 'taste', 'finish', and 'texture'.
Be extremely detailed and specific to this distillery's DNA.
Aim for at least 8-10 tags per category.
Output ONLY a valid JSON object in this format:
{
"nose": ["tag1", "tag2", ...],
"taste": ["tag1", "tag2", ...],
"finish": ["tag1", "tag2", ...],
"texture": ["tag1", "tag2", ...]
}
`;
try {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'HTTP-Referer': 'https://whiskyvault.app',
'X-Title': 'Whisky Vault Scraper'
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' }
})
});
const data = await response.json();
if (!response.ok) {
// Check for rate limit specifically
if (response.status === 429 || (data.error && data.error.code === 429)) {
return null; // Trigger retry
}
console.error(`❌ API Error for ${name}: ${response.status}`, data.error || data);
return null;
}
const content = data.choices?.[0]?.message?.content;
if (!content) {
// Check if error is inside the data
if (data.error) {
console.error(`⚠️ OpenRouter Error for ${name}:`, data.error.message);
return null;
}
console.error(`⚠️ No content returned for ${name}. Full response:`, JSON.stringify(data, null, 2));
return null;
}
return JSON.parse(content);
} catch (error) {
console.error(`❌ Fetch Exception for ${name}:`, error);
return null;
}
}
// --- MAIN RUNNER ---
async function main() {
const distilleriesPath = path.join(process.cwd(), 'src/data/distilleries.json');
const distilleries = JSON.parse(fs.readFileSync(distilleriesPath, 'utf8'));
let processedResults: Record<string, any> = {};
let lastIndex = 0;
// Load progress
if (fs.existsSync(TAGS_OUTPUT_FILE)) {
processedResults = JSON.parse(fs.readFileSync(TAGS_OUTPUT_FILE, 'utf8'));
lastIndex = distilleries.findIndex((d: any) => !processedResults[d.name]);
if (lastIndex === -1) lastIndex = distilleries.length;
console.log(`🔄 Resuming from index ${lastIndex} (${distilleries[lastIndex]?.name || 'Finished'})...`);
}
const total = distilleries.length;
console.log(`🚀 Starting scraper for ${total} distilleries using ${OPENROUTER_MODEL}`);
// Process sequentially for free models to avoid heavy rate limits
for (let i = lastIndex; i < total; i++) {
const d = distilleries[i];
const result = await fetchTagsWithRetry(d.name, d.region);
if (result) {
processedResults[d.name] = result;
// Save every success to be safe
fs.writeFileSync(TAGS_OUTPUT_FILE, JSON.stringify(processedResults, null, 2));
console.log(`✅ [${i + 1}/${total}] Saved: ${d.name}`);
} else {
console.error(`⏭️ Skipping ${d.name} after failed retries.`);
}
// Small cooling delay between requests
await sleep(500);
}
console.log('🎉 Scraping complete! Consolidating and writing to database...');
await consolidateAndPush(processedResults);
}
async function consolidateAndPush(allData: Record<string, any>) {
const uniqueTags: Map<string, string> = new Map(); // "Tag Name" -> "Category"
Object.entries(allData).forEach(([distillery, categories]: [string, any]) => {
['nose', 'taste', 'finish', 'texture'].forEach(cat => {
const tags = categories[cat] || [];
tags.forEach((tag: string) => {
const normalized = tag.trim();
// Filter: Only allow tags that are 1 or 2 words long
const wordCount = normalized.split(/\s+/).filter(w => w.length > 0).length;
if (normalized && wordCount <= 2) {
// In our schema, a tag is unique per category
const key = `${normalized}:${cat}`;
uniqueTags.set(key, cat);
}
});
});
});
console.log(`📊 Found ${uniqueTags.size} unique (Tag, Category) pairs.`);
const tagsToInsert = Array.from(uniqueTags.entries()).map(([key, category]) => {
const name = key.split(':')[0];
return {
name,
category,
is_system_default: true,
popularity_score: 3
};
});
// Chunk database inserts
const DB_BATCH_SIZE = 100;
for (let i = 0; i < tagsToInsert.length; i += DB_BATCH_SIZE) {
const chunk = tagsToInsert.slice(i, i + DB_BATCH_SIZE);
const { error } = await supabase
.from('tags')
.upsert(chunk, { onConflict: 'name,category' });
if (error) {
console.error('❌ Database error:', error);
} else {
console.log(`📤 Pushed ${i + chunk.length}/${tagsToInsert.length} tags to database.`);
}
}
console.log('🏁 All done!');
}
main().catch(err => {
console.error('💨 Script crashed:', err);
});

View File

@@ -3,6 +3,7 @@
import React, { useEffect, useState } from 'react';
import BottleDetails from '@/components/BottleDetails';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/context/AuthContext';
import { validateSession } from '@/services/validate-session';
import OfflineIndicator from '@/components/OfflineIndicator';
import { useParams, useSearchParams } from 'next/navigation';
@@ -12,12 +13,15 @@ export default function BottlePage() {
const searchParams = useSearchParams();
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [userId, setUserId] = useState<string | undefined>(undefined);
const { user, isLoading: isAuthLoading } = useAuth();
const supabase = createClient();
const bottleId = params?.id as string;
const rawSessionId = searchParams?.get('session_id');
useEffect(() => {
if (isAuthLoading) return;
const checkSession = async () => {
if (rawSessionId) {
const isValid = await validateSession(rawSessionId);
@@ -27,16 +31,11 @@ export default function BottlePage() {
}
};
const getAuth = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
setUserId(user.id);
}
};
checkSession();
getAuth();
}, [rawSessionId, supabase]);
if (user) {
setUserId(user.id);
checkSession();
}
}, [rawSessionId, user, isAuthLoading]);
if (!bottleId) return null;

View File

@@ -6,9 +6,9 @@ import OfflineIndicator from "@/components/OfflineIndicator";
import UploadQueue from "@/components/UploadQueue";
import { I18nProvider } from "@/i18n/I18nContext";
import { SessionProvider } from "@/context/SessionContext";
import { AuthProvider } from "@/context/AuthContext";
import ActiveSessionBanner from "@/components/ActiveSessionBanner";
import MainContentWrapper from "@/components/MainContentWrapper";
import AuthListener from "@/components/AuthListener";
import SyncHandler from "@/components/SyncHandler";
import CookieBanner from "@/components/CookieBanner";
import OnboardingTutorial from "@/components/OnboardingTutorial";
@@ -49,18 +49,19 @@ export default function RootLayout({
<html lang="de" suppressHydrationWarning={true}>
<body className={`${inter.variable} font-sans`}>
<I18nProvider>
<SessionProvider>
<AuthListener />
<ActiveSessionBanner />
<MainContentWrapper>
<SyncHandler />
<PWARegistration />
<UploadQueue />
{children}
</MainContentWrapper>
<CookieBanner />
<OnboardingTutorial />
</SessionProvider>
<AuthProvider>
<SessionProvider>
<ActiveSessionBanner />
<MainContentWrapper>
<SyncHandler />
<PWARegistration />
<UploadQueue />
{children}
</MainContentWrapper>
<CookieBanner />
<OnboardingTutorial />
</SessionProvider>
</AuthProvider>
</I18nProvider>
</body>
</html>

View File

@@ -12,6 +12,7 @@ import DramOfTheDay from "@/components/DramOfTheDay";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import OfflineIndicator from "@/components/OfflineIndicator";
import { useI18n } from "@/i18n/I18nContext";
import { useAuth } from "@/context/AuthContext";
import { useSession } from "@/context/SessionContext";
import TastingHub from "@/components/TastingHub";
import { Sparkles, X, Loader2 } from "lucide-react";
@@ -25,8 +26,8 @@ export default function Home() {
const supabase = createClient();
const router = useRouter();
const [bottles, setBottles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<any>(null);
const { user, isLoading: isAuthLoading } = useAuth();
const [isInternalLoading, setIsInternalLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const { t } = useI18n();
const { activeSession } = useSession();
@@ -46,39 +47,15 @@ export default function Home() {
};
useEffect(() => {
// Check session
const checkUser = async () => {
try {
// Proactively get session - this will trigger a refresh if needed
const { data: { session }, error } = await supabase.auth.getSession();
if (session) {
console.log('[Auth] Valid session found:', {
userId: session.user.id,
expiry: new Date(session.expires_at! * 1000).toLocaleString()
});
} else {
console.log('[Auth] No active session found.');
}
if (error) {
console.error('[Auth] Session check error:', error);
}
setUser(session?.user ?? null);
if (session?.user) {
fetchCollection();
}
} catch (err) {
console.error('[Auth] Unexpected error during session check:', err);
setUser(null);
} finally {
setIsLoading(false);
}
};
checkUser();
// Only fetch if auth is ready and user exists
if (!isAuthLoading && user) {
fetchCollection();
} else if (!isAuthLoading && !user) {
setBottles([]);
}
}, [user, isAuthLoading]);
useEffect(() => {
// Fetch public splits if guest
getActiveSplits().then(res => {
if (res.success && res.splits) {
@@ -86,33 +63,6 @@ export default function Home() {
}
});
// Listen for visibility change (wake up from sleep)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
console.log('[Auth] App became visible, refreshing session...');
checkUser();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: string, session: any) => {
console.log('[Auth] State change event:', event, {
hasSession: !!session,
userId: session?.user?.id,
email: session?.user?.email
});
setUser(session?.user ?? null);
if (session?.user) {
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
fetchCollection();
}
} else {
setBottles([]);
}
});
// Listen for collection updates (e.g., after offline sync completes)
const handleCollectionUpdated = () => {
console.log('[Home] Collection update event received, refreshing...');
@@ -121,14 +71,12 @@ export default function Home() {
window.addEventListener('collection-updated', handleCollectionUpdated);
return () => {
subscription.unsubscribe();
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('collection-updated', handleCollectionUpdated);
};
}, []);
const fetchCollection = async () => {
setIsLoading(true);
setIsInternalLoading(true);
try {
// Fetch bottles with their latest tasting date
const { data, error } = await supabase
@@ -194,7 +142,7 @@ export default function Home() {
setFetchError(errorMessage);
}
} finally {
setIsLoading(false);
setIsInternalLoading(false);
}
};
@@ -249,6 +197,8 @@ export default function Home() {
);
}
const isLoading = isAuthLoading || isInternalLoading;
return (
<main className="flex min-h-screen flex-col items-center gap-6 md:gap-12 p-4 md:p-24 bg-[var(--background)] pb-32">
<div className="z-10 max-w-5xl w-full flex flex-col items-center gap-12">

View File

@@ -10,6 +10,7 @@ import { closeSession } from '@/services/close-session';
import { useSession } from '@/context/SessionContext';
import { useParams, useRouter } from 'next/navigation';
import { useI18n } from '@/i18n/I18nContext';
import { useAuth } from '@/context/AuthContext';
import SessionTimeline from '@/components/SessionTimeline';
import SessionABVCurve from '@/components/SessionABVCurve';
import OfflineIndicator from '@/components/OfflineIndicator';
@@ -65,6 +66,7 @@ export default function SessionDetailPage() {
const [tastings, setTastings] = useState<SessionTasting[]>([]);
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { user, isLoading: isAuthLoading } = useAuth();
const { activeSession, setActiveSession } = useSession();
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -72,7 +74,9 @@ export default function SessionDetailPage() {
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
useEffect(() => {
fetchSessionData();
if (!isAuthLoading && user) {
fetchSessionData();
}
// Subscribe to bottle updates for realtime processing status
const channel = supabase

View File

@@ -1,28 +0,0 @@
'use client';
import { useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
export default function AuthListener() {
const supabase = createClient();
useEffect(() => {
// Listener für Auth-Status Änderungen
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event) => {
if (event === 'SIGNED_OUT') {
console.log(`[Auth] Event ${event} detected, forcing reload...`);
// Zwinge den Browser zum kompletten Neuladen, um Caches zu leeren
// Wir nutzen window.location.href statt router.push für einen harten Reload
window.location.href = '/';
}
});
return () => {
subscription.unsubscribe();
};
}, [supabase]);
return null;
}

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Users, UserPlus, Trash2, Loader2, ChevronDown, ChevronUp, Link2 } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
import { useAuth } from '@/context/AuthContext';
import { addBuddy, deleteBuddy } from '@/services/buddy';
import BuddyHandshake from './BuddyHandshake';
@@ -27,10 +28,13 @@ export default function BuddyList() {
return false;
});
const [isHandshakeOpen, setIsHandshakeOpen] = useState(false);
const { user, isLoading: isAuthLoading } = useAuth();
useEffect(() => {
fetchBuddies();
}, []);
if (!isAuthLoading && user) {
fetchBuddies();
}
}, [user, isAuthLoading]);
const fetchBuddies = async () => {
const { data: { user } } = await supabase.auth.getUser();

View File

@@ -8,6 +8,7 @@ import AvatarStack from './AvatarStack';
import { deleteSession } from '@/services/delete-session';
import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import { useAuth } from '@/context/AuthContext';
interface Session {
id: string;
@@ -34,10 +35,13 @@ export default function SessionList() {
});
const [newName, setNewName] = useState('');
const { activeSession, setActiveSession } = useSession();
const { user, isLoading: isAuthLoading } = useAuth();
useEffect(() => {
fetchSessions();
}, []);
if (!isAuthLoading && user) {
fetchSessions();
}
}, [user, isAuthLoading]);
const fetchSessions = async () => {
const { data, error } = await supabase

View File

@@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext';
import { useAuth } from '@/context/AuthContext';
import { useSession } from '@/context/SessionContext';
import { getHostSplits, getParticipatingSplits } from '@/services/split-actions';
import AvatarStack from './AvatarStack';
@@ -49,6 +50,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
const { t, locale } = useI18n();
const supabase = createClient();
const { activeSession, setActiveSession } = useSession();
const { user, isLoading: isAuthLoading } = useAuth();
const [activeTab, setActiveTab] = useState<'tastings' | 'splits'>('tastings');
const [mySessions, setMySessions] = useState<Session[]>([]);
@@ -62,10 +64,10 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
const [newName, setNewName] = useState('');
useEffect(() => {
if (isOpen) {
if (isOpen && !isAuthLoading && user) {
fetchAll();
}
}, [isOpen]);
}, [isOpen, isAuthLoading, user]);
const fetchAll = async () => {
setIsLoading(true);

View File

@@ -7,8 +7,9 @@ import { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import TagSelector from './TagSelector';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';
import { useAuth } from '@/context/AuthContext';
import { useLiveQuery } from 'dexie-react-hooks';
import { AlertTriangle } from 'lucide-react';
import TastingFormBody from './TastingFormBody';
@@ -42,6 +43,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
const [suggestedCustomTags, setSuggestedCustomTags] = useState<string[]>([]);
const { activeSession } = useSession();
const { user, isLoading: isAuthLoading } = useAuth();
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
@@ -56,16 +58,14 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
const effectiveSessionId = sessionId || activeSession?.id;
useEffect(() => {
const getAuth = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (user) setCurrentUserId(user.id);
};
getAuth();
}, [supabase]);
if (!isAuthLoading && user) {
setCurrentUserId(user.id);
}
}, [user, isAuthLoading]);
useEffect(() => {
const fetchData = async () => {
if (!bottleId) return;
if (!bottleId || isAuthLoading || !user) return;
// Fetch Bottle Suggestions and Owner
const { data: bottleData } = await supabase
@@ -130,7 +130,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
}
};
fetchData();
}, [supabase, effectiveSessionId, bottleId]);
}, [supabase, effectiveSessionId, bottleId, user, isAuthLoading]);
// Live Palette Checker Logic
useEffect(() => {

View File

@@ -0,0 +1,77 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Session, User } from '@supabase/supabase-js';
import { createClient } from '@/lib/supabase/client';
interface AuthContextType {
user: User | null;
session: Session | null;
isLoading: boolean;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState(true);
const supabase = createClient();
useEffect(() => {
// Initial session check
const initAuth = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
setSession(session);
setUser(session?.user ?? null);
} catch (error) {
console.error('[AuthContext] Error getting initial session:', error);
} finally {
setIsLoading(false);
}
};
initAuth();
// Listen for auth changes (Magic Link, OAuth, Sign In/Out)
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, currentSession) => {
console.log(`[AuthContext] event: ${event}`, {
userId: currentSession?.user?.id,
email: currentSession?.user?.email
});
setSession(currentSession);
setUser(currentSession?.user ?? null);
setIsLoading(false);
if (event === 'SIGNED_OUT') {
// Hard reload to clear all state/cache on logout
window.location.href = '/';
}
});
return () => {
subscription.unsubscribe();
};
}, [supabase]);
const signOut = async () => {
await supabase.auth.signOut();
};
return (
<AuthContext.Provider value={{ user, session, isLoading, signOut }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -4,11 +4,13 @@ import { useState, useEffect, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client';
import { db, type CachedBottle, type CachedTasting } from '@/lib/db';
import { useLiveQuery } from 'dexie-react-hooks';
import { useAuth } from '@/context/AuthContext';
export function useBottleData(bottleId: string) {
const supabase = createClient();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user, isLoading: isAuthLoading } = useAuth();
// Live queries from Dexie
const cachedBottle = useLiveQuery(() => db.cache_bottles.get(bottleId), [bottleId]);
@@ -18,8 +20,8 @@ export function useBottleData(bottleId: string) {
);
const refreshData = useCallback(async () => {
if (!navigator.onLine) {
setLoading(false);
if (!navigator.onLine || isAuthLoading || !user) {
if (!isAuthLoading && !user) setLoading(false);
return;
}
@@ -70,7 +72,7 @@ export function useBottleData(bottleId: string) {
useEffect(() => {
refreshData();
}, [refreshData]);
}, [refreshData, user, isAuthLoading]);
return {
bottle: cachedBottle,

View File

@@ -1,19 +1,18 @@
import { createBrowserClient } from '@supabase/ssr';
import type { SupabaseClient } from '@supabase/supabase-js';
const globalForSupabase = globalThis as typeof globalThis & {
supabaseBrowserClient?: SupabaseClient;
};
let supabaseClient: SupabaseClient | null = null;
export function createClient() {
if (globalForSupabase.supabaseBrowserClient) {
return globalForSupabase.supabaseBrowserClient;
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');
}
globalForSupabase.supabaseBrowserClient = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
return globalForSupabase.supabaseBrowserClient;
supabaseClient = createBrowserClient(supabaseUrl, supabaseAnonKey);
return supabaseClient;
}

View File

@@ -1,40 +1,33 @@
-- Supabase SQL Setup for Whisky Vault
-- ============================================
-- Supabase SQL Setup for Whisky Vault (Consolidated)
-- ============================================
-- Profiles table
CREATE TABLE IF NOT EXISTS profiles (
-- 1. EXTENSIONS
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 2. ENUM (Must be created first)
-- If this fails because the type exists, you can safely skip this line.
CREATE TYPE public.tag_category AS ENUM ('nose', 'taste', 'finish', 'texture');
-- ============================================
-- 3. TABLES (Ordered by Dependencies)
-- ============================================
-- Profiles
CREATE TABLE IF NOT EXISTS public.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('Europe/Berlin'::text, now())
);
-- Function to handle new user signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url)
VALUES (
new.id,
COALESCE(new.raw_user_meta_data->>'username', 'user_' || substr(new.id::text, 1, 8)),
new.raw_user_meta_data->>'avatar_url'
)
ON CONFLICT (id) DO NOTHING;
RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '';
-- Manual sync for existing users (Run this once)
-- INSERT INTO public.profiles (id)
-- SELECT id FROM auth.users
-- ON CONFLICT (id) DO NOTHING;
-- Bottles table
CREATE TABLE IF NOT EXISTS bottles (
-- Bottles
CREATE TABLE IF NOT EXISTS public.bottles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL,
distillery TEXT,
category TEXT, -- Single Malt, Bourbon, etc.
category TEXT,
abv DECIMAL,
age INTEGER,
status TEXT DEFAULT 'sealed' CHECK (status IN ('sealed', 'open', 'sampled', 'empty')),
@@ -48,308 +41,110 @@ CREATE TABLE IF NOT EXISTS bottles (
distilled_at TEXT,
bottled_at TEXT,
batch_info TEXT,
suggested_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())
);
-- Buddies table
CREATE TABLE IF NOT EXISTS buddies (
-- Buddies
CREATE TABLE IF NOT EXISTS public.buddies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES public.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
buddy_profile_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
-- Tasting Sessions table
CREATE TABLE IF NOT EXISTS tasting_sessions (
-- Tasting Sessions
CREATE TABLE IF NOT EXISTS public.tasting_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL,
scheduled_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
started_at TIMESTAMP WITH TIME ZONE,
ended_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
-- Session Participants junction (updated with user_id to avoid RLS recursion)
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,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL, -- The owner of the session
-- Session Participants
CREATE TABLE IF NOT EXISTS public.session_participants (
session_id UUID REFERENCES public.tasting_sessions(id) ON DELETE CASCADE NOT NULL,
buddy_id UUID REFERENCES public.buddies(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES public.profiles(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 (
-- Tastings
CREATE TABLE IF NOT EXISTS public.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,
bottle_id UUID REFERENCES public.bottles(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
session_id UUID REFERENCES public.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,
tasted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
-- Tasting Tagging (updated with user_id to avoid RLS recursion)
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,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL, -- The owner of the tasting
PRIMARY KEY (tasting_id, buddy_id)
);
-- Enable Row Level Security (RLS)
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE bottles ENABLE ROW LEVEL SECURITY;
ALTER TABLE tastings ENABLE ROW LEVEL SECURITY;
-- Policies for Profiles
DROP POLICY IF EXISTS "profiles_select_policy" ON profiles;
CREATE POLICY "profiles_select_policy" ON profiles
FOR SELECT USING (
(SELECT auth.uid()) = id OR
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
);
DROP POLICY IF EXISTS "profiles_update_policy" ON profiles;
CREATE POLICY "profiles_update_policy" ON profiles
FOR UPDATE USING (
(SELECT auth.uid()) = id OR
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
);
-- Policies for Bottles
DROP POLICY IF EXISTS "Relaxed bottles access" ON bottles;
DROP POLICY IF EXISTS "bottles_owner_policy" ON bottles;
CREATE POLICY "bottles_owner_policy" ON bottles
FOR ALL USING ((SELECT auth.uid()) = user_id);
-- Policies for Tastings
DROP POLICY IF EXISTS "tastings_owner_all" ON tastings;
DROP POLICY IF EXISTS "tastings_select_policy" ON tastings;
CREATE POLICY "tastings_select_policy" ON tastings
FOR SELECT USING (
(SELECT auth.uid()) = user_id OR
id IN (
SELECT tasting_id FROM tasting_tags
WHERE buddy_id IN (SELECT id FROM buddies WHERE buddy_profile_id = (SELECT auth.uid()))
)
);
DROP POLICY IF EXISTS "tastings_modify_policy" ON tastings;
CREATE POLICY "tastings_modify_policy" ON tastings
FOR ALL USING ((SELECT auth.uid()) = user_id);
-- Policies for Buddies
ALTER TABLE buddies ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Manage own buddies" ON buddies;
DROP POLICY IF EXISTS "buddies_access_policy" ON buddies;
CREATE POLICY "buddies_access_policy" ON buddies
FOR ALL USING (
(SELECT auth.uid()) = user_id OR
buddy_profile_id = (SELECT auth.uid())
);
-- Policies for Tasting Sessions
ALTER TABLE tasting_sessions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Manage own sessions" ON tasting_sessions;
DROP POLICY IF EXISTS "sessions_access_policy" ON tasting_sessions;
CREATE POLICY "sessions_access_policy" ON tasting_sessions
FOR ALL USING (
(SELECT auth.uid()) = user_id OR
id IN (
SELECT session_id FROM session_participants
WHERE buddy_id IN (SELECT id FROM buddies WHERE buddy_profile_id = (SELECT auth.uid()))
)
);
-- SESSION PARTICIPANTS
ALTER TABLE session_participants ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "session_owner_all" ON session_participants;
DROP POLICY IF EXISTS "session_participants_owner_policy" ON session_participants;
CREATE POLICY "session_participants_owner_policy" ON session_participants
FOR ALL USING ((SELECT auth.uid()) = user_id);
-- TASTING TAGS
ALTER TABLE tasting_tags ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "tags_owner_all" ON tasting_tags;
DROP POLICY IF EXISTS "tasting_tags_owner_policy" ON tasting_tags;
CREATE POLICY "tasting_tags_owner_policy" ON tasting_tags
FOR ALL USING ((SELECT auth.uid()) = user_id);
-- STORAGE SETUP
-- Create 'bottles' bucket if it doesn't exist
INSERT INTO storage.buckets (id, name, public)
VALUES ('bottles', 'bottles', true)
ON CONFLICT (id) DO NOTHING;
-- Policy to allow authenticated users to upload images to their own folder
-- Falls der Folder-Check zu strikt ist, erlauben wir hier generell Uploads für Authenticated User
-- Aber wir behalten die Zuordnung im Dateinamen bei.
CREATE POLICY "Allow authenticated uploads"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'bottles'
);
-- Policy to allow users to update/delete their own images
CREATE POLICY "Allow users to manage own images"
ON storage.objects FOR ALL
TO authenticated
USING (
bucket_id = 'bottles' AND
(storage.foldername(name))[1] = (SELECT auth.uid())::text
);
-- Policy to allow public to view images
CREATE POLICY "Allow public view access"
ON storage.objects FOR SELECT
TO public
USING (bucket_id = 'bottles');
-- VISION CACHE
CREATE TABLE IF NOT EXISTS vision_cache (
hash TEXT PRIMARY KEY,
result JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now())
);
-- Enable RLS for vision_cache (though it's only accessed via Service Role/Server Actions)
ALTER TABLE vision_cache ENABLE ROW LEVEL SECURITY;
-- Policy to allow authenticated users to view the cache (optional, but good for transparency)
CREATE POLICY "Allow authenticated users to view cache"
ON vision_cache FOR SELECT
TO authenticated
USING (true);
-- ============================================
-- API Usage Tracking & Credits System
-- ============================================
-- API Usage tracking table
CREATE TABLE IF NOT EXISTS api_usage (
-- Tags Master
CREATE TABLE IF NOT EXISTS public.tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
api_type TEXT NOT NULL CHECK (api_type IN ('google_search', 'gemini_ai')),
endpoint TEXT,
success BOOLEAN DEFAULT true,
error_message TEXT,
model TEXT,
provider TEXT,
response_text TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
name TEXT NOT NULL,
category public.tag_category NOT NULL,
is_system_default BOOLEAN DEFAULT false,
popularity_score INTEGER DEFAULT 3,
created_by UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
UNIQUE(name, category)
);
CREATE INDEX idx_api_usage_user_id ON api_usage(user_id);
CREATE INDEX idx_api_usage_api_type ON api_usage(api_type);
CREATE INDEX idx_api_usage_created_at ON api_usage(created_at);
-- User credits table (for future credits system)
CREATE TABLE IF NOT EXISTS user_credits (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
balance INTEGER DEFAULT 0,
total_purchased INTEGER DEFAULT 0,
total_used INTEGER DEFAULT 0,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
-- Junction Tables
CREATE TABLE IF NOT EXISTS public.tasting_tags (
tasting_id UUID REFERENCES public.tastings(id) ON DELETE CASCADE NOT NULL,
tag_id UUID REFERENCES public.tags(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
PRIMARY KEY (tasting_id, tag_id)
);
-- Admin users table
CREATE TABLE IF NOT EXISTS admin_users (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'admin' CHECK (role IN ('admin', 'super_admin')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
CREATE TABLE IF NOT EXISTS public.tasting_buddies (
tasting_id UUID REFERENCES public.tastings(id) ON DELETE CASCADE NOT NULL,
buddy_id UUID REFERENCES public.buddies(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
PRIMARY KEY (tasting_id, buddy_id)
);
-- Enable RLS for API tracking tables
ALTER TABLE api_usage ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_credits ENABLE ROW LEVEL SECURITY;
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
-- Policies for api_usage (users can view their own, admins can view all)
CREATE POLICY "api_usage_select_policy" ON api_usage FOR SELECT USING (
(SELECT auth.uid()) = user_id OR
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
);
CREATE POLICY "api_usage_insert_policy" ON api_usage FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
-- Policies for user_credits
CREATE POLICY "user_credits_select_policy" ON user_credits FOR SELECT USING (
(SELECT auth.uid()) = user_id OR
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
-- Bottle Splits
CREATE TABLE IF NOT EXISTS public.bottle_splits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bottle_id UUID REFERENCES public.bottles(id) ON DELETE CASCADE UNIQUE,
host_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
total_volume INTEGER DEFAULT 70,
host_share INTEGER DEFAULT 10,
price_bottle DECIMAL(10, 2) NOT NULL,
sample_sizes JSONB DEFAULT '[{"cl": 5, "glass_cost": 0.80}, {"cl": 10, "glass_cost": 1.50}]'::jsonb,
shipping_options JSONB DEFAULT '[]'::jsonb,
is_active BOOLEAN DEFAULT true,
public_slug TEXT UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
-- Policies for admin_users (users can see their own admin record)
CREATE POLICY "admin_users_select_policy" ON admin_users FOR SELECT USING (
(SELECT auth.uid()) = user_id
CREATE TABLE IF NOT EXISTS public.split_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
split_id UUID REFERENCES public.bottle_splits(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
amount_cl INTEGER NOT NULL CHECK (amount_cl > 0),
shipping_method TEXT NOT NULL,
total_cost DECIMAL(10, 2) NOT NULL,
status TEXT DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'APPROVED', 'PAID', 'SHIPPED', 'REJECTED', 'WAITLIST')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
UNIQUE(split_id, user_id)
);
-- Note: To add robin as admin, run this after getting the user_id:
-- INSERT INTO admin_users (user_id, role) VALUES ('<robin_user_id>', 'super_admin');
-- ============================================
-- Credits Management System
-- ============================================
-- Extend user_credits table with additional fields
ALTER TABLE user_credits
ADD COLUMN IF NOT EXISTS daily_limit INTEGER DEFAULT NULL,
ADD COLUMN IF NOT EXISTS google_search_cost INTEGER DEFAULT 1,
ADD COLUMN IF NOT EXISTS gemini_ai_cost INTEGER DEFAULT 1,
ADD COLUMN IF NOT EXISTS last_reset_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now());
-- Credit transactions table
CREATE TABLE IF NOT EXISTS credit_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
amount INTEGER NOT NULL,
type TEXT NOT NULL CHECK (type IN ('deduction', 'addition', 'admin_adjustment')),
reason TEXT,
api_type TEXT CHECK (api_type IN ('google_search', 'gemini_ai')),
admin_id UUID REFERENCES auth.users(id),
balance_after INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
CREATE INDEX idx_credit_transactions_user_id ON credit_transactions(user_id);
CREATE INDEX idx_credit_transactions_created_at ON credit_transactions(created_at);
CREATE INDEX idx_credit_transactions_type ON credit_transactions(type);
-- Enable RLS for credit_transactions
ALTER TABLE credit_transactions ENABLE ROW LEVEL SECURITY;
-- Policies for credit_transactions
CREATE POLICY "credit_transactions_select_policy" ON credit_transactions
FOR SELECT USING (
(SELECT auth.uid()) = user_id OR
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
);
CREATE POLICY "credit_transactions_insert_policy" ON credit_transactions
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
-- Update user_credits policies to allow admin updates
CREATE POLICY "user_credits_update_policy" ON user_credits
FOR UPDATE USING (
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
);
-- Initialize credits for existing users (run manually if needed)
-- INSERT INTO user_credits (user_id, balance)
-- SELECT id, 100
-- FROM auth.users
-- ON CONFLICT (user_id) DO UPDATE SET balance = EXCLUDED.balance
-- WHERE user_credits.balance = 0;
-- ============================================
-- Subscription Plans System
-- ============================================
-- Subscription plans table
CREATE TABLE IF NOT EXISTS subscription_plans (
-- Subscriptions & Credits
CREATE TABLE IF NOT EXISTS public.subscription_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
@@ -362,161 +157,231 @@ CREATE TABLE IF NOT EXISTS subscription_plans (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
CREATE INDEX idx_subscription_plans_active ON subscription_plans(is_active);
CREATE INDEX idx_subscription_plans_sort_order ON subscription_plans(sort_order);
-- User subscriptions table
CREATE TABLE IF NOT EXISTS user_subscriptions (
CREATE TABLE IF NOT EXISTS public.user_subscriptions (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
plan_id UUID REFERENCES subscription_plans(id) ON DELETE SET NULL,
plan_id UUID REFERENCES public.subscription_plans(id) ON DELETE SET NULL,
started_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
last_credit_grant_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
CREATE INDEX idx_user_subscriptions_plan_id ON user_subscriptions(plan_id);
-- Enable RLS
ALTER TABLE subscription_plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_subscriptions ENABLE ROW LEVEL SECURITY;
-- Policies for subscription_plans (everyone can view active plans)
CREATE POLICY "subscription_plans_select_policy" ON subscription_plans
FOR SELECT USING (
is_active = true OR
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
CREATE TABLE IF NOT EXISTS public.user_credits (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
balance INTEGER DEFAULT 0,
total_purchased INTEGER DEFAULT 0,
total_used INTEGER DEFAULT 0,
daily_limit INTEGER DEFAULT NULL,
google_search_cost INTEGER DEFAULT 1,
gemini_ai_cost INTEGER DEFAULT 1,
last_reset_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
CREATE POLICY "subscription_plans_admin_policy" ON subscription_plans
FOR ALL USING (
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
CREATE TABLE IF NOT EXISTS public.credit_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
amount INTEGER NOT NULL,
type TEXT NOT NULL CHECK (type IN ('deduction', 'addition', 'admin_adjustment')),
reason TEXT,
api_type TEXT CHECK (api_type IN ('google_search', 'gemini_ai')),
admin_id UUID REFERENCES auth.users(id),
balance_after INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
-- Policies for user_subscriptions
CREATE POLICY "user_subscriptions_select_policy" ON user_subscriptions
FOR SELECT USING (
(SELECT auth.uid()) = user_id OR
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
-- Admin & Utility
CREATE TABLE IF NOT EXISTS public.admin_users (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'admin' CHECK (role IN ('admin', 'super_admin')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
CREATE POLICY "user_subscriptions_admin_policy" ON user_subscriptions
FOR ALL USING (
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
CREATE TABLE IF NOT EXISTS public.vision_cache (
hash TEXT PRIMARY KEY,
result JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now())
);
-- Insert default plans
INSERT INTO subscription_plans (name, display_name, monthly_credits, price, description, sort_order) VALUES
CREATE TABLE IF NOT EXISTS public.global_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wb_id TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('simple', full_name)) STORED,
image_hash TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now())
);
CREATE TABLE IF NOT EXISTS public.enrichment_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
distillery TEXT NOT NULL UNIQUE,
suggested_tags TEXT[],
suggested_custom_tags TEXT[],
search_string TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
hit_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS public.api_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
api_type TEXT NOT NULL CHECK (api_type IN ('google_search', 'gemini_ai')),
endpoint TEXT,
success BOOLEAN DEFAULT true,
error_message TEXT,
model TEXT,
provider TEXT,
response_text TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
CREATE TABLE IF NOT EXISTS public.buddy_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
creator_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
code TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
-- ============================================
-- 4. FUNCTIONS & TRIGGERS
-- ============================================
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url)
VALUES (
new.id,
COALESCE(new.raw_user_meta_data->>'username', 'user_' || substr(new.id::text, 1, 8)),
new.raw_user_meta_data->>'avatar_url'
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.user_subscriptions (user_id, plan_id)
VALUES (new.id, (SELECT id FROM public.subscription_plans WHERE name = 'starter' LIMIT 1))
ON CONFLICT (user_id) DO NOTHING;
INSERT INTO public.user_credits (user_id, balance)
VALUES (new.id, 10)
ON CONFLICT (user_id) DO NOTHING;
RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- Helper Functions for RLS
CREATE OR REPLACE FUNCTION public.check_is_split_host(check_split_id UUID, check_user_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.bottle_splits
WHERE id = check_split_id AND host_id = check_user_id
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION public.check_is_split_participant(check_split_id UUID, check_user_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.split_participants
WHERE split_id = check_split_id AND user_id = check_user_id
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================
-- 5. ROW LEVEL SECURITY (RLS)
-- ============================================
-- Explicitly enable for all
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.bottles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tastings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.buddies ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tasting_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.session_participants ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tasting_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tasting_buddies ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.bottle_splits ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.split_participants ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.global_products ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.enrichment_cache ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.api_usage ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_credits ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.credit_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.buddy_invites ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.vision_cache ENABLE ROW LEVEL SECURITY;
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_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 (
auth.uid() = user_id OR
EXISTS (SELECT 1 FROM public.bottle_splits WHERE bottle_id = public.bottles.id AND is_active = true)
);
CREATE POLICY "bottles_insert_policy" ON public.bottles FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "bottles_update_policy" ON public.bottles FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "bottles_delete_policy" ON public.bottles FOR DELETE USING (auth.uid() = user_id);
CREATE POLICY "tastings_select_policy" ON public.tastings FOR SELECT USING (
auth.uid() = user_id OR
EXISTS (SELECT 1 FROM public.tasting_buddies tb JOIN public.buddies b ON b.id = tb.buddy_id WHERE tb.tasting_id = public.tastings.id AND b.buddy_profile_id = auth.uid())
);
CREATE POLICY "tastings_insert_policy" ON public.tastings FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "tastings_modify_policy" ON public.tastings FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "tasting_buddies_all" ON public.tasting_buddies FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "tasting_tags_all" ON public.tasting_tags FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "session_participants_all" ON public.session_participants FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "sessions_access" ON public.tasting_sessions FOR ALL USING (auth.uid() = user_id OR id IN (SELECT session_id FROM public.session_participants WHERE buddy_id IN (SELECT id FROM public.buddies WHERE buddy_profile_id = auth.uid())));
CREATE POLICY "tags_select" ON public.tags FOR SELECT USING (is_system_default = true OR auth.uid() = created_by OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
CREATE POLICY "tags_insert" ON public.tags FOR INSERT WITH CHECK (auth.uid() = created_by);
CREATE POLICY "bottle_splits_host" ON public.bottle_splits FOR ALL USING (auth.uid() = host_id);
CREATE POLICY "bottle_splits_view" ON public.bottle_splits FOR SELECT USING (is_active = true OR public.check_is_split_participant(id, auth.uid()));
CREATE POLICY "split_participants_own" ON public.split_participants FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "split_participants_host" ON public.split_participants FOR ALL USING (public.check_is_split_host(split_id, auth.uid()));
CREATE POLICY "split_participants_view" ON public.split_participants FOR SELECT USING (EXISTS (SELECT 1 FROM public.bottle_splits WHERE id = split_id AND is_active = true));
CREATE POLICY "global_products_select" ON public.global_products FOR SELECT USING (true);
CREATE POLICY "global_products_admin" ON public.global_products FOR ALL USING (EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
CREATE POLICY "enrichment_cache_read_write" ON public.enrichment_cache FOR ALL TO authenticated USING (true);
CREATE POLICY "vision_cache_select" ON public.vision_cache FOR SELECT TO authenticated USING (true);
CREATE POLICY "api_usage_own" ON public.api_usage FOR SELECT USING (auth.uid() = user_id OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
CREATE POLICY "user_credits_own" ON public.user_credits FOR SELECT USING (auth.uid() = user_id OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
CREATE POLICY "admin_only_updates" ON public.user_credits FOR UPDATE USING (EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
CREATE POLICY "subscription_plans_public" ON public.subscription_plans FOR SELECT USING (is_active = true OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
CREATE POLICY "user_subscriptions_own" ON public.user_subscriptions FOR SELECT USING (auth.uid() = user_id OR EXISTS (SELECT 1 FROM public.admin_users WHERE user_id = auth.uid()));
-- ============================================
-- 6. INDEXES
-- ============================================
CREATE INDEX IF NOT EXISTS idx_bottles_user_id ON public.bottles(user_id);
CREATE INDEX IF NOT EXISTS idx_buddies_user_id ON public.buddies(user_id);
CREATE INDEX IF NOT EXISTS idx_tastings_bottle_id ON public.tastings(bottle_id);
CREATE INDEX IF NOT EXISTS idx_tags_category_name ON public.tags(category, name);
-- ============================================
-- 7. INITIAL DATA
-- ============================================
INSERT INTO public.subscription_plans (name, display_name, monthly_credits, price, description, sort_order) VALUES
('starter', 'Starter', 10, 0.00, 'Perfect for occasional use', 1),
('bronze', 'Bronze', 50, 4.99, 'Great for regular users', 2),
('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;
-- Set all existing users to Starter plan
INSERT INTO user_subscriptions (user_id, plan_id)
SELECT
u.id,
(SELECT id FROM subscription_plans WHERE name = 'starter' LIMIT 1)
FROM auth.users u
ON CONFLICT (user_id) DO NOTHING;
-- ============================================
-- Buddy Invites (Handshake Codes)
-- ============================================
CREATE TABLE IF NOT EXISTS buddy_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
creator_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
code TEXT NOT NULL UNIQUE, -- 6 char uppercase alphanumeric
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
CREATE INDEX IF NOT EXISTS idx_buddy_invites_code ON buddy_invites(code);
CREATE INDEX IF NOT EXISTS idx_buddy_invites_creator_id ON buddy_invites(creator_id);
CREATE INDEX IF NOT EXISTS idx_buddy_invites_expires_at ON buddy_invites(expires_at);
ALTER TABLE buddy_invites ENABLE ROW LEVEL SECURITY;
-- Only creator can see their own invites
DROP POLICY IF EXISTS "buddy_invites_creator_policy" ON buddy_invites;
CREATE POLICY "buddy_invites_creator_policy" ON buddy_invites
FOR ALL USING ((SELECT auth.uid()) = creator_id);
-- Allow anyone to SELECT by code (needed for redemption) but only if not expired
DROP POLICY IF EXISTS "buddy_invites_redeem_policy" ON buddy_invites;
CREATE POLICY "buddy_invites_redeem_policy" ON buddy_invites
FOR SELECT USING (expires_at > now());
-- ============================================
-- Bottle Splits (Flaschenteilung)
-- ============================================
CREATE TABLE IF NOT EXISTS bottle_splits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bottle_id UUID REFERENCES bottles(id) ON DELETE CASCADE UNIQUE,
host_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
total_volume INTEGER DEFAULT 70, -- in cl
host_share INTEGER DEFAULT 10, -- what the host keeps, in cl
price_bottle DECIMAL(10, 2) NOT NULL,
sample_sizes JSONB DEFAULT '[{"cl": 5, "glass_cost": 0.80}, {"cl": 10, "glass_cost": 1.50}]'::jsonb,
shipping_options JSONB DEFAULT '[]'::jsonb,
is_active BOOLEAN DEFAULT true,
public_slug TEXT UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
CREATE INDEX IF NOT EXISTS idx_bottle_splits_host_id ON bottle_splits(host_id);
CREATE INDEX IF NOT EXISTS idx_bottle_splits_public_slug ON bottle_splits(public_slug);
CREATE INDEX IF NOT EXISTS idx_bottle_splits_bottle_id ON bottle_splits(bottle_id);
ALTER TABLE bottle_splits ENABLE ROW LEVEL SECURITY;
-- Host can manage their own splits
DROP POLICY IF EXISTS "bottle_splits_host_policy" ON bottle_splits;
CREATE POLICY "bottle_splits_host_policy" ON bottle_splits
FOR ALL USING ((SELECT auth.uid()) = host_id);
-- Anyone can view active splits (for public page)
DROP POLICY IF EXISTS "bottle_splits_public_view" ON bottle_splits;
CREATE POLICY "bottle_splits_public_view" ON bottle_splits
FOR SELECT USING (is_active = true);
-- Split Participants
CREATE TABLE IF NOT EXISTS split_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
split_id UUID REFERENCES bottle_splits(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
amount_cl INTEGER NOT NULL CHECK (amount_cl > 0),
shipping_method TEXT NOT NULL,
total_cost DECIMAL(10, 2) NOT NULL,
status TEXT DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'APPROVED', 'PAID', 'SHIPPED', 'REJECTED', 'WAITLIST')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
UNIQUE(split_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_split_participants_split_id ON split_participants(split_id);
CREATE INDEX IF NOT EXISTS idx_split_participants_user_id ON split_participants(user_id);
CREATE INDEX IF NOT EXISTS idx_split_participants_status ON split_participants(status);
ALTER TABLE split_participants ENABLE ROW LEVEL SECURITY;
-- Users can view their own participations
DROP POLICY IF EXISTS "split_participants_own_policy" ON split_participants;
CREATE POLICY "split_participants_own_policy" ON split_participants
FOR ALL USING ((SELECT auth.uid()) = user_id);
-- Hosts can view/manage participants for their splits
DROP POLICY IF EXISTS "split_participants_host_policy" ON split_participants;
CREATE POLICY "split_participants_host_policy" ON split_participants
FOR ALL USING (
split_id IN (SELECT id FROM bottle_splits WHERE host_id = (SELECT auth.uid()))
);
-- Anyone can view participants for public splits (to show fill-level)
DROP POLICY IF EXISTS "split_participants_public_view" ON split_participants;
CREATE POLICY "split_participants_public_view" ON split_participants
FOR SELECT USING (
split_id IN (SELECT id FROM bottle_splits WHERE is_active = true)
);