feat: implement API usage tracking and admin dashboard

- Added database schema for API tracking system:
  * api_usage table - tracks all Google Search and Gemini AI calls
  * user_credits table - prepared for future credits system
  * admin_users table - controls admin dashboard access
- Created comprehensive tracking service (track-api-usage.ts):
  * trackApiUsage() - records API calls with success/failure
  * checkDailyLimit() - enforces 80 Google Search calls/day limit
  * getUserApiStats() - per-user statistics
  * getGlobalApiStats() - app-wide statistics (admin only)
  * checkIsAdmin() - server-side authorization
- Integrated tracking into discover-whiskybase.ts:
  * Pre-call limit checking with friendly error messages
  * Post-call usage tracking for success and failures
  * User authentication verification
- Built admin dashboard at /admin:
  * Global statistics cards (total, today, by API type)
  * Top 10 users by API usage
  * Recent activity log with 50 latest calls
  * Color-coded status indicators
  * Secure access with RLS policies
- Features:
  * Daily limit resets at midnight Europe/Berlin timezone
  * Graceful error handling (allows on tracking failure)
  * Comprehensive indexes for fast queries
  * Ready for future credits/monetization system
This commit is contained in:
2025-12-18 13:56:21 +01:00
parent 334bece471
commit dd27cfe0e7
4 changed files with 536 additions and 0 deletions

214
src/app/admin/page.tsx Normal file
View File

@@ -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<string, { googleSearch: number; geminiAi: number; total: number }>();
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 (
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
<div className="max-w-7xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">Admin Dashboard</h1>
<p className="text-zinc-500 mt-1">API Usage Monitoring & Statistics</p>
</div>
<Link
href="/"
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
>
Back to App
</Link>
</div>
{/* Global Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<BarChart3 size={20} className="text-blue-600 dark:text-blue-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Total Calls</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.totalCalls || 0}</div>
<div className="text-xs text-zinc-500 mt-1">All time</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Calendar size={20} className="text-green-600 dark:text-green-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Today</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.todayCalls || 0}</div>
<div className="text-xs text-zinc-500 mt-1">
{stats?.todayCalls && stats.todayCalls >= 80 ? (
<span className="text-red-500 font-bold flex items-center gap-1">
<AlertCircle size={12} /> Limit reached
</span>
) : (
`${80 - (stats?.todayCalls || 0)} remaining`
)}
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Google Search</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.googleSearchCalls || 0}</div>
<div className="text-xs text-zinc-500 mt-1">Whiskybase searches</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<Users size={20} className="text-purple-600 dark:text-purple-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Gemini AI</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats?.geminiAiCalls || 0}</div>
<div className="text-xs text-zinc-500 mt-1">Bottle analyses</div>
</div>
</div>
{/* Top Users */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Top Users by API Usage</h2>
<div className="space-y-3">
{topUsersWithStats.map((user, index) => (
<div key={user.id} className="flex items-center justify-between p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-amber-700 dark:text-amber-500 font-black text-sm">
{index + 1}
</div>
<div>
<div className="font-bold text-zinc-900 dark:text-white">{user.username}</div>
<div className="text-xs text-zinc-500">
{user.stats.googleSearch} searches · {user.stats.geminiAi} analyses
</div>
</div>
</div>
<div className="text-2xl font-black text-zinc-900 dark:text-white">{user.stats.total}</div>
</div>
))}
</div>
</div>
{/* Recent Activity */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent API Calls</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-zinc-200 dark:border-zinc-800">
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Time</th>
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">User</th>
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">API Type</th>
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Status</th>
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Error</th>
</tr>
</thead>
<tbody>
{recentUsage?.map((item) => (
<tr key={item.id} className="border-b border-zinc-100 dark:border-zinc-800/50">
<td className="py-3 px-4 text-sm text-zinc-600 dark:text-zinc-400">
{new Date(item.created_at).toLocaleString('de-DE')}
</td>
<td className="py-3 px-4 text-sm font-semibold text-zinc-900 dark:text-white">
{(item.profiles as any)?.username || 'Unknown'}
</td>
<td className="py-3 px-4">
<span className={`text-xs font-black uppercase px-2 py-1 rounded ${item.api_type === 'google_search'
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-500'
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-500'
}`}>
{item.api_type === 'google_search' ? 'Search' : 'AI'}
</span>
</td>
<td className="py-3 px-4">
<span className={`text-xs font-black uppercase px-2 py-1 rounded ${item.success
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-500'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-500'
}`}>
{item.success ? 'Success' : 'Failed'}
</span>
</td>
<td className="py-3 px-4 text-xs text-zinc-500 max-w-xs truncate">
{item.error_message || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</main>
);
}

View File

@@ -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.'

View File

@@ -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<DailyLimitCheck> {
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<ApiStats | null> {
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<ApiStats | null> {
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<boolean> {
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;
}
}

View File

@@ -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 ('<robin_user_id>', 'super_admin');