diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..7d467fc --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,214 @@ +import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage'; +import { BarChart3, TrendingUp, Users, Calendar, AlertCircle } from 'lucide-react'; +import Link from 'next/link'; + +export default async function AdminPage() { + const supabase = createServerComponentClient({ cookies }); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + redirect('/'); + } + + const isAdmin = await checkIsAdmin(user.id); + if (!isAdmin) { + redirect('/'); + } + + // Fetch global API stats + const stats = await getGlobalApiStats(); + + // Fetch recent API usage + const { data: recentUsage } = await supabase + .from('api_usage') + .select(` + *, + profiles:user_id ( + username + ) + `) + .order('created_at', { ascending: false }) + .limit(50); + + // Fetch per-user statistics + const { data: userStats } = await supabase + .from('api_usage') + .select('user_id, api_type') + .order('created_at', { ascending: false }); + + // Group by user + const userStatsMap = new Map(); + userStats?.forEach(item => { + const current = userStatsMap.get(item.user_id) || { googleSearch: 0, geminiAi: 0, total: 0 }; + if (item.api_type === 'google_search') current.googleSearch++; + if (item.api_type === 'gemini_ai') current.geminiAi++; + current.total++; + userStatsMap.set(item.user_id, current); + }); + + // Get user details for top users + const topUserIds = Array.from(userStatsMap.entries()) + .sort((a, b) => b[1].total - a[1].total) + .slice(0, 10) + .map(([userId]) => userId); + + const { data: topUsers } = await supabase + .from('profiles') + .select('id, username') + .in('id', topUserIds); + + const topUsersWithStats = topUsers?.map(user => ({ + ...user, + stats: userStatsMap.get(user.id)! + })) || []; + + return ( +
+
+ {/* Header */} +
+
+

Admin Dashboard

+

API Usage Monitoring & Statistics

+
+ + Back to App + +
+ + {/* Global Stats Cards */} +
+
+
+
+ +
+ Total Calls +
+
{stats?.totalCalls || 0}
+
All time
+
+ +
+
+
+ +
+ Today +
+
{stats?.todayCalls || 0}
+
+ {stats?.todayCalls && stats.todayCalls >= 80 ? ( + + Limit reached + + ) : ( + `${80 - (stats?.todayCalls || 0)} remaining` + )} +
+
+ +
+
+
+ +
+ Google Search +
+
{stats?.googleSearchCalls || 0}
+
Whiskybase searches
+
+ +
+
+
+ +
+ Gemini AI +
+
{stats?.geminiAiCalls || 0}
+
Bottle analyses
+
+
+ + {/* Top Users */} +
+

Top Users by API Usage

+
+ {topUsersWithStats.map((user, index) => ( +
+
+
+ {index + 1} +
+
+
{user.username}
+
+ {user.stats.googleSearch} searches · {user.stats.geminiAi} analyses +
+
+
+
{user.stats.total}
+
+ ))} +
+
+ + {/* Recent Activity */} +
+

Recent API Calls

+
+ + + + + + + + + + + + {recentUsage?.map((item) => ( + + + + + + + + ))} + +
TimeUserAPI TypeStatusError
+ {new Date(item.created_at).toLocaleString('de-DE')} + + {(item.profiles as any)?.username || 'Unknown'} + + + {item.api_type === 'google_search' ? 'Search' : 'AI'} + + + + {item.success ? 'Success' : 'Failed'} + + + {item.error_message || '-'} +
+
+
+
+
+ ); +} diff --git a/src/services/discover-whiskybase.ts b/src/services/discover-whiskybase.ts index 5572595..0bb0487 100644 --- a/src/services/discover-whiskybase.ts +++ b/src/services/discover-whiskybase.ts @@ -1,5 +1,9 @@ 'use server'; +import { trackApiUsage, checkDailyLimit } from './track-api-usage'; +import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; + /** * Service to discover a Whiskybase ID for a given bottle. * Uses Google Custom Search JSON API to search Google and extracts the ID from the first result. @@ -24,6 +28,27 @@ export async function discoverWhiskybaseId(bottle: { }; } + // Get current user for tracking + const supabase = createServerComponentClient({ cookies }); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return { + success: false, + error: 'Benutzer nicht authentifiziert.' + }; + } + + // Check daily limit before making API call + const limitCheck = await checkDailyLimit('google_search'); + if (!limitCheck.allowed) { + return { + success: false, + error: `Tageslimit für Whiskybase-Suchen erreicht (${limitCheck.limit} pro Tag). Versuche es morgen erneut.`, + limitReached: true + }; + } + try { // Construct targeted search query const queryParts = [ @@ -46,9 +71,25 @@ export async function discoverWhiskybaseId(bottle: { if (data.error) { console.error('Google API Error Response:', data.error); + // Track failed API call + await trackApiUsage({ + userId: user.id, + apiType: 'google_search', + endpoint: 'customsearch/v1', + success: false, + errorMessage: data.error.message + }); throw new Error(data.error.message || 'Google API Error'); } + // Track successful API call + await trackApiUsage({ + userId: user.id, + apiType: 'google_search', + endpoint: 'customsearch/v1', + success: true + }); + if (!data.items || data.items.length === 0) { return { success: false, @@ -77,6 +118,16 @@ export async function discoverWhiskybaseId(bottle: { return { success: false, error: 'Konnte keine gültige Whiskybase-ID im Suchergebnis finden.' }; } catch (error) { console.error('Whiskybase Discovery Error:', error); + // Track failed attempt (if not already tracked) + if (user) { + await trackApiUsage({ + userId: user.id, + apiType: 'google_search', + endpoint: 'customsearch/v1', + success: false, + errorMessage: error instanceof Error ? error.message : 'Unknown error' + }); + } return { success: false, error: error instanceof Error ? error.message : 'Fehler bei der Suche auf Whiskybase.' diff --git a/src/services/track-api-usage.ts b/src/services/track-api-usage.ts new file mode 100644 index 0000000..073e0ba --- /dev/null +++ b/src/services/track-api-usage.ts @@ -0,0 +1,209 @@ +'use server'; + +import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; + +interface TrackApiUsageParams { + userId: string; + apiType: 'google_search' | 'gemini_ai'; + endpoint?: string; + success: boolean; + errorMessage?: string; +} + +interface ApiStats { + totalCalls: number; + successfulCalls: number; + failedCalls: number; + todayCalls: number; + googleSearchCalls: number; + geminiAiCalls: number; +} + +interface DailyLimitCheck { + allowed: boolean; + remaining: number; + limit: number; +} + +const GOOGLE_SEARCH_DAILY_LIMIT = 80; + +/** + * Track an API usage event + */ +export async function trackApiUsage(params: TrackApiUsageParams): Promise<{ success: boolean; error?: string }> { + try { + const supabase = createServerComponentClient({ cookies }); + + const { error } = await supabase + .from('api_usage') + .insert({ + user_id: params.userId, + api_type: params.apiType, + endpoint: params.endpoint, + success: params.success, + error_message: params.errorMessage, + }); + + if (error) { + console.error('Failed to track API usage:', error); + return { success: false, error: error.message }; + } + + return { success: true }; + } catch (err) { + console.error('Error tracking API usage:', err); + return { success: false, error: 'Failed to track API usage' }; + } +} + +/** + * Check if daily limit has been reached for a specific API type + */ +export async function checkDailyLimit(apiType: 'google_search' | 'gemini_ai'): Promise { + try { + const supabase = createServerComponentClient({ cookies }); + + // Only enforce limit for Google Search + if (apiType !== 'google_search') { + return { allowed: true, remaining: 999999, limit: 999999 }; + } + + // Get today's date in Europe/Berlin timezone + const today = new Date(); + const berlinDate = new Date(today.toLocaleString('en-US', { timeZone: 'Europe/Berlin' })); + const startOfDay = new Date(berlinDate); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(berlinDate); + endOfDay.setHours(23, 59, 59, 999); + + const { count, error } = await supabase + .from('api_usage') + .select('*', { count: 'exact', head: true }) + .eq('api_type', apiType) + .gte('created_at', startOfDay.toISOString()) + .lte('created_at', endOfDay.toISOString()); + + if (error) { + console.error('Error checking daily limit:', error); + // Allow on error to avoid blocking users + return { allowed: true, remaining: GOOGLE_SEARCH_DAILY_LIMIT, limit: GOOGLE_SEARCH_DAILY_LIMIT }; + } + + const currentCount = count || 0; + const remaining = Math.max(0, GOOGLE_SEARCH_DAILY_LIMIT - currentCount); + const allowed = currentCount < GOOGLE_SEARCH_DAILY_LIMIT; + + return { allowed, remaining, limit: GOOGLE_SEARCH_DAILY_LIMIT }; + } catch (err) { + console.error('Error in checkDailyLimit:', err); + // Allow on error + return { allowed: true, remaining: GOOGLE_SEARCH_DAILY_LIMIT, limit: GOOGLE_SEARCH_DAILY_LIMIT }; + } +} + +/** + * Get API usage statistics for a specific user + */ +export async function getUserApiStats(userId: string): Promise { + try { + const supabase = createServerComponentClient({ cookies }); + + const { data, error } = await supabase + .from('api_usage') + .select('*') + .eq('user_id', userId); + + if (error) { + console.error('Error fetching user API stats:', error); + return null; + } + + const today = new Date(); + const berlinDate = new Date(today.toLocaleString('en-US', { timeZone: 'Europe/Berlin' })); + const startOfDay = new Date(berlinDate); + startOfDay.setHours(0, 0, 0, 0); + + const todayData = data.filter(item => new Date(item.created_at) >= startOfDay); + + return { + totalCalls: data.length, + successfulCalls: data.filter(item => item.success).length, + failedCalls: data.filter(item => !item.success).length, + todayCalls: todayData.length, + googleSearchCalls: data.filter(item => item.api_type === 'google_search').length, + geminiAiCalls: data.filter(item => item.api_type === 'gemini_ai').length, + }; + } catch (err) { + console.error('Error in getUserApiStats:', err); + return null; + } +} + +/** + * Get global API usage statistics (admin only) + */ +export async function getGlobalApiStats(): Promise { + try { + const supabase = createServerComponentClient({ cookies }); + + // Check if user is admin + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return null; + + const isAdmin = await checkIsAdmin(user.id); + if (!isAdmin) return null; + + const { data, error } = await supabase + .from('api_usage') + .select('*'); + + if (error) { + console.error('Error fetching global API stats:', error); + return null; + } + + const today = new Date(); + const berlinDate = new Date(today.toLocaleString('en-US', { timeZone: 'Europe/Berlin' })); + const startOfDay = new Date(berlinDate); + startOfDay.setHours(0, 0, 0, 0); + + const todayData = data.filter(item => new Date(item.created_at) >= startOfDay); + + return { + totalCalls: data.length, + successfulCalls: data.filter(item => item.success).length, + failedCalls: data.filter(item => !item.success).length, + todayCalls: todayData.length, + googleSearchCalls: data.filter(item => item.api_type === 'google_search').length, + geminiAiCalls: data.filter(item => item.api_type === 'gemini_ai').length, + }; + } catch (err) { + console.error('Error in getGlobalApiStats:', err); + return null; + } +} + +/** + * Check if a user is an admin + */ +export async function checkIsAdmin(userId: string): Promise { + try { + const supabase = createServerComponentClient({ cookies }); + + const { data, error } = await supabase + .from('admin_users') + .select('role') + .eq('user_id', userId) + .single(); + + if (error) { + return false; + } + + return !!data; + } catch (err) { + console.error('Error checking admin status:', err); + return false; + } +} diff --git a/supa_schema.sql b/supa_schema.sql index 2c8db52..e891327 100644 --- a/supa_schema.sql +++ b/supa_schema.sql @@ -229,3 +229,65 @@ 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(), + 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, + 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); +CREATE INDEX idx_api_usage_user_date ON api_usage(user_id, DATE(created_at)); + +-- User credits table (for future credits system) +CREATE TABLE IF NOT EXISTS user_credits ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + balance INTEGER DEFAULT 0, + total_purchased INTEGER DEFAULT 0, + total_used INTEGER DEFAULT 0, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) +); + +-- 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 "Users can view their own API usage" ON api_usage FOR SELECT USING (auth.uid() = user_id); +CREATE POLICY "Admins can view all API usage" ON api_usage FOR SELECT USING ( + EXISTS (SELECT 1 FROM admin_users WHERE user_id = auth.uid()) +); +CREATE POLICY "System can insert API usage" ON api_usage FOR INSERT WITH CHECK (true); + +-- Policies for user_credits +CREATE POLICY "Users can view their own credits" ON user_credits FOR SELECT USING (auth.uid() = user_id); +CREATE POLICY "Admins can view all credits" ON user_credits FOR SELECT USING ( + EXISTS (SELECT 1 FROM admin_users WHERE user_id = auth.uid()) +); + +-- Policies for admin_users (only admins can view) +CREATE POLICY "Admins can view admin users" ON admin_users FOR SELECT USING ( + EXISTS (SELECT 1 FROM admin_users WHERE user_id = auth.uid()) +); + +-- Note: To add robin as admin, run this after getting the user_id: +-- INSERT INTO admin_users (user_id, role) VALUES ('', 'super_admin');