Refactor: Centralized Supabase Auth and implemented Auth Guards to prevent 401 errors
This commit is contained in:
18001
distillery_tags_results.json
Normal file
18001
distillery_tags_results.json
Normal file
File diff suppressed because it is too large
Load Diff
222
scripts/scrape-distillery-tags.ts
Normal file
222
scripts/scrape-distillery-tags.ts
Normal 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);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
}, [rawSessionId, user, isAuthLoading]);
|
||||
|
||||
if (!bottleId) return null;
|
||||
|
||||
|
||||
@@ -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,8 +49,8 @@ export default function RootLayout({
|
||||
<html lang="de" suppressHydrationWarning={true}>
|
||||
<body className={`${inter.variable} font-sans`}>
|
||||
<I18nProvider>
|
||||
<AuthProvider>
|
||||
<SessionProvider>
|
||||
<AuthListener />
|
||||
<ActiveSessionBanner />
|
||||
<MainContentWrapper>
|
||||
<SyncHandler />
|
||||
@@ -61,6 +61,7 @@ export default function RootLayout({
|
||||
<CookieBanner />
|
||||
<OnboardingTutorial />
|
||||
</SessionProvider>
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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) {
|
||||
// Only fetch if auth is ready and user exists
|
||||
if (!isAuthLoading && user) {
|
||||
fetchCollection();
|
||||
} else if (!isAuthLoading && !user) {
|
||||
setBottles([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] Unexpected error during session check:', err);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkUser();
|
||||
}, [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">
|
||||
|
||||
@@ -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(() => {
|
||||
if (!isAuthLoading && user) {
|
||||
fetchSessionData();
|
||||
}
|
||||
|
||||
// Subscribe to bottle updates for realtime processing status
|
||||
const channel = supabase
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
if (!isAuthLoading && user) {
|
||||
fetchBuddies();
|
||||
}, []);
|
||||
}
|
||||
}, [user, isAuthLoading]);
|
||||
|
||||
const fetchBuddies = async () => {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
@@ -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(() => {
|
||||
if (!isAuthLoading && user) {
|
||||
fetchSessions();
|
||||
}, []);
|
||||
}
|
||||
}, [user, isAuthLoading]);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
const { data, error } = await supabase
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
77
src/context/AuthContext.tsx
Normal file
77
src/context/AuthContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
727
supa_schema.sql
727
supa_schema.sql
@@ -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
|
||||
-- Tags Master
|
||||
CREATE TABLE IF NOT EXISTS public.tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
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)
|
||||
);
|
||||
|
||||
-- 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)
|
||||
);
|
||||
|
||||
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 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 (
|
||||
-- Bottle Splits
|
||||
CREATE TABLE IF NOT EXISTS public.bottle_splits (
|
||||
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 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,
|
||||
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())
|
||||
);
|
||||
|
||||
-- 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())
|
||||
);
|
||||
|
||||
-- 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()))
|
||||
);
|
||||
|
||||
-- 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
|
||||
);
|
||||
|
||||
-- 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 (
|
||||
CREATE TABLE IF NOT EXISTS public.split_participants (
|
||||
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())
|
||||
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)
|
||||
);
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user