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 React, { useEffect, useState } from 'react';
|
||||||
import BottleDetails from '@/components/BottleDetails';
|
import BottleDetails from '@/components/BottleDetails';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { validateSession } from '@/services/validate-session';
|
import { validateSession } from '@/services/validate-session';
|
||||||
import OfflineIndicator from '@/components/OfflineIndicator';
|
import OfflineIndicator from '@/components/OfflineIndicator';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
@@ -12,12 +13,15 @@ export default function BottlePage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
||||||
const [userId, setUserId] = useState<string | undefined>(undefined);
|
const [userId, setUserId] = useState<string | undefined>(undefined);
|
||||||
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
|
||||||
const bottleId = params?.id as string;
|
const bottleId = params?.id as string;
|
||||||
const rawSessionId = searchParams?.get('session_id');
|
const rawSessionId = searchParams?.get('session_id');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isAuthLoading) return;
|
||||||
|
|
||||||
const checkSession = async () => {
|
const checkSession = async () => {
|
||||||
if (rawSessionId) {
|
if (rawSessionId) {
|
||||||
const isValid = await validateSession(rawSessionId);
|
const isValid = await validateSession(rawSessionId);
|
||||||
@@ -27,16 +31,11 @@ export default function BottlePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAuth = async () => {
|
if (user) {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
setUserId(user.id);
|
||||||
if (user) {
|
checkSession();
|
||||||
setUserId(user.id);
|
}
|
||||||
}
|
}, [rawSessionId, user, isAuthLoading]);
|
||||||
};
|
|
||||||
|
|
||||||
checkSession();
|
|
||||||
getAuth();
|
|
||||||
}, [rawSessionId, supabase]);
|
|
||||||
|
|
||||||
if (!bottleId) return null;
|
if (!bottleId) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import OfflineIndicator from "@/components/OfflineIndicator";
|
|||||||
import UploadQueue from "@/components/UploadQueue";
|
import UploadQueue from "@/components/UploadQueue";
|
||||||
import { I18nProvider } from "@/i18n/I18nContext";
|
import { I18nProvider } from "@/i18n/I18nContext";
|
||||||
import { SessionProvider } from "@/context/SessionContext";
|
import { SessionProvider } from "@/context/SessionContext";
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import ActiveSessionBanner from "@/components/ActiveSessionBanner";
|
import ActiveSessionBanner from "@/components/ActiveSessionBanner";
|
||||||
import MainContentWrapper from "@/components/MainContentWrapper";
|
import MainContentWrapper from "@/components/MainContentWrapper";
|
||||||
import AuthListener from "@/components/AuthListener";
|
|
||||||
import SyncHandler from "@/components/SyncHandler";
|
import SyncHandler from "@/components/SyncHandler";
|
||||||
import CookieBanner from "@/components/CookieBanner";
|
import CookieBanner from "@/components/CookieBanner";
|
||||||
import OnboardingTutorial from "@/components/OnboardingTutorial";
|
import OnboardingTutorial from "@/components/OnboardingTutorial";
|
||||||
@@ -49,18 +49,19 @@ export default function RootLayout({
|
|||||||
<html lang="de" suppressHydrationWarning={true}>
|
<html lang="de" suppressHydrationWarning={true}>
|
||||||
<body className={`${inter.variable} font-sans`}>
|
<body className={`${inter.variable} font-sans`}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<SessionProvider>
|
<AuthProvider>
|
||||||
<AuthListener />
|
<SessionProvider>
|
||||||
<ActiveSessionBanner />
|
<ActiveSessionBanner />
|
||||||
<MainContentWrapper>
|
<MainContentWrapper>
|
||||||
<SyncHandler />
|
<SyncHandler />
|
||||||
<PWARegistration />
|
<PWARegistration />
|
||||||
<UploadQueue />
|
<UploadQueue />
|
||||||
{children}
|
{children}
|
||||||
</MainContentWrapper>
|
</MainContentWrapper>
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
<OnboardingTutorial />
|
<OnboardingTutorial />
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
</AuthProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import DramOfTheDay from "@/components/DramOfTheDay";
|
|||||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||||
import OfflineIndicator from "@/components/OfflineIndicator";
|
import OfflineIndicator from "@/components/OfflineIndicator";
|
||||||
import { useI18n } from "@/i18n/I18nContext";
|
import { useI18n } from "@/i18n/I18nContext";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { useSession } from "@/context/SessionContext";
|
import { useSession } from "@/context/SessionContext";
|
||||||
import TastingHub from "@/components/TastingHub";
|
import TastingHub from "@/components/TastingHub";
|
||||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
import { Sparkles, X, Loader2 } from "lucide-react";
|
||||||
@@ -25,8 +26,8 @@ export default function Home() {
|
|||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [bottles, setBottles] = useState<any[]>([]);
|
const [bottles, setBottles] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
const [user, setUser] = useState<any>(null);
|
const [isInternalLoading, setIsInternalLoading] = useState(false);
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { activeSession } = useSession();
|
const { activeSession } = useSession();
|
||||||
@@ -46,39 +47,15 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check session
|
// Only fetch if auth is ready and user exists
|
||||||
const checkUser = async () => {
|
if (!isAuthLoading && user) {
|
||||||
try {
|
fetchCollection();
|
||||||
// Proactively get session - this will trigger a refresh if needed
|
} else if (!isAuthLoading && !user) {
|
||||||
const { data: { session }, error } = await supabase.auth.getSession();
|
setBottles([]);
|
||||||
|
}
|
||||||
if (session) {
|
}, [user, isAuthLoading]);
|
||||||
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();
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
// Fetch public splits if guest
|
// Fetch public splits if guest
|
||||||
getActiveSplits().then(res => {
|
getActiveSplits().then(res => {
|
||||||
if (res.success && res.splits) {
|
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)
|
// Listen for collection updates (e.g., after offline sync completes)
|
||||||
const handleCollectionUpdated = () => {
|
const handleCollectionUpdated = () => {
|
||||||
console.log('[Home] Collection update event received, refreshing...');
|
console.log('[Home] Collection update event received, refreshing...');
|
||||||
@@ -121,14 +71,12 @@ export default function Home() {
|
|||||||
window.addEventListener('collection-updated', handleCollectionUpdated);
|
window.addEventListener('collection-updated', handleCollectionUpdated);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.unsubscribe();
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
window.removeEventListener('collection-updated', handleCollectionUpdated);
|
window.removeEventListener('collection-updated', handleCollectionUpdated);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchCollection = async () => {
|
const fetchCollection = async () => {
|
||||||
setIsLoading(true);
|
setIsInternalLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch bottles with their latest tasting date
|
// Fetch bottles with their latest tasting date
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -194,7 +142,7 @@ export default function Home() {
|
|||||||
setFetchError(errorMessage);
|
setFetchError(errorMessage);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsInternalLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,6 +197,8 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLoading = isAuthLoading || isInternalLoading;
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<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 { useSession } from '@/context/SessionContext';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import SessionTimeline from '@/components/SessionTimeline';
|
import SessionTimeline from '@/components/SessionTimeline';
|
||||||
import SessionABVCurve from '@/components/SessionABVCurve';
|
import SessionABVCurve from '@/components/SessionABVCurve';
|
||||||
import OfflineIndicator from '@/components/OfflineIndicator';
|
import OfflineIndicator from '@/components/OfflineIndicator';
|
||||||
@@ -65,6 +66,7 @@ export default function SessionDetailPage() {
|
|||||||
const [tastings, setTastings] = useState<SessionTasting[]>([]);
|
const [tastings, setTastings] = useState<SessionTasting[]>([]);
|
||||||
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
|
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
const { activeSession, setActiveSession } = useSession();
|
const { activeSession, setActiveSession } = useSession();
|
||||||
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
|
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
@@ -72,7 +74,9 @@ export default function SessionDetailPage() {
|
|||||||
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
|
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSessionData();
|
if (!isAuthLoading && user) {
|
||||||
|
fetchSessionData();
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to bottle updates for realtime processing status
|
// Subscribe to bottle updates for realtime processing status
|
||||||
const channel = supabase
|
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 { createClient } from '@/lib/supabase/client';
|
||||||
import { Users, UserPlus, Trash2, Loader2, ChevronDown, ChevronUp, Link2 } from 'lucide-react';
|
import { Users, UserPlus, Trash2, Loader2, ChevronDown, ChevronUp, Link2 } from 'lucide-react';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { addBuddy, deleteBuddy } from '@/services/buddy';
|
import { addBuddy, deleteBuddy } from '@/services/buddy';
|
||||||
import BuddyHandshake from './BuddyHandshake';
|
import BuddyHandshake from './BuddyHandshake';
|
||||||
|
|
||||||
@@ -27,10 +28,13 @@ export default function BuddyList() {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
const [isHandshakeOpen, setIsHandshakeOpen] = useState(false);
|
const [isHandshakeOpen, setIsHandshakeOpen] = useState(false);
|
||||||
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBuddies();
|
if (!isAuthLoading && user) {
|
||||||
}, []);
|
fetchBuddies();
|
||||||
|
}
|
||||||
|
}, [user, isAuthLoading]);
|
||||||
|
|
||||||
const fetchBuddies = async () => {
|
const fetchBuddies = async () => {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import AvatarStack from './AvatarStack';
|
|||||||
import { deleteSession } from '@/services/delete-session';
|
import { deleteSession } from '@/services/delete-session';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { useSession } from '@/context/SessionContext';
|
import { useSession } from '@/context/SessionContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,10 +35,13 @@ export default function SessionList() {
|
|||||||
});
|
});
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const { activeSession, setActiveSession } = useSession();
|
const { activeSession, setActiveSession } = useSession();
|
||||||
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSessions();
|
if (!isAuthLoading && user) {
|
||||||
}, []);
|
fetchSessions();
|
||||||
|
}
|
||||||
|
}, [user, isAuthLoading]);
|
||||||
|
|
||||||
const fetchSessions = async () => {
|
const fetchSessions = async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useSession } from '@/context/SessionContext';
|
import { useSession } from '@/context/SessionContext';
|
||||||
import { getHostSplits, getParticipatingSplits } from '@/services/split-actions';
|
import { getHostSplits, getParticipatingSplits } from '@/services/split-actions';
|
||||||
import AvatarStack from './AvatarStack';
|
import AvatarStack from './AvatarStack';
|
||||||
@@ -49,6 +50,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
|
|||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { activeSession, setActiveSession } = useSession();
|
const { activeSession, setActiveSession } = useSession();
|
||||||
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'tastings' | 'splits'>('tastings');
|
const [activeTab, setActiveTab] = useState<'tastings' | 'splits'>('tastings');
|
||||||
const [mySessions, setMySessions] = useState<Session[]>([]);
|
const [mySessions, setMySessions] = useState<Session[]>([]);
|
||||||
@@ -62,10 +64,10 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
|
|||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen && !isAuthLoading && user) {
|
||||||
fetchAll();
|
fetchAll();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen, isAuthLoading, user]);
|
||||||
|
|
||||||
const fetchAll = async () => {
|
const fetchAll = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { createClient } from '@/lib/supabase/client';
|
|||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { useSession } from '@/context/SessionContext';
|
import { useSession } from '@/context/SessionContext';
|
||||||
import TagSelector from './TagSelector';
|
import TagSelector from './TagSelector';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import TastingFormBody from './TastingFormBody';
|
import TastingFormBody from './TastingFormBody';
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
|
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
|
||||||
const [suggestedCustomTags, setSuggestedCustomTags] = useState<string[]>([]);
|
const [suggestedCustomTags, setSuggestedCustomTags] = useState<string[]>([]);
|
||||||
const { activeSession } = useSession();
|
const { activeSession } = useSession();
|
||||||
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
|
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
|
||||||
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
|
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
|
||||||
|
|
||||||
@@ -56,16 +58,14 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
const effectiveSessionId = sessionId || activeSession?.id;
|
const effectiveSessionId = sessionId || activeSession?.id;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getAuth = async () => {
|
if (!isAuthLoading && user) {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
setCurrentUserId(user.id);
|
||||||
if (user) setCurrentUserId(user.id);
|
}
|
||||||
};
|
}, [user, isAuthLoading]);
|
||||||
getAuth();
|
|
||||||
}, [supabase]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!bottleId) return;
|
if (!bottleId || isAuthLoading || !user) return;
|
||||||
|
|
||||||
// Fetch Bottle Suggestions and Owner
|
// Fetch Bottle Suggestions and Owner
|
||||||
const { data: bottleData } = await supabase
|
const { data: bottleData } = await supabase
|
||||||
@@ -130,7 +130,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [supabase, effectiveSessionId, bottleId]);
|
}, [supabase, effectiveSessionId, bottleId, user, isAuthLoading]);
|
||||||
|
|
||||||
// Live Palette Checker Logic
|
// Live Palette Checker Logic
|
||||||
useEffect(() => {
|
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 { createClient } from '@/lib/supabase/client';
|
||||||
import { db, type CachedBottle, type CachedTasting } from '@/lib/db';
|
import { db, type CachedBottle, type CachedTasting } from '@/lib/db';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
export function useBottleData(bottleId: string) {
|
export function useBottleData(bottleId: string) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
|
|
||||||
// Live queries from Dexie
|
// Live queries from Dexie
|
||||||
const cachedBottle = useLiveQuery(() => db.cache_bottles.get(bottleId), [bottleId]);
|
const cachedBottle = useLiveQuery(() => db.cache_bottles.get(bottleId), [bottleId]);
|
||||||
@@ -18,8 +20,8 @@ export function useBottleData(bottleId: string) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const refreshData = useCallback(async () => {
|
const refreshData = useCallback(async () => {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine || isAuthLoading || !user) {
|
||||||
setLoading(false);
|
if (!isAuthLoading && !user) setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ export function useBottleData(bottleId: string) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshData();
|
refreshData();
|
||||||
}, [refreshData]);
|
}, [refreshData, user, isAuthLoading]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bottle: cachedBottle,
|
bottle: cachedBottle,
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { createBrowserClient } from '@supabase/ssr';
|
import { createBrowserClient } from '@supabase/ssr';
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const globalForSupabase = globalThis as typeof globalThis & {
|
let supabaseClient: SupabaseClient | null = null;
|
||||||
supabaseBrowserClient?: SupabaseClient;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createClient() {
|
export function createClient() {
|
||||||
if (globalForSupabase.supabaseBrowserClient) {
|
if (supabaseClient) return supabaseClient;
|
||||||
return globalForSupabase.supabaseBrowserClient;
|
|
||||||
|
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(
|
supabaseClient = createBrowserClient(supabaseUrl, supabaseAnonKey);
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
return supabaseClient;
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
||||||
);
|
|
||||||
|
|
||||||
return globalForSupabase.supabaseBrowserClient;
|
|
||||||
}
|
}
|
||||||
|
|||||||
725
supa_schema.sql
725
supa_schema.sql
@@ -1,40 +1,33 @@
|
|||||||
-- Supabase SQL Setup for Whisky Vault
|
-- ============================================
|
||||||
|
-- Supabase SQL Setup for Whisky Vault (Consolidated)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
-- Profiles table
|
-- 1. EXTENSIONS
|
||||||
CREATE TABLE IF NOT EXISTS profiles (
|
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,
|
id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
|
||||||
username TEXT UNIQUE,
|
username TEXT UNIQUE,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Function to handle new user signup
|
-- Bottles
|
||||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
CREATE TABLE IF NOT EXISTS public.bottles (
|
||||||
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 (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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,
|
name TEXT NOT NULL,
|
||||||
distillery TEXT,
|
distillery TEXT,
|
||||||
category TEXT, -- Single Malt, Bourbon, etc.
|
category TEXT,
|
||||||
abv DECIMAL,
|
abv DECIMAL,
|
||||||
age INTEGER,
|
age INTEGER,
|
||||||
status TEXT DEFAULT 'sealed' CHECK (status IN ('sealed', 'open', 'sampled', 'empty')),
|
status TEXT DEFAULT 'sealed' CHECK (status IN ('sealed', 'open', 'sampled', 'empty')),
|
||||||
@@ -48,308 +41,110 @@ CREATE TABLE IF NOT EXISTS bottles (
|
|||||||
distilled_at TEXT,
|
distilled_at TEXT,
|
||||||
bottled_at TEXT,
|
bottled_at TEXT,
|
||||||
batch_info TEXT,
|
batch_info TEXT,
|
||||||
|
suggested_tags TEXT[],
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Buddies table
|
-- Buddies
|
||||||
CREATE TABLE IF NOT EXISTS buddies (
|
CREATE TABLE IF NOT EXISTS public.buddies (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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,
|
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())
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Tasting Sessions table
|
-- Tasting Sessions
|
||||||
CREATE TABLE IF NOT EXISTS tasting_sessions (
|
CREATE TABLE IF NOT EXISTS public.tasting_sessions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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,
|
name TEXT NOT NULL,
|
||||||
scheduled_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
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())
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Session Participants junction (updated with user_id to avoid RLS recursion)
|
-- Session Participants
|
||||||
CREATE TABLE IF NOT EXISTS session_participants (
|
CREATE TABLE IF NOT EXISTS public.session_participants (
|
||||||
session_id UUID REFERENCES tasting_sessions(id) ON DELETE CASCADE NOT NULL,
|
session_id UUID REFERENCES public.tasting_sessions(id) ON DELETE CASCADE NOT NULL,
|
||||||
buddy_id UUID REFERENCES buddies(id) ON DELETE CASCADE NOT NULL,
|
buddy_id UUID REFERENCES public.buddies(id) ON DELETE CASCADE NOT NULL,
|
||||||
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL, -- The owner of the session
|
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
|
||||||
PRIMARY KEY (session_id, buddy_id)
|
PRIMARY KEY (session_id, buddy_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Tastings table (updated with session and buddy tagging)
|
-- Tastings
|
||||||
CREATE TABLE IF NOT EXISTS tastings (
|
CREATE TABLE IF NOT EXISTS public.tastings (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bottle_id UUID REFERENCES bottles(id) ON DELETE CASCADE NOT NULL,
|
bottle_id UUID REFERENCES public.bottles(id) ON DELETE CASCADE NOT NULL,
|
||||||
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
|
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
|
||||||
session_id UUID REFERENCES tasting_sessions(id) ON DELETE SET NULL,
|
session_id UUID REFERENCES public.tasting_sessions(id) ON DELETE SET NULL,
|
||||||
rating INTEGER CHECK (rating >= 0 AND rating <= 100),
|
rating INTEGER CHECK (rating >= 0 AND rating <= 100),
|
||||||
nose_notes TEXT,
|
nose_notes TEXT,
|
||||||
palate_notes TEXT,
|
palate_notes TEXT,
|
||||||
finish_notes TEXT,
|
finish_notes TEXT,
|
||||||
audio_transcript_url 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())
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Tasting Tagging (updated with user_id to avoid RLS recursion)
|
-- Tags Master
|
||||||
CREATE TABLE IF NOT EXISTS tasting_tags (
|
CREATE TABLE IF NOT EXISTS public.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 (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
name TEXT NOT NULL,
|
||||||
api_type TEXT NOT NULL CHECK (api_type IN ('google_search', 'gemini_ai')),
|
category public.tag_category NOT NULL,
|
||||||
endpoint TEXT,
|
is_system_default BOOLEAN DEFAULT false,
|
||||||
success BOOLEAN DEFAULT true,
|
popularity_score INTEGER DEFAULT 3,
|
||||||
error_message TEXT,
|
created_by UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
model TEXT,
|
UNIQUE(name, category)
|
||||||
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);
|
-- Junction Tables
|
||||||
CREATE INDEX idx_api_usage_api_type ON api_usage(api_type);
|
CREATE TABLE IF NOT EXISTS public.tasting_tags (
|
||||||
CREATE INDEX idx_api_usage_created_at ON api_usage(created_at);
|
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 credits table (for future credits system)
|
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
|
||||||
CREATE TABLE IF NOT EXISTS user_credits (
|
PRIMARY KEY (tasting_id, tag_id)
|
||||||
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())
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Admin users table
|
CREATE TABLE IF NOT EXISTS public.tasting_buddies (
|
||||||
CREATE TABLE IF NOT EXISTS admin_users (
|
tasting_id UUID REFERENCES public.tastings(id) ON DELETE CASCADE NOT NULL,
|
||||||
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
buddy_id UUID REFERENCES public.buddies(id) ON DELETE CASCADE NOT NULL,
|
||||||
role TEXT DEFAULT 'admin' CHECK (role IN ('admin', 'super_admin')),
|
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
PRIMARY KEY (tasting_id, buddy_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Enable RLS for API tracking tables
|
-- Bottle Splits
|
||||||
ALTER TABLE api_usage ENABLE ROW LEVEL SECURITY;
|
CREATE TABLE IF NOT EXISTS public.bottle_splits (
|
||||||
ALTER TABLE user_credits ENABLE ROW LEVEL SECURITY;
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
|
bottle_id UUID REFERENCES public.bottles(id) ON DELETE CASCADE UNIQUE,
|
||||||
|
host_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
|
||||||
-- Policies for api_usage (users can view their own, admins can view all)
|
total_volume INTEGER DEFAULT 70,
|
||||||
CREATE POLICY "api_usage_select_policy" ON api_usage FOR SELECT USING (
|
host_share INTEGER DEFAULT 10,
|
||||||
(SELECT auth.uid()) = user_id OR
|
price_bottle DECIMAL(10, 2) NOT NULL,
|
||||||
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
|
sample_sizes JSONB DEFAULT '[{"cl": 5, "glass_cost": 0.80}, {"cl": 10, "glass_cost": 1.50}]'::jsonb,
|
||||||
);
|
shipping_options JSONB DEFAULT '[]'::jsonb,
|
||||||
CREATE POLICY "api_usage_insert_policy" ON api_usage FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
public_slug TEXT UNIQUE NOT NULL,
|
||||||
-- Policies for user_credits
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||||
CREATE POLICY "user_credits_select_policy" ON user_credits FOR SELECT USING (
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
(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 TABLE IF NOT EXISTS public.split_participants (
|
||||||
CREATE POLICY "admin_users_select_policy" ON admin_users FOR SELECT USING (
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
(SELECT auth.uid()) = user_id
|
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:
|
-- Subscriptions & Credits
|
||||||
-- INSERT INTO admin_users (user_id, role) VALUES ('<robin_user_id>', 'super_admin');
|
CREATE TABLE IF NOT EXISTS public.subscription_plans (
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- 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 (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
display_name TEXT NOT NULL,
|
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())
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_subscription_plans_active ON subscription_plans(is_active);
|
CREATE TABLE IF NOT EXISTS public.user_subscriptions (
|
||||||
CREATE INDEX idx_subscription_plans_sort_order ON subscription_plans(sort_order);
|
|
||||||
|
|
||||||
-- User subscriptions table
|
|
||||||
CREATE TABLE IF NOT EXISTS user_subscriptions (
|
|
||||||
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
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()),
|
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()),
|
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())
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_user_subscriptions_plan_id ON user_subscriptions(plan_id);
|
CREATE TABLE IF NOT EXISTS public.user_credits (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
-- Enable RLS
|
balance INTEGER DEFAULT 0,
|
||||||
ALTER TABLE subscription_plans ENABLE ROW LEVEL SECURITY;
|
total_purchased INTEGER DEFAULT 0,
|
||||||
ALTER TABLE user_subscriptions ENABLE ROW LEVEL SECURITY;
|
total_used INTEGER DEFAULT 0,
|
||||||
|
daily_limit INTEGER DEFAULT NULL,
|
||||||
-- Policies for subscription_plans (everyone can view active plans)
|
google_search_cost INTEGER DEFAULT 1,
|
||||||
CREATE POLICY "subscription_plans_select_policy" ON subscription_plans
|
gemini_ai_cost INTEGER DEFAULT 1,
|
||||||
FOR SELECT USING (
|
last_reset_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||||
is_active = true OR
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY "subscription_plans_admin_policy" ON subscription_plans
|
CREATE TABLE IF NOT EXISTS public.credit_transactions (
|
||||||
FOR ALL USING (
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
|
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
|
-- Admin & Utility
|
||||||
CREATE POLICY "user_subscriptions_select_policy" ON user_subscriptions
|
CREATE TABLE IF NOT EXISTS public.admin_users (
|
||||||
FOR SELECT USING (
|
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
(SELECT auth.uid()) = user_id OR
|
role TEXT DEFAULT 'admin' CHECK (role IN ('admin', 'super_admin')),
|
||||||
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY "user_subscriptions_admin_policy" ON user_subscriptions
|
CREATE TABLE IF NOT EXISTS public.vision_cache (
|
||||||
FOR ALL USING (
|
hash TEXT PRIMARY KEY,
|
||||||
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
|
result JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now())
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Insert default plans
|
CREATE TABLE IF NOT EXISTS public.global_products (
|
||||||
INSERT INTO subscription_plans (name, display_name, monthly_credits, price, description, sort_order) VALUES
|
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),
|
('starter', 'Starter', 10, 0.00, 'Perfect for occasional use', 1),
|
||||||
('bronze', 'Bronze', 50, 4.99, 'Great for regular users', 2),
|
('bronze', 'Bronze', 50, 4.99, 'Great for regular users', 2),
|
||||||
('silver', 'Silver', 100, 8.99, 'Best value for power users', 3),
|
('silver', 'Silver', 100, 8.99, 'Best value for power users', 3),
|
||||||
('gold', 'Gold', 250, 19.99, 'Unlimited searches for professionals', 4)
|
('gold', 'Gold', 250, 19.99, 'Unlimited searches for professionals', 4)
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
-- 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